refactorai-cli 0.3.2__tar.gz → 0.3.3__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.
Files changed (34) hide show
  1. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/PKG-INFO +3 -3
  2. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/README.md +1 -1
  3. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/pyproject.toml +2 -2
  4. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/__init__.py +1 -1
  5. refactorai_cli-0.3.3/refactorai_cli/cloud_rr.py +531 -0
  6. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/run_cmds.py +41 -184
  7. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/PKG-INFO +3 -3
  8. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  9. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/requires.txt +1 -1
  10. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/auth.py +0 -0
  11. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/client.py +0 -0
  12. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/__init__.py +0 -0
  13. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/auth_cmds.py +0 -0
  14. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/cloud_cmds.py +0 -0
  15. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/engine_cmds.py +0 -0
  16. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/model_cmds.py +0 -0
  17. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/rules_cmds.py +0 -0
  18. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/runtime_cmds.py +0 -0
  19. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  20. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/commands/setup_cmds.py +0 -0
  21. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/control_plane.py +0 -0
  22. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/credentials.py +0 -0
  23. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/local_constitution.py +0 -0
  24. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/local_engine_runtime.py +0 -0
  25. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/local_paths.py +0 -0
  26. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/main.py +0 -0
  27. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/model_policy.py +0 -0
  28. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/runtime_manager.py +0 -0
  29. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/settings.py +0 -0
  30. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli/setup_flow.py +0 -0
  31. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  32. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/entry_points.txt +0 -0
  33. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/top_level.txt +0 -0
  34. {refactorai_cli-0.3.2 → refactorai_cli-0.3.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.2
3
+ Version: 0.3.3
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.2
11
+ Requires-Dist: refactorai-core>=3.0.3
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.2-py3-none-any.whl
57
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-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.2-py3-none-any.whl
45
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-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.2"
3
+ version = "0.3.3"
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.2",
15
+ "refactorai-core>=3.0.3",
16
16
  ]
17
17
 
18
18
  [project.scripts]
@@ -5,4 +5,4 @@ the shared `refactor_core` pipeline from a project folder while staying
5
5
  authenticated to the hosted platform via a developer key.
