snodo-engine 0.7.2__py3-none-any.whl

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.
@@ -0,0 +1,267 @@
1
+ """OpenCode Docker container lifecycle manager.
2
+
3
+ FILE: snodo/coders/opencode_container.py
4
+
5
+ Manages the opencode server container — start, stop, health check.
6
+ Built on docker-py (same dependency as DockerSandbox).
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import time
12
+ import urllib.parse
13
+ from pathlib import Path
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+ _IMAGE = "snodo-opencode:latest"
18
+ _PORT = 55440
19
+
20
+
21
+ class OpenCodeContainerError(Exception):
22
+ """Container operation failed."""
23
+
24
+
25
+ class OpenCodeContainer:
26
+ """Manages a long-lived opencode server container.
27
+
28
+ The container runs ``opencode serve --port {port}`` inside the
29
+ workspace directory and exposes the HTTP API on localhost.
30
+ """
31
+
32
+ def __init__(self, image: str = _IMAGE, port: int = _PORT):
33
+ self._image = image
34
+ self._port = port
35
+ self._client = None
36
+ self._container = None
37
+
38
+ docker_host = os.environ.get("DOCKER_HOST", "")
39
+ if docker_host.startswith(("ssh://", "tcp://")):
40
+ self._host = urllib.parse.urlparse(docker_host).hostname or "localhost"
41
+ else:
42
+ self._host = "localhost"
43
+
44
+ @property
45
+ def client(self):
46
+ if self._client is None:
47
+ import docker
48
+ self._client = docker.from_env()
49
+ return self._client
50
+
51
+ def is_available(self) -> bool:
52
+ """Check if Docker daemon is reachable."""
53
+ try:
54
+ self.client.ping()
55
+ return True
56
+ except Exception:
57
+ return False
58
+
59
+ def image_exists(self) -> bool:
60
+ """Check if the opencode image exists locally."""
61
+ try:
62
+ self.client.images.get(self._image)
63
+ return True
64
+ except Exception:
65
+ return False
66
+
67
+ def build_image(self) -> str:
68
+ """Build the opencode Docker image.
69
+
70
+ Returns the image ID.
71
+
72
+ Raises OpenCodeContainerError on failure.
73
+ """
74
+ dockerfile_dir = Path(__file__).parent.parent.parent / "docker"
75
+ dockerfile_path = dockerfile_dir / "Dockerfile.opencode"
76
+ if not dockerfile_path.exists():
77
+ raise OpenCodeContainerError(
78
+ f"Dockerfile not found at {dockerfile_path}"
79
+ )
80
+ try:
81
+ image, _ = self.client.images.build(
82
+ path=str(dockerfile_dir),
83
+ dockerfile=dockerfile_path.name,
84
+ tag=self._image,
85
+ rm=True,
86
+ )
87
+ return image.id
88
+ except Exception as e:
89
+ raise OpenCodeContainerError(f"Failed to build image: {e}") from e
90
+
91
+ def start(self, workspace: Path) -> None:
92
+ """Start the opencode server container, reusing an existing one if healthy.
93
+
94
+ The *workspace* directory is mounted at /workspace inside the
95
+ container so opencode can read project files.
96
+
97
+ Raises OpenCodeContainerError if startup fails.
98
+ """
99
+ # If we already hold a reference and it's healthy, skip
100
+ if self._container is not None and self._is_container_healthy():
101
+ return
102
+
103
+ # Check for an existing container from a previous session
104
+ existing = self._find_existing_container()
105
+ if existing is not None:
106
+ self._container = existing
107
+ if self._is_container_healthy():
108
+ _logger.info("Reusing existing opencode container %s", existing.id[:12])
109
+ return
110
+ _logger.debug("Existing container %s is unhealthy — removing", existing.id[:12])
111
+ self.stop()
112
+
113
+ # Start fresh
114
+ try:
115
+ volumes = {
116
+ str(workspace.resolve()): {"bind": "/workspace", "mode": "rw"},
117
+ }
118
+ env = _build_provider_env()
119
+ env["OPENCODE_PORT"] = str(self._port)
120
+ self._container = self.client.containers.run(
121
+ self._image,
122
+ detach=True,
123
+ volumes=volumes,
124
+ ports={f"{self._port}/tcp": self._port},
125
+ publish_all_ports=False,
126
+ remove=True,
127
+ environment=env,
128
+ )
129
+ except Exception as e:
130
+ self._container = None
131
+ raise OpenCodeContainerError(f"Failed to start container: {e}") from e
132
+
133
+ self._wait_ready()
134
+
135
+ _logger.info("Started new opencode container")
136
+ self._log_readiness()
137
+
138
+ def _find_existing_container(self):
139
+ """Return an existing running container with this image, or None."""
140
+ try:
141
+ containers = self.client.containers.list(
142
+ filters={"ancestor": self._image, "status": "running"},
143
+ )
144
+ if containers:
145
+ return containers[0]
146
+ except Exception as e:
147
+ _logger.debug("Failed to list running container by image: %s", e)
148
+ return None
149
+
150
+ def _is_container_healthy(self) -> bool:
151
+ """Check if the container is running AND /global/health responds."""
152
+ import httpx
153
+
154
+ if not self.is_running():
155
+ return False
156
+ try:
157
+ resp = httpx.get(
158
+ f"{self.base_url}/global/health", timeout=2.0,
159
+ )
160
+ return resp.status_code == 200
161
+ except Exception:
162
+ return False
163
+
164
+ def _wait_ready(self, timeout: float = 60.0) -> None:
165
+ """Poll the opencode HTTP API until it responds or times out."""
166
+ import httpx
167
+
168
+ deadline = time.time() + timeout
169
+ url = self.base_url + "/global/health"
170
+ while time.time() < deadline:
171
+ if self._container is None:
172
+ raise OpenCodeContainerError("Container stopped during startup")
173
+ try:
174
+ resp = httpx.get(url, timeout=2.0)
175
+ if resp.status_code < 500:
176
+ _logger.debug("OpenCode server ready at %s", self.base_url)
177
+ return
178
+ except Exception as e:
179
+ _logger.debug("OpenCode server health check poll error: %s", e)
180
+ time.sleep(0.5)
181
+ raise OpenCodeContainerError(
182
+ f"OpenCode server did not become ready within {timeout}s"
183
+ )
184
+
185
+ def _log_readiness(self) -> None:
186
+ """Log container health information at DEBUG level."""
187
+ import httpx
188
+
189
+ try:
190
+ resp = httpx.get(
191
+ f"{self.base_url}/global/health", timeout=2.0,
192
+ )
193
+ data = resp.json() if resp.status_code == 200 else {}
194
+ version = data.get("version", "unknown")
195
+ _logger.debug(
196
+ "OpenCode container ready: http://%s:%d (v%s)",
197
+ self._host, self._port, version,
198
+ )
199
+ except Exception:
200
+ _logger.debug(
201
+ "OpenCode container ready: http://%s:%d",
202
+ self._host, self._port,
203
+ )
204
+
205
+ @property
206
+ def base_url(self) -> str:
207
+ return f"http://{self._host}:{self._port}"
208
+
209
+ def is_running(self) -> bool:
210
+ """Return True if the container is alive."""
211
+ if self._container is None:
212
+ return False
213
+ try:
214
+ self._container.reload()
215
+ return self._container.status == "running"
216
+ except Exception:
217
+ return False
218
+
219
+ def stop(self) -> None:
220
+ """Stop and remove the container."""
221
+ if self._container is not None:
222
+ try:
223
+ self._container.stop(timeout=5)
224
+ except Exception as e:
225
+ _logger.debug("Failed to stop opencode container: %s", e)
226
+ try:
227
+ self._container.remove(force=True)
228
+ except Exception as e:
229
+ _logger.debug("Failed to remove opencode container: %s", e)
230
+ self._container = None
231
+
232
+
233
+ _PROVIDER_ENV_MAP = {
234
+ "google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
235
+ "anthropic": ("ANTHROPIC_API_KEY",),
236
+ "openai": ("OPENAI_API_KEY",),
237
+ "deepseek": ("DEEPSEEK_API_KEY",),
238
+ "openrouter": ("OPENROUTER_API_KEY",),
239
+ "opencode": ("OPENCODE_API_KEY",),
240
+ }
241
+
242
+
243
+ def _build_provider_env() -> dict:
244
+ """Read provider API keys from snodo config and return env vars for opencode."""
245
+ env: dict[str, str] = {}
246
+ try:
247
+ from snodo.config import ConfigManager
248
+ import os as _os
249
+ config = ConfigManager().load()
250
+ providers = config.get("providers", {})
251
+ if not isinstance(providers, dict):
252
+ return env
253
+ for provider_name, env_var_names in _PROVIDER_ENV_MAP.items():
254
+ provider_config = providers.get(provider_name, {})
255
+ if not isinstance(provider_config, dict):
256
+ continue
257
+ key = provider_config.get("api_key", "")
258
+ if not key:
259
+ api_key_env_name = provider_config.get("api_key_env", "")
260
+ if api_key_env_name:
261
+ key = _os.environ.get(api_key_env_name, "")
262
+ if key:
263
+ for env_name in env_var_names:
264
+ env[env_name] = key
265
+ except Exception as e:
266
+ _logger.warning("Failed to build provider env for opencode: %s", e)
267
+ return env
@@ -0,0 +1,219 @@
1
+ """Base adapter for host CLI tools that write files in place via subprocess.
2
+
3
+ FILE: snodo/coders/subprocess_adapter.py
4
+
5
+ Abstracts host CLI tools (opencode-cli, agy) that execute in-place edits in the
6
+ working tree via subprocess invocation. Base class handles:
7
+ - Prompt formatting (_build_prompt)
8
+ - Subprocess invocation and error handling (missing binary, timeout, non-zero returncode)
9
+ - Git working tree readback (_read_changes_from_disk)
10
+ - Artifact construction (_diff_to_artifact)
11
+ - .snodo/ mutation protection and git commit (inherited from InPlaceCoderAdapter)
12
+ """
13
+
14
+ import logging
15
+ import subprocess
16
+ from abc import abstractmethod
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from snodo.coders.base import InPlaceCoderAdapter, LLMCallError
21
+ from snodo.core.interfaces import CodeArtifact, FileArtifact, TaskSpec
22
+
23
+ _logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SubprocessCoderAdapter(InPlaceCoderAdapter):
27
+ """Base coder adapter for host CLI subprocess tools."""
28
+
29
+ skip_engine_commit: bool = True
30
+ skip_workspace_write: bool = True
31
+
32
+ binary: str = ""
33
+ model_prefix: str = ""
34
+ install_hint: str = ""
35
+ timeout_seconds: int = 1800
36
+
37
+ def __init__(
38
+ self,
39
+ model: str = "",
40
+ temperature: float = 0.7,
41
+ workspace: Optional[Path] = None,
42
+ workspace_mcp: Optional[Any] = None,
43
+ **kwargs: Any,
44
+ ):
45
+ self.model = model or self.model_prefix
46
+ self.temperature = temperature
47
+
48
+ if workspace is not None:
49
+ self._workspace = Path(workspace)
50
+ elif workspace_mcp is not None:
51
+ from snodo.tools.workspace import WorkspaceMCP
52
+ if isinstance(workspace_mcp, WorkspaceMCP):
53
+ self._workspace = Path(workspace_mcp.project_root)
54
+ elif hasattr(workspace_mcp, "project_root") and workspace_mcp.project_root is not None:
55
+ self._workspace = Path(workspace_mcp.project_root)
56
+ else:
57
+ raise ValueError(
58
+ f"{self.__class__.__name__} requires an explicit workspace or a valid "
59
+ f"WorkspaceMCP with project_root; received invalid workspace_mcp: {workspace_mcp!r}. "
60
+ "Inferring a containment boundary from Path.cwd() is prohibited (ADR 024/025)."
61
+ )
62
+ else:
63
+ raise ValueError(
64
+ f"{self.__class__.__name__} requires an explicit workspace or workspace_mcp; "
65
+ "none was provided. Inferring a containment boundary from Path.cwd() is prohibited (ADR 024/025)."
66
+ )
67
+
68
+ def _bare_model(self) -> str:
69
+ """Return the model to pass to the CLI, or "" to let it choose.
70
+
71
+ An external coding agent owns its own model catalog — agy offers
72
+ "Gemini 3.6 Flash (Medium)", opencode offers whatever its providers
73
+ expose. snodo's ``-m`` names the model that JUDGES the work: it is
74
+ resolved through litellm for the validators and the classifier, and it
75
+ is meaningless to the CLI. Forwarding it produced:
76
+
77
+ agy run failed (rc=1): invalid model selection
78
+ (--model "deepseek/deepseek-v4-flash"): not recognized as a known
79
+ model or custom model in settings
80
+
81
+ So a model reaches the CLI only when the operator named one in THIS
82
+ adapter's namespace (``agy/...``, ``opencode-cli/...``). Anything else
83
+ yields "", and ``_build_argv`` omits the flag so the tool falls back to
84
+ its own last-selected default.
85
+
86
+ (Selecting the coder's model explicitly, while the validators keep
87
+ theirs, needs a separate flag — ``-m`` cannot carry both.)
88
+ """
89
+ model = self.model
90
+ if not self.model_prefix:
91
+ return model
92
+ prefixes = (self.model_prefix,)
93
+ if self.model_prefix == "opencode-cli/":
94
+ prefixes = ("opencode-cli/", "opencode/")
95
+ for prefix in prefixes:
96
+ if model.startswith(prefix):
97
+ return model[len(prefix):]
98
+ return ""
99
+
100
+ @abstractmethod
101
+ def _build_argv(self, prompt: str, project_root: str, model: str) -> list[str]:
102
+ """Construct the subprocess argument list for the specific CLI tool."""
103
+
104
+ def _implement_in_place(self, spec: TaskSpec) -> CodeArtifact:
105
+ prompt = self._build_prompt(spec)
106
+ project_root = str(self._workspace)
107
+ bare_model = self._bare_model()
108
+ argv = self._build_argv(prompt, project_root, bare_model)
109
+
110
+ _logger.info(
111
+ "%s: executing with containment boundary at %s",
112
+ self.binary, project_root,
113
+ )
114
+
115
+ try:
116
+ # noqa carried over from opencode_cli_adapter when this call moved
117
+ # here in the subprocess-adapter refactor. The binary name is a
118
+ # class attribute, never operator input; the prompt and model are
119
+ # single argv elements and are never shell-interpreted.
120
+ proc = subprocess.run( # noqa: S603 - argv list, no shell; binary is a class attribute
121
+ argv,
122
+ cwd=project_root,
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=self.timeout_seconds,
126
+ )
127
+ except FileNotFoundError as e:
128
+ raise LLMCallError(
129
+ f"{self.binary} not found on PATH. {self.install_hint}"
130
+ ) from e
131
+ except subprocess.TimeoutExpired as e:
132
+ raise LLMCallError(
133
+ f"{self.binary} run timed out after {self.timeout_seconds}s"
134
+ ) from e
135
+
136
+ if proc.returncode != 0:
137
+ tail = (proc.stderr or "")[:2000] or (proc.stdout or "")[:2000]
138
+ raise LLMCallError(
139
+ f"{self.binary} run failed (rc={proc.returncode}): {tail}"
140
+ )
141
+
142
+ diff_entries = self._read_changes_from_disk()
143
+ if not diff_entries:
144
+ combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
145
+ tail = combined[-1000:]
146
+ _logger.warning(
147
+ "%s run completed but no changes detected (rc=0). output tail: %s",
148
+ self.binary, tail,
149
+ )
150
+
151
+ return self._diff_to_artifact(diff_entries)
152
+
153
+ def _diff_to_artifact(self, diff_entries: list) -> CodeArtifact:
154
+ """Build a CodeArtifact from diff entries, re-reading content from disk."""
155
+ _ignored_dirs = {"__pycache__", ".venv", "venv", "node_modules", ".git", ".pytest_cache", ".mypy_cache"}
156
+ _ignored_exts = {".pyc", ".pyo", ".DS_Store"}
157
+
158
+ files = []
159
+ for entry in diff_entries:
160
+ path = entry.get("file", "")
161
+ if not path:
162
+ continue
163
+ parts = Path(path).parts
164
+ if any(p in _ignored_dirs for p in parts) or Path(path).suffix in _ignored_exts:
165
+ continue
166
+ status = entry.get("status", "modified")
167
+
168
+ if status == "deleted":
169
+ files.append(FileArtifact(path=path, content="", action="delete"))
170
+ continue
171
+
172
+ file_path = Path(self._workspace) / path
173
+ try:
174
+ content = file_path.read_text()
175
+ except Exception as exc:
176
+ _logger.warning("%s: failed to read %s: %s", self.binary, file_path, exc)
177
+ content = f"<unreadable: {exc}>"
178
+
179
+ files.append(FileArtifact(path=path, content=content, action="write"))
180
+
181
+ if not files:
182
+ _logger.warning("%s returned no files — task completed with no changes", self.binary)
183
+ return CodeArtifact(files=[])
184
+
185
+ return CodeArtifact(files=files)
186
+
187
+ def _build_prompt(self, spec: TaskSpec) -> str:
188
+ """Build prompt from TaskSpec."""
189
+ language = spec.project_context.get("language", "unknown")
190
+ lang_hint = f" ({language} project)" if language != "unknown" else ""
191
+
192
+ parts = [
193
+ f"You are an expert software engineer{lang_hint}.",
194
+ "Generate code based on the following specification.",
195
+ "",
196
+ ]
197
+
198
+ structure = spec.project_context.get("structure", "")
199
+ if structure:
200
+ parts.append(f"## Directory Structure\n```\n{structure}\n```")
201
+ parts.append("")
202
+
203
+ if spec.memory_summary:
204
+ parts.append(f"## Session History\n{spec.memory_summary}")
205
+ parts.append("")
206
+
207
+ parts.append(f"## Task\n{spec.description}")
208
+
209
+ if spec.constraints:
210
+ parts.append("\n## Constraints")
211
+ for c in spec.constraints:
212
+ parts.append(f"- {c}")
213
+
214
+ parts.append("")
215
+ parts.append(
216
+ "Write the implementation to disk. Create all necessary files."
217
+ )
218
+
219
+ return "\n".join(parts)
File without changes