refactorai-cli 0.3.1__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.1 → refactorai_cli-0.3.3}/PKG-INFO +3 -3
  2. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/README.md +1 -1
  3. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/pyproject.toml +2 -2
  4. {refactorai_cli-0.3.1 → 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.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/run_cmds.py +71 -94
  7. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/PKG-INFO +3 -3
  8. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  9. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/requires.txt +1 -1
  10. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/auth.py +0 -0
  11. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/client.py +0 -0
  12. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/__init__.py +0 -0
  13. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/auth_cmds.py +0 -0
  14. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/cloud_cmds.py +0 -0
  15. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/engine_cmds.py +0 -0
  16. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/model_cmds.py +0 -0
  17. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/rules_cmds.py +0 -0
  18. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/runtime_cmds.py +0 -0
  19. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  20. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/commands/setup_cmds.py +0 -0
  21. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/control_plane.py +0 -0
  22. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/credentials.py +0 -0
  23. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/local_constitution.py +0 -0
  24. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/local_engine_runtime.py +0 -0
  25. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/local_paths.py +0 -0
  26. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/main.py +0 -0
  27. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/model_policy.py +0 -0
  28. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/runtime_manager.py +0 -0
  29. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/settings.py +0 -0
  30. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli/setup_flow.py +0 -0
  31. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  32. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/entry_points.txt +0 -0
  33. {refactorai_cli-0.3.1 → refactorai_cli-0.3.3}/refactorai_cli.egg-info/top_level.txt +0 -0
  34. {refactorai_cli-0.3.1 → 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.1
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.1
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.1-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.1-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.1"
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.1",
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.1"
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
@@ -665,29 +665,7 @@ def _cloud_inference(
665
665
  }
666
666
 
667
667
  if execution_mode == "cloud_byok":
668
- provider_name = str(constitution.get_setting("provider", "") or "").strip()
669
- provider_key = str(constitution.get_setting("provider_key", "") or "").strip()
670
- credential_ref = str(constitution.get_setting("credential_ref", "") or "").strip()
671
-
672
- # Env-key-first BYOK path: when provider_key is configured (typically via
673
- # ${ENV_VAR} interpolation), prefer it over credential_ref.
674
- if provider_key:
675
- if not provider_name or provider_name.lower() == "cloud":
676
- raise RuntimeError(
677
- "cloud_byok with provider_key requires an explicit provider "
678
- "(e.g. provider: openai)."
679
- )
680
- payload["provider_key"] = provider_key
681
- payload["provider"] = provider_name
682
- elif credential_ref:
683
- payload["credential_ref"] = credential_ref
684
- if provider_name and provider_name.lower() != "cloud":
685
- payload["provider"] = provider_name
686
- else:
687
- raise RuntimeError(
688
- "cloud_byok requires either `provider_key` (preferred env-key path) "
689
- "or `credential_ref` (platform-stored credential path) in refactor.config."
690
- )
668
+ _apply_byok_payload(payload, constitution)
691
669
 
692
670
  idempotency_key = _stable_idempotency_key(
693
671
  account_id=auth_ctx.account_id,
@@ -764,6 +742,36 @@ def _cloud_inference(
764
742
  )
765
743
 
766
744
 
745
+ def _apply_byok_payload(payload: dict, constitution) -> None:
746
+ """Populate BYOK provider fields on a cloud inference ``payload`` in place.
747
+
748
+ Env-key-first: prefer ``provider_key`` (typically ``${ENV_VAR}``) over a
749
+ platform-stored ``credential_ref``. Shared by the review/refactor path and
750
+ the per-RR proposal path so BYOK resolution stays consistent.
751
+ """
752
+ provider_name = str(constitution.get_setting("provider", "") or "").strip()
753
+ provider_key = str(constitution.get_setting("provider_key", "") or "").strip()
754
+ credential_ref = str(constitution.get_setting("credential_ref", "") or "").strip()
755
+
756
+ if provider_key:
757
+ if not provider_name or provider_name.lower() == "cloud":
758
+ raise RuntimeError(
759
+ "cloud_byok with provider_key requires an explicit provider "
760
+ "(e.g. provider: openai)."
761
+ )
762
+ payload["provider_key"] = provider_key
763
+ payload["provider"] = provider_name
764
+ elif credential_ref:
765
+ payload["credential_ref"] = credential_ref
766
+ if provider_name and provider_name.lower() != "cloud":
767
+ payload["provider"] = provider_name
768
+ else:
769
+ raise RuntimeError(
770
+ "cloud_byok requires either `provider_key` (preferred env-key path) "
771
+ "or `credential_ref` (platform-stored credential path) in refactor.config."
772
+ )
773
+
774
+
767
775
  def _load_project() -> tuple[Path, object, object]:
768
776
  files = find_project_files()
769
777
  if not files:
@@ -1802,7 +1810,8 @@ def review(
1802
1810
  if _sandbox_enabled(project_config):
1803
1811
  if cloud:
1804
1812
  console.print(
1805
- "[dim]review: sandbox.enabled is ignored in cloud_managed mode.[/dim]"
1813
+ f"[dim]review: sandbox.enabled is ignored in "
1814
+ f"{_cloud_execution_mode(constitution)} mode.[/dim]"
1806
1815
  )
1807
1816
  else:
1808
1817
  sandbox_ctx = _ensure_sandbox_ready(
@@ -2029,7 +2038,8 @@ def code(
2029
2038
  if _sandbox_enabled(project_config):
2030
2039
  if cloud:
2031
2040
  console.print(
2032
- "[dim]code: sandbox.enabled is ignored in cloud_managed mode.[/dim]"
2041
+ f"[dim]code: sandbox.enabled is ignored in "
2042
+ f"{_cloud_execution_mode(constitution)} mode.[/dim]"
2033
2043
  )
2034
2044
  else:
2035
2045
  sandbox_ctx = _ensure_sandbox_ready(
@@ -2133,82 +2143,49 @@ def code(
2133
2143
  console.print("[green]No issues to refactor.[/green]")
2134
2144
  return
2135
2145
 
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 {})
2151
+
2136
2152
  if cloud:
2137
- # Cloud modes generate patches via the platform inference API rather
2138
- # than the local RR interactive loop. Reuse prior review findings as
2139
- # prompt context; fall back to the latest stored review findings when
2140
- # requests were carried over from an earlier run.
2141
- if not cloud_review_findings:
2142
- existing_dir = head_run_dir(project_root)
2143
- if existing_dir is not None:
2144
- try:
2145
- cloud_review_findings = [
2146
- Finding.model_validate(item) for item in (read_findings(existing_dir) or [])
2147
- ]
2148
- except Exception:
2149
- cloud_review_findings = []
2150
- with console.status("[cyan]code[/cyan] generating patches (cloud)...", spinner="dots"):
2151
- artifact = _cloud_inference(
2152
- stage="refactor",
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(
2153
2163
  project_root=project_root,
2154
2164
  constitution=constitution,
2155
- target=target,
2165
+ config=rr_config,
2166
+ requests=requests,
2156
2167
  model_hint=provider,
2157
- findings=cloud_review_findings,
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,
2158
2171
  )
2159
- cloud_store = create_run(project_root, run_id=artifact.run_id)
2160
- cloud_store.write_artifact(artifact, target=target, status="completed")
2161
-
2162
- _print_rr_summary(artifact)
2163
- _print_patches(artifact.patches)
2164
- gate_color = "green" if artifact.gate.status == "passed" else "red"
2165
- console.print(
2166
- f"\n[bold]result[/bold]: [{gate_color}]{artifact.gate.status}[/{gate_color}] "
2167
- f"[dim]model={artifact.model_id} mode={_cloud_execution_mode(constitution)} "
2168
- f"run={artifact.run_id}[/dim]"
2169
- )
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
2170
2176
 
2171
- if apply:
2172
- gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
2173
- diffs = [p.diff_text for p in (artifact.patches or [])]
2174
- if not diffs:
2175
- console.print("[dim]No patches to apply.[/dim]")
2176
- else:
2177
- _apply_diffs(
2178
- project_root,
2179
- cloud_store.run_dir,
2180
- diffs,
2181
- artifact.gate.status,
2182
- gate_mode,
2183
- force,
2184
- project_config,
2185
- )
2186
- elif artifact.patches:
2187
- console.print(
2188
- "[dim]Review patches with `refactor diff`, then apply with "
2189
- "`refactor code --apply` or `refactor apply`.[/dim]"
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,
2190
2188
  )
2191
- return
2192
-
2193
- from refactor_core.execution.rr_executor import run_refactor_requests
2194
-
2195
- with console.status("[cyan]code[/cyan] running...", spinner="dots") as status:
2196
- rr_config = (
2197
- dict(getattr(project_config, "settings", {}) or {})
2198
- | ({"sandbox": {"enabled": True}} if sandbox_on else {})
2199
- | ({"_sandbox_context": sandbox_ctx} if sandbox_ctx else {})
2200
- ) or dict(getattr(constitution, "settings", {}) or {})
2201
- artifact = run_refactor_requests(
2202
- project_root=project_root,
2203
- constitution=constitution,
2204
- provider=selected_provider,
2205
- requests=requests,
2206
- config=rr_config,
2207
- progress_cb=_rr_progress_logger(status=status),
2208
- approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
2209
- on_rr_failure=on_rr_failure,
2210
- equivalence_mode=equivalence,
2211
- )
2212
2189
  except typer.Exit:
2213
2190
  # Intentional exits (e.g. gate-blocked apply) keep their own code/message.
2214
2191
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.1
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.1
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.1-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.1
5
+ refactorai-core>=3.0.3
File without changes