refactorai-cli 0.5.3__tar.gz → 0.5.6__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.5.3 → refactorai_cli-0.5.6}/PKG-INFO +1 -1
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/pyproject.toml +1 -1
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/cloud_rr.py +256 -58
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/watch_cmds.py +70 -3
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commit_telemetry.py +83 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/watch_state.py +15 -1
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/PKG-INFO +1 -1
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/README.md +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/hook_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/pre_push_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/run_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/toolchains_cmds.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commit_queue.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/git_scope.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/main.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/pre_push_gate.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/refactor_branch_store.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/watch_supervisor.py +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/SOURCES.txt +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/requires.txt +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/setup.cfg +0 -0
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
from collections import deque
|
|
26
26
|
import contextlib
|
|
27
27
|
import hashlib
|
|
28
|
+
import os
|
|
28
29
|
import re
|
|
29
30
|
import shlex
|
|
30
31
|
import shutil
|
|
@@ -186,15 +187,55 @@ def _run_command_with_progress(
|
|
|
186
187
|
return False, ("\n".join(tail))[-700:]
|
|
187
188
|
|
|
188
189
|
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
# Splits a composite shell command into its simple segments. We intentionally
|
|
191
|
+
# keep this conservative (no support for redirections/heredocs) — those don't
|
|
192
|
+
# appear in generated/detected test commands.
|
|
193
|
+
_COMPOSITE_SPLIT_RE = re.compile(r"&&|\|\||;|\|")
|
|
194
|
+
|
|
195
|
+
# Interpreters that are functionally interchangeable for our generated test
|
|
196
|
+
# commands. A host with only ``python3`` must still satisfy a ``python`` command
|
|
197
|
+
# (and vice versa) instead of triggering a needless managed-runtime download.
|
|
198
|
+
_INTERPRETER_ALIASES = {"python": "python3", "python3": "python"}
|
|
199
|
+
|
|
191
200
|
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
def _command_segments(command: str | None) -> list[str]:
|
|
202
|
+
"""Split a (possibly composite) shell command into simple segments.
|
|
203
|
+
|
|
204
|
+
Splits on ``&&``/``||``/``;``/pipes, strips subshell parens, and drops pure
|
|
205
|
+
``cd <dir>`` navigation segments so each returned segment begins with the
|
|
206
|
+
real executable it invokes (e.g. ``cd client && npm test`` -> ``["npm
|
|
207
|
+
test"]``). Used so preflight/provisioning see the *actual* runner binary of a
|
|
208
|
+
monorepo command instead of treating the whole thing as opaque.
|
|
209
|
+
"""
|
|
210
|
+
if not command or not str(command).strip():
|
|
211
|
+
return []
|
|
212
|
+
segments: list[str] = []
|
|
213
|
+
for part in _COMPOSITE_SPLIT_RE.split(command):
|
|
214
|
+
seg = part.strip().strip("()").strip()
|
|
215
|
+
if not seg:
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
tokens = shlex.split(seg, posix=True)
|
|
219
|
+
except ValueError:
|
|
220
|
+
segments.append(seg)
|
|
221
|
+
continue
|
|
222
|
+
# Drop a bare `cd <dir>` navigation segment; it carries no binary.
|
|
223
|
+
stripped = list(tokens)
|
|
224
|
+
while stripped and _ENV_ASSIGN_RE.match(stripped[0]):
|
|
225
|
+
stripped.pop(0)
|
|
226
|
+
if stripped and stripped[0] == "cd":
|
|
227
|
+
continue
|
|
228
|
+
segments.append(seg)
|
|
229
|
+
return segments
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _simple_leading_binary(command: str | None) -> str | None:
|
|
233
|
+
"""Leading executable of a *simple* (non-composite) command, or None.
|
|
234
|
+
|
|
235
|
+
Skips leading ``VAR=value`` env assignments. Does not attempt to parse
|
|
236
|
+
composite commands — callers use :func:`_required_binaries` for those.
|
|
194
237
|
"""
|
|
195
238
|
if not command or not str(command).strip():
|
|
196
|
-
return None
|
|
197
|
-
if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
|
|
198
239
|
return None
|
|
199
240
|
try:
|
|
200
241
|
tokens = shlex.split(command, posix=True)
|
|
@@ -207,29 +248,101 @@ def _leading_binary(command: str | None) -> str | None:
|
|
|
207
248
|
return tokens[0]
|
|
208
249
|
|
|
209
250
|
|
|
251
|
+
def _required_binaries(command: str | None) -> list[str]:
|
|
252
|
+
"""Distinct leading binaries required by a (possibly composite) command.
|
|
253
|
+
|
|
254
|
+
A monorepo runner like ``cd client && npm test && cd server && pytest`` needs
|
|
255
|
+
several runtimes; returning each lets preflight validate — and provisioning
|
|
256
|
+
cover — all of them instead of trusting the command blindly. Order preserved,
|
|
257
|
+
duplicates removed, ``cd`` ignored.
|
|
258
|
+
"""
|
|
259
|
+
binaries: list[str] = []
|
|
260
|
+
for seg in _command_segments(command):
|
|
261
|
+
binary = _simple_leading_binary(seg)
|
|
262
|
+
if binary and binary != "cd" and binary not in binaries:
|
|
263
|
+
binaries.append(binary)
|
|
264
|
+
return binaries
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _leading_binary(command: str | None) -> str | None:
|
|
268
|
+
"""Primary runner binary of a command (first of :func:`_required_binaries`).
|
|
269
|
+
|
|
270
|
+
Kept for callers that only need a single representative binary; unlike the
|
|
271
|
+
old implementation this is composite-aware (``cd x && npm test`` -> ``npm``).
|
|
272
|
+
"""
|
|
273
|
+
required = _required_binaries(command)
|
|
274
|
+
return required[0] if required else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _binary_on_path(binary: str, *, env: dict | None = None) -> bool:
|
|
278
|
+
"""True when ``binary`` (or an interchangeable interpreter alias) is runnable.
|
|
279
|
+
|
|
280
|
+
An absolute/relative path is checked on disk; a bare name is looked up on
|
|
281
|
+
``PATH`` (optionally the provisioned ``env``'s PATH). ``python``/``python3``
|
|
282
|
+
are treated as interchangeable so a ``python3``-only host satisfies a
|
|
283
|
+
``python`` command without provisioning a managed runtime.
|
|
284
|
+
"""
|
|
285
|
+
if not binary:
|
|
286
|
+
return False
|
|
287
|
+
if "/" in binary:
|
|
288
|
+
return Path(binary).expanduser().exists()
|
|
289
|
+
search_path = (env or {}).get("PATH") if env else None
|
|
290
|
+
if shutil.which(binary, path=search_path) is not None:
|
|
291
|
+
return True
|
|
292
|
+
alias = _INTERPRETER_ALIASES.get(binary)
|
|
293
|
+
return bool(alias and shutil.which(alias, path=search_path) is not None)
|
|
294
|
+
|
|
295
|
+
|
|
210
296
|
def _client_preflight_command(
|
|
211
297
|
project_root: Path, command: str | None, *, env: dict | None = None
|
|
212
298
|
) -> bool:
|
|
213
|
-
"""Return True when ``command``
|
|
214
|
-
|
|
215
|
-
Composite/shell commands
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
299
|
+
"""Return True when *every* binary ``command`` needs is runnable on this host.
|
|
300
|
+
|
|
301
|
+
Composite/shell commands run under ``shell=True``, but we no longer trust
|
|
302
|
+
them blindly: we extract each segment's leading binary (ignoring ``cd``) and
|
|
303
|
+
require all of them to be present on PATH. This is what stops an un-runnable
|
|
304
|
+
``cd client && python ...`` from reaching the baseline and failing at runtime
|
|
305
|
+
with ``python: not found``. ``env`` optionally supplies the PATH to check
|
|
306
|
+
against (e.g. after a managed toolchain is provisioned).
|
|
221
307
|
"""
|
|
222
308
|
if not command or not str(command).strip():
|
|
223
309
|
return False
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
binary = _leading_binary(command)
|
|
227
|
-
if not binary:
|
|
310
|
+
required = _required_binaries(command)
|
|
311
|
+
if not required:
|
|
228
312
|
return False
|
|
229
|
-
|
|
230
|
-
|
|
313
|
+
return all(_binary_on_path(binary, env=env) for binary in required)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _missing_binaries(command: str | None, *, env: dict | None = None) -> list[str]:
|
|
317
|
+
"""Required binaries of ``command`` that are NOT runnable on this host."""
|
|
318
|
+
return [b for b in _required_binaries(command) if not _binary_on_path(b, env=env)]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _normalize_command_interpreters(command: str | None, *, env: dict | None = None) -> str | None:
|
|
322
|
+
"""Rewrite a bare ``python`` command token to ``python3`` when needed.
|
|
323
|
+
|
|
324
|
+
On ``python3``-only hosts (common on Debian/Ubuntu) a generated
|
|
325
|
+
``python -m unittest ...`` command fails with ``python: not found`` even
|
|
326
|
+
though a perfectly good interpreter exists. When ``python`` is absent but
|
|
327
|
+
``python3`` is present we rewrite the *command token* (word-boundary, never
|
|
328
|
+
touching ``python3`` or a ``/usr/bin/python`` path) so the baseline runs.
|
|
329
|
+
No-op when ``python`` is available (incl. via a provisioned toolchain).
|
|
330
|
+
"""
|
|
331
|
+
if not command or not str(command).strip():
|
|
332
|
+
return command
|
|
231
333
|
search_path = (env or {}).get("PATH") if env else None
|
|
232
|
-
|
|
334
|
+
for src, dst in (("python", "python3"), ("python3", "python")):
|
|
335
|
+
if src not in command:
|
|
336
|
+
continue
|
|
337
|
+
if shutil.which(src, path=search_path) is not None:
|
|
338
|
+
continue
|
|
339
|
+
if shutil.which(dst, path=search_path) is None:
|
|
340
|
+
continue
|
|
341
|
+
# Replace ``src`` only when it is a standalone command token: not part of
|
|
342
|
+
# a longer word (python3/pythonic) and not a path segment (/usr/bin/...).
|
|
343
|
+
pattern = re.compile(rf"(?<![\w./-]){re.escape(src)}(?![\w])")
|
|
344
|
+
command = pattern.sub(dst, command)
|
|
345
|
+
return command
|
|
233
346
|
|
|
234
347
|
|
|
235
348
|
def _managed_toolchain_enabled(config: dict) -> bool:
|
|
@@ -245,18 +358,47 @@ def _managed_toolchain_enabled(config: dict) -> bool:
|
|
|
245
358
|
|
|
246
359
|
|
|
247
360
|
def _detected_is_provisionable(command: str | None) -> bool:
|
|
248
|
-
"""True when ``command``
|
|
361
|
+
"""True when every missing binary of ``command`` maps to a managed runtime.
|
|
249
362
|
|
|
250
|
-
Lets the resolver keep a detected command (e.g. ``npm test``)
|
|
251
|
-
is missing from the host PATH but which the managed toolchain
|
|
252
|
-
instead of discarding it and deferring the run.
|
|
363
|
+
Lets the resolver keep a detected command (e.g. ``cd client && npm test``)
|
|
364
|
+
whose runtime is missing from the host PATH but which the managed toolchain
|
|
365
|
+
can download, instead of discarding it and deferring the run. Requires *all*
|
|
366
|
+
missing binaries to be provisionable so a command that mixes a managed
|
|
367
|
+
runtime with an un-provisionable Tier-3 binary still falls through.
|
|
253
368
|
"""
|
|
254
369
|
from refactor_core.host_toolchain import binary_to_runtime
|
|
255
370
|
|
|
256
|
-
|
|
257
|
-
if not
|
|
371
|
+
missing = _missing_binaries(command)
|
|
372
|
+
if not missing:
|
|
258
373
|
return False
|
|
259
|
-
return binary_to_runtime(binary) is not None
|
|
374
|
+
return all(binary_to_runtime(binary) is not None for binary in missing)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _merge_toolchain_env(base: dict | None, new: dict) -> dict:
|
|
378
|
+
"""Merge a freshly provisioned toolchain env into an accumulated one.
|
|
379
|
+
|
|
380
|
+
Composite commands can need several managed runtimes (e.g. ``node`` +
|
|
381
|
+
``python``). Each ``provision_for_command`` call returns a full env built
|
|
382
|
+
from ``os.environ`` with its bin dir prepended to ``PATH``; naively using the
|
|
383
|
+
last one would drop earlier runtimes. This prepends the new toolchain's PATH
|
|
384
|
+
entries ahead of the accumulated PATH and carries over runtime-specific vars
|
|
385
|
+
(e.g. ``GOROOT``). ``base`` None => first provisioned runtime.
|
|
386
|
+
"""
|
|
387
|
+
if base is None:
|
|
388
|
+
return dict(new)
|
|
389
|
+
merged = dict(base)
|
|
390
|
+
new_path = new.get("PATH", "")
|
|
391
|
+
base_path = base.get("PATH", "")
|
|
392
|
+
if new_path and new_path != base_path:
|
|
393
|
+
env_tail = os.environ.get("PATH", "")
|
|
394
|
+
prefix = new_path
|
|
395
|
+
if env_tail and new_path.endswith(env_tail):
|
|
396
|
+
prefix = new_path[: -len(env_tail)].rstrip(os.pathsep)
|
|
397
|
+
merged["PATH"] = f"{prefix}{os.pathsep}{base_path}" if prefix else base_path
|
|
398
|
+
for key, value in new.items():
|
|
399
|
+
if key != "PATH" and key not in os.environ:
|
|
400
|
+
merged[key] = value
|
|
401
|
+
return merged
|
|
260
402
|
|
|
261
403
|
|
|
262
404
|
def _provision_toolchain(
|
|
@@ -267,45 +409,56 @@ def _provision_toolchain(
|
|
|
267
409
|
consent_cb: Callable[[dict], bool] | None,
|
|
268
410
|
progress_cb: Callable[[dict], None] | None,
|
|
269
411
|
) -> tuple[dict | None, str]:
|
|
270
|
-
"""Provision
|
|
271
|
-
|
|
272
|
-
Returns ``(env, note)``. ``env`` (with the toolchain on ``PATH``) is
|
|
273
|
-
only when
|
|
274
|
-
|
|
275
|
-
|
|
412
|
+
"""Provision managed runtimes for every missing binary in ``test_command``.
|
|
413
|
+
|
|
414
|
+
Returns ``(env, note)``. ``env`` (with the toolchain(s) on ``PATH``) is
|
|
415
|
+
returned only when at least one runtime was actually provisioned; otherwise
|
|
416
|
+
``None`` (the caller runs with the inherited environment). Composite commands
|
|
417
|
+
are handled per segment so a monorepo runner like ``cd client && npm test &&
|
|
418
|
+
cd server && pytest`` provisions BOTH node and python. No-ops when the command
|
|
419
|
+
is already runnable, has no managed runtime, or provisioning is disabled.
|
|
276
420
|
"""
|
|
277
421
|
if not test_command or not _managed_toolchain_enabled(config):
|
|
278
422
|
return None, ""
|
|
279
|
-
|
|
423
|
+
missing = _missing_binaries(test_command)
|
|
424
|
+
if not missing:
|
|
280
425
|
return None, "" # already runnable on the host
|
|
281
|
-
binary = _leading_binary(test_command)
|
|
282
|
-
if not binary:
|
|
283
|
-
return None, ""
|
|
284
|
-
from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
|
|
285
|
-
|
|
286
|
-
runtime = binary_to_runtime(binary)
|
|
287
|
-
if runtime is None:
|
|
288
|
-
# No self-provisionable runtime -> Tier 3 guided-install guidance so the
|
|
289
|
-
# developer gets an actionable message instead of a silent defer.
|
|
290
|
-
guidance = guidance_for_binary(binary)
|
|
291
|
-
if guidance:
|
|
292
|
-
return None, f"'{binary}' is not installed and cannot be auto-provisioned. {guidance}"
|
|
293
|
-
return None, ""
|
|
294
426
|
|
|
295
|
-
|
|
296
|
-
approved = bool(consent_cb({"binary": binary, "runtime": runtime}))
|
|
297
|
-
if not approved:
|
|
298
|
-
return None, f"managed toolchain for '{binary}' declined; skipping provisioning."
|
|
427
|
+
from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
|
|
299
428
|
|
|
300
429
|
def _on_progress(msg: str) -> None:
|
|
301
430
|
_emit(progress_cb, phase="rr", event="toolchain_provisioning", detail=msg)
|
|
302
431
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
432
|
+
merged_env: dict | None = None
|
|
433
|
+
notes: list[str] = []
|
|
434
|
+
for binary in missing:
|
|
435
|
+
# A single provisioned runtime can satisfy several missing binaries
|
|
436
|
+
# (e.g. python also provides pip), so re-check against progress so far.
|
|
437
|
+
if merged_env is not None and _binary_on_path(binary, env=merged_env):
|
|
438
|
+
continue
|
|
439
|
+
runtime = binary_to_runtime(binary)
|
|
440
|
+
if runtime is None:
|
|
441
|
+
# No self-provisionable runtime -> Tier 3 guided-install guidance so
|
|
442
|
+
# the developer gets an actionable message instead of a silent defer.
|
|
443
|
+
guidance = guidance_for_binary(binary)
|
|
444
|
+
notes.append(
|
|
445
|
+
f"'{binary}' is not installed and cannot be auto-provisioned."
|
|
446
|
+
+ (f" {guidance}" if guidance else "")
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
if consent_cb is not None and not bool(consent_cb({"binary": binary, "runtime": runtime})):
|
|
450
|
+
notes.append(f"managed toolchain for '{binary}' declined; skipping provisioning.")
|
|
451
|
+
continue
|
|
452
|
+
env, note = provision_for_command(binary, on_progress=_on_progress)
|
|
453
|
+
if env is None:
|
|
454
|
+
if note:
|
|
455
|
+
notes.append(note)
|
|
456
|
+
continue
|
|
457
|
+
_emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
|
|
458
|
+
merged_env = _merge_toolchain_env(merged_env, env)
|
|
459
|
+
_restore_dependencies(project_root, runtime, merged_env, progress_cb)
|
|
460
|
+
|
|
461
|
+
return merged_env, " ".join(n for n in notes if n).strip()
|
|
309
462
|
|
|
310
463
|
|
|
311
464
|
def _restore_dependencies(
|
|
@@ -713,6 +866,22 @@ def run_cloud_refactor_requests(
|
|
|
713
866
|
progress_cb=progress_cb,
|
|
714
867
|
)
|
|
715
868
|
|
|
869
|
+
# python3-only hosts: rewrite a bare `python` test command to `python3` when
|
|
870
|
+
# we did NOT provision a managed runtime (managed provisioning already
|
|
871
|
+
# supplies a `python`). Prevents a `python: not found` baseline failure and
|
|
872
|
+
# the endless-defer loop it caused.
|
|
873
|
+
if toolchain_env is None and test_command:
|
|
874
|
+
normalized = _normalize_command_interpreters(test_command)
|
|
875
|
+
if normalized and normalized != test_command:
|
|
876
|
+
_emit(
|
|
877
|
+
progress_cb,
|
|
878
|
+
phase="rr",
|
|
879
|
+
event="testcmd_normalized",
|
|
880
|
+
test_command=normalized,
|
|
881
|
+
previous=test_command,
|
|
882
|
+
)
|
|
883
|
+
test_command = normalized
|
|
884
|
+
|
|
716
885
|
run_store = create_run(project_root)
|
|
717
886
|
run_id = run_store.run_id
|
|
718
887
|
resolved_model = model_hint or str(
|
|
@@ -736,6 +905,35 @@ def run_cloud_refactor_requests(
|
|
|
736
905
|
notes, stopped=False, stop_reason="", test_command=test_command,
|
|
737
906
|
)
|
|
738
907
|
|
|
908
|
+
# Fail-fast: if a test command is configured but STILL not runnable after
|
|
909
|
+
# provisioning + interpreter normalization, stop up front with an actionable
|
|
910
|
+
# message instead of opening a session and deferring every RR behind an
|
|
911
|
+
# opaque `python: not found`. (A run with NO test command keeps prior
|
|
912
|
+
# behavior: the server decides per-RR whether it can proceed.)
|
|
913
|
+
if test_command and not _client_preflight_command(
|
|
914
|
+
project_root, test_command, env=toolchain_env
|
|
915
|
+
):
|
|
916
|
+
missing = _missing_binaries(test_command, env=toolchain_env)
|
|
917
|
+
reason = (
|
|
918
|
+
"baseline test command is not runnable on this host "
|
|
919
|
+
f"(missing: {', '.join(missing) or 'required toolchain'}). "
|
|
920
|
+
"Install the toolchain or enable managed provisioning, then re-run. "
|
|
921
|
+
"No changes were attempted."
|
|
922
|
+
)
|
|
923
|
+
_emit(
|
|
924
|
+
progress_cb,
|
|
925
|
+
phase="rr",
|
|
926
|
+
event="preflight_failed",
|
|
927
|
+
reason=reason,
|
|
928
|
+
test_command=test_command,
|
|
929
|
+
missing=missing,
|
|
930
|
+
)
|
|
931
|
+
notes.append(reason)
|
|
932
|
+
return _build_artifact(
|
|
933
|
+
run_store, run_id, constitution, model_id, outcomes, counts,
|
|
934
|
+
notes, stopped=True, stop_reason=reason, test_command=test_command,
|
|
935
|
+
)
|
|
936
|
+
|
|
739
937
|
session = _Session(
|
|
740
938
|
auth_ctx=auth_ctx,
|
|
741
939
|
execution_mode=execution_mode,
|
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
import select
|
|
19
19
|
import subprocess
|
|
20
20
|
import sys
|
|
21
|
+
import threading
|
|
21
22
|
import time
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Callable
|
|
@@ -330,6 +331,21 @@ def _report_node(project_root: Path, node) -> None:
|
|
|
330
331
|
pass
|
|
331
332
|
|
|
332
333
|
|
|
334
|
+
def _report_refactor_requests(project_root: Path) -> None:
|
|
335
|
+
"""Best-effort sync of the local RR store to the platform Inbox (docs/44).
|
|
336
|
+
|
|
337
|
+
Fail-open like commit telemetry: no-ops unless the repo is bound to a project
|
|
338
|
+
and never raises. Called after any pass/action that can change RR state so the
|
|
339
|
+
cloud Inbox mirrors the developer's local requests.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
from refactorai_cli import commit_telemetry
|
|
343
|
+
|
|
344
|
+
commit_telemetry.post_refactor_requests(project_root)
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
|
|
333
349
|
def process_pending(
|
|
334
350
|
project_root: Path,
|
|
335
351
|
state: WatchState,
|
|
@@ -382,6 +398,8 @@ def process_pending(
|
|
|
382
398
|
refresh_finalize(project_root, state)
|
|
383
399
|
if on_update:
|
|
384
400
|
on_update(state)
|
|
401
|
+
if reviewed:
|
|
402
|
+
_report_refactor_requests(project_root)
|
|
385
403
|
return reviewed
|
|
386
404
|
|
|
387
405
|
|
|
@@ -574,6 +592,9 @@ def _run_action(
|
|
|
574
592
|
refactor_branch_store.set_outcome(project_root, branch, refactor_branch_store.OUTCOME_DROPPED)
|
|
575
593
|
_sync_current_branch(project_root, state)
|
|
576
594
|
refresh_finalize(project_root, state)
|
|
595
|
+
# `[r]`/`[m]`/`[d]` can change RR status; mirror the local store to the Inbox.
|
|
596
|
+
if action in ("code", "merge", "drop"):
|
|
597
|
+
_report_refactor_requests(project_root)
|
|
577
598
|
console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
|
|
578
599
|
live.start()
|
|
579
600
|
|
|
@@ -609,6 +630,7 @@ def _run_revert_action(live, project_root: Path, node, state: WatchState) -> Non
|
|
|
609
630
|
build_action_command("revert", node.sha, rr_id=rr_id), project_root
|
|
610
631
|
)
|
|
611
632
|
refresh_finalize(project_root, state)
|
|
633
|
+
_report_refactor_requests(project_root)
|
|
612
634
|
else:
|
|
613
635
|
console.print("[dim]watch: nothing reverted (out of range).[/dim]")
|
|
614
636
|
else:
|
|
@@ -717,6 +739,46 @@ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None
|
|
|
717
739
|
)
|
|
718
740
|
|
|
719
741
|
|
|
742
|
+
class _AnalysisAnimator:
|
|
743
|
+
"""Repaint the Live graph on a timer so a blocking review animates.
|
|
744
|
+
|
|
745
|
+
While a commit is being analyzed the main thread is blocked inside
|
|
746
|
+
``runner.run``; without this the "analyzing..." spinner on the reviewing node
|
|
747
|
+
would freeze and the terminal would look stuck. A daemon thread re-renders
|
|
748
|
+
the graph every ``interval`` seconds so the spinner keeps moving. Refreshes
|
|
749
|
+
share ``lock`` with the foreground refresh so Rich is never driven from two
|
|
750
|
+
threads at once.
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
def __init__(self, live, state: WatchState, lock: threading.Lock, *, interval: float = 0.08):
|
|
754
|
+
self._live = live
|
|
755
|
+
self._state = state
|
|
756
|
+
self._lock = lock
|
|
757
|
+
self._interval = interval
|
|
758
|
+
self._stop = threading.Event()
|
|
759
|
+
self._thread: threading.Thread | None = None
|
|
760
|
+
|
|
761
|
+
def __enter__(self) -> "_AnalysisAnimator":
|
|
762
|
+
self._thread = threading.Thread(target=self._run, name="watch-animator", daemon=True)
|
|
763
|
+
self._thread.start()
|
|
764
|
+
return self
|
|
765
|
+
|
|
766
|
+
def __exit__(self, *exc) -> bool:
|
|
767
|
+
self._stop.set()
|
|
768
|
+
if self._thread is not None:
|
|
769
|
+
self._thread.join(timeout=1.0)
|
|
770
|
+
return False
|
|
771
|
+
|
|
772
|
+
def _run(self) -> None:
|
|
773
|
+
while not self._stop.wait(self._interval):
|
|
774
|
+
with self._lock:
|
|
775
|
+
try:
|
|
776
|
+
self._live.update(render_graph(self._state))
|
|
777
|
+
self._live.refresh()
|
|
778
|
+
except Exception:
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
|
|
720
782
|
def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> None:
|
|
721
783
|
"""Render the live Graphite-style graph and consume the queue serially."""
|
|
722
784
|
from refactorai_cli.main import _print_agent_intro
|
|
@@ -726,14 +788,19 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
|
|
|
726
788
|
|
|
727
789
|
live = Live(render_graph(state), console=console, auto_refresh=False, screen=False)
|
|
728
790
|
live.start()
|
|
791
|
+
refresh_lock = threading.Lock()
|
|
729
792
|
|
|
730
793
|
def refresh(st: WatchState) -> None:
|
|
731
|
-
|
|
732
|
-
|
|
794
|
+
with refresh_lock:
|
|
795
|
+
live.update(render_graph(st))
|
|
796
|
+
live.refresh()
|
|
733
797
|
|
|
734
798
|
try:
|
|
735
799
|
while True:
|
|
736
|
-
|
|
800
|
+
# Animate the spinner while reviews run (the review call blocks the
|
|
801
|
+
# main thread, so a background refresher drives the animation).
|
|
802
|
+
with _AnalysisAnimator(live, state, refresh_lock):
|
|
803
|
+
process_pending(project_root, state, runner, on_update=refresh)
|
|
737
804
|
state.select_default()
|
|
738
805
|
refresh(state)
|
|
739
806
|
|
|
@@ -47,6 +47,25 @@ _GATE_MAP = {
|
|
|
47
47
|
"blocked": "high",
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
# Local RR lifecycle states -> server RefactorRequest.status. The server Inbox
|
|
51
|
+
# treats {pending,in_progress,deferred,failed} as open; applied is terminal-done;
|
|
52
|
+
# denied is a human "won't fix" (mapped to suppressed); reverted re-opens the
|
|
53
|
+
# finding locally, so it maps back to pending.
|
|
54
|
+
_RR_STATUS_MAP = {
|
|
55
|
+
"pending": "pending",
|
|
56
|
+
"in_progress": "in_progress",
|
|
57
|
+
"deferred": "deferred",
|
|
58
|
+
"failed": "failed",
|
|
59
|
+
"applied": "applied",
|
|
60
|
+
"denied": "suppressed",
|
|
61
|
+
"reverted": "pending",
|
|
62
|
+
}
|
|
63
|
+
# Local RR states that imply a resolution outcome on the server row.
|
|
64
|
+
_RR_OUTCOME_MAP = {
|
|
65
|
+
"applied": "applied",
|
|
66
|
+
"denied": "suppressed",
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
|
|
51
70
|
def telemetry_disabled() -> bool:
|
|
52
71
|
return str(os.environ.get(ENV_DISABLE, "")).strip().lower() in _TRUTHY
|
|
@@ -130,6 +149,46 @@ def build_commit_event_from_queue(event: dict) -> dict:
|
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
|
|
152
|
+
def build_refactor_request(rr) -> dict:
|
|
153
|
+
"""Map a local ``refactor_core`` RefactorRequest to the RR ingest body.
|
|
154
|
+
|
|
155
|
+
Accepts the dataclass instance (uses attributes) and normalizes the local
|
|
156
|
+
lifecycle status/outcome to the server's enum. Attempts are flattened to a
|
|
157
|
+
compact per-proposal snapshot so the Inbox can show attempt history.
|
|
158
|
+
"""
|
|
159
|
+
status = _RR_STATUS_MAP.get(str(getattr(rr, "status", "") or "").strip().lower(), "pending")
|
|
160
|
+
outcome = _RR_OUTCOME_MAP.get(str(getattr(rr, "status", "") or "").strip().lower())
|
|
161
|
+
attempts: list[dict] = []
|
|
162
|
+
for att in getattr(rr, "attempts", None) or []:
|
|
163
|
+
to_dict = getattr(att, "to_dict", None)
|
|
164
|
+
if callable(to_dict):
|
|
165
|
+
try:
|
|
166
|
+
attempts.append(to_dict())
|
|
167
|
+
continue
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
if isinstance(att, dict):
|
|
171
|
+
attempts.append(att)
|
|
172
|
+
return {
|
|
173
|
+
"rr_id": str(getattr(rr, "rr_id", "") or ""),
|
|
174
|
+
"file_path": str(getattr(rr, "file_path", "") or ""),
|
|
175
|
+
"symbol": getattr(rr, "symbol", None),
|
|
176
|
+
"category": str(getattr(rr, "category", "") or ""),
|
|
177
|
+
"severity": str(getattr(rr, "severity", "medium") or "medium"),
|
|
178
|
+
"rationale": str(getattr(rr, "rationale", "") or ""),
|
|
179
|
+
"recommendation": str(getattr(rr, "recommendation", "") or ""),
|
|
180
|
+
"operation_type": str(getattr(rr, "operation_type", "content_patch") or "content_patch"),
|
|
181
|
+
"status": status,
|
|
182
|
+
"outcome": outcome,
|
|
183
|
+
"attempt_count": int(getattr(rr, "attempt_count", 0) or 0),
|
|
184
|
+
"detail": None,
|
|
185
|
+
"attempts": attempts,
|
|
186
|
+
"refactor_branch": str(getattr(rr, "refactor_branch", "") or ""),
|
|
187
|
+
"commit_sha": str(getattr(rr, "commit_sha", "") or ""),
|
|
188
|
+
"reverted": bool(getattr(rr, "reverted", False)),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
133
192
|
def _auth_and_project(project_root: Path | str, config: dict | None) -> tuple[object | None, str]:
|
|
134
193
|
if telemetry_disabled():
|
|
135
194
|
return None, ""
|
|
@@ -190,6 +249,30 @@ def post_heartbeat(
|
|
|
190
249
|
return _post(url, auth.key.key, body, timeout)
|
|
191
250
|
|
|
192
251
|
|
|
252
|
+
def post_refactor_requests(
|
|
253
|
+
project_root: Path | str, *, config: dict | None = None, timeout: float = 8.0
|
|
254
|
+
) -> bool:
|
|
255
|
+
"""Sync the full local RR store to the platform. Best-effort + fail-open.
|
|
256
|
+
|
|
257
|
+
Sends the complete local snapshot (so terminal RRs land as their resolved
|
|
258
|
+
status) and asks the server to reconcile any previously-synced open RRs that
|
|
259
|
+
are no longer present locally (e.g. cleared by a fresh review). Never raises.
|
|
260
|
+
"""
|
|
261
|
+
auth, project_id = _auth_and_project(project_root, config)
|
|
262
|
+
if not auth or not project_id:
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
from refactor_core.refactor_requests import list_requests
|
|
266
|
+
|
|
267
|
+
requests = list_requests(Path(project_root))
|
|
268
|
+
except Exception:
|
|
269
|
+
return False
|
|
270
|
+
items = [build_refactor_request(rr) for rr in requests]
|
|
271
|
+
url = f"{platform_url()}/v1/projects/{project_id}/refactor-requests/ingest"
|
|
272
|
+
body = {"reconcile": True, "requests": items}
|
|
273
|
+
return _post(url, auth.key.key, body, timeout)
|
|
274
|
+
|
|
275
|
+
|
|
193
276
|
def emit_node(project_root: Path | str, node, *, config: dict | None = None) -> bool:
|
|
194
277
|
"""Build + post a commit event from a watch ``CommitNode``. Never raises."""
|
|
195
278
|
try:
|
|
@@ -9,12 +9,25 @@ without a live terminal, a queue file, or the review engine.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import time
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
|
|
15
16
|
from rich.console import Group
|
|
16
17
|
from rich.text import Text
|
|
17
18
|
|
|
19
|
+
# Braille spinner frames for the "reviewing" (commit-analysis) status line, so a
|
|
20
|
+
# long analysis animates instead of looking hung. Frame is derived from the wall
|
|
21
|
+
# clock so the graph animates whenever it is re-rendered (the TUI drives that via
|
|
22
|
+
# a background refresher during the blocking review).
|
|
23
|
+
_SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _spinner_frame(now: datetime | None = None) -> str:
|
|
27
|
+
"""Return the current spinner glyph (time-based; ~12.5 fps)."""
|
|
28
|
+
tick = time.monotonic() if now is None else now.timestamp()
|
|
29
|
+
return _SPINNER_FRAMES[int(tick * 12.5) % len(_SPINNER_FRAMES)]
|
|
30
|
+
|
|
18
31
|
# Node lifecycle states (docs/41 Part F).
|
|
19
32
|
QUEUED = "queued"
|
|
20
33
|
REVIEWING = "reviewing"
|
|
@@ -393,7 +406,8 @@ def _status_line(node: CommitNode) -> str:
|
|
|
393
406
|
return "[dim]queued[/dim]"
|
|
394
407
|
if node.state == REVIEWING:
|
|
395
408
|
scope = f" ({node.changed} files)" if node.changed else ""
|
|
396
|
-
|
|
409
|
+
spinner = _spinner_frame()
|
|
410
|
+
return f"[cyan]{spinner}[/cyan] [dim]analyzing changed+associated{scope}...[/dim]"
|
|
397
411
|
if node.state == ERROR:
|
|
398
412
|
return f"[red]review error:[/red] {node.error or 'unknown'}"
|
|
399
413
|
# reviewed
|
|
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
|
|
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
|