remoroo 0.1.0__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.
- remoroo-0.1.0/PKG-INFO +9 -0
- remoroo-0.1.0/pyproject.toml +21 -0
- remoroo-0.1.0/remoroo/__init__.py +1 -0
- remoroo-0.1.0/remoroo/auth.py +111 -0
- remoroo-0.1.0/remoroo/cli.py +134 -0
- remoroo-0.1.0/remoroo/configs.py +7 -0
- remoroo-0.1.0/remoroo/engine/__init__.py +2 -0
- remoroo-0.1.0/remoroo/engine/core/applier.py +277 -0
- remoroo-0.1.0/remoroo/engine/core/context_packer.py +2039 -0
- remoroo-0.1.0/remoroo/engine/core/env_setup.py +235 -0
- remoroo-0.1.0/remoroo/engine/core/executor.py +515 -0
- remoroo-0.1.0/remoroo/engine/core/repo_indexer.py +930 -0
- remoroo-0.1.0/remoroo/engine/core/worker.py +441 -0
- remoroo-0.1.0/remoroo/engine/local_worker.py +1008 -0
- remoroo-0.1.0/remoroo/engine/protocol.py +3 -0
- remoroo-0.1.0/remoroo/engine/sandbox.py +209 -0
- remoroo-0.1.0/remoroo/engine/schemas.py +1023 -0
- remoroo-0.1.0/remoroo/engine/utils/configs.py +75 -0
- remoroo-0.1.0/remoroo/engine/utils/file_access_tracker.py +122 -0
- remoroo-0.1.0/remoroo/engine/utils/fs_utils.py +152 -0
- remoroo-0.1.0/remoroo/engine/utils/log_packer.py +279 -0
- remoroo-0.1.0/remoroo/engine/utils/syntax_validator.py +500 -0
- remoroo-0.1.0/remoroo/execution/__init__.py +0 -0
- remoroo-0.1.0/remoroo/execution/configs.py +103 -0
- remoroo-0.1.0/remoroo/execution/env_doctor.py +867 -0
- remoroo-0.1.0/remoroo/execution/file_access_tracker.py +123 -0
- remoroo-0.1.0/remoroo/execution/import_diagnostics.py +247 -0
- remoroo-0.1.0/remoroo/execution/instrumentation_pipeline.py +746 -0
- remoroo-0.1.0/remoroo/execution/instrumentation_targets.py +408 -0
- remoroo-0.1.0/remoroo/execution/metrics_utils.py +68 -0
- remoroo-0.1.0/remoroo/execution/repo_manager.py +273 -0
- remoroo-0.1.0/remoroo/execution/scan_repo.py +247 -0
- remoroo-0.1.0/remoroo/http_transport.py +138 -0
- remoroo-0.1.0/remoroo/ids.py +11 -0
- remoroo-0.1.0/remoroo/paths.py +20 -0
- remoroo-0.1.0/remoroo/prompts.py +33 -0
- remoroo-0.1.0/remoroo/repo_packer.py +73 -0
- remoroo-0.1.0/remoroo/run_local.py +248 -0
- remoroo-0.1.0/remoroo/run_remote.py +158 -0
- remoroo-0.1.0/remoroo/runtime/monitor.py +100 -0
- remoroo-0.1.0/remoroo/worker_cmd.py +126 -0
- remoroo-0.1.0/remoroo.egg-info/PKG-INFO +9 -0
- remoroo-0.1.0/remoroo.egg-info/SOURCES.txt +47 -0
- remoroo-0.1.0/remoroo.egg-info/dependency_links.txt +1 -0
- remoroo-0.1.0/remoroo.egg-info/entry_points.txt +2 -0
- remoroo-0.1.0/remoroo.egg-info/requires.txt +4 -0
- remoroo-0.1.0/remoroo.egg-info/top_level.txt +1 -0
- remoroo-0.1.0/setup.cfg +4 -0
- remoroo-0.1.0/setup.py +19 -0
remoroo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools<70.0.0", "wheel", "packaging>=23.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remoroo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Remoroo Offline Engine CLI"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.9.0",
|
|
12
|
+
"jsonschema>=4.22.0",
|
|
13
|
+
"openai>=1.40.0",
|
|
14
|
+
"requests>=2.31.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
remoroo = "remoroo.cli:app"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["remoroo*", "remoroo_offline*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
import typer
|
|
6
|
+
import webbrowser
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
CRED_PATH = Path.home() / ".config" / "remoroo" / "credentials"
|
|
10
|
+
|
|
11
|
+
class AuthClient:
|
|
12
|
+
def __init__(self, base_url: Optional[str] = None):
|
|
13
|
+
self.base_url = base_url or os.environ.get("REMOROO_API_URL", "https://api.remoroo.com")
|
|
14
|
+
self.auth_url = os.environ.get("REMOROO_AUTH_URL", "http://localhost:3000/console") # Point to console for key generation
|
|
15
|
+
self._token: Optional[str] = None
|
|
16
|
+
self._load_token()
|
|
17
|
+
|
|
18
|
+
def _load_token(self):
|
|
19
|
+
if CRED_PATH.exists():
|
|
20
|
+
try:
|
|
21
|
+
content = CRED_PATH.read_text().strip()
|
|
22
|
+
# Simple format: just the token for now, or JSON later
|
|
23
|
+
self._token = content
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def is_authenticated(self) -> bool:
|
|
28
|
+
return bool(self._token)
|
|
29
|
+
|
|
30
|
+
def get_token(self) -> Optional[str]:
|
|
31
|
+
return self._token
|
|
32
|
+
|
|
33
|
+
def login(self) -> None:
|
|
34
|
+
"""Interactive login flow."""
|
|
35
|
+
if self.is_authenticated():
|
|
36
|
+
typer.echo("Already logged in.")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
CRED_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
typer.secho("Opening browser to sign in...", fg=typer.colors.BLUE)
|
|
42
|
+
print(f"URL: {self.auth_url}")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
webbrowser.open(self.auth_url)
|
|
46
|
+
except Exception:
|
|
47
|
+
typer.echo("Could not open browser automatically.")
|
|
48
|
+
|
|
49
|
+
token = typer.prompt("Paste your API key (will be saved locally)", hide_input=True)
|
|
50
|
+
token = (token or "").strip()
|
|
51
|
+
|
|
52
|
+
if not token:
|
|
53
|
+
typer.secho("Login aborted.", fg=typer.colors.RED)
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
|
|
56
|
+
# Validate Token
|
|
57
|
+
try:
|
|
58
|
+
import requests # Make sure requests is imported or available
|
|
59
|
+
typer.echo("Verifying credentials...")
|
|
60
|
+
res = requests.get(f"{self.base_url}/user/me", headers={"Authorization": token})
|
|
61
|
+
if res.status_code == 200:
|
|
62
|
+
user = res.json()
|
|
63
|
+
typer.secho(f"Successfully logged in as {user.get('email') or user.get('username')}", fg=typer.colors.GREEN)
|
|
64
|
+
self._save_token(token)
|
|
65
|
+
else:
|
|
66
|
+
typer.secho(f"Login failed: {res.status_code} - Invalid API Key", fg=typer.colors.RED)
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
typer.secho(f"Verification error: {e}", fg=typer.colors.YELLOW)
|
|
70
|
+
# Proceed? No, fail secure.
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
def logout(self) -> None:
|
|
74
|
+
if CRED_PATH.exists():
|
|
75
|
+
CRED_PATH.unlink()
|
|
76
|
+
self._token = None
|
|
77
|
+
typer.echo("Logged out.")
|
|
78
|
+
|
|
79
|
+
def _save_token(self, token: str) -> None:
|
|
80
|
+
self._token = token
|
|
81
|
+
tmp = CRED_PATH.with_suffix(".tmp")
|
|
82
|
+
tmp.write_text(token + "\n", encoding="utf-8")
|
|
83
|
+
tmp.replace(CRED_PATH)
|
|
84
|
+
try:
|
|
85
|
+
os.chmod(CRED_PATH, 0o600)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def create_run(self, repo_path: str, goal: str, mode: str) -> Dict[str, Any]:
|
|
90
|
+
"""
|
|
91
|
+
Mock create run - in future this hits the Control Plane API.
|
|
92
|
+
Returns a run configuration/ID.
|
|
93
|
+
"""
|
|
94
|
+
# Mock response
|
|
95
|
+
run_id = time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(4).hex()
|
|
96
|
+
return {
|
|
97
|
+
"run_id": run_id,
|
|
98
|
+
"status": "created",
|
|
99
|
+
"config": {
|
|
100
|
+
"goal": goal,
|
|
101
|
+
"mode": mode
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Global instance for CLI convenience, but preferable to instantiate in commands
|
|
106
|
+
_client = AuthClient()
|
|
107
|
+
|
|
108
|
+
def ensure_logged_in() -> AuthClient:
|
|
109
|
+
if not _client.is_authenticated():
|
|
110
|
+
_client.login()
|
|
111
|
+
return _client
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .auth import ensure_logged_in
|
|
5
|
+
from .prompts import prompt_goal, prompt_metrics, confirm_run
|
|
6
|
+
from .ids import new_run_id
|
|
7
|
+
from .paths import resolve_repo_path, resolve_out_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from .run_local import run_local_worker
|
|
11
|
+
|
|
12
|
+
from .worker_cmd import worker
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
app.command(name="worker")(worker)
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def login():
|
|
18
|
+
"""Lock in your API Key."""
|
|
19
|
+
from .auth import _client
|
|
20
|
+
_client.login()
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def logout():
|
|
24
|
+
"""Remove stored credentials."""
|
|
25
|
+
from .auth import _client
|
|
26
|
+
_client.logout()
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def main():
|
|
30
|
+
"""
|
|
31
|
+
Remoroo CLI
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def run(
|
|
37
|
+
local: bool = typer.Option(False, "--local", help="Run execution on this machine (Free/Offline)."),
|
|
38
|
+
remote: bool = typer.Option(True, "--remote", help="Run execution on hosted Cloud Engine (Commercial)."),
|
|
39
|
+
repo: Path = typer.Option(Path("."), "--repo", exists=True, file_okay=False, dir_okay=True),
|
|
40
|
+
out: Path = typer.Option(None, "--out", help="Base directory for run outputs."),
|
|
41
|
+
yes: bool = typer.Option(False, "--yes", help="Skip confirmation."),
|
|
42
|
+
verbose: bool = typer.Option(False, "--verbose", help="Verbose output."),
|
|
43
|
+
goal: str = typer.Option(None, "--goal", help="Goal of the run."),
|
|
44
|
+
metrics: str = typer.Option(None, "--metrics", help="Comma-separated metrics."),
|
|
45
|
+
brain_url: str = typer.Option(None, "--brain-url", help="URL of the Brain Server."),
|
|
46
|
+
):
|
|
47
|
+
from .configs import get_api_url
|
|
48
|
+
if brain_url is None:
|
|
49
|
+
brain_url = get_api_url()
|
|
50
|
+
# Logic: Default is Remote.
|
|
51
|
+
# If user explicitly says --local, then remote=False.
|
|
52
|
+
# Because 'remote' defaults to True, we check if local is True.
|
|
53
|
+
if local:
|
|
54
|
+
remote = False
|
|
55
|
+
|
|
56
|
+
if remote:
|
|
57
|
+
from .run_remote import run_remote_experiment
|
|
58
|
+
# typer.secho("Remote execution is not available yet.", fg=typer.colors.YELLOW)
|
|
59
|
+
# raise typer.Exit(code=2)
|
|
60
|
+
# Prepare arguments
|
|
61
|
+
if not goal:
|
|
62
|
+
goal = prompt_goal()
|
|
63
|
+
|
|
64
|
+
metrics_list = []
|
|
65
|
+
if metrics:
|
|
66
|
+
metrics_list = [m.strip() for m in metrics.split(",") if m.strip()]
|
|
67
|
+
if not metrics_list:
|
|
68
|
+
metrics_list = prompt_metrics()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
res = run_remote_experiment(
|
|
72
|
+
run_id=new_run_id(),
|
|
73
|
+
repo_path=resolve_repo_path(repo),
|
|
74
|
+
out_dir=resolve_out_dir(out, resolve_repo_path(repo)),
|
|
75
|
+
goal=goal,
|
|
76
|
+
metrics=metrics_list,
|
|
77
|
+
)
|
|
78
|
+
typer.echo(f"Run outcome: {res.outcome}")
|
|
79
|
+
raise typer.Exit(code=0)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
typer.echo(f"Error: {e}")
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
|
|
84
|
+
ensure_logged_in()
|
|
85
|
+
|
|
86
|
+
repo_path = resolve_repo_path(repo)
|
|
87
|
+
out_dir = resolve_out_dir(out, repo_path)
|
|
88
|
+
|
|
89
|
+
if not goal:
|
|
90
|
+
goal = prompt_goal()
|
|
91
|
+
|
|
92
|
+
metrics_list = []
|
|
93
|
+
if metrics:
|
|
94
|
+
metrics_list = [m.strip() for m in metrics.split(",") if m.strip()]
|
|
95
|
+
|
|
96
|
+
if not metrics_list:
|
|
97
|
+
metrics_list = prompt_metrics()
|
|
98
|
+
|
|
99
|
+
run_id = new_run_id()
|
|
100
|
+
|
|
101
|
+
if not yes:
|
|
102
|
+
if not confirm_run(repo_path, goal, metrics_list, mode="local"):
|
|
103
|
+
raise typer.Exit(code=0)
|
|
104
|
+
|
|
105
|
+
typer.secho(f"\nStarting run {run_id}...", fg=typer.colors.BLUE)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
result = run_local_worker(
|
|
109
|
+
run_id=run_id,
|
|
110
|
+
repo_path=repo_path,
|
|
111
|
+
out_dir=out_dir,
|
|
112
|
+
goal=goal,
|
|
113
|
+
metrics=metrics_list,
|
|
114
|
+
brain_url=brain_url,
|
|
115
|
+
verbose=verbose,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if result.success:
|
|
119
|
+
typer.secho("Run completed successfully.", fg=typer.colors.GREEN)
|
|
120
|
+
else:
|
|
121
|
+
typer.secho(f"Run finished with outcome: {result.outcome}", fg=typer.colors.YELLOW)
|
|
122
|
+
|
|
123
|
+
typer.echo(f"Run ID: {result.run_id}")
|
|
124
|
+
typer.echo(f"Results saved to: {result.run_root}")
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
typer.secho(f"Run failed with error: {e}", fg=typer.colors.RED)
|
|
128
|
+
if verbose:
|
|
129
|
+
import traceback
|
|
130
|
+
traceback.print_exc()
|
|
131
|
+
raise typer.Exit(code=1)
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
app()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import codecs
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Any, List, Tuple, Optional, TYPE_CHECKING
|
|
6
|
+
from ..utils.syntax_validator import validate_python_syntax, preserve_indentation, get_indentation_level, auto_fix_indentation
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..utils.file_access_tracker import FileAccessTracker
|
|
10
|
+
|
|
11
|
+
class ApplyError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
def _read_text(path: str) -> str:
|
|
15
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
16
|
+
return f.read()
|
|
17
|
+
|
|
18
|
+
def _read_lines(path: str) -> List[str]:
|
|
19
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
20
|
+
return f.readlines()
|
|
21
|
+
|
|
22
|
+
def _write_text(path: str, text: str) -> None:
|
|
23
|
+
try:
|
|
24
|
+
dir_path = os.path.dirname(path)
|
|
25
|
+
if dir_path:
|
|
26
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
27
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
28
|
+
f.write(text)
|
|
29
|
+
except (OSError, PermissionError) as e:
|
|
30
|
+
raise ApplyError(f"Failed to create directory or write file '{path}': {str(e)}") from e
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise ApplyError(f"Failed to write file '{path}': {str(e)}") from e
|
|
33
|
+
|
|
34
|
+
def _write_lines(path: str, lines: List[str]) -> None:
|
|
35
|
+
try:
|
|
36
|
+
dir_path = os.path.dirname(path)
|
|
37
|
+
if dir_path:
|
|
38
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
39
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
40
|
+
f.writelines(lines)
|
|
41
|
+
except (OSError, PermissionError) as e:
|
|
42
|
+
raise ApplyError(f"Failed to create directory or write file '{path}': {str(e)}") from e
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise ApplyError(f"Failed to write file '{path}': {str(e)}") from e
|
|
45
|
+
|
|
46
|
+
def _normalize_repl(repl: str) -> str:
|
|
47
|
+
if repl == "":
|
|
48
|
+
return ""
|
|
49
|
+
return repl if repl.endswith("\n") else (repl + "\n")
|
|
50
|
+
|
|
51
|
+
def edit_already_satisfied(repo_root: str, e: Dict[str, Any]) -> bool:
|
|
52
|
+
abs_path = os.path.join(repo_root, e["path"])
|
|
53
|
+
kind = e["kind"]
|
|
54
|
+
|
|
55
|
+
if kind == "create_file":
|
|
56
|
+
if not os.path.exists(abs_path):
|
|
57
|
+
return False
|
|
58
|
+
return _read_text(abs_path) == e.get("replacement", "")
|
|
59
|
+
|
|
60
|
+
if not os.path.exists(abs_path):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
if kind == "insert":
|
|
64
|
+
rep = e.get("replacement", "")
|
|
65
|
+
if rep.strip() == "":
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
current_text = _read_text(abs_path)
|
|
70
|
+
lines = _read_lines(abs_path)
|
|
71
|
+
idx = min(int(e.get("after_line", 0)), len(lines))
|
|
72
|
+
repl = _normalize_repl(rep)
|
|
73
|
+
|
|
74
|
+
if repl and idx > 0 and e.get("preserve_indentation", True):
|
|
75
|
+
if idx < len(lines):
|
|
76
|
+
base_indent = get_indentation_level(lines[idx])
|
|
77
|
+
else:
|
|
78
|
+
base_indent = get_indentation_level(lines[idx - 1]) if idx > 0 else 0
|
|
79
|
+
|
|
80
|
+
repl_lines = repl.splitlines(keepends=False)
|
|
81
|
+
if repl_lines:
|
|
82
|
+
first_line_indent = get_indentation_level(repl_lines[0])
|
|
83
|
+
if first_line_indent == 0 and base_indent > 0:
|
|
84
|
+
adjusted_lines = []
|
|
85
|
+
for line in repl_lines:
|
|
86
|
+
if line.strip():
|
|
87
|
+
adjusted_lines.append(" " * base_indent + line)
|
|
88
|
+
else:
|
|
89
|
+
adjusted_lines.append(line)
|
|
90
|
+
repl = "\n".join(adjusted_lines) + ("\n" if repl.endswith("\n") else "")
|
|
91
|
+
|
|
92
|
+
return repl in current_text
|
|
93
|
+
except Exception:
|
|
94
|
+
return rep in _read_text(abs_path)
|
|
95
|
+
|
|
96
|
+
if kind == "delete":
|
|
97
|
+
lines = _read_lines(abs_path)
|
|
98
|
+
s = int(e["start_line"]) - 1
|
|
99
|
+
t = int(e["end_line"])
|
|
100
|
+
if s < 0 or t < s or t > len(lines):
|
|
101
|
+
return False
|
|
102
|
+
if s >= len(lines):
|
|
103
|
+
return True
|
|
104
|
+
if s == t:
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
if kind == "replace_file":
|
|
109
|
+
if not os.path.exists(abs_path):
|
|
110
|
+
return False
|
|
111
|
+
current_content = _read_text(abs_path)
|
|
112
|
+
desired_content = e.get("replacement", "")
|
|
113
|
+
return current_content == desired_content
|
|
114
|
+
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def apply_patchproposal(
|
|
118
|
+
repo_root: str,
|
|
119
|
+
patch: Dict[str, Any],
|
|
120
|
+
file_access_tracker: Optional['FileAccessTracker'] = None
|
|
121
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
122
|
+
applied: List[Dict[str, Any]] = []
|
|
123
|
+
skipped: List[Dict[str, Any]] = []
|
|
124
|
+
|
|
125
|
+
print(f"\n📝 Applying patch '{patch.get('patch_id', 'unknown')}' with {len(patch.get('edits', []))} edits")
|
|
126
|
+
print(f" Repo root: {repo_root}")
|
|
127
|
+
|
|
128
|
+
for i, e in enumerate(patch.get("edits", []), 1):
|
|
129
|
+
abs_path = os.path.join(repo_root, e["path"])
|
|
130
|
+
file_path = e["path"]
|
|
131
|
+
kind = e["kind"]
|
|
132
|
+
|
|
133
|
+
if kind == "replace_file":
|
|
134
|
+
if not e.get("replacement"):
|
|
135
|
+
raise ApplyError(f"replace_file operation requires non-empty 'replacement' field for {e['path']}")
|
|
136
|
+
elif kind == "create_file":
|
|
137
|
+
if not e.get("replacement"):
|
|
138
|
+
raise ApplyError(f"create_file operation requires non-empty 'replacement' field for {e['path']}")
|
|
139
|
+
elif kind == "insert":
|
|
140
|
+
if e.get("after_line") is None:
|
|
141
|
+
raise ApplyError(f"insert operation requires non-None 'after_line' field for {e['path']}")
|
|
142
|
+
if not e.get("replacement"):
|
|
143
|
+
raise ApplyError(f"insert operation requires non-empty 'replacement' field for {e['path']}")
|
|
144
|
+
elif kind == "delete":
|
|
145
|
+
if e.get("start_line") is None:
|
|
146
|
+
raise ApplyError(f"delete operation requires non-None 'start_line' field for {e['path']}")
|
|
147
|
+
if e.get("end_line") is None:
|
|
148
|
+
raise ApplyError(f"delete operation requires non-None 'end_line' field for {e['path']}")
|
|
149
|
+
|
|
150
|
+
print(f"\n Edit {i}/{len(patch.get('edits', []))}: {kind} on {e['path']}")
|
|
151
|
+
|
|
152
|
+
if file_access_tracker:
|
|
153
|
+
if kind in ["replace_file", "insert", "delete"]:
|
|
154
|
+
if os.path.exists(abs_path):
|
|
155
|
+
if not file_access_tracker.can_edit(file_path):
|
|
156
|
+
status = file_access_tracker.get_status(file_path)
|
|
157
|
+
raise ApplyError(
|
|
158
|
+
f"Cannot edit {file_path}: file not read in full. "
|
|
159
|
+
f"Current status: {status}."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if kind not in ["delete", "replace_file"] and edit_already_satisfied(repo_root, e):
|
|
163
|
+
skipped.append(e)
|
|
164
|
+
print(f" ⏭️ Skipping {kind} edit for {e['path']} (already satisfied)")
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if kind == "create_file":
|
|
168
|
+
overwrite = bool(e.get("overwrite", False))
|
|
169
|
+
if os.path.exists(abs_path) and not overwrite:
|
|
170
|
+
print(f" ⚠️ File '{e['path']}' already exists but overwrite=False. Overwriting anyway.")
|
|
171
|
+
|
|
172
|
+
replacement = e.get("replacement", "")
|
|
173
|
+
_write_text(abs_path, replacement)
|
|
174
|
+
|
|
175
|
+
if abs_path.endswith('.py') and replacement.strip():
|
|
176
|
+
is_valid, error_msg = validate_python_syntax(abs_path)
|
|
177
|
+
if not is_valid:
|
|
178
|
+
if os.path.exists(abs_path):
|
|
179
|
+
os.remove(abs_path)
|
|
180
|
+
raise ApplyError(f"Syntax error in {e['path']} after create_file: {error_msg}")
|
|
181
|
+
|
|
182
|
+
applied.append(e)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if not os.path.exists(abs_path):
|
|
186
|
+
error_msg = f"{kind} operation failed: File '{e['path']}' does not exist."
|
|
187
|
+
print(f" ❌ {error_msg}")
|
|
188
|
+
raise ApplyError(error_msg)
|
|
189
|
+
|
|
190
|
+
if kind == "insert":
|
|
191
|
+
lines = _read_lines(abs_path)
|
|
192
|
+
idx = min(int(e.get("after_line", 0)), len(lines))
|
|
193
|
+
repl = _normalize_repl(e.get("replacement", ""))
|
|
194
|
+
|
|
195
|
+
if repl and idx > 0 and e.get("preserve_indentation", True):
|
|
196
|
+
if idx < len(lines):
|
|
197
|
+
base_indent = get_indentation_level(lines[idx])
|
|
198
|
+
else:
|
|
199
|
+
base_indent = get_indentation_level(lines[idx - 1]) if idx > 0 else 0
|
|
200
|
+
|
|
201
|
+
repl_lines = repl.splitlines(keepends=False)
|
|
202
|
+
if repl_lines:
|
|
203
|
+
first_line_indent = get_indentation_level(repl_lines[0])
|
|
204
|
+
if first_line_indent == 0:
|
|
205
|
+
adjusted_lines = []
|
|
206
|
+
for line in repl_lines:
|
|
207
|
+
if line.strip():
|
|
208
|
+
adjusted_lines.append(" " * base_indent + line)
|
|
209
|
+
else:
|
|
210
|
+
adjusted_lines.append(line)
|
|
211
|
+
repl = "\n".join(adjusted_lines) + ("\n" if repl.endswith("\n") else "")
|
|
212
|
+
|
|
213
|
+
ins = repl.splitlines(keepends=True) if repl else []
|
|
214
|
+
|
|
215
|
+
original_lines = lines.copy()
|
|
216
|
+
lines[idx:idx] = ins
|
|
217
|
+
_write_lines(abs_path, lines)
|
|
218
|
+
|
|
219
|
+
if abs_path.endswith('.py'):
|
|
220
|
+
is_valid, error_msg = validate_python_syntax(abs_path)
|
|
221
|
+
if not is_valid:
|
|
222
|
+
_write_lines(abs_path, original_lines)
|
|
223
|
+
raise ApplyError(f"Syntax error in {e['path']} after insert: {error_msg}")
|
|
224
|
+
|
|
225
|
+
applied.append(e)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if kind == "delete":
|
|
229
|
+
lines = _read_lines(abs_path)
|
|
230
|
+
s = int(e["start_line"]) - 1
|
|
231
|
+
t = int(e["end_line"])
|
|
232
|
+
if s < 0 or t < s or t > len(lines):
|
|
233
|
+
raise ApplyError(f"delete out of bounds for {e['path']}: {s+1}-{t} (file has {len(lines)} lines)")
|
|
234
|
+
|
|
235
|
+
if s >= len(lines) or s == t:
|
|
236
|
+
skipped.append(e)
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
original_lines = lines.copy()
|
|
240
|
+
del lines[s:t]
|
|
241
|
+
_write_lines(abs_path, lines)
|
|
242
|
+
|
|
243
|
+
if abs_path.endswith('.py'):
|
|
244
|
+
is_valid, error_msg = validate_python_syntax(abs_path)
|
|
245
|
+
if not is_valid:
|
|
246
|
+
_write_lines(abs_path, original_lines)
|
|
247
|
+
raise ApplyError(f"Syntax error in {e['path']} after delete: {error_msg}")
|
|
248
|
+
|
|
249
|
+
applied.append(e)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
if kind == "replace_file":
|
|
253
|
+
if not os.path.exists(abs_path):
|
|
254
|
+
raise ApplyError(f"replace_file operation failed: File '{e['path']}' does not exist.")
|
|
255
|
+
|
|
256
|
+
replacement = e.get("replacement", "")
|
|
257
|
+
original_content = _read_text(abs_path)
|
|
258
|
+
|
|
259
|
+
abs_path_obj = Path(abs_path)
|
|
260
|
+
abs_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
with abs_path_obj.open("w", encoding="utf-8", newline="\n") as f:
|
|
262
|
+
f.write(replacement)
|
|
263
|
+
|
|
264
|
+
if abs_path.endswith('.py'):
|
|
265
|
+
is_valid, error_msg = validate_python_syntax(abs_path)
|
|
266
|
+
if not is_valid:
|
|
267
|
+
with abs_path_obj.open("w", encoding="utf-8", newline="\n") as f:
|
|
268
|
+
f.write(original_content)
|
|
269
|
+
raise ApplyError(f"Syntax error in {e['path']} after replace_file: {error_msg}")
|
|
270
|
+
|
|
271
|
+
applied.append(e)
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
raise ApplyError(f"Unsupported kind: {kind}")
|
|
275
|
+
|
|
276
|
+
print(f"\n✅ Patch application complete: {len(applied)} applied, {len(skipped)} skipped")
|
|
277
|
+
return applied, skipped
|