6
6
  """
7
7
 
8
- __version__ = "0.3.2"
8
+ __version__ = "0.3.3"
@@ -0,0 +1,531 @@
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 subprocess
27
+ from pathlib import Path
28
+ from typing import Callable
29
+
30
+ import httpx
31
+
32
+ # Client-safe core modules only (see module docstring).
33
+ from refactor_core.apply import (
34
+ apply_changes,
35
+ compute_file_changes,
36
+ revert_from_backup,
37
+ validate_diff,
38
+ )
39
+ from refactor_core.models import GateResult, RunArtifact
40
+ from refactor_core.refactor_requests import (
41
+ APPLIED,
42
+ DEFERRED,
43
+ DENIED,
44
+ FAILED,
45
+ IN_PROGRESS,
46
+ pending_requests,
47
+ rr_test_dir,
48
+ save_request,
49
+ )
50
+ from refactor_core.store import create_run
51
+ from refactor_core.testcmd import resolve_test_command
52
+
53
+ from refactorai_cli.auth import ensure_authenticated
54
+ from refactorai_cli.settings import platform_url
55
+
56
+
57
+ class CloudRRError(RuntimeError):
58
+ """Raised when the cloud RR session cannot proceed."""
59
+
60
+
61
+ def _read_source(project_root: Path, rel: str) -> str:
62
+ path = project_root / rel
63
+ if not path.is_file():
64
+ return ""
65
+ try:
66
+ return path.read_text(encoding="utf-8")
67
+ except OSError:
68
+ return ""
69
+
70
+
71
+ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
72
+ """Run the configured test command on the host; (passed, detail).
73
+
74
+ Cloud runs execute tests client-side because the developer's code + deps live
75
+ here. Behavior preservation (FR3/FR3a) requires a runnable test: if NO test
76
+ command is configured we return ``(False, ...)`` so the server defers the RR
77
+ rather than applying an unverifiable change. This differs from local's
78
+ permissive skip on purpose -- cloud never applies an unverified change.
79
+ """
80
+ if not test_command or not str(test_command).strip():
81
+ return False, "no test command configured; cannot verify behavior preservation"
82
+ command = str(test_command).strip()
83
+ if command == "npm test":
84
+ command = "CI=true npm test -- --watch=false"
85
+ try:
86
+ proc = subprocess.run(
87
+ command,
88
+ shell=True,
89
+ cwd=str(project_root),
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=600,
93
+ )
94
+ except (subprocess.SubprocessError, OSError) as exc:
95
+ return False, f"test command error: {exc}"
96
+ if proc.returncode == 0:
97
+ return True, "tests passed"
98
+ return False, (proc.stdout or proc.stderr or "")[-500:]
99
+
100
+
101
+ class _Session:
102
+ """Transport for the RR session endpoints with status-code error mapping."""
103
+
104
+ def __init__(self, *, auth_ctx, execution_mode: str, constitution_hash: str) -> None:
105
+ self._auth = auth_ctx
106
+ self._mode = execution_mode
107
+ self._consti = constitution_hash
108
+ self._base = f"{platform_url()}/v1/rr"
109
+
110
+ def _idempotency_key(self, path: str, payload: dict) -> str:
111
+ """Deterministic key per (account, mode, consti, rr, step, attempt).
112
+
113
+ Makes a step POST safe to retry without duplicating work or double
114
+ charging (FR6). Distinct RRs/attempts/steps get distinct keys so unrelated
115
+ attempts never collapse into one replayed result.
116
+ """
117
+ seed = "|".join(
118
+ [
119
+ self._auth.account_id,
120
+ self._mode,
121
+ self._consti,
122
+ path,
123
+ str(payload.get("rr_id", "")),
124
+ str(payload.get("step", "")),
125
+ str(payload.get("attempt", "")),
126
+ ]
127
+ )
128
+ return hashlib.sha256(seed.encode("utf-8")).hexdigest()
129
+
130
+ def _post(self, path: str, payload: dict) -> dict:
131
+ try:
132
+ resp = httpx.post(
133
+ f"{self._base}{path}",
134
+ headers={
135
+ "Authorization": f"Bearer {self._auth.key.key}",
136
+ "Content-Type": "application/json",
137
+ "Idempotency-Key": self._idempotency_key(path, payload),
138
+ },
139
+ json=payload,
140
+ timeout=45.0,
141
+ )
142
+ except httpx.HTTPError as exc:
143
+ raise CloudRRError(f"Could not reach cloud RR service: {exc}") from exc
144
+
145
+ if resp.status_code == 401:
146
+ raise CloudRRError("Cloud RR unauthorized (developer key invalid or revoked).")
147
+ if resp.status_code == 402:
148
+ if self._mode == "cloud_byok":
149
+ raise CloudRRError(
150
+ "Cloud BYOK is not enabled for this account (missing `run:byok`). "
151
+ "Contact your admin to enable BYOK cloud execution."
152
+ )
153
+ raise CloudRRError("Cloud RR requires paid tier (`mode: cloud_managed`).")
154
+ if resp.status_code == 404 and self._mode == "cloud_byok":
155
+ raise CloudRRError(
156
+ "BYOK credential not found or not owned by this account. "
157
+ "Check `refactor cloud credentials list` and your `credential_ref`."
158
+ )
159
+ if resp.status_code in (400, 403, 409):
160
+ detail = ""
161
+ try:
162
+ detail = str((resp.json() or {}).get("detail", ""))
163
+ except Exception:
164
+ detail = resp.text[:300]
165
+ raise CloudRRError(detail or f"Cloud RR error ({resp.status_code}).")
166
+ if resp.status_code == 429:
167
+ raise CloudRRError("Cloud RR quota exhausted or rate-limited.")
168
+ if resp.status_code >= 400:
169
+ raise CloudRRError(f"Cloud RR failed ({resp.status_code}): {resp.text[:300]}")
170
+ return resp.json() if resp.content else {}
171
+
172
+ def open(self, payload: dict) -> dict:
173
+ return self._post("/session", payload)
174
+
175
+ def step(self, payload: dict) -> dict:
176
+ return self._post("/session/step", payload)
177
+
178
+
179
+ def run_cloud_refactor_requests(
180
+ *,
181
+ project_root: Path,
182
+ constitution,
183
+ config: dict,
184
+ requests=None,
185
+ model_hint: str | None = None,
186
+ progress_cb: Callable[[dict], None] | None = None,
187
+ approval_cb: Callable[[dict], bool] | None = None,
188
+ apply_byok_payload: Callable[[dict, object], None] | None = None,
189
+ ) -> RunArtifact:
190
+ """Drive pending RRs through the cloud server-run loop and return a summary.
191
+
192
+ ``progress_cb`` / ``approval_cb`` are the SAME callbacks local uses
193
+ (``_rr_progress_logger`` / ``_rr_approval_prompt``), so the run streams the
194
+ same events and prompts per RR mid-run (FR8). ``apply_byok_payload`` is the
195
+ shared BYOK resolver injected by the caller to avoid a circular import.
196
+ """
197
+ project_root = Path(project_root)
198
+ progress_cb = progress_cb or (lambda e: None)
199
+ approval_cb = approval_cb or (lambda r: False)
200
+
201
+ execution_mode = str(constitution.get_setting("mode", "cloud_managed") or "cloud_managed")
202
+ auth_ctx = ensure_authenticated(project_root)
203
+ test_command = resolve_test_command(project_root, config)
204
+
205
+ if requests is None:
206
+ requests = pending_requests(project_root)
207
+
208
+ run_store = create_run(project_root)
209
+ run_id = run_store.run_id
210
+ resolved_model = model_hint or str(
211
+ constitution.get_setting("model_id", "managed-default") or "managed-default"
212
+ )
213
+
214
+ _emit(progress_cb, phase="rr", event="run_start", total_requests=len(requests),
215
+ test_command=test_command or "", execution_env="cloud")
216
+
217
+ outcomes: list[dict] = []
218
+ counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
219
+ notes: list[str] = []
220
+ stopped = False
221
+ stop_reason = ""
222
+ model_id = resolved_model
223
+
224
+ if not requests:
225
+ return _build_artifact(
226
+ run_store, run_id, constitution, model_id, outcomes, counts,
227
+ notes, stopped=False, stop_reason="", test_command=test_command,
228
+ )
229
+
230
+ session = _Session(
231
+ auth_ctx=auth_ctx,
232
+ execution_mode=execution_mode,
233
+ constitution_hash=constitution.content_hash,
234
+ )
235
+
236
+ # --- open the session ---------------------------------------------------
237
+ open_payload = {
238
+ "execution_mode": execution_mode,
239
+ "constitution_hash": constitution.content_hash,
240
+ "constitution_body": getattr(constitution, "body", "") or "",
241
+ "model_hint": resolved_model,
242
+ "rrs": [
243
+ {
244
+ "rr_id": rr.rr_id,
245
+ "rr_context": {
246
+ "location": rr.location,
247
+ "file_path": rr.file_path,
248
+ "category": rr.category,
249
+ "severity": rr.severity,
250
+ "recommendation": rr.recommendation,
251
+ "operation_type": rr.operation_type,
252
+ },
253
+ }
254
+ for rr in requests
255
+ ],
256
+ }
257
+ if execution_mode == "cloud_byok" and apply_byok_payload is not None:
258
+ apply_byok_payload(open_payload, constitution)
259
+
260
+ action = session.open(open_payload)
261
+ token = action.get("session_token", "")
262
+
263
+ # Map rr_id -> RefactorRequest for status updates.
264
+ by_id = {rr.rr_id: rr for rr in requests}
265
+ attempts_by_id: dict[str, int] = {}
266
+ started: set[str] = set()
267
+
268
+ def _start_rr(rr_id: str):
269
+ """Emit rr_start (once) and send the `start` step for ``rr_id``."""
270
+ rr = by_id.get(rr_id)
271
+ if rr is not None and rr_id not in started:
272
+ started.add(rr_id)
273
+ rr.status = IN_PROGRESS
274
+ rr.resolved_in_run = run_id
275
+ save_request(project_root, rr)
276
+ _emit(progress_cb, phase="rr", event="rr_start", rr_id=rr_id, location=rr.location)
277
+ return session.step(_step_payload(token, rr_id, "start", by_id, project_root))
278
+
279
+ # --- drive the loop -----------------------------------------------------
280
+ # Uniform contract: the server returns the next action; the client executes a
281
+ # primitive and posts the result. Terminal per-RR verdicts carry `next_rr_id`
282
+ # (empty => finished). We always send the current file source so the server
283
+ # can (re)build prompts. A generous iteration cap guards against runaway.
284
+ current_rr_id = action.get("rr_id", "")
285
+ if str(action.get("action", "")) == "next_rr" and current_rr_id:
286
+ action = _start_rr(current_rr_id)
287
+ token = action.get("session_token", token)
288
+
289
+ guard = 0
290
+ max_iters = max(50, len(requests) * 25)
291
+ while guard < max_iters:
292
+ guard += 1
293
+ act = str(action.get("action", ""))
294
+ model_id = action.get("model_id", model_id) or model_id
295
+
296
+ if act == "done":
297
+ break
298
+
299
+ if act == "run_baseline_test":
300
+ current_rr_id = action.get("rr_id", "") or current_rr_id
301
+ _emit(progress_cb, phase="rr", event="test_generating", rr_id=current_rr_id)
302
+ _write_generated_test(project_root, current_rr_id, action, by_id)
303
+ passed, detail = _run_test(project_root, test_command)
304
+ _emit(progress_cb, phase="rr", event="baseline_test", rr_id=current_rr_id,
305
+ passed=passed, detail=detail, execution_env="host")
306
+ action = session.step(
307
+ _step_payload(token, current_rr_id, "baseline_result", by_id, project_root,
308
+ baseline_passed=passed, baseline_detail=detail)
309
+ )
310
+ token = action.get("session_token", token)
311
+ continue
312
+
313
+ if act == "show_proposal":
314
+ current_rr_id = action.get("rr_id", "") or current_rr_id
315
+ rr = by_id.get(current_rr_id)
316
+ diff_text = str(action.get("diff_text", "") or "")
317
+ attempt = int(action.get("attempt", 1) or 1)
318
+ attempts_by_id[current_rr_id] = attempt
319
+ ok, why = validate_diff(project_root, diff_text)
320
+ if not ok:
321
+ _emit(progress_cb, phase="rr", event="proposal_invalid", attempt=attempt, reason=why)
322
+ action = session.step(
323
+ _step_payload(token, current_rr_id, "verify_result", by_id, project_root,
324
+ attempt=attempt,
325
+ verify_passed=False, verify_detail=f"invalid diff: {why}")
326
+ )
327
+ token = action.get("session_token", token)
328
+ continue
329
+ _emit(progress_cb, phase="rr", event="approval_requested", rr_id=current_rr_id)
330
+ approved = bool(approval_cb({
331
+ "rr_id": current_rr_id,
332
+ "location": getattr(rr, "location", current_rr_id),
333
+ "attempt": attempt,
334
+ "summary": action.get("summary", ""),
335
+ "risk": action.get("risk", "medium"),
336
+ "diff_text": diff_text,
337
+ }))
338
+ _emit(progress_cb, phase="rr", event="approval_decision", rr_id=current_rr_id, approved=approved)
339
+ _PENDING_DIFF[current_rr_id] = diff_text
340
+ action = session.step(
341
+ _step_payload(token, current_rr_id, "approval", by_id, project_root,
342
+ attempt=attempt, approved=approved)
343
+ )
344
+ token = action.get("session_token", token)
345
+ continue
346
+
347
+ if act in ("apply_result", "deferred"):
348
+ verdict = str(action.get("verdict", "") or "")
349
+ if verdict == "":
350
+ # Server approved: client applies + verifies, then posts result.
351
+ current_rr_id = action.get("rr_id", "") or current_rr_id
352
+ diff_text = _PENDING_DIFF.get(current_rr_id, "")
353
+ passed, detail, files = _apply_and_verify(
354
+ project_root, run_store.run_dir, diff_text, test_command
355
+ )
356
+ if passed:
357
+ _emit(progress_cb, phase="rr", event="rr_applied", rr_id=current_rr_id, files=files)
358
+ else:
359
+ _emit(progress_cb, phase="rr", event="rr_reverted", rr_id=current_rr_id,
360
+ attempt=attempts_by_id.get(current_rr_id, 1), detail=detail)
361
+ action = session.step(
362
+ _step_payload(token, current_rr_id, "verify_result", by_id, project_root,
363
+ attempt=attempts_by_id.get(current_rr_id, 1),
364
+ verify_passed=passed, verify_detail=detail)
365
+ )
366
+ token = action.get("session_token", token)
367
+ continue
368
+ # Terminal verdict for the completed RR.
369
+ completed_id = action.get("rr_id", "") or current_rr_id
370
+ _record_outcome(
371
+ project_root, by_id, completed_id, verdict, action.get("message", ""),
372
+ attempts_by_id.get(completed_id, 0 if verdict == "deferred" else 1),
373
+ outcomes, counts, progress_cb,
374
+ )
375
+ if verdict == "failed":
376
+ stopped = True
377
+ stop_reason = str(action.get("message", "") or "refactor request failed")
378
+ _emit(progress_cb, phase="rr", event="run_stopped", rr_id=completed_id, reason=stop_reason)
379
+ break
380
+ next_id = str(action.get("next_rr_id", "") or "")
381
+ if not next_id:
382
+ break
383
+ current_rr_id = next_id
384
+ action = _start_rr(next_id)
385
+ token = action.get("session_token", token)
386
+ continue
387
+
388
+ notes.append(f"unexpected cloud action '{act}'")
389
+ break
390
+
391
+ return _build_artifact(
392
+ run_store, run_id, constitution, model_id, outcomes, counts,
393
+ notes, stopped=stopped, stop_reason=stop_reason, test_command=test_command,
394
+ )
395
+
396
+
397
+ # Diffs approved but not yet applied, keyed by rr_id (client-local, per run).
398
+ _PENDING_DIFF: dict[str, str] = {}
399
+
400
+
401
+ def _step_payload(token, rr_id, step, by_id, project_root, *, attempt: int = 0, **kw) -> dict:
402
+ rr = by_id.get(rr_id) if rr_id else None
403
+ source = _read_source(project_root, rr.file_path) if rr is not None else ""
404
+ payload = {
405
+ "session_token": token,
406
+ "rr_id": rr_id,
407
+ "step": step,
408
+ "source": source,
409
+ "attempt": int(attempt),
410
+ }
411
+ payload.update(kw)
412
+ return payload
413
+
414
+
415
+ def _write_generated_test(project_root: Path, rr_id: str, action: dict, by_id: dict) -> None:
416
+ content = str(action.get("test_content", "") or "")
417
+ if not content:
418
+ return
419
+ test_path = str(action.get("test_path", "") or "")
420
+ name = Path(test_path).name if test_path else f"{rr_id}.test.txt"
421
+ test_dir = rr_test_dir(project_root, rr_id)
422
+ (test_dir / name).write_text(content, encoding="utf-8")
423
+ rr = by_id.get(rr_id)
424
+ if rr is not None:
425
+ rr.rrtest_path = str(test_dir / name)
426
+ save_request(project_root, rr)
427
+
428
+
429
+ def _apply_and_verify(
430
+ project_root: Path, run_dir: Path, diff_text: str, test_command
431
+ ) -> tuple[bool, str, list[str]]:
432
+ """Apply the diff (with backup), run the test; revert on red.
433
+
434
+ Returns ``(passed, detail, changed_files)``. ``changed_files`` is computed
435
+ from the diff BEFORE applying (the tree is mutated in place afterward, so it
436
+ cannot be recomputed against the modified files).
437
+ """
438
+ if not diff_text:
439
+ return False, "empty diff", []
440
+ try:
441
+ changes = compute_file_changes(project_root, [diff_text])
442
+ except Exception as exc:
443
+ return False, f"could not compute changes: {exc}", []
444
+ if not changes:
445
+ return False, "diff produced no file changes", []
446
+ changed_files = list(changes.keys())
447
+ try:
448
+ apply_changes(project_root, run_dir, changes)
449
+ except Exception as exc:
450
+ return False, f"apply failed: {exc}", changed_files
451
+ passed, detail = _run_test(project_root, test_command)
452
+ if not passed:
453
+ try:
454
+ revert_from_backup(project_root, run_dir)
455
+ except Exception as exc: # keep going; report both
456
+ detail = f"{detail}; revert error: {exc}"
457
+ return passed, detail, changed_files
458
+
459
+
460
+ def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
461
+ outcomes: list, counts: dict, progress_cb) -> None:
462
+ status_map = {
463
+ "applied": APPLIED,
464
+ "denied": DENIED,
465
+ "failed": FAILED,
466
+ "deferred": DEFERRED,
467
+ }
468
+ rr = by_id.get(rr_id)
469
+ if rr is not None:
470
+ rr.status = status_map.get(verdict, rr.status)
471
+ rr.attempt_count = max(int(getattr(rr, "attempt_count", 0) or 0), int(attempts or 0))
472
+ save_request(project_root, rr)
473
+ outcomes.append({
474
+ "location": getattr(rr, "location", rr_id) if rr is not None else rr_id,
475
+ "operation_type": getattr(rr, "operation_type", "content_patch") if rr is not None else "content_patch",
476
+ "outcome": verdict,
477
+ "attempts": int(attempts or 0),
478
+ })
479
+ if verdict in counts:
480
+ counts[verdict] += 1
481
+ ev = {
482
+ "applied": "rr_applied",
483
+ "denied": None,
484
+ "failed": "rr_failed",
485
+ "deferred": "rr_deferred",
486
+ }.get(verdict)
487
+ if ev == "rr_deferred":
488
+ _emit(progress_cb, phase="rr", event="rr_deferred", rr_id=rr_id, reason=message)
489
+ elif ev == "rr_failed":
490
+ _emit(progress_cb, phase="rr", event="rr_failed", rr_id=rr_id, reason=message)
491
+
492
+
493
+ def _build_artifact(run_store, run_id, constitution, model_id, outcomes, counts,
494
+ notes, *, stopped, stop_reason, test_command) -> RunArtifact:
495
+ gate = GateResult(status="blocked" if stopped else "passed")
496
+ applied = counts["applied"]
497
+ summary = (
498
+ f"Cloud refactor requests: applied={applied} denied={counts['denied']} "
499
+ f"failed={counts['failed']} deferred={counts['deferred']}"
500
+ )
501
+ artifact = RunArtifact(
502
+ run_id=run_id,
503
+ findings=[],
504
+ patches=[],
505
+ generated_tests=[],
506
+ gate=gate,
507
+ summary=summary,
508
+ constitution_hash=constitution.content_hash,
509
+ model_id=model_id,
510
+ notes=notes,
511
+ execution_mode="rr_cloud",
512
+ context_stats={"mode": "rr_cloud", "test_command": test_command or ""},
513
+ agent_trace_summary={
514
+ "outcomes": outcomes,
515
+ "applied": counts["applied"],
516
+ "denied": counts["denied"],
517
+ "failed": counts["failed"],
518
+ "deferred": counts["deferred"],
519
+ "stopped": stopped,
520
+ "stop_reason": stop_reason,
521
+ },
522
+ )
523
+ run_store.write_artifact(artifact, target=".", status="completed")
524
+ return artifact
525
+
526
+
527
+ def _emit(progress_cb, **event) -> None:
528
+ try:
529
+ progress_cb(event)
530
+ except Exception:
531
+ 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:
@@ -2292,43 +2143,49 @@ def code(
2292
2143
  console.print("[green]No issues to refactor.[/green]")
2293
2144
  return
2294
2145
 
2295
- from refactor_core.execution.rr_executor import run_refactor_requests
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
- cloud_propose_fn, cloud_gen_test_fn, cloud_model_id = _cloud_rr_hooks(
2307
- project_root=project_root,
2308
- constitution=constitution,
2309
- model_hint=provider,
2310
- )
2311
- rr_provider = _CloudRRProvider(model_id=cloud_model_id)
2312
-
2313
- with console.status("[cyan]code[/cyan] running...", spinner="dots") as status:
2314
- rr_config = (
2315
- dict(getattr(project_config, "settings", {}) or {})
2316
- | ({"sandbox": {"enabled": True}} if sandbox_on else {})
2317
- | ({"_sandbox_context": sandbox_ctx} if sandbox_ctx else {})
2318
- ) or dict(getattr(constitution, "settings", {}) or {})
2319
- artifact = run_refactor_requests(
2320
- project_root=project_root,
2321
- constitution=constitution,
2322
- provider=rr_provider,
2323
- requests=requests,
2324
- config=rr_config,
2325
- progress_cb=_rr_progress_logger(status=status),
2326
- approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
2327
- on_rr_failure=on_rr_failure,
2328
- equivalence_mode=equivalence,
2329
- propose_fn=cloud_propose_fn,
2330
- gen_test_fn=cloud_gen_test_fn,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.2
3
+ Version: 0.3.3
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.2
11
+ Requires-Dist: refactorai-core>=3.0.3
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.2-py3-none-any.whl
57
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-py3-none-any.whl
58
58
  refactor --version
59
59
  ```
60
60
 
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  refactorai_cli/__init__.py
4
4
  refactorai_cli/auth.py
5
5
  refactorai_cli/client.py
6
+ refactorai_cli/cloud_rr.py
6
7
  refactorai_cli/control_plane.py
7
8
  refactorai_cli/credentials.py
8
9
  refactorai_cli/local_constitution.py
@@ -2,4 +2,4 @@ typer>=0.12.0
2
2
  httpx>=0.27.0
3
3
  rich>=13.7.0
4
4
  PyYAML>=6.0.1
5
- refactorai-core>=3.0.2
5
+ refactorai-core>=3.0.3
File without changes