dlab-cli 0.1.0__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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
dlab/docker.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker container management for dlab.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Building Docker images from decision-pack config directories
|
|
6
|
+
- Starting/stopping containers with volume mounts
|
|
7
|
+
- Executing commands inside containers
|
|
8
|
+
- Automatic rebuild detection when docker/ contents change
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Template for the wrapper Dockerfile that adds opencode to the base image
|
|
20
|
+
# Dependencies:
|
|
21
|
+
# - git: version control (used by coding agents)
|
|
22
|
+
# - ripgrep: required by opencode for grep/glob/list tools
|
|
23
|
+
# - curl: needed to install Node.js
|
|
24
|
+
# - nodejs: required to run opencode (installed via npm)
|
|
25
|
+
OPENCODE_WRAPPER_DOCKERFILE: str = """FROM {base_image}
|
|
26
|
+
|
|
27
|
+
# Install git, ripgrep, and Node.js (required for opencode)
|
|
28
|
+
RUN apt-get update && apt-get install -y git ripgrep curl && \\
|
|
29
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\
|
|
30
|
+
apt-get install -y nodejs && \\
|
|
31
|
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
32
|
+
|
|
33
|
+
# Install opencode
|
|
34
|
+
RUN npm install -g {opencode_package}
|
|
35
|
+
|
|
36
|
+
# Verify installation
|
|
37
|
+
RUN opencode --version
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Label name for storing the source hash in Docker images
|
|
41
|
+
SOURCE_HASH_LABEL: str = "dlab.source-hash"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compute_docker_dir_hash(
|
|
45
|
+
docker_dir: Path, opencode_version: str = "latest",
|
|
46
|
+
) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Compute a SHA256 hash of all files in the docker/ directory plus opencode version.
|
|
49
|
+
|
|
50
|
+
This hash is used to detect when the docker/ contents or opencode version
|
|
51
|
+
have changed, triggering an automatic rebuild of the Docker image.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
docker_dir : Path
|
|
56
|
+
Path to the docker/ directory.
|
|
57
|
+
opencode_version : str
|
|
58
|
+
Version of opencode to install (included in hash so version
|
|
59
|
+
changes trigger rebuilds).
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
str
|
|
64
|
+
Hex-encoded SHA256 hash of the directory contents and opencode version.
|
|
65
|
+
"""
|
|
66
|
+
hasher = hashlib.sha256()
|
|
67
|
+
hasher.update(f"opencode_version={opencode_version}".encode("utf-8"))
|
|
68
|
+
|
|
69
|
+
# Get all files sorted by path for deterministic ordering
|
|
70
|
+
files: list[Path] = sorted(docker_dir.rglob("*"))
|
|
71
|
+
|
|
72
|
+
for file_path in files:
|
|
73
|
+
if file_path.is_file():
|
|
74
|
+
# Skip __pycache__ directories (contain timestamps that change on import)
|
|
75
|
+
if "__pycache__" in file_path.parts:
|
|
76
|
+
continue
|
|
77
|
+
# Skip .pyc files
|
|
78
|
+
if file_path.suffix == ".pyc":
|
|
79
|
+
continue
|
|
80
|
+
# Include relative path in hash (so renames are detected)
|
|
81
|
+
rel_path: str = str(file_path.relative_to(docker_dir))
|
|
82
|
+
hasher.update(rel_path.encode("utf-8"))
|
|
83
|
+
# Include file contents
|
|
84
|
+
hasher.update(file_path.read_bytes())
|
|
85
|
+
|
|
86
|
+
return hasher.hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_image_source_hash(image_name: str) -> str | None:
|
|
90
|
+
"""
|
|
91
|
+
Get the source hash label from a Docker image.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
image_name : str
|
|
96
|
+
Name of the Docker image.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
str | None
|
|
101
|
+
The source hash if the label exists, None otherwise.
|
|
102
|
+
"""
|
|
103
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
104
|
+
["docker", "inspect", "--format", "{{json .Config.Labels}}", image_name],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
labels: dict[str, str] = json.loads(result.stdout.strip())
|
|
114
|
+
if labels is None:
|
|
115
|
+
return None
|
|
116
|
+
return labels.get(SOURCE_HASH_LABEL)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def needs_rebuild(
|
|
122
|
+
config_dir: str, image_name: str, opencode_version: str = "latest",
|
|
123
|
+
) -> tuple[bool, str]:
|
|
124
|
+
"""
|
|
125
|
+
Check if a Docker image needs to be rebuilt based on source changes.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
config_dir : str
|
|
130
|
+
Path to the decision-pack config directory (contains docker/ subdirectory).
|
|
131
|
+
image_name : str
|
|
132
|
+
Name of the Docker image.
|
|
133
|
+
opencode_version : str
|
|
134
|
+
Version of opencode to install (included in hash check).
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
tuple[bool, str]
|
|
139
|
+
Tuple of (needs_rebuild, reason).
|
|
140
|
+
reason is a human-readable explanation of why rebuild is needed.
|
|
141
|
+
"""
|
|
142
|
+
docker_dir: Path = Path(config_dir) / "docker"
|
|
143
|
+
|
|
144
|
+
if not docker_dir.exists():
|
|
145
|
+
return True, "docker/ directory not found"
|
|
146
|
+
|
|
147
|
+
# Check if image exists at all
|
|
148
|
+
if not image_exists(image_name):
|
|
149
|
+
return True, "image does not exist"
|
|
150
|
+
|
|
151
|
+
# Compute current hash (includes opencode version)
|
|
152
|
+
current_hash: str = compute_docker_dir_hash(docker_dir, opencode_version)
|
|
153
|
+
|
|
154
|
+
# Get stored hash from image
|
|
155
|
+
stored_hash: str | None = get_image_source_hash(image_name)
|
|
156
|
+
|
|
157
|
+
if stored_hash is None:
|
|
158
|
+
return True, "image missing source hash label (built before auto-rebuild)"
|
|
159
|
+
|
|
160
|
+
if current_hash != stored_hash:
|
|
161
|
+
return True, "docker/ contents or opencode version have changed"
|
|
162
|
+
|
|
163
|
+
return False, "image is up to date"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def image_exists(image_name: str) -> bool:
|
|
167
|
+
"""
|
|
168
|
+
Check if a Docker image exists locally.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
image_name : str
|
|
173
|
+
Name of the Docker image to check.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
bool
|
|
178
|
+
True if image exists, False otherwise.
|
|
179
|
+
"""
|
|
180
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
181
|
+
["docker", "images", "-q", image_name],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
)
|
|
185
|
+
# docker images -q returns image ID if found, empty string if not
|
|
186
|
+
return bool(result.stdout.strip())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _get_image_id(image_name: str) -> str | None:
|
|
190
|
+
"""Get the image ID for a given image name, or None if not found."""
|
|
191
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
192
|
+
["docker", "images", "-q", "--no-trunc", image_name],
|
|
193
|
+
capture_output=True,
|
|
194
|
+
text=True,
|
|
195
|
+
)
|
|
196
|
+
image_id: str = result.stdout.strip()
|
|
197
|
+
return image_id if image_id else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def count_dangling_images() -> int:
|
|
201
|
+
"""Return the number of dangling (untagged) Docker images."""
|
|
202
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
203
|
+
["docker", "images", "-q", "--filter", "dangling=true"],
|
|
204
|
+
capture_output=True,
|
|
205
|
+
text=True,
|
|
206
|
+
)
|
|
207
|
+
return len(result.stdout.strip().splitlines()) if result.stdout.strip() else 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _remove_dangling_image(old_id: str | None, current_name: str) -> None:
|
|
211
|
+
"""Remove an old image by ID if it's now dangling (untagged)."""
|
|
212
|
+
if old_id is None:
|
|
213
|
+
return
|
|
214
|
+
new_id: str | None = _get_image_id(current_name)
|
|
215
|
+
if new_id == old_id:
|
|
216
|
+
return
|
|
217
|
+
# Old ID is now dangling — remove it
|
|
218
|
+
subprocess.run(["docker", "rmi", old_id], capture_output=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run_docker_build(
|
|
222
|
+
cmd: list[str],
|
|
223
|
+
on_output: Callable[[str], None] | None = None,
|
|
224
|
+
) -> tuple[int, str]:
|
|
225
|
+
"""
|
|
226
|
+
Run a docker build command, streaming output line by line.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
cmd : list[str]
|
|
231
|
+
Docker build command.
|
|
232
|
+
on_output : Callable[[str], None] | None
|
|
233
|
+
Called for each output line.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
tuple[int, str]
|
|
238
|
+
(return_code, stderr_text).
|
|
239
|
+
"""
|
|
240
|
+
proc: subprocess.Popen[str] = subprocess.Popen(
|
|
241
|
+
cmd,
|
|
242
|
+
stdout=subprocess.PIPE,
|
|
243
|
+
stderr=subprocess.STDOUT,
|
|
244
|
+
text=True,
|
|
245
|
+
)
|
|
246
|
+
stderr_lines: list[str] = []
|
|
247
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
248
|
+
line = line.rstrip("\n")
|
|
249
|
+
if on_output:
|
|
250
|
+
on_output(line)
|
|
251
|
+
proc.wait()
|
|
252
|
+
return proc.returncode, "\n".join(stderr_lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_image(
|
|
256
|
+
config_dir: str,
|
|
257
|
+
image_name: str,
|
|
258
|
+
opencode_version: str = "latest",
|
|
259
|
+
on_output: Callable[[str], None] | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Build a Docker image from a decision-pack's docker/ directory with opencode installed.
|
|
263
|
+
|
|
264
|
+
This function:
|
|
265
|
+
1. Builds the decision-pack's Dockerfile as a base image
|
|
266
|
+
2. Creates a wrapper Dockerfile that adds opencode
|
|
267
|
+
3. Builds the final image with opencode installed
|
|
268
|
+
4. Removes previous image IDs if they became dangling
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
config_dir : str
|
|
273
|
+
Path to the decision-pack config directory (contains docker/ subdirectory).
|
|
274
|
+
image_name : str
|
|
275
|
+
Name to tag the built image with.
|
|
276
|
+
opencode_version : str
|
|
277
|
+
Version of opencode to install (default: "latest").
|
|
278
|
+
on_output : Callable[[str], None] | None
|
|
279
|
+
Optional callback invoked for each line of build output.
|
|
280
|
+
|
|
281
|
+
Raises
|
|
282
|
+
------
|
|
283
|
+
ValueError
|
|
284
|
+
If the docker/ directory doesn't exist or build fails.
|
|
285
|
+
"""
|
|
286
|
+
docker_dir: Path = Path(config_dir) / "docker"
|
|
287
|
+
|
|
288
|
+
if not docker_dir.exists():
|
|
289
|
+
raise ValueError(f"docker/ directory not found in: {config_dir}")
|
|
290
|
+
|
|
291
|
+
base_image_name: str = f"{image_name}-base"
|
|
292
|
+
|
|
293
|
+
# Capture old image IDs so we can clean up dangling images after build
|
|
294
|
+
old_base_id: str | None = _get_image_id(base_image_name)
|
|
295
|
+
old_wrapper_id: str | None = _get_image_id(image_name)
|
|
296
|
+
|
|
297
|
+
# Step 1: Build the base image from decision-pack's Dockerfile
|
|
298
|
+
returncode, stderr = _run_docker_build(
|
|
299
|
+
["docker", "build", "-t", base_image_name, str(docker_dir)],
|
|
300
|
+
on_output=on_output,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if returncode != 0:
|
|
304
|
+
raise ValueError(f"Docker build failed: {stderr}")
|
|
305
|
+
|
|
306
|
+
# Step 2: Create wrapper Dockerfile that adds opencode
|
|
307
|
+
if opencode_version == "latest":
|
|
308
|
+
opencode_package: str = "opencode-ai@latest"
|
|
309
|
+
else:
|
|
310
|
+
opencode_package = f"opencode-ai@{opencode_version}"
|
|
311
|
+
|
|
312
|
+
wrapper_dockerfile: str = OPENCODE_WRAPPER_DOCKERFILE.format(
|
|
313
|
+
base_image=base_image_name,
|
|
314
|
+
opencode_package=opencode_package,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Step 3: Compute source hash for auto-rebuild detection
|
|
318
|
+
source_hash: str = compute_docker_dir_hash(docker_dir, opencode_version)
|
|
319
|
+
|
|
320
|
+
# Step 4: Build final image with opencode and source hash label
|
|
321
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
322
|
+
dockerfile_path: Path = Path(tmpdir) / "Dockerfile"
|
|
323
|
+
dockerfile_path.write_text(wrapper_dockerfile)
|
|
324
|
+
|
|
325
|
+
returncode, stderr = _run_docker_build(
|
|
326
|
+
[
|
|
327
|
+
"docker", "build",
|
|
328
|
+
"-t", image_name,
|
|
329
|
+
"--label", f"{SOURCE_HASH_LABEL}={source_hash}",
|
|
330
|
+
tmpdir,
|
|
331
|
+
],
|
|
332
|
+
on_output=on_output,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if returncode != 0:
|
|
336
|
+
raise ValueError(f"Docker build (opencode wrapper) failed: {stderr}")
|
|
337
|
+
|
|
338
|
+
# Step 5: Remove old images if they became dangling after re-tagging
|
|
339
|
+
_remove_dangling_image(old_base_id, base_image_name)
|
|
340
|
+
_remove_dangling_image(old_wrapper_id, image_name)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def container_exists(container_name: str) -> bool:
|
|
344
|
+
"""
|
|
345
|
+
Check if a Docker container exists (running or stopped).
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
container_name : str
|
|
350
|
+
Name of the container to check.
|
|
351
|
+
|
|
352
|
+
Returns
|
|
353
|
+
-------
|
|
354
|
+
bool
|
|
355
|
+
True if container exists, False otherwise.
|
|
356
|
+
"""
|
|
357
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
358
|
+
["docker", "ps", "-a", "-q", "-f", f"name=^{container_name}$"],
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
)
|
|
362
|
+
# docker ps -q returns container ID if found, empty string if not
|
|
363
|
+
return bool(result.stdout.strip())
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def start_container(
|
|
367
|
+
image_name: str,
|
|
368
|
+
work_dir: str,
|
|
369
|
+
container_name: str,
|
|
370
|
+
env_file: str | None = None,
|
|
371
|
+
extra_env: dict[str, str] | None = None,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""
|
|
374
|
+
Start a new Docker container with volume mounts.
|
|
375
|
+
|
|
376
|
+
The container runs in detached mode with `tail -f /dev/null` to keep it
|
|
377
|
+
alive for subsequent `docker exec` commands.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
image_name : str
|
|
382
|
+
Name of the Docker image to use.
|
|
383
|
+
work_dir : str
|
|
384
|
+
Path to the work directory to mount at /workspace.
|
|
385
|
+
container_name : str
|
|
386
|
+
Name to give the container.
|
|
387
|
+
env_file : str | None
|
|
388
|
+
Optional path to an environment file to pass to the container.
|
|
389
|
+
extra_env : dict[str, str] | None
|
|
390
|
+
Additional environment variables to pass via -e flags.
|
|
391
|
+
|
|
392
|
+
Raises
|
|
393
|
+
------
|
|
394
|
+
ValueError
|
|
395
|
+
If the container already exists, env file not found, or fails to start.
|
|
396
|
+
"""
|
|
397
|
+
if container_exists(container_name):
|
|
398
|
+
raise ValueError(f"Container already exists: {container_name}")
|
|
399
|
+
|
|
400
|
+
if env_file is not None:
|
|
401
|
+
env_path: Path = Path(env_file).resolve()
|
|
402
|
+
if not env_path.exists():
|
|
403
|
+
raise ValueError(f"Environment file not found: {env_file}")
|
|
404
|
+
|
|
405
|
+
work_path: Path = Path(work_dir).resolve()
|
|
406
|
+
|
|
407
|
+
# Build the docker run command
|
|
408
|
+
cmd: list[str] = [
|
|
409
|
+
"docker", "run",
|
|
410
|
+
"-d", # Detached mode
|
|
411
|
+
"--name", container_name, # Container name
|
|
412
|
+
"-v", f"{work_path}:/workspace", # Mount work_dir at /workspace
|
|
413
|
+
"-v", f"{work_path}/_opencode_logs:/_opencode_logs", # Mount logs at /_opencode_logs
|
|
414
|
+
"-w", "/workspace", # Set working directory
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
# Add env file if provided
|
|
418
|
+
if env_file is not None:
|
|
419
|
+
cmd.extend(["--env-file", str(env_path)])
|
|
420
|
+
|
|
421
|
+
# Add extra environment variables
|
|
422
|
+
if extra_env:
|
|
423
|
+
for key, value in extra_env.items():
|
|
424
|
+
cmd.extend(["-e", f"{key}={value}"])
|
|
425
|
+
|
|
426
|
+
cmd.extend([
|
|
427
|
+
image_name,
|
|
428
|
+
"tail", "-f", "/dev/null", # Keep container running
|
|
429
|
+
])
|
|
430
|
+
|
|
431
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
432
|
+
cmd,
|
|
433
|
+
capture_output=True,
|
|
434
|
+
text=True,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if result.returncode != 0:
|
|
438
|
+
raise ValueError(f"Failed to start container: {result.stderr}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def exec_command(
|
|
442
|
+
container_name: str,
|
|
443
|
+
command: list[str],
|
|
444
|
+
timeout: int | None = None,
|
|
445
|
+
) -> tuple[int, str, str]:
|
|
446
|
+
"""
|
|
447
|
+
Execute a command inside a running container.
|
|
448
|
+
|
|
449
|
+
Parameters
|
|
450
|
+
----------
|
|
451
|
+
container_name : str
|
|
452
|
+
Name of the running container.
|
|
453
|
+
command : list[str]
|
|
454
|
+
Command and arguments to execute.
|
|
455
|
+
timeout : int | None
|
|
456
|
+
Timeout in seconds. None means no timeout.
|
|
457
|
+
|
|
458
|
+
Returns
|
|
459
|
+
-------
|
|
460
|
+
tuple[int, str, str]
|
|
461
|
+
Tuple of (exit_code, stdout, stderr).
|
|
462
|
+
"""
|
|
463
|
+
cmd: list[str] = ["docker", "exec", container_name] + command
|
|
464
|
+
|
|
465
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
466
|
+
cmd,
|
|
467
|
+
capture_output=True,
|
|
468
|
+
text=True,
|
|
469
|
+
timeout=timeout,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
return result.returncode, result.stdout, result.stderr
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def stop_container(container_name: str) -> None:
|
|
476
|
+
"""
|
|
477
|
+
Stop and remove a Docker container.
|
|
478
|
+
|
|
479
|
+
Parameters
|
|
480
|
+
----------
|
|
481
|
+
container_name : str
|
|
482
|
+
Name of the container to stop and remove.
|
|
483
|
+
|
|
484
|
+
Notes
|
|
485
|
+
-----
|
|
486
|
+
This function is idempotent - it silently succeeds if the container
|
|
487
|
+
doesn't exist or is already stopped.
|
|
488
|
+
"""
|
|
489
|
+
# Stop the container (ignore errors if already stopped)
|
|
490
|
+
subprocess.run(
|
|
491
|
+
["docker", "stop", container_name],
|
|
492
|
+
capture_output=True,
|
|
493
|
+
text=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Remove the container (ignore errors if doesn't exist)
|
|
497
|
+
subprocess.run(
|
|
498
|
+
["docker", "rm", container_name],
|
|
499
|
+
capture_output=True,
|
|
500
|
+
text=True,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def build_runner_script(
|
|
505
|
+
prompt_file: str,
|
|
506
|
+
model: str,
|
|
507
|
+
log_prefix: str,
|
|
508
|
+
) -> str:
|
|
509
|
+
"""
|
|
510
|
+
Build the bash runner script that runs opencode inside a container.
|
|
511
|
+
|
|
512
|
+
Parameters
|
|
513
|
+
----------
|
|
514
|
+
prompt_file : str
|
|
515
|
+
Path to the prompt file inside the container.
|
|
516
|
+
model : str
|
|
517
|
+
The model to use.
|
|
518
|
+
log_prefix : str
|
|
519
|
+
Prefix for log files.
|
|
520
|
+
|
|
521
|
+
Returns
|
|
522
|
+
-------
|
|
523
|
+
str
|
|
524
|
+
The bash script content.
|
|
525
|
+
"""
|
|
526
|
+
return f'''#!/bin/bash
|
|
527
|
+
set -o pipefail
|
|
528
|
+
prompt=$(cat {prompt_file})
|
|
529
|
+
opencode run --format json --log-level DEBUG --model "{model}" "$prompt" 2>&1 | tee /_opencode_logs/{log_prefix}.log
|
|
530
|
+
'''
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def run_opencode(
|
|
534
|
+
container_name: str,
|
|
535
|
+
prompt: str,
|
|
536
|
+
model: str,
|
|
537
|
+
timeout: int | None = None,
|
|
538
|
+
log_prefix: str = "main",
|
|
539
|
+
) -> tuple[int, str, str]:
|
|
540
|
+
"""
|
|
541
|
+
Run opencode with a prompt inside a container, logging output to _opencode_logs.
|
|
542
|
+
|
|
543
|
+
Parameters
|
|
544
|
+
----------
|
|
545
|
+
container_name : str
|
|
546
|
+
Name of the running container.
|
|
547
|
+
prompt : str
|
|
548
|
+
The prompt to send to opencode.
|
|
549
|
+
model : str
|
|
550
|
+
The model to use (e.g., "anthropic/claude-sonnet-4-5").
|
|
551
|
+
timeout : int | None
|
|
552
|
+
Timeout in seconds. None means no timeout.
|
|
553
|
+
log_prefix : str
|
|
554
|
+
Prefix for log files (default: "main").
|
|
555
|
+
|
|
556
|
+
Returns
|
|
557
|
+
-------
|
|
558
|
+
tuple[int, str, str]
|
|
559
|
+
Tuple of (exit_code, stdout, stderr).
|
|
560
|
+
"""
|
|
561
|
+
# Write prompt to a file to avoid shell quoting issues
|
|
562
|
+
# (prompts can contain quotes, $, backticks, newlines, etc.)
|
|
563
|
+
# The file is written via docker exec with stdin - completely safe
|
|
564
|
+
prompt_file: str = "/.prompt.txt"
|
|
565
|
+
|
|
566
|
+
# Write prompt file using cat with stdin (bypasses all shell escaping)
|
|
567
|
+
write_result: subprocess.CompletedProcess[bytes] = subprocess.run(
|
|
568
|
+
["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {prompt_file}"],
|
|
569
|
+
input=prompt.encode(),
|
|
570
|
+
capture_output=True,
|
|
571
|
+
)
|
|
572
|
+
if write_result.returncode != 0:
|
|
573
|
+
return write_result.returncode, "", write_result.stderr.decode()
|
|
574
|
+
|
|
575
|
+
# Build the runner script that reads the prompt file and runs opencode
|
|
576
|
+
# This avoids any shell expansion of the prompt content
|
|
577
|
+
runner_script: str = build_runner_script(prompt_file, model, log_prefix)
|
|
578
|
+
runner_file: str = "/.run_opencode.sh"
|
|
579
|
+
|
|
580
|
+
write_runner: subprocess.CompletedProcess[bytes] = subprocess.run(
|
|
581
|
+
["docker", "exec", "-i", container_name, "sh", "-c", f"cat > {runner_file} && chmod +x {runner_file}"],
|
|
582
|
+
input=runner_script.encode(),
|
|
583
|
+
capture_output=True,
|
|
584
|
+
)
|
|
585
|
+
if write_runner.returncode != 0:
|
|
586
|
+
return write_runner.returncode, "", write_runner.stderr.decode()
|
|
587
|
+
|
|
588
|
+
# Run the script
|
|
589
|
+
command: list[str] = ["bash", runner_file]
|
|
590
|
+
|
|
591
|
+
return exec_command(container_name, command, timeout=timeout)
|