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.
Files changed (49) hide show
  1. remoroo-0.1.0/PKG-INFO +9 -0
  2. remoroo-0.1.0/pyproject.toml +21 -0
  3. remoroo-0.1.0/remoroo/__init__.py +1 -0
  4. remoroo-0.1.0/remoroo/auth.py +111 -0
  5. remoroo-0.1.0/remoroo/cli.py +134 -0
  6. remoroo-0.1.0/remoroo/configs.py +7 -0
  7. remoroo-0.1.0/remoroo/engine/__init__.py +2 -0
  8. remoroo-0.1.0/remoroo/engine/core/applier.py +277 -0
  9. remoroo-0.1.0/remoroo/engine/core/context_packer.py +2039 -0
  10. remoroo-0.1.0/remoroo/engine/core/env_setup.py +235 -0
  11. remoroo-0.1.0/remoroo/engine/core/executor.py +515 -0
  12. remoroo-0.1.0/remoroo/engine/core/repo_indexer.py +930 -0
  13. remoroo-0.1.0/remoroo/engine/core/worker.py +441 -0
  14. remoroo-0.1.0/remoroo/engine/local_worker.py +1008 -0
  15. remoroo-0.1.0/remoroo/engine/protocol.py +3 -0
  16. remoroo-0.1.0/remoroo/engine/sandbox.py +209 -0
  17. remoroo-0.1.0/remoroo/engine/schemas.py +1023 -0
  18. remoroo-0.1.0/remoroo/engine/utils/configs.py +75 -0
  19. remoroo-0.1.0/remoroo/engine/utils/file_access_tracker.py +122 -0
  20. remoroo-0.1.0/remoroo/engine/utils/fs_utils.py +152 -0
  21. remoroo-0.1.0/remoroo/engine/utils/log_packer.py +279 -0
  22. remoroo-0.1.0/remoroo/engine/utils/syntax_validator.py +500 -0
  23. remoroo-0.1.0/remoroo/execution/__init__.py +0 -0
  24. remoroo-0.1.0/remoroo/execution/configs.py +103 -0
  25. remoroo-0.1.0/remoroo/execution/env_doctor.py +867 -0
  26. remoroo-0.1.0/remoroo/execution/file_access_tracker.py +123 -0
  27. remoroo-0.1.0/remoroo/execution/import_diagnostics.py +247 -0
  28. remoroo-0.1.0/remoroo/execution/instrumentation_pipeline.py +746 -0
  29. remoroo-0.1.0/remoroo/execution/instrumentation_targets.py +408 -0
  30. remoroo-0.1.0/remoroo/execution/metrics_utils.py +68 -0
  31. remoroo-0.1.0/remoroo/execution/repo_manager.py +273 -0
  32. remoroo-0.1.0/remoroo/execution/scan_repo.py +247 -0
  33. remoroo-0.1.0/remoroo/http_transport.py +138 -0
  34. remoroo-0.1.0/remoroo/ids.py +11 -0
  35. remoroo-0.1.0/remoroo/paths.py +20 -0
  36. remoroo-0.1.0/remoroo/prompts.py +33 -0
  37. remoroo-0.1.0/remoroo/repo_packer.py +73 -0
  38. remoroo-0.1.0/remoroo/run_local.py +248 -0
  39. remoroo-0.1.0/remoroo/run_remote.py +158 -0
  40. remoroo-0.1.0/remoroo/runtime/monitor.py +100 -0
  41. remoroo-0.1.0/remoroo/worker_cmd.py +126 -0
  42. remoroo-0.1.0/remoroo.egg-info/PKG-INFO +9 -0
  43. remoroo-0.1.0/remoroo.egg-info/SOURCES.txt +47 -0
  44. remoroo-0.1.0/remoroo.egg-info/dependency_links.txt +1 -0
  45. remoroo-0.1.0/remoroo.egg-info/entry_points.txt +2 -0
  46. remoroo-0.1.0/remoroo.egg-info/requires.txt +4 -0
  47. remoroo-0.1.0/remoroo.egg-info/top_level.txt +1 -0
  48. remoroo-0.1.0/setup.cfg +4 -0
  49. remoroo-0.1.0/setup.py +19 -0
remoroo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.1
2
+ Name: remoroo
3
+ Version: 0.1.0
4
+ Summary: Remoroo Offline Engine CLI
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: typer>=0.9.0
7
+ Requires-Dist: jsonschema>=4.22.0
8
+ Requires-Dist: openai>=1.40.0
9
+ Requires-Dist: requests>=2.31.0
@@ -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,7 @@
1
+ import os
2
+
3
+ def get_api_url() -> str:
4
+ """
5
+ Get the Remoroo API URL from environment variables or use default.
6
+ """
7
+ return os.getenv("REMOROO_API_URL", "http://localhost:8000").rstrip("/")
@@ -0,0 +1,2 @@
1
+ # Remoroo CLI Local Execution Engine
2
+ # Embeds minimal execution logic for local runs.
@@ -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