refactorai-cli 0.3.2__tar.gz → 0.3.4__tar.gz
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.
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/PKG-INFO +3 -3
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/README.md +1 -1
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/pyproject.toml +2 -2
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/__init__.py +1 -1
- refactorai_cli-0.3.4/refactorai_cli/cloud_rr.py +730 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/run_cmds.py +44 -187
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/PKG-INFO +3 -3
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/SOURCES.txt +1 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/requires.txt +1 -1
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/main.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.3.2 → refactorai_cli-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.0.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.0.4
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
54
54
|
## Install test (local)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
57
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
58
58
|
refactor --version
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -42,7 +42,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
42
42
|
## Install test (local)
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
45
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
46
46
|
refactor --version
|
|
47
47
|
```
|
|
48
48
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "refactorai-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
description = "Local-first CLI for the refactor platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
"httpx>=0.27.0",
|
|
13
13
|
"rich>=13.7.0",
|
|
14
14
|
"PyYAML>=6.0.1",
|
|
15
|
-
"refactorai-core>=3.0.
|
|
15
|
+
"refactorai-core>=3.0.4",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""Client-side orchestrator for the cloud server-run per-RR loop (R23; doc 35).
|
|
2
|
+
|
|
3
|
+
This module is the thin "hand" in the Cursor-style split: the proprietary
|
|
4
|
+
per-RR decision loop runs on the platform (see ``backend/app/api/v1/rr_session``),
|
|
5
|
+
and this client executes only generic primitives and reports results:
|
|
6
|
+
|
|
7
|
+
- open a session with the pending RRs' context,
|
|
8
|
+
- write the server-generated characterization test and run it (baseline),
|
|
9
|
+
- render the server proposal and collect approval (per RR, mid-run),
|
|
10
|
+
- apply the approved diff, run the test again, report the result,
|
|
11
|
+
- honor the server verdict (applied / retry+revert / denied / deferred / stop),
|
|
12
|
+
- persist RR status + build the run summary.
|
|
13
|
+
|
|
14
|
+
IMPORTANT (IP + packaging boundary): this module MUST import only client-safe
|
|
15
|
+
``refactor_core`` modules (``apply``, ``refactor_requests``, ``models``,
|
|
16
|
+
``constitution``, ``store``, ``testcmd``). It MUST NOT import any engine-only
|
|
17
|
+
subpackage (``execution``, ``codeintel``, ``enginecore``, ``providers``); doing
|
|
18
|
+
so would both leak proprietary code to pip-only installs and re-introduce the
|
|
19
|
+
``ModuleNotFoundError: No module named 'refactor_core.execution'`` crash that
|
|
20
|
+
this design fixes. ``tests/test_cloud_rr_client_boundary.py`` enforces this.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import re
|
|
27
|
+
import shlex
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
# Client-safe core modules only (see module docstring).
|
|
36
|
+
from refactor_core.apply import (
|
|
37
|
+
apply_changes,
|
|
38
|
+
compute_file_changes,
|
|
39
|
+
revert_from_backup,
|
|
40
|
+
validate_diff,
|
|
41
|
+
)
|
|
42
|
+
from refactor_core.models import GateResult, RunArtifact
|
|
43
|
+
from refactor_core.refactor_requests import (
|
|
44
|
+
APPLIED,
|
|
45
|
+
DEFERRED,
|
|
46
|
+
DENIED,
|
|
47
|
+
FAILED,
|
|
48
|
+
IN_PROGRESS,
|
|
49
|
+
pending_requests,
|
|
50
|
+
rr_test_dir,
|
|
51
|
+
save_request,
|
|
52
|
+
)
|
|
53
|
+
from refactor_core.store import create_run
|
|
54
|
+
from refactor_core.testcmd import _SKIP_DIRS, discover_test_command, resolve_test_command
|
|
55
|
+
|
|
56
|
+
from refactorai_cli.auth import ensure_authenticated
|
|
57
|
+
from refactorai_cli.settings import platform_url
|
|
58
|
+
|
|
59
|
+
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
60
|
+
|
|
61
|
+
# Manifest/config files that reveal how a project runs its tests. Sent (truncated)
|
|
62
|
+
# to the server for agentic test-command discovery when deterministic detection
|
|
63
|
+
# finds nothing.
|
|
64
|
+
_MANIFEST_NAMES = (
|
|
65
|
+
"package.json",
|
|
66
|
+
"pyproject.toml",
|
|
67
|
+
"setup.cfg",
|
|
68
|
+
"setup.py",
|
|
69
|
+
"pytest.ini",
|
|
70
|
+
"tox.ini",
|
|
71
|
+
"noxfile.py",
|
|
72
|
+
"requirements.txt",
|
|
73
|
+
"Makefile",
|
|
74
|
+
"makefile",
|
|
75
|
+
"go.mod",
|
|
76
|
+
"Cargo.toml",
|
|
77
|
+
"build.gradle",
|
|
78
|
+
"pom.xml",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CloudRRError(RuntimeError):
|
|
83
|
+
"""Raised when the cloud RR session cannot proceed."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_source(project_root: Path, rel: str) -> str:
|
|
87
|
+
path = project_root / rel
|
|
88
|
+
if not path.is_file():
|
|
89
|
+
return ""
|
|
90
|
+
try:
|
|
91
|
+
return path.read_text(encoding="utf-8")
|
|
92
|
+
except OSError:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
|
|
97
|
+
"""Run the configured test command on the host; (passed, detail).
|
|
98
|
+
|
|
99
|
+
Cloud runs execute tests client-side because the developer's code + deps live
|
|
100
|
+
here. Behavior preservation (FR3/FR3a) requires a runnable test: if NO test
|
|
101
|
+
command is configured we return ``(False, ...)`` so the server defers the RR
|
|
102
|
+
rather than applying an unverifiable change. This differs from local's
|
|
103
|
+
permissive skip on purpose -- cloud never applies an unverified change.
|
|
104
|
+
"""
|
|
105
|
+
if not test_command or not str(test_command).strip():
|
|
106
|
+
return False, "no test command configured; cannot verify behavior preservation"
|
|
107
|
+
command = str(test_command).strip()
|
|
108
|
+
if command == "npm test":
|
|
109
|
+
command = "CI=true npm test -- --watch=false"
|
|
110
|
+
try:
|
|
111
|
+
proc = subprocess.run(
|
|
112
|
+
command,
|
|
113
|
+
shell=True,
|
|
114
|
+
cwd=str(project_root),
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
timeout=600,
|
|
118
|
+
)
|
|
119
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
120
|
+
return False, f"test command error: {exc}"
|
|
121
|
+
if proc.returncode == 0:
|
|
122
|
+
return True, "tests passed"
|
|
123
|
+
return False, (proc.stdout or proc.stderr or "")[-500:]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _client_preflight_command(project_root: Path, command: str | None) -> bool:
|
|
127
|
+
"""Return True when ``command`` looks runnable on this host.
|
|
128
|
+
|
|
129
|
+
Composite/shell commands (``&&``, subshells, pipes) run under ``shell=True``
|
|
130
|
+
and can't be reduced to one binary, so we trust them and let the real exit
|
|
131
|
+
code gate. Simple commands are validated by checking the leading binary is on
|
|
132
|
+
PATH — this guards against running (and charging for) a server-suggested
|
|
133
|
+
command whose toolchain isn't installed.
|
|
134
|
+
"""
|
|
135
|
+
if not command or not str(command).strip():
|
|
136
|
+
return False
|
|
137
|
+
if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
|
|
138
|
+
return True
|
|
139
|
+
try:
|
|
140
|
+
tokens = shlex.split(command, posix=True)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return False
|
|
143
|
+
while tokens and _ENV_ASSIGN_RE.match(tokens[0]):
|
|
144
|
+
tokens.pop(0)
|
|
145
|
+
if not tokens:
|
|
146
|
+
return False
|
|
147
|
+
binary = tokens[0]
|
|
148
|
+
if "/" in binary:
|
|
149
|
+
return Path(binary).expanduser().exists()
|
|
150
|
+
return shutil.which(binary) is not None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_test_manifests(
|
|
154
|
+
project_root: Path, *, max_files: int = 24, max_bytes: int = 4000
|
|
155
|
+
) -> list[dict]:
|
|
156
|
+
"""Gather manifest snippets (repo root + immediate subdirs) for discovery."""
|
|
157
|
+
root = Path(project_root)
|
|
158
|
+
docs: list[dict] = []
|
|
159
|
+
dirs: list[Path] = [root]
|
|
160
|
+
try:
|
|
161
|
+
for child in sorted(root.iterdir(), key=lambda p: p.name):
|
|
162
|
+
if child.is_dir() and child.name not in _SKIP_DIRS and not child.name.startswith("."):
|
|
163
|
+
dirs.append(child)
|
|
164
|
+
except OSError:
|
|
165
|
+
return docs
|
|
166
|
+
|
|
167
|
+
for directory in dirs:
|
|
168
|
+
for name in _MANIFEST_NAMES:
|
|
169
|
+
path = directory / name
|
|
170
|
+
if not path.is_file():
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
content = path.read_text(encoding="utf-8", errors="replace")[:max_bytes]
|
|
174
|
+
except OSError:
|
|
175
|
+
continue
|
|
176
|
+
rel = path.relative_to(root)
|
|
177
|
+
docs.append({"path": str(rel), "content": content})
|
|
178
|
+
if len(docs) >= max_files:
|
|
179
|
+
return docs
|
|
180
|
+
tests_dir = directory / "tests"
|
|
181
|
+
if tests_dir.is_dir():
|
|
182
|
+
try:
|
|
183
|
+
names = sorted(p.name for p in tests_dir.iterdir() if p.is_file())[:40]
|
|
184
|
+
except OSError:
|
|
185
|
+
names = []
|
|
186
|
+
rel = tests_dir.relative_to(root)
|
|
187
|
+
docs.append({"path": f"{rel}/ (listing)", "content": "\n".join(names)})
|
|
188
|
+
if len(docs) >= max_files:
|
|
189
|
+
return docs
|
|
190
|
+
return docs
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _discover_test_command_via_cloud(
|
|
194
|
+
*,
|
|
195
|
+
project_root: Path,
|
|
196
|
+
constitution,
|
|
197
|
+
execution_mode: str,
|
|
198
|
+
auth_ctx,
|
|
199
|
+
model_hint: str | None,
|
|
200
|
+
apply_byok_payload: Callable[[dict, object], None] | None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Ask the server to infer a test command from the project's manifests.
|
|
203
|
+
|
|
204
|
+
Best-effort: any transport/parse/auth issue returns ``""`` so the caller
|
|
205
|
+
falls back to the existing defer-when-unverifiable behavior.
|
|
206
|
+
"""
|
|
207
|
+
documents = _collect_test_manifests(project_root)
|
|
208
|
+
if not documents:
|
|
209
|
+
return ""
|
|
210
|
+
payload = {
|
|
211
|
+
"stage": "rr_discover_testcmd",
|
|
212
|
+
"constitution_hash": constitution.content_hash,
|
|
213
|
+
"constitution_body": getattr(constitution, "body", "") or "",
|
|
214
|
+
"model_hint": model_hint,
|
|
215
|
+
"documents": documents,
|
|
216
|
+
"execution_mode": execution_mode,
|
|
217
|
+
}
|
|
218
|
+
if execution_mode == "cloud_byok" and apply_byok_payload is not None:
|
|
219
|
+
apply_byok_payload(payload, constitution)
|
|
220
|
+
idem = hashlib.sha256(
|
|
221
|
+
"|".join(
|
|
222
|
+
[auth_ctx.account_id, execution_mode, constitution.content_hash, "rr_discover_testcmd"]
|
|
223
|
+
).encode("utf-8")
|
|
224
|
+
).hexdigest()
|
|
225
|
+
try:
|
|
226
|
+
resp = httpx.post(
|
|
227
|
+
f"{platform_url()}/v1/inference/refactor",
|
|
228
|
+
headers={
|
|
229
|
+
"Authorization": f"Bearer {auth_ctx.key.key}",
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
"Idempotency-Key": idem,
|
|
232
|
+
},
|
|
233
|
+
json=payload,
|
|
234
|
+
timeout=45.0,
|
|
235
|
+
)
|
|
236
|
+
except httpx.HTTPError:
|
|
237
|
+
return ""
|
|
238
|
+
if resp.status_code != 200:
|
|
239
|
+
return ""
|
|
240
|
+
try:
|
|
241
|
+
data = resp.json() if resp.content else {}
|
|
242
|
+
except ValueError:
|
|
243
|
+
return ""
|
|
244
|
+
return str((data or {}).get("test_command", "") or "").strip()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_cloud_test_command(
|
|
248
|
+
*,
|
|
249
|
+
project_root: Path,
|
|
250
|
+
config: dict,
|
|
251
|
+
constitution,
|
|
252
|
+
execution_mode: str,
|
|
253
|
+
auth_ctx,
|
|
254
|
+
model_hint: str | None,
|
|
255
|
+
apply_byok_payload: Callable[[dict, object], None] | None,
|
|
256
|
+
) -> tuple[str | None, str]:
|
|
257
|
+
"""Resolve a test command for a cloud run; return ``(command, note)``.
|
|
258
|
+
|
|
259
|
+
Layered resolution so the developer usually needs no config:
|
|
260
|
+
1. configured ``verification.tests.command`` or root-marker detection,
|
|
261
|
+
2. deterministic monorepo discovery (subproject test setups),
|
|
262
|
+
3. agentic discovery (server infers from manifests), preflight-validated.
|
|
263
|
+
"""
|
|
264
|
+
configured = resolve_test_command(project_root, config)
|
|
265
|
+
if configured:
|
|
266
|
+
return configured, ""
|
|
267
|
+
|
|
268
|
+
discovered = discover_test_command(project_root)
|
|
269
|
+
if discovered:
|
|
270
|
+
return discovered, f"auto-detected test command: {discovered}"
|
|
271
|
+
|
|
272
|
+
suggested = _discover_test_command_via_cloud(
|
|
273
|
+
project_root=project_root,
|
|
274
|
+
constitution=constitution,
|
|
275
|
+
execution_mode=execution_mode,
|
|
276
|
+
auth_ctx=auth_ctx,
|
|
277
|
+
model_hint=model_hint,
|
|
278
|
+
apply_byok_payload=apply_byok_payload,
|
|
279
|
+
)
|
|
280
|
+
if suggested and _client_preflight_command(project_root, suggested):
|
|
281
|
+
return suggested, f"server-suggested test command: {suggested}"
|
|
282
|
+
if suggested:
|
|
283
|
+
return None, (
|
|
284
|
+
f"server suggested test command '{suggested}' but its toolchain was not "
|
|
285
|
+
"found on PATH; skipped."
|
|
286
|
+
)
|
|
287
|
+
return None, ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _Session:
|
|
291
|
+
"""Transport for the RR session endpoints with status-code error mapping."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, *, auth_ctx, execution_mode: str, constitution_hash: str) -> None:
|
|
294
|
+
self._auth = auth_ctx
|
|
295
|
+
self._mode = execution_mode
|
|
296
|
+
self._consti = constitution_hash
|
|
297
|
+
self._base = f"{platform_url()}/v1/rr"
|
|
298
|
+
|
|
299
|
+
def _idempotency_key(self, path: str, payload: dict) -> str:
|
|
300
|
+
"""Deterministic key per (account, mode, consti, rr, step, attempt).
|
|
301
|
+
|
|
302
|
+
Makes a step POST safe to retry without duplicating work or double
|
|
303
|
+
charging (FR6). Distinct RRs/attempts/steps get distinct keys so unrelated
|
|
304
|
+
attempts never collapse into one replayed result.
|
|
305
|
+
"""
|
|
306
|
+
seed = "|".join(
|
|
307
|
+
[
|
|
308
|
+
self._auth.account_id,
|
|
309
|
+
self._mode,
|
|
310
|
+
self._consti,
|
|
311
|
+
path,
|
|
312
|
+
str(payload.get("rr_id", "")),
|
|
313
|
+
str(payload.get("step", "")),
|
|
314
|
+
str(payload.get("attempt", "")),
|
|
315
|
+
]
|
|
316
|
+
)
|
|
317
|
+
return hashlib.sha256(seed.encode("utf-8")).hexdigest()
|
|
318
|
+
|
|
319
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
320
|
+
try:
|
|
321
|
+
resp = httpx.post(
|
|
322
|
+
f"{self._base}{path}",
|
|
323
|
+
headers={
|
|
324
|
+
"Authorization": f"Bearer {self._auth.key.key}",
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
"Idempotency-Key": self._idempotency_key(path, payload),
|
|
327
|
+
},
|
|
328
|
+
json=payload,
|
|
329
|
+
timeout=45.0,
|
|
330
|
+
)
|
|
331
|
+
except httpx.HTTPError as exc:
|
|
332
|
+
raise CloudRRError(f"Could not reach cloud RR service: {exc}") from exc
|
|
333
|
+
|
|
334
|
+
if resp.status_code == 401:
|
|
335
|
+
raise CloudRRError("Cloud RR unauthorized (developer key invalid or revoked).")
|
|
336
|
+
if resp.status_code == 402:
|
|
337
|
+
if self._mode == "cloud_byok":
|
|
338
|
+
raise CloudRRError(
|
|
339
|
+
"Cloud BYOK is not enabled for this account (missing `run:byok`). "
|
|
340
|
+
"Contact your admin to enable BYOK cloud execution."
|
|
341
|
+
)
|
|
342
|
+
raise CloudRRError("Cloud RR requires paid tier (`mode: cloud_managed`).")
|
|
343
|
+
if resp.status_code == 404 and self._mode == "cloud_byok":
|
|
344
|
+
raise CloudRRError(
|
|
345
|
+
"BYOK credential not found or not owned by this account. "
|
|
346
|
+
"Check `refactor cloud credentials list` and your `credential_ref`."
|
|
347
|
+
)
|
|
348
|
+
if resp.status_code in (400, 403, 409):
|
|
349
|
+
detail = ""
|
|
350
|
+
try:
|
|
351
|
+
detail = str((resp.json() or {}).get("detail", ""))
|
|
352
|
+
except Exception:
|
|
353
|
+
detail = resp.text[:300]
|
|
354
|
+
raise CloudRRError(detail or f"Cloud RR error ({resp.status_code}).")
|
|
355
|
+
if resp.status_code == 429:
|
|
356
|
+
raise CloudRRError("Cloud RR quota exhausted or rate-limited.")
|
|
357
|
+
if resp.status_code >= 400:
|
|
358
|
+
raise CloudRRError(f"Cloud RR failed ({resp.status_code}): {resp.text[:300]}")
|
|
359
|
+
return resp.json() if resp.content else {}
|
|
360
|
+
|
|
361
|
+
def open(self, payload: dict) -> dict:
|
|
362
|
+
return self._post("/session", payload)
|
|
363
|
+
|
|
364
|
+
def step(self, payload: dict) -> dict:
|
|
365
|
+
return self._post("/session/step", payload)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def run_cloud_refactor_requests(
|
|
369
|
+
*,
|
|
370
|
+
project_root: Path,
|
|
371
|
+
constitution,
|
|
372
|
+
config: dict,
|
|
373
|
+
requests=None,
|
|
374
|
+
model_hint: str | None = None,
|
|
375
|
+
progress_cb: Callable[[dict], None] | None = None,
|
|
376
|
+
approval_cb: Callable[[dict], bool] | None = None,
|
|
377
|
+
apply_byok_payload: Callable[[dict, object], None] | None = None,
|
|
378
|
+
) -> RunArtifact:
|
|
379
|
+
"""Drive pending RRs through the cloud server-run loop and return a summary.
|
|
380
|
+
|
|
381
|
+
``progress_cb`` / ``approval_cb`` are the SAME callbacks local uses
|
|
382
|
+
(``_rr_progress_logger`` / ``_rr_approval_prompt``), so the run streams the
|
|
383
|
+
same events and prompts per RR mid-run (FR8). ``apply_byok_payload`` is the
|
|
384
|
+
shared BYOK resolver injected by the caller to avoid a circular import.
|
|
385
|
+
"""
|
|
386
|
+
project_root = Path(project_root)
|
|
387
|
+
progress_cb = progress_cb or (lambda e: None)
|
|
388
|
+
approval_cb = approval_cb or (lambda r: False)
|
|
389
|
+
|
|
390
|
+
execution_mode = str(constitution.get_setting("mode", "cloud_managed") or "cloud_managed")
|
|
391
|
+
auth_ctx = ensure_authenticated(project_root)
|
|
392
|
+
test_command, testcmd_note = _resolve_cloud_test_command(
|
|
393
|
+
project_root=project_root,
|
|
394
|
+
config=config,
|
|
395
|
+
constitution=constitution,
|
|
396
|
+
execution_mode=execution_mode,
|
|
397
|
+
auth_ctx=auth_ctx,
|
|
398
|
+
model_hint=model_hint,
|
|
399
|
+
apply_byok_payload=apply_byok_payload,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if requests is None:
|
|
403
|
+
requests = pending_requests(project_root)
|
|
404
|
+
|
|
405
|
+
run_store = create_run(project_root)
|
|
406
|
+
run_id = run_store.run_id
|
|
407
|
+
resolved_model = model_hint or str(
|
|
408
|
+
constitution.get_setting("model_id", "managed-default") or "managed-default"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
_emit(progress_cb, phase="rr", event="run_start", total_requests=len(requests),
|
|
412
|
+
test_command=test_command or "", execution_env="cloud")
|
|
413
|
+
|
|
414
|
+
outcomes: list[dict] = []
|
|
415
|
+
counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
|
|
416
|
+
notes: list[str] = []
|
|
417
|
+
if testcmd_note:
|
|
418
|
+
notes.append(testcmd_note)
|
|
419
|
+
stopped = False
|
|
420
|
+
stop_reason = ""
|
|
421
|
+
model_id = resolved_model
|
|
422
|
+
|
|
423
|
+
if not requests:
|
|
424
|
+
return _build_artifact(
|
|
425
|
+
run_store, run_id, constitution, model_id, outcomes, counts,
|
|
426
|
+
notes, stopped=False, stop_reason="", test_command=test_command,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
session = _Session(
|
|
430
|
+
auth_ctx=auth_ctx,
|
|
431
|
+
execution_mode=execution_mode,
|
|
432
|
+
constitution_hash=constitution.content_hash,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# --- open the session ---------------------------------------------------
|
|
436
|
+
open_payload = {
|
|
437
|
+
"execution_mode": execution_mode,
|
|
438
|
+
"constitution_hash": constitution.content_hash,
|
|
439
|
+
"constitution_body": getattr(constitution, "body", "") or "",
|
|
440
|
+
"model_hint": resolved_model,
|
|
441
|
+
"rrs": [
|
|
442
|
+
{
|
|
443
|
+
"rr_id": rr.rr_id,
|
|
444
|
+
"rr_context": {
|
|
445
|
+
"location": rr.location,
|
|
446
|
+
"file_path": rr.file_path,
|
|
447
|
+
"category": rr.category,
|
|
448
|
+
"severity": rr.severity,
|
|
449
|
+
"recommendation": rr.recommendation,
|
|
450
|
+
"operation_type": rr.operation_type,
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
for rr in requests
|
|
454
|
+
],
|
|
455
|
+
}
|
|
456
|
+
if execution_mode == "cloud_byok" and apply_byok_payload is not None:
|
|
457
|
+
apply_byok_payload(open_payload, constitution)
|
|
458
|
+
|
|
459
|
+
action = session.open(open_payload)
|
|
460
|
+
token = action.get("session_token", "")
|
|
461
|
+
|
|
462
|
+
# Map rr_id -> RefactorRequest for status updates.
|
|
463
|
+
by_id = {rr.rr_id: rr for rr in requests}
|
|
464
|
+
attempts_by_id: dict[str, int] = {}
|
|
465
|
+
started: set[str] = set()
|
|
466
|
+
|
|
467
|
+
def _start_rr(rr_id: str):
|
|
468
|
+
"""Emit rr_start (once) and send the `start` step for ``rr_id``."""
|
|
469
|
+
rr = by_id.get(rr_id)
|
|
470
|
+
if rr is not None and rr_id not in started:
|
|
471
|
+
started.add(rr_id)
|
|
472
|
+
rr.status = IN_PROGRESS
|
|
473
|
+
rr.resolved_in_run = run_id
|
|
474
|
+
save_request(project_root, rr)
|
|
475
|
+
_emit(progress_cb, phase="rr", event="rr_start", rr_id=rr_id, location=rr.location)
|
|
476
|
+
return session.step(_step_payload(token, rr_id, "start", by_id, project_root))
|
|
477
|
+
|
|
478
|
+
# --- drive the loop -----------------------------------------------------
|
|
479
|
+
# Uniform contract: the server returns the next action; the client executes a
|
|
480
|
+
# primitive and posts the result. Terminal per-RR verdicts carry `next_rr_id`
|
|
481
|
+
# (empty => finished). We always send the current file source so the server
|
|
482
|
+
# can (re)build prompts. A generous iteration cap guards against runaway.
|
|
483
|
+
current_rr_id = action.get("rr_id", "")
|
|
484
|
+
if str(action.get("action", "")) == "next_rr" and current_rr_id:
|
|
485
|
+
action = _start_rr(current_rr_id)
|
|
486
|
+
token = action.get("session_token", token)
|
|
487
|
+
|
|
488
|
+
guard = 0
|
|
489
|
+
max_iters = max(50, len(requests) * 25)
|
|
490
|
+
while guard < max_iters:
|
|
491
|
+
guard += 1
|
|
492
|
+
act = str(action.get("action", ""))
|
|
493
|
+
model_id = action.get("model_id", model_id) or model_id
|
|
494
|
+
|
|
495
|
+
if act == "done":
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
if act == "run_baseline_test":
|
|
499
|
+
current_rr_id = action.get("rr_id", "") or current_rr_id
|
|
500
|
+
_emit(progress_cb, phase="rr", event="test_generating", rr_id=current_rr_id)
|
|
501
|
+
_write_generated_test(project_root, current_rr_id, action, by_id)
|
|
502
|
+
passed, detail = _run_test(project_root, test_command)
|
|
503
|
+
_emit(progress_cb, phase="rr", event="baseline_test", rr_id=current_rr_id,
|
|
504
|
+
passed=passed, detail=detail, execution_env="host")
|
|
505
|
+
action = session.step(
|
|
506
|
+
_step_payload(token, current_rr_id, "baseline_result", by_id, project_root,
|
|
507
|
+
baseline_passed=passed, baseline_detail=detail)
|
|
508
|
+
)
|
|
509
|
+
token = action.get("session_token", token)
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
if act == "show_proposal":
|
|
513
|
+
current_rr_id = action.get("rr_id", "") or current_rr_id
|
|
514
|
+
rr = by_id.get(current_rr_id)
|
|
515
|
+
diff_text = str(action.get("diff_text", "") or "")
|
|
516
|
+
attempt = int(action.get("attempt", 1) or 1)
|
|
517
|
+
attempts_by_id[current_rr_id] = attempt
|
|
518
|
+
ok, why = validate_diff(project_root, diff_text)
|
|
519
|
+
if not ok:
|
|
520
|
+
_emit(progress_cb, phase="rr", event="proposal_invalid", attempt=attempt, reason=why)
|
|
521
|
+
action = session.step(
|
|
522
|
+
_step_payload(token, current_rr_id, "verify_result", by_id, project_root,
|
|
523
|
+
attempt=attempt,
|
|
524
|
+
verify_passed=False, verify_detail=f"invalid diff: {why}")
|
|
525
|
+
)
|
|
526
|
+
token = action.get("session_token", token)
|
|
527
|
+
continue
|
|
528
|
+
_emit(progress_cb, phase="rr", event="approval_requested", rr_id=current_rr_id)
|
|
529
|
+
approved = bool(approval_cb({
|
|
530
|
+
"rr_id": current_rr_id,
|
|
531
|
+
"location": getattr(rr, "location", current_rr_id),
|
|
532
|
+
"attempt": attempt,
|
|
533
|
+
"summary": action.get("summary", ""),
|
|
534
|
+
"risk": action.get("risk", "medium"),
|
|
535
|
+
"diff_text": diff_text,
|
|
536
|
+
}))
|
|
537
|
+
_emit(progress_cb, phase="rr", event="approval_decision", rr_id=current_rr_id, approved=approved)
|
|
538
|
+
_PENDING_DIFF[current_rr_id] = diff_text
|
|
539
|
+
action = session.step(
|
|
540
|
+
_step_payload(token, current_rr_id, "approval", by_id, project_root,
|
|
541
|
+
attempt=attempt, approved=approved)
|
|
542
|
+
)
|
|
543
|
+
token = action.get("session_token", token)
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
if act in ("apply_result", "deferred"):
|
|
547
|
+
verdict = str(action.get("verdict", "") or "")
|
|
548
|
+
if verdict == "":
|
|
549
|
+
# Server approved: client applies + verifies, then posts result.
|
|
550
|
+
current_rr_id = action.get("rr_id", "") or current_rr_id
|
|
551
|
+
diff_text = _PENDING_DIFF.get(current_rr_id, "")
|
|
552
|
+
passed, detail, files = _apply_and_verify(
|
|
553
|
+
project_root, run_store.run_dir, diff_text, test_command
|
|
554
|
+
)
|
|
555
|
+
if passed:
|
|
556
|
+
_emit(progress_cb, phase="rr", event="rr_applied", rr_id=current_rr_id, files=files)
|
|
557
|
+
else:
|
|
558
|
+
_emit(progress_cb, phase="rr", event="rr_reverted", rr_id=current_rr_id,
|
|
559
|
+
attempt=attempts_by_id.get(current_rr_id, 1), detail=detail)
|
|
560
|
+
action = session.step(
|
|
561
|
+
_step_payload(token, current_rr_id, "verify_result", by_id, project_root,
|
|
562
|
+
attempt=attempts_by_id.get(current_rr_id, 1),
|
|
563
|
+
verify_passed=passed, verify_detail=detail)
|
|
564
|
+
)
|
|
565
|
+
token = action.get("session_token", token)
|
|
566
|
+
continue
|
|
567
|
+
# Terminal verdict for the completed RR.
|
|
568
|
+
completed_id = action.get("rr_id", "") or current_rr_id
|
|
569
|
+
_record_outcome(
|
|
570
|
+
project_root, by_id, completed_id, verdict, action.get("message", ""),
|
|
571
|
+
attempts_by_id.get(completed_id, 0 if verdict == "deferred" else 1),
|
|
572
|
+
outcomes, counts, progress_cb,
|
|
573
|
+
)
|
|
574
|
+
if verdict == "failed":
|
|
575
|
+
stopped = True
|
|
576
|
+
stop_reason = str(action.get("message", "") or "refactor request failed")
|
|
577
|
+
_emit(progress_cb, phase="rr", event="run_stopped", rr_id=completed_id, reason=stop_reason)
|
|
578
|
+
break
|
|
579
|
+
next_id = str(action.get("next_rr_id", "") or "")
|
|
580
|
+
if not next_id:
|
|
581
|
+
break
|
|
582
|
+
current_rr_id = next_id
|
|
583
|
+
action = _start_rr(next_id)
|
|
584
|
+
token = action.get("session_token", token)
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
notes.append(f"unexpected cloud action '{act}'")
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
return _build_artifact(
|
|
591
|
+
run_store, run_id, constitution, model_id, outcomes, counts,
|
|
592
|
+
notes, stopped=stopped, stop_reason=stop_reason, test_command=test_command,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# Diffs approved but not yet applied, keyed by rr_id (client-local, per run).
|
|
597
|
+
_PENDING_DIFF: dict[str, str] = {}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _step_payload(token, rr_id, step, by_id, project_root, *, attempt: int = 0, **kw) -> dict:
|
|
601
|
+
rr = by_id.get(rr_id) if rr_id else None
|
|
602
|
+
source = _read_source(project_root, rr.file_path) if rr is not None else ""
|
|
603
|
+
payload = {
|
|
604
|
+
"session_token": token,
|
|
605
|
+
"rr_id": rr_id,
|
|
606
|
+
"step": step,
|
|
607
|
+
"source": source,
|
|
608
|
+
"attempt": int(attempt),
|
|
609
|
+
}
|
|
610
|
+
payload.update(kw)
|
|
611
|
+
return payload
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _write_generated_test(project_root: Path, rr_id: str, action: dict, by_id: dict) -> None:
|
|
615
|
+
content = str(action.get("test_content", "") or "")
|
|
616
|
+
if not content:
|
|
617
|
+
return
|
|
618
|
+
test_path = str(action.get("test_path", "") or "")
|
|
619
|
+
name = Path(test_path).name if test_path else f"{rr_id}.test.txt"
|
|
620
|
+
test_dir = rr_test_dir(project_root, rr_id)
|
|
621
|
+
(test_dir / name).write_text(content, encoding="utf-8")
|
|
622
|
+
rr = by_id.get(rr_id)
|
|
623
|
+
if rr is not None:
|
|
624
|
+
rr.rrtest_path = str(test_dir / name)
|
|
625
|
+
save_request(project_root, rr)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _apply_and_verify(
|
|
629
|
+
project_root: Path, run_dir: Path, diff_text: str, test_command
|
|
630
|
+
) -> tuple[bool, str, list[str]]:
|
|
631
|
+
"""Apply the diff (with backup), run the test; revert on red.
|
|
632
|
+
|
|
633
|
+
Returns ``(passed, detail, changed_files)``. ``changed_files`` is computed
|
|
634
|
+
from the diff BEFORE applying (the tree is mutated in place afterward, so it
|
|
635
|
+
cannot be recomputed against the modified files).
|
|
636
|
+
"""
|
|
637
|
+
if not diff_text:
|
|
638
|
+
return False, "empty diff", []
|
|
639
|
+
try:
|
|
640
|
+
changes = compute_file_changes(project_root, [diff_text])
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
return False, f"could not compute changes: {exc}", []
|
|
643
|
+
if not changes:
|
|
644
|
+
return False, "diff produced no file changes", []
|
|
645
|
+
changed_files = list(changes.keys())
|
|
646
|
+
try:
|
|
647
|
+
apply_changes(project_root, run_dir, changes)
|
|
648
|
+
except Exception as exc:
|
|
649
|
+
return False, f"apply failed: {exc}", changed_files
|
|
650
|
+
passed, detail = _run_test(project_root, test_command)
|
|
651
|
+
if not passed:
|
|
652
|
+
try:
|
|
653
|
+
revert_from_backup(project_root, run_dir)
|
|
654
|
+
except Exception as exc: # keep going; report both
|
|
655
|
+
detail = f"{detail}; revert error: {exc}"
|
|
656
|
+
return passed, detail, changed_files
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
|
|
660
|
+
outcomes: list, counts: dict, progress_cb) -> None:
|
|
661
|
+
status_map = {
|
|
662
|
+
"applied": APPLIED,
|
|
663
|
+
"denied": DENIED,
|
|
664
|
+
"failed": FAILED,
|
|
665
|
+
"deferred": DEFERRED,
|
|
666
|
+
}
|
|
667
|
+
rr = by_id.get(rr_id)
|
|
668
|
+
if rr is not None:
|
|
669
|
+
rr.status = status_map.get(verdict, rr.status)
|
|
670
|
+
rr.attempt_count = max(int(getattr(rr, "attempt_count", 0) or 0), int(attempts or 0))
|
|
671
|
+
save_request(project_root, rr)
|
|
672
|
+
outcomes.append({
|
|
673
|
+
"location": getattr(rr, "location", rr_id) if rr is not None else rr_id,
|
|
674
|
+
"operation_type": getattr(rr, "operation_type", "content_patch") if rr is not None else "content_patch",
|
|
675
|
+
"outcome": verdict,
|
|
676
|
+
"attempts": int(attempts or 0),
|
|
677
|
+
})
|
|
678
|
+
if verdict in counts:
|
|
679
|
+
counts[verdict] += 1
|
|
680
|
+
ev = {
|
|
681
|
+
"applied": "rr_applied",
|
|
682
|
+
"denied": None,
|
|
683
|
+
"failed": "rr_failed",
|
|
684
|
+
"deferred": "rr_deferred",
|
|
685
|
+
}.get(verdict)
|
|
686
|
+
if ev == "rr_deferred":
|
|
687
|
+
_emit(progress_cb, phase="rr", event="rr_deferred", rr_id=rr_id, reason=message)
|
|
688
|
+
elif ev == "rr_failed":
|
|
689
|
+
_emit(progress_cb, phase="rr", event="rr_failed", rr_id=rr_id, reason=message)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _build_artifact(run_store, run_id, constitution, model_id, outcomes, counts,
|
|
693
|
+
notes, *, stopped, stop_reason, test_command) -> RunArtifact:
|
|
694
|
+
gate = GateResult(status="blocked" if stopped else "passed")
|
|
695
|
+
applied = counts["applied"]
|
|
696
|
+
summary = (
|
|
697
|
+
f"Cloud refactor requests: applied={applied} denied={counts['denied']} "
|
|
698
|
+
f"failed={counts['failed']} deferred={counts['deferred']}"
|
|
699
|
+
)
|
|
700
|
+
artifact = RunArtifact(
|
|
701
|
+
run_id=run_id,
|
|
702
|
+
findings=[],
|
|
703
|
+
patches=[],
|
|
704
|
+
generated_tests=[],
|
|
705
|
+
gate=gate,
|
|
706
|
+
summary=summary,
|
|
707
|
+
constitution_hash=constitution.content_hash,
|
|
708
|
+
model_id=model_id,
|
|
709
|
+
notes=notes,
|
|
710
|
+
execution_mode="rr_cloud",
|
|
711
|
+
context_stats={"mode": "rr_cloud", "test_command": test_command or ""},
|
|
712
|
+
agent_trace_summary={
|
|
713
|
+
"outcomes": outcomes,
|
|
714
|
+
"applied": counts["applied"],
|
|
715
|
+
"denied": counts["denied"],
|
|
716
|
+
"failed": counts["failed"],
|
|
717
|
+
"deferred": counts["deferred"],
|
|
718
|
+
"stopped": stopped,
|
|
719
|
+
"stop_reason": stop_reason,
|
|
720
|
+
},
|
|
721
|
+
)
|
|
722
|
+
run_store.write_artifact(artifact, target=".", status="completed")
|
|
723
|
+
return artifact
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _emit(progress_cb, **event) -> None:
|
|
727
|
+
try:
|
|
728
|
+
progress_cb(event)
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
@@ -772,155 +772,6 @@ def _apply_byok_payload(payload: dict, constitution) -> None:
|
|
|
772
772
|
)
|
|
773
773
|
|
|
774
774
|
|
|
775
|
-
def _post_cloud_inference(*, payload: dict, idempotency_key: str, auth_ctx, execution_mode: str) -> dict:
|
|
776
|
-
"""POST a cloud inference request and return the parsed JSON body.
|
|
777
|
-
|
|
778
|
-
Centralizes transport + status-code error mapping so the per-RR proposal
|
|
779
|
-
path surfaces the same remediation messages as review/refactor.
|
|
780
|
-
"""
|
|
781
|
-
try:
|
|
782
|
-
response = httpx.post(
|
|
783
|
-
f"{platform_url()}/v1/inference/refactor",
|
|
784
|
-
headers={
|
|
785
|
-
"Authorization": f"Bearer {auth_ctx.key.key}",
|
|
786
|
-
"Content-Type": "application/json",
|
|
787
|
-
"Idempotency-Key": idempotency_key,
|
|
788
|
-
},
|
|
789
|
-
json=payload,
|
|
790
|
-
timeout=45.0,
|
|
791
|
-
)
|
|
792
|
-
except httpx.HTTPError as exc:
|
|
793
|
-
raise RuntimeError(f"Could not reach cloud inference service: {exc}") from exc
|
|
794
|
-
|
|
795
|
-
if response.status_code == 401:
|
|
796
|
-
raise RuntimeError("Cloud inference unauthorized (developer key invalid or revoked).")
|
|
797
|
-
if response.status_code == 402:
|
|
798
|
-
if execution_mode == "cloud_byok":
|
|
799
|
-
raise RuntimeError(
|
|
800
|
-
"Cloud BYOK is not enabled for this account (missing `run:byok`). "
|
|
801
|
-
"Contact your admin to enable BYOK cloud execution."
|
|
802
|
-
)
|
|
803
|
-
raise RuntimeError("Cloud inference requires paid tier (`mode: cloud_managed`).")
|
|
804
|
-
if response.status_code == 404 and execution_mode == "cloud_byok":
|
|
805
|
-
raise RuntimeError(
|
|
806
|
-
"BYOK credential not found or not owned by this account. "
|
|
807
|
-
"Check `refactor cloud credentials list` and your `credential_ref`."
|
|
808
|
-
)
|
|
809
|
-
if response.status_code == 429:
|
|
810
|
-
raise RuntimeError("Cloud inference quota exhausted or rate-limited.")
|
|
811
|
-
if response.status_code >= 400:
|
|
812
|
-
raise RuntimeError(f"Cloud inference failed ({response.status_code}): {response.text[:500]}")
|
|
813
|
-
|
|
814
|
-
return response.json() if response.content else {}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
class _CloudRRProvider:
|
|
818
|
-
"""Lightweight provider stub for cloud RR execution (R22).
|
|
819
|
-
|
|
820
|
-
The executor reads ``provider.model_id`` for the artifact and would call
|
|
821
|
-
``provider.complete(...)`` for proposals/tests. In cloud mode the proposal
|
|
822
|
-
and test-gen hooks are supplied, so ``complete`` is never invoked; it raises
|
|
823
|
-
to make any accidental local-prompt path explicit rather than silent.
|
|
824
|
-
"""
|
|
825
|
-
|
|
826
|
-
def __init__(self, *, model_id: str) -> None:
|
|
827
|
-
self.model_id = model_id
|
|
828
|
-
self.provider_name = "cloud"
|
|
829
|
-
self.supports_refactor = True
|
|
830
|
-
|
|
831
|
-
def complete(self, system: str, user: str) -> str: # pragma: no cover - guard
|
|
832
|
-
raise RuntimeError(
|
|
833
|
-
"cloud RR execution must use server-side proposal hooks; "
|
|
834
|
-
"provider.complete should not be called."
|
|
835
|
-
)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
def _cloud_rr_hooks(
|
|
839
|
-
*,
|
|
840
|
-
project_root: Path,
|
|
841
|
-
constitution,
|
|
842
|
-
model_hint: str | None,
|
|
843
|
-
) -> tuple[Callable, Callable, str]:
|
|
844
|
-
"""Build (propose_fn, gen_test_fn, model_id) for cloud per-RR execution (R22).
|
|
845
|
-
|
|
846
|
-
The returned callables match the executor's hook contract. They send the
|
|
847
|
-
single RR's context + current source to the platform, which builds the
|
|
848
|
-
proprietary prompts server-side and returns a proposal / characterization
|
|
849
|
-
test. Idempotency keys include the RR id and attempt so retries of distinct
|
|
850
|
-
RRs/attempts never collapse into a replayed result.
|
|
851
|
-
"""
|
|
852
|
-
execution_mode = _cloud_execution_mode(constitution) or "cloud_managed"
|
|
853
|
-
auth_ctx = ensure_authenticated(project_root)
|
|
854
|
-
constitution_body = getattr(constitution, "body", "") or ""
|
|
855
|
-
resolved_model = model_hint or str(
|
|
856
|
-
constitution.get_setting("model_id", "managed-default") or "managed-default"
|
|
857
|
-
)
|
|
858
|
-
|
|
859
|
-
def _rr_context(rr) -> dict:
|
|
860
|
-
return {
|
|
861
|
-
"location": getattr(rr, "location", ""),
|
|
862
|
-
"file_path": getattr(rr, "file_path", ""),
|
|
863
|
-
"category": getattr(rr, "category", ""),
|
|
864
|
-
"severity": getattr(rr, "severity", ""),
|
|
865
|
-
"recommendation": getattr(rr, "recommendation", ""),
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
def _base_payload(stage: str, rr, source: str) -> dict:
|
|
869
|
-
payload = {
|
|
870
|
-
"stage": stage,
|
|
871
|
-
"execution_mode": execution_mode,
|
|
872
|
-
"constitution_hash": constitution.content_hash,
|
|
873
|
-
"constitution_body": constitution_body,
|
|
874
|
-
"rr_context": _rr_context(rr),
|
|
875
|
-
"source": source,
|
|
876
|
-
"max_alerts": int(constitution.get_setting("max_alerts_per_run", 5) or 5),
|
|
877
|
-
"model_hint": resolved_model,
|
|
878
|
-
}
|
|
879
|
-
if execution_mode == "cloud_byok":
|
|
880
|
-
_apply_byok_payload(payload, constitution)
|
|
881
|
-
return payload
|
|
882
|
-
|
|
883
|
-
def _idempotency(stage: str, rr, attempt: int) -> str:
|
|
884
|
-
seed = "|".join(
|
|
885
|
-
[
|
|
886
|
-
auth_ctx.account_id,
|
|
887
|
-
execution_mode,
|
|
888
|
-
stage,
|
|
889
|
-
constitution.content_hash,
|
|
890
|
-
str(getattr(rr, "rr_id", "")),
|
|
891
|
-
str(int(attempt)),
|
|
892
|
-
]
|
|
893
|
-
)
|
|
894
|
-
return hashlib.sha256(seed.encode("utf-8")).hexdigest()
|
|
895
|
-
|
|
896
|
-
def propose_fn(rr, source: str, prior_failures: list[str]) -> dict:
|
|
897
|
-
# Attempt number is derived from prior failures so each retry gets a
|
|
898
|
-
# distinct idempotency key (1-based).
|
|
899
|
-
attempt = len(prior_failures or []) + 1
|
|
900
|
-
payload = _base_payload("rr_propose", rr, source)
|
|
901
|
-
payload["prior_failures"] = list(prior_failures or [])
|
|
902
|
-
data = _post_cloud_inference(
|
|
903
|
-
payload=payload,
|
|
904
|
-
idempotency_key=_idempotency("rr_propose", rr, attempt),
|
|
905
|
-
auth_ctx=auth_ctx,
|
|
906
|
-
execution_mode=execution_mode,
|
|
907
|
-
)
|
|
908
|
-
proposal = data.get("proposal") or {}
|
|
909
|
-
if not isinstance(proposal, dict):
|
|
910
|
-
return {}
|
|
911
|
-
return proposal
|
|
912
|
-
|
|
913
|
-
def gen_test_fn(rr, source: str) -> dict:
|
|
914
|
-
# Server-side characterization-test generation is not part of Phase 1.
|
|
915
|
-
# Returning no test makes the executor fall back to the configured test
|
|
916
|
-
# command for behavioral verification (parity with a local run that has
|
|
917
|
-
# no generated test). This keeps cloud RR execution safe today and lets
|
|
918
|
-
# a future phase add a dedicated server test-gen stage.
|
|
919
|
-
return {}
|
|
920
|
-
|
|
921
|
-
return propose_fn, gen_test_fn, resolved_model
|
|
922
|
-
|
|
923
|
-
|
|
924
775
|
def _load_project() -> tuple[Path, object, object]:
|
|
925
776
|
files = find_project_files()
|
|
926
777
|
if not files:
|
|
@@ -2105,7 +1956,7 @@ def code(
|
|
|
2105
1956
|
help="Skip interactive issue approvals in agentic code mode.",
|
|
2106
1957
|
),
|
|
2107
1958
|
on_rr_failure: str = typer.Option(
|
|
2108
|
-
"
|
|
1959
|
+
"continue",
|
|
2109
1960
|
"--on-rr-failure",
|
|
2110
1961
|
help="RR failure behavior: stop | continue | defer.",
|
|
2111
1962
|
),
|
|
@@ -2292,43 +2143,49 @@ def code(
|
|
|
2292
2143
|
console.print("[green]No issues to refactor.[/green]")
|
|
2293
2144
|
return
|
|
2294
2145
|
|
|
2295
|
-
|
|
2146
|
+
rr_config = (
|
|
2147
|
+
dict(getattr(project_config, "settings", {}) or {})
|
|
2148
|
+
| ({"sandbox": {"enabled": True}} if sandbox_on else {})
|
|
2149
|
+
| ({"_sandbox_context": sandbox_ctx} if sandbox_ctx else {})
|
|
2150
|
+
) or dict(getattr(constitution, "settings", {}) or {})
|
|
2296
2151
|
|
|
2297
|
-
# Cloud and local both drive the SAME per-RR interactive loop (R22 cloud
|
|
2298
|
-
# RR parity). The only difference is where proposals come from: cloud
|
|
2299
|
-
# passes server-side proposal hooks; local uses the local provider and
|
|
2300
|
-
# client-side prompt builders. This guarantees identical UX (proposal
|
|
2301
|
-
# diff + approval prompt), status transitions, and outcomes table.
|
|
2302
|
-
cloud_propose_fn = None
|
|
2303
|
-
cloud_gen_test_fn = None
|
|
2304
|
-
rr_provider = selected_provider
|
|
2305
2152
|
if cloud:
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2153
|
+
# Cloud modes: the proprietary per-RR loop runs SERVER-SIDE (R23; doc
|
|
2154
|
+
# 35). The client is a thin executor speaking the RR session protocol
|
|
2155
|
+
# and uses ONLY client-safe modules — it never imports the engine-only
|
|
2156
|
+
# ``rr_executor``. It reuses the SAME progress logger + approval prompt
|
|
2157
|
+
# as local so the run streams events and asks for approval per RR,
|
|
2158
|
+
# mid-run (identical UX), with tests guarding every change.
|
|
2159
|
+
from refactorai_cli.cloud_rr import run_cloud_refactor_requests
|
|
2160
|
+
|
|
2161
|
+
with console.status("[cyan]code[/cyan] running (cloud)...", spinner="dots") as status:
|
|
2162
|
+
artifact = run_cloud_refactor_requests(
|
|
2163
|
+
project_root=project_root,
|
|
2164
|
+
constitution=constitution,
|
|
2165
|
+
config=rr_config,
|
|
2166
|
+
requests=requests,
|
|
2167
|
+
model_hint=provider,
|
|
2168
|
+
progress_cb=_rr_progress_logger(status=status),
|
|
2169
|
+
approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
|
|
2170
|
+
apply_byok_payload=_apply_byok_payload,
|
|
2171
|
+
)
|
|
2172
|
+
else:
|
|
2173
|
+
# Local mode is unchanged: dispatch to the local executor with the
|
|
2174
|
+
# local provider and no cloud hooks.
|
|
2175
|
+
from refactor_core.execution.rr_executor import run_refactor_requests
|
|
2176
|
+
|
|
2177
|
+
with console.status("[cyan]code[/cyan] running...", spinner="dots") as status:
|
|
2178
|
+
artifact = run_refactor_requests(
|
|
2179
|
+
project_root=project_root,
|
|
2180
|
+
constitution=constitution,
|
|
2181
|
+
provider=selected_provider,
|
|
2182
|
+
requests=requests,
|
|
2183
|
+
config=rr_config,
|
|
2184
|
+
progress_cb=_rr_progress_logger(status=status),
|
|
2185
|
+
approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
|
|
2186
|
+
on_rr_failure=on_rr_failure,
|
|
2187
|
+
equivalence_mode=equivalence,
|
|
2188
|
+
)
|
|
2332
2189
|
except typer.Exit:
|
|
2333
2190
|
# Intentional exits (e.g. gate-blocked apply) keep their own code/message.
|
|
2334
2191
|
raise
|
|
@@ -2370,7 +2227,7 @@ def requests(
|
|
|
2370
2227
|
help="Auto-approve retry-deferred proposals.",
|
|
2371
2228
|
),
|
|
2372
2229
|
on_rr_failure: str = typer.Option(
|
|
2373
|
-
"
|
|
2230
|
+
"continue",
|
|
2374
2231
|
"--on-rr-failure",
|
|
2375
2232
|
help="RR failure behavior for retry: stop | continue | defer.",
|
|
2376
2233
|
),
|
|
@@ -3627,7 +3484,7 @@ def _report_structural_readiness(project_root: Path, config) -> None:
|
|
|
3627
3484
|
if not isinstance(structural, dict):
|
|
3628
3485
|
structural = {}
|
|
3629
3486
|
eq_mode = str(structural.get("equivalence_mode", "strict") or "strict")
|
|
3630
|
-
rr_policy = str(structural.get("on_rr_failure", "
|
|
3487
|
+
rr_policy = str(structural.get("on_rr_failure", "continue") or "continue")
|
|
3631
3488
|
rewrite_mode = str(structural.get("reference_rewrite", "auto") or "auto")
|
|
3632
3489
|
langs = structural.get("languages", [])
|
|
3633
3490
|
rollout = str(structural.get("rollout", "default") or "default")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.0.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.0.4
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
54
54
|
## Install test (local)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
57
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
58
58
|
refactor --version
|
|
59
59
|
```
|
|
60
60
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|