openhands-workspace 1.0.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.
- openhands/workspace/__init__.py +10 -0
- openhands/workspace/docker/__init__.py +6 -0
- openhands/workspace/docker/workspace.py +339 -0
- openhands/workspace/py.typed +0 -0
- openhands/workspace/remote_api/__init__.py +6 -0
- openhands/workspace/remote_api/workspace.py +296 -0
- openhands_workspace-1.0.0.dist-info/METADATA +7 -0
- openhands_workspace-1.0.0.dist-info/RECORD +10 -0
- openhands_workspace-1.0.0.dist-info/WHEEL +5 -0
- openhands_workspace-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Docker-based remote workspace implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.request import urlopen
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, PrivateAttr, model_validator
|
|
13
|
+
|
|
14
|
+
from openhands.agent_server.docker.build import (
|
|
15
|
+
BuildOptions,
|
|
16
|
+
PlatformType,
|
|
17
|
+
TargetType,
|
|
18
|
+
build,
|
|
19
|
+
)
|
|
20
|
+
from openhands.sdk.logger import get_logger
|
|
21
|
+
from openhands.sdk.utils.command import execute_command
|
|
22
|
+
from openhands.sdk.workspace import RemoteWorkspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_port_available(port: int) -> bool:
|
|
29
|
+
"""Check if a port is available for binding."""
|
|
30
|
+
import socket
|
|
31
|
+
|
|
32
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
33
|
+
try:
|
|
34
|
+
sock.bind(("0.0.0.0", port))
|
|
35
|
+
return True
|
|
36
|
+
except OSError:
|
|
37
|
+
time.sleep(0.1)
|
|
38
|
+
return False
|
|
39
|
+
finally:
|
|
40
|
+
sock.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_available_tcp_port(
|
|
44
|
+
min_port: int = 30000, max_port: int = 39999, max_attempts: int = 50
|
|
45
|
+
) -> int:
|
|
46
|
+
"""Find an available TCP port in a specified range."""
|
|
47
|
+
import random
|
|
48
|
+
|
|
49
|
+
rng = random.SystemRandom()
|
|
50
|
+
ports = list(range(min_port, max_port + 1))
|
|
51
|
+
rng.shuffle(ports)
|
|
52
|
+
|
|
53
|
+
for port in ports[:max_attempts]:
|
|
54
|
+
if check_port_available(port):
|
|
55
|
+
return port
|
|
56
|
+
return -1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DockerWorkspace(RemoteWorkspace):
|
|
60
|
+
"""Remote workspace that sets up and manages a Docker container.
|
|
61
|
+
|
|
62
|
+
This workspace creates a Docker container running the OpenHands agent server,
|
|
63
|
+
waits for it to become healthy, and then provides remote workspace operations
|
|
64
|
+
through the container's HTTP API.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
with DockerWorkspace(base_image="python:3.12") as workspace:
|
|
68
|
+
result = workspace.execute_command("ls -la")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# Override parent fields with defaults
|
|
72
|
+
working_dir: str = Field(
|
|
73
|
+
default="/workspace",
|
|
74
|
+
description="Working directory inside the container.",
|
|
75
|
+
)
|
|
76
|
+
host: str = Field(
|
|
77
|
+
default="",
|
|
78
|
+
description=("Remote host URL (set automatically during container startup)."),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Docker-specific configuration
|
|
82
|
+
base_image: str | None = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
description="Base Docker image to use for the agent server container. "
|
|
85
|
+
"Mutually exclusive with server_image.",
|
|
86
|
+
)
|
|
87
|
+
server_image: str | None = Field(
|
|
88
|
+
default=None,
|
|
89
|
+
description=(
|
|
90
|
+
"Pre-built agent server image to use. If None, builds from base_image."
|
|
91
|
+
"Mutually exclusive with base_image."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
host_port: int | None = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
description="Port to bind the container to. If None, finds available port.",
|
|
97
|
+
)
|
|
98
|
+
forward_env: list[str] = Field(
|
|
99
|
+
default_factory=lambda: ["DEBUG"],
|
|
100
|
+
description="Environment variables to forward to the container.",
|
|
101
|
+
)
|
|
102
|
+
mount_dir: str | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
description="Optional host directory to mount into the container.",
|
|
105
|
+
)
|
|
106
|
+
detach_logs: bool = Field(
|
|
107
|
+
default=True, description="Whether to stream Docker logs in background."
|
|
108
|
+
)
|
|
109
|
+
target: TargetType = Field(
|
|
110
|
+
default="source", description="Build target for the Docker image."
|
|
111
|
+
)
|
|
112
|
+
platform: PlatformType = Field(
|
|
113
|
+
default="linux/amd64", description="Platform for the Docker image."
|
|
114
|
+
)
|
|
115
|
+
extra_ports: bool = Field(
|
|
116
|
+
default=False,
|
|
117
|
+
description="Whether to expose additional ports (VSCode, VNC).",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
_container_id: str | None = PrivateAttr(default=None)
|
|
121
|
+
_logs_thread: threading.Thread | None = PrivateAttr(default=None)
|
|
122
|
+
_stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event)
|
|
123
|
+
_image: str = PrivateAttr()
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="after")
|
|
126
|
+
def _validate_images(self):
|
|
127
|
+
"""Ensure exactly one of base_image or server_image is provided; cache it."""
|
|
128
|
+
if (self.base_image is None) == (self.server_image is None):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"Exactly one of 'base_image' or 'server_image' must be set."
|
|
131
|
+
)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def model_post_init(self, context: Any) -> None:
|
|
135
|
+
"""Set up the Docker container and initialize the remote workspace."""
|
|
136
|
+
# Determine port
|
|
137
|
+
if self.host_port is None:
|
|
138
|
+
self.host_port = find_available_tcp_port()
|
|
139
|
+
else:
|
|
140
|
+
self.host_port = int(self.host_port)
|
|
141
|
+
|
|
142
|
+
if not check_port_available(self.host_port):
|
|
143
|
+
raise RuntimeError(f"Port {self.host_port} is not available")
|
|
144
|
+
|
|
145
|
+
if self.extra_ports:
|
|
146
|
+
if not check_port_available(self.host_port + 1):
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"Port {self.host_port + 1} is not available for VSCode"
|
|
149
|
+
)
|
|
150
|
+
if not check_port_available(self.host_port + 2):
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"Port {self.host_port + 2} is not available for VNC"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Ensure docker is available
|
|
156
|
+
docker_ver = execute_command(["docker", "version"]).returncode
|
|
157
|
+
if docker_ver != 0:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"Docker is not available. Please install and start "
|
|
160
|
+
"Docker Desktop/daemon."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Build image if needed
|
|
164
|
+
|
|
165
|
+
if self.base_image:
|
|
166
|
+
if "ghcr.io/openhands/agent-server" in self.base_image:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"base_image cannot be a pre-built agent-server image. "
|
|
169
|
+
"Use server_image=... instead."
|
|
170
|
+
)
|
|
171
|
+
build_opts = BuildOptions(
|
|
172
|
+
base_image=self.base_image,
|
|
173
|
+
target=self.target,
|
|
174
|
+
platforms=[self.platform],
|
|
175
|
+
push=False,
|
|
176
|
+
)
|
|
177
|
+
tags = build(opts=build_opts)
|
|
178
|
+
assert tags and len(tags) > 0, "Build failed, no image tags returned"
|
|
179
|
+
self._image = tags[0]
|
|
180
|
+
|
|
181
|
+
elif self.server_image:
|
|
182
|
+
self._image = self.server_image
|
|
183
|
+
else:
|
|
184
|
+
raise RuntimeError("Unreachable: one of base_image or server_image is set")
|
|
185
|
+
|
|
186
|
+
# Prepare Docker run flags
|
|
187
|
+
flags: list[str] = []
|
|
188
|
+
for key in self.forward_env:
|
|
189
|
+
if key in os.environ:
|
|
190
|
+
flags += ["-e", f"{key}={os.environ[key]}"]
|
|
191
|
+
|
|
192
|
+
if self.mount_dir:
|
|
193
|
+
mount_path = "/workspace"
|
|
194
|
+
flags += ["-v", f"{self.mount_dir}:{mount_path}"]
|
|
195
|
+
logger.info(
|
|
196
|
+
"Mounting host dir %s to container path %s",
|
|
197
|
+
self.mount_dir,
|
|
198
|
+
mount_path,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
ports = ["-p", f"{self.host_port}:8000"]
|
|
202
|
+
if self.extra_ports:
|
|
203
|
+
ports += [
|
|
204
|
+
"-p",
|
|
205
|
+
f"{self.host_port + 1}:8001", # VSCode
|
|
206
|
+
"-p",
|
|
207
|
+
f"{self.host_port + 2}:8002", # Desktop VNC
|
|
208
|
+
]
|
|
209
|
+
flags += ports
|
|
210
|
+
|
|
211
|
+
# Run container
|
|
212
|
+
run_cmd = [
|
|
213
|
+
"docker",
|
|
214
|
+
"run",
|
|
215
|
+
"-d",
|
|
216
|
+
"--platform",
|
|
217
|
+
self.platform,
|
|
218
|
+
"--rm",
|
|
219
|
+
"--name",
|
|
220
|
+
f"agent-server-{uuid.uuid4()}",
|
|
221
|
+
*flags,
|
|
222
|
+
self._image,
|
|
223
|
+
"--host",
|
|
224
|
+
"0.0.0.0",
|
|
225
|
+
"--port",
|
|
226
|
+
"8000",
|
|
227
|
+
]
|
|
228
|
+
proc = execute_command(run_cmd)
|
|
229
|
+
if proc.returncode != 0:
|
|
230
|
+
raise RuntimeError(f"Failed to run docker container: {proc.stderr}")
|
|
231
|
+
|
|
232
|
+
self._container_id = proc.stdout.strip()
|
|
233
|
+
logger.info("Started container: %s", self._container_id)
|
|
234
|
+
|
|
235
|
+
# Optionally stream logs in background
|
|
236
|
+
if self.detach_logs:
|
|
237
|
+
self._logs_thread = threading.Thread(
|
|
238
|
+
target=self._stream_docker_logs, daemon=True
|
|
239
|
+
)
|
|
240
|
+
self._logs_thread.start()
|
|
241
|
+
|
|
242
|
+
# Set host for RemoteWorkspace to use
|
|
243
|
+
# The container exposes port 8000, mapped to self.host_port
|
|
244
|
+
# Override parent's host initialization
|
|
245
|
+
object.__setattr__(self, "host", f"http://localhost:{self.host_port}")
|
|
246
|
+
object.__setattr__(self, "api_key", None)
|
|
247
|
+
|
|
248
|
+
# Wait for container to be healthy
|
|
249
|
+
self._wait_for_health()
|
|
250
|
+
logger.info("Docker workspace is ready at %s", self.host)
|
|
251
|
+
|
|
252
|
+
# Now initialize the parent RemoteWorkspace with the container URL
|
|
253
|
+
super().model_post_init(context)
|
|
254
|
+
|
|
255
|
+
def _stream_docker_logs(self) -> None:
|
|
256
|
+
"""Stream Docker logs to stdout in the background."""
|
|
257
|
+
if not self._container_id:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
p = subprocess.Popen(
|
|
261
|
+
["docker", "logs", "-f", self._container_id],
|
|
262
|
+
stdout=subprocess.PIPE,
|
|
263
|
+
stderr=subprocess.STDOUT,
|
|
264
|
+
text=True,
|
|
265
|
+
)
|
|
266
|
+
if p.stdout is None:
|
|
267
|
+
return
|
|
268
|
+
for line in iter(p.stdout.readline, ""):
|
|
269
|
+
if self._stop_logs.is_set():
|
|
270
|
+
break
|
|
271
|
+
if line:
|
|
272
|
+
sys.stdout.write(f"[DOCKER] {line}")
|
|
273
|
+
sys.stdout.flush()
|
|
274
|
+
except Exception as e:
|
|
275
|
+
sys.stderr.write(f"Error streaming docker logs: {e}\n")
|
|
276
|
+
finally:
|
|
277
|
+
try:
|
|
278
|
+
self._stop_logs.set()
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
def _wait_for_health(self, timeout: float = 120.0) -> None:
|
|
283
|
+
"""Wait for the Docker container to become healthy."""
|
|
284
|
+
start = time.time()
|
|
285
|
+
health_url = f"http://127.0.0.1:{self.host_port}/health"
|
|
286
|
+
|
|
287
|
+
while time.time() - start < timeout:
|
|
288
|
+
try:
|
|
289
|
+
with urlopen(health_url, timeout=1.0) as resp:
|
|
290
|
+
if 200 <= getattr(resp, "status", 200) < 300:
|
|
291
|
+
return
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
# Check if container is still running
|
|
296
|
+
if self._container_id:
|
|
297
|
+
ps = execute_command(
|
|
298
|
+
[
|
|
299
|
+
"docker",
|
|
300
|
+
"inspect",
|
|
301
|
+
"-f",
|
|
302
|
+
"{{.State.Running}}",
|
|
303
|
+
self._container_id,
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
if ps.stdout.strip() != "true":
|
|
307
|
+
logs = execute_command(["docker", "logs", self._container_id])
|
|
308
|
+
msg = (
|
|
309
|
+
"Container stopped unexpectedly. Logs:\n"
|
|
310
|
+
f"{logs.stdout}\n{logs.stderr}"
|
|
311
|
+
)
|
|
312
|
+
raise RuntimeError(msg)
|
|
313
|
+
time.sleep(1)
|
|
314
|
+
raise RuntimeError("Container failed to become healthy in time")
|
|
315
|
+
|
|
316
|
+
def __enter__(self) -> "DockerWorkspace":
|
|
317
|
+
"""Context manager entry - returns the workspace itself."""
|
|
318
|
+
return self
|
|
319
|
+
|
|
320
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
321
|
+
"""Context manager exit - cleans up the Docker container."""
|
|
322
|
+
self.cleanup()
|
|
323
|
+
|
|
324
|
+
def __del__(self) -> None:
|
|
325
|
+
"""Clean up the Docker container when the workspace is destroyed."""
|
|
326
|
+
self.cleanup()
|
|
327
|
+
|
|
328
|
+
def cleanup(self) -> None:
|
|
329
|
+
"""Stop and remove the Docker container."""
|
|
330
|
+
if self._container_id:
|
|
331
|
+
# Stop logs streaming
|
|
332
|
+
self._stop_logs.set()
|
|
333
|
+
if self._logs_thread and self._logs_thread.is_alive():
|
|
334
|
+
self._logs_thread.join(timeout=2)
|
|
335
|
+
|
|
336
|
+
# Stop and remove the container
|
|
337
|
+
logger.info("Stopping container: %s", self._container_id)
|
|
338
|
+
execute_command(["docker", "stop", self._container_id])
|
|
339
|
+
self._container_id = None
|
|
File without changes
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""API-based remote workspace implementation using runtime API."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.request import urlopen
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import tenacity
|
|
9
|
+
from pydantic import Field, PrivateAttr
|
|
10
|
+
|
|
11
|
+
from openhands.sdk.logger import get_logger
|
|
12
|
+
from openhands.sdk.workspace.remote.base import RemoteWorkspace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIRemoteWorkspace(RemoteWorkspace):
|
|
19
|
+
"""Remote workspace using OpenHands runtime API.
|
|
20
|
+
|
|
21
|
+
Runtime API: https://runtime.all-hands.dev/
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
workspace = APIRemoteWorkspace(
|
|
25
|
+
runtime_api_url="https://runtime.eval.all-hands.dev",
|
|
26
|
+
runtime_api_key="your-api-key",
|
|
27
|
+
server_image="ghcr.io/openhands/agent-server:lastest-python",
|
|
28
|
+
)
|
|
29
|
+
""" # noqa: E501
|
|
30
|
+
|
|
31
|
+
# Parent fields
|
|
32
|
+
working_dir: str = Field(
|
|
33
|
+
default="/workspace",
|
|
34
|
+
description="Working directory inside the remote workspace",
|
|
35
|
+
)
|
|
36
|
+
host: str = Field(
|
|
37
|
+
default="undefined",
|
|
38
|
+
description="The remote host URL for the workspace."
|
|
39
|
+
" It will be set to the runtime URL after connecting.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Runtime API fields
|
|
43
|
+
runtime_api_url: str = Field(description="Base URL of the runtime API")
|
|
44
|
+
runtime_api_key: str = Field(description="API key for authentication")
|
|
45
|
+
server_image: str = Field(
|
|
46
|
+
description="Container image for the agent server. "
|
|
47
|
+
"It must be a public image or in a registry accessible by runtime API."
|
|
48
|
+
)
|
|
49
|
+
session_id: str | None = Field(
|
|
50
|
+
default_factory=lambda: f"agent-server-{uuid.uuid4()}",
|
|
51
|
+
description="Session ID (auto-generated if None)",
|
|
52
|
+
)
|
|
53
|
+
resource_factor: int = Field(
|
|
54
|
+
default=1, description="Resource scaling (1, 2, 4, or 8)"
|
|
55
|
+
)
|
|
56
|
+
runtime_class: str | None = Field(
|
|
57
|
+
default="sysbox", description="Runtime class (e.g., 'sysbox')"
|
|
58
|
+
)
|
|
59
|
+
init_timeout: float = Field(
|
|
60
|
+
default=300.0, description="Runtime init timeout (seconds)"
|
|
61
|
+
)
|
|
62
|
+
api_timeout: float = Field(
|
|
63
|
+
default=60.0, description="API request timeout (seconds)"
|
|
64
|
+
)
|
|
65
|
+
keep_alive: bool = Field(default=False, description="Keep runtime alive on cleanup")
|
|
66
|
+
pause_on_close: bool = Field(
|
|
67
|
+
default=False, description="Pause instead of stop on cleanup"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_runtime_id: str | None = PrivateAttr(default=None)
|
|
71
|
+
_runtime_url: str | None = PrivateAttr(default=None)
|
|
72
|
+
_session_api_key: str | None = PrivateAttr(default=None)
|
|
73
|
+
|
|
74
|
+
def model_post_init(self, context: Any) -> None:
|
|
75
|
+
"""Set up the remote runtime and initialize the workspace."""
|
|
76
|
+
if self.resource_factor not in [1, 2, 4, 8]:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"resource_factor must be 1, 2, 4, or 8, got {self.resource_factor}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.runtime_api_url = self.runtime_api_url.rstrip("/")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
self._start_or_attach_to_runtime()
|
|
85
|
+
super().model_post_init(context)
|
|
86
|
+
except Exception:
|
|
87
|
+
self.cleanup()
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
def _start_or_attach_to_runtime(self) -> None:
|
|
91
|
+
"""Start or attach to an existing runtime."""
|
|
92
|
+
if not self._check_existing_runtime():
|
|
93
|
+
self._start_runtime()
|
|
94
|
+
|
|
95
|
+
assert self._runtime_id and self._runtime_url, "Runtime ID/URL not set"
|
|
96
|
+
self._wait_until_runtime_alive()
|
|
97
|
+
logger.info(f"Runtime ready at {self._runtime_url}")
|
|
98
|
+
self.host = self._runtime_url.rstrip("/")
|
|
99
|
+
self.api_key = self._session_api_key
|
|
100
|
+
|
|
101
|
+
def _check_existing_runtime(self) -> bool:
|
|
102
|
+
"""Check if there's an existing runtime for this session."""
|
|
103
|
+
try:
|
|
104
|
+
resp = self._send_api_request(
|
|
105
|
+
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
|
|
106
|
+
)
|
|
107
|
+
data = resp.json()
|
|
108
|
+
status = data.get("status")
|
|
109
|
+
logger.info(f"Runtime status: {status}")
|
|
110
|
+
|
|
111
|
+
if status in ("running", "paused"):
|
|
112
|
+
self._parse_runtime_response(resp)
|
|
113
|
+
if status == "paused":
|
|
114
|
+
try:
|
|
115
|
+
self._resume_runtime()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"Resume failed: {e}")
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
except httpx.HTTPStatusError as e:
|
|
122
|
+
if e.response.status_code == 404:
|
|
123
|
+
return False
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
def _start_runtime(self) -> None:
|
|
127
|
+
"""Start a new runtime."""
|
|
128
|
+
# For binary target, use the standalone binary
|
|
129
|
+
payload: dict[str, Any] = {
|
|
130
|
+
"image": self.server_image,
|
|
131
|
+
"command": "/usr/local/bin/openhands-agent-server --port 60000",
|
|
132
|
+
"working_dir": "/", # Match Dockerfile WORKDIR
|
|
133
|
+
"environment": {},
|
|
134
|
+
"session_id": self.session_id,
|
|
135
|
+
"run_as_user": 10001,
|
|
136
|
+
"fs_group": 10001,
|
|
137
|
+
# "environment": {"DEBUG": "true"},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if self.runtime_class:
|
|
141
|
+
payload["runtime_class"] = self.runtime_class
|
|
142
|
+
if self.resource_factor != 1:
|
|
143
|
+
payload["resource_factor"] = self.resource_factor
|
|
144
|
+
|
|
145
|
+
logger.info(f"Starting runtime with {self.server_image}")
|
|
146
|
+
logger.info(f"Payload: {payload}")
|
|
147
|
+
resp = self._send_api_request(
|
|
148
|
+
"POST",
|
|
149
|
+
f"{self.runtime_api_url}/start",
|
|
150
|
+
json=payload,
|
|
151
|
+
timeout=self.init_timeout,
|
|
152
|
+
)
|
|
153
|
+
self._parse_runtime_response(resp)
|
|
154
|
+
logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}")
|
|
155
|
+
|
|
156
|
+
def _resume_runtime(self) -> None:
|
|
157
|
+
"""Resume a paused runtime."""
|
|
158
|
+
resp = self._send_api_request(
|
|
159
|
+
"POST",
|
|
160
|
+
f"{self.runtime_api_url}/resume",
|
|
161
|
+
json={"runtime_id": self._runtime_id},
|
|
162
|
+
timeout=self.init_timeout,
|
|
163
|
+
)
|
|
164
|
+
self._parse_runtime_response(resp)
|
|
165
|
+
|
|
166
|
+
def _parse_runtime_response(self, response: httpx.Response) -> None:
|
|
167
|
+
"""Parse the runtime response and extract connection info."""
|
|
168
|
+
data = response.json()
|
|
169
|
+
self._runtime_id = data.get("runtime_id") or data.get("id")
|
|
170
|
+
self._runtime_url = data.get("url")
|
|
171
|
+
self._session_api_key = data.get("session_api_key")
|
|
172
|
+
if not self._runtime_id or not self._runtime_url:
|
|
173
|
+
raise ValueError(f"Invalid runtime response: {data}")
|
|
174
|
+
|
|
175
|
+
@tenacity.retry(
|
|
176
|
+
stop=tenacity.stop_after_delay(300),
|
|
177
|
+
wait=tenacity.wait_exponential(multiplier=1, min=2, max=10),
|
|
178
|
+
retry=tenacity.retry_if_exception_type(RuntimeError),
|
|
179
|
+
reraise=True,
|
|
180
|
+
)
|
|
181
|
+
def _wait_until_runtime_alive(self) -> None:
|
|
182
|
+
"""Wait until the runtime becomes alive and responsive."""
|
|
183
|
+
logger.info("Waiting for runtime to become alive...")
|
|
184
|
+
|
|
185
|
+
resp = self._send_api_request(
|
|
186
|
+
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
|
|
187
|
+
)
|
|
188
|
+
data = resp.json()
|
|
189
|
+
pod_status = data.get("pod_status", "").lower()
|
|
190
|
+
logger.info(f"Pod status: {pod_status}")
|
|
191
|
+
|
|
192
|
+
# Log additional details for debugging
|
|
193
|
+
if pod_status == "pending":
|
|
194
|
+
container_statuses = data.get("container_statuses", [])
|
|
195
|
+
events = data.get("events", [])
|
|
196
|
+
if container_statuses:
|
|
197
|
+
logger.warning(f"Container statuses: {container_statuses}")
|
|
198
|
+
if events:
|
|
199
|
+
logger.warning(f"Pod events: {events}")
|
|
200
|
+
logger.debug(f"Full response: {data}")
|
|
201
|
+
|
|
202
|
+
restart_count = data.get("restart_count", 0)
|
|
203
|
+
if restart_count > 0:
|
|
204
|
+
restart_reasons = data.get("restart_reasons", [])
|
|
205
|
+
logger.warning(f"Pod restarts: {restart_count}, reasons: {restart_reasons}")
|
|
206
|
+
|
|
207
|
+
# Handle different pod states
|
|
208
|
+
if pod_status == "ready":
|
|
209
|
+
# Pod is ready, check health endpoint
|
|
210
|
+
health_url = f"{self._runtime_url}/health"
|
|
211
|
+
logger.info(f"Checking health at: {health_url}")
|
|
212
|
+
try:
|
|
213
|
+
with urlopen(health_url, timeout=5.0) as resp:
|
|
214
|
+
status = getattr(resp, "status", 200)
|
|
215
|
+
logger.info(f"Health check response: {status}")
|
|
216
|
+
if 200 <= status < 300:
|
|
217
|
+
logger.info("Runtime is alive!")
|
|
218
|
+
return
|
|
219
|
+
raise RuntimeError(f"Health check failed with status: {status}")
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Health check failed: {e}")
|
|
222
|
+
raise RuntimeError(f"Runtime /health failed: {e}")
|
|
223
|
+
elif pod_status in ("not found", "pending", "running"):
|
|
224
|
+
# Transient states - continue retrying
|
|
225
|
+
logger.debug(f"Runtime not yet ready. Status: {pod_status}")
|
|
226
|
+
raise RuntimeError(f"Runtime not yet ready (status: {pod_status})")
|
|
227
|
+
elif pod_status in ("failed", "unknown", "crashloopbackoff"):
|
|
228
|
+
# Terminal failure states
|
|
229
|
+
pod_logs = data.get("pod_logs", "")
|
|
230
|
+
error_msg = f"Runtime failed (status: {pod_status})"
|
|
231
|
+
if pod_logs:
|
|
232
|
+
logger.error(f"Pod logs: {pod_logs}")
|
|
233
|
+
error_msg += f"\nPod logs: {pod_logs}"
|
|
234
|
+
if pod_status == "crashloopbackoff":
|
|
235
|
+
error_msg = (
|
|
236
|
+
"Runtime crashed and is restarting (possibly OOM). Try again."
|
|
237
|
+
)
|
|
238
|
+
raise ValueError(error_msg)
|
|
239
|
+
else:
|
|
240
|
+
# Unknown status - log and retry
|
|
241
|
+
logger.warning(f"Unknown pod status: {pod_status}, full response: {data}")
|
|
242
|
+
raise RuntimeError(f"Unknown pod status: {pod_status}")
|
|
243
|
+
|
|
244
|
+
def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
245
|
+
"""Send an API request with error handling."""
|
|
246
|
+
logger.debug(f"Sending {method} request to {url}")
|
|
247
|
+
logger.debug(f"Client headers: {self._headers}")
|
|
248
|
+
response = self.client.request(method, url, **kwargs)
|
|
249
|
+
try:
|
|
250
|
+
response.raise_for_status()
|
|
251
|
+
except httpx.HTTPStatusError:
|
|
252
|
+
logger.debug(f"Request headers: {response.request.headers}")
|
|
253
|
+
try:
|
|
254
|
+
error_detail = response.json()
|
|
255
|
+
logger.info(f"API request failed: {error_detail}")
|
|
256
|
+
except Exception:
|
|
257
|
+
logger.info(f"API request failed: {response.text}")
|
|
258
|
+
raise
|
|
259
|
+
return response
|
|
260
|
+
|
|
261
|
+
def cleanup(self) -> None:
|
|
262
|
+
"""Clean up the remote runtime."""
|
|
263
|
+
if not self._runtime_id:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
if self.keep_alive:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
action = "pause" if self.pause_on_close else "stop"
|
|
271
|
+
logger.info(f"{action.capitalize()}ing runtime {self._runtime_id}")
|
|
272
|
+
self._send_api_request(
|
|
273
|
+
"POST",
|
|
274
|
+
f"{self.runtime_api_url}/{action}",
|
|
275
|
+
json={"runtime_id": self._runtime_id},
|
|
276
|
+
timeout=30.0,
|
|
277
|
+
)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.error(f"Cleanup error: {e}")
|
|
280
|
+
finally:
|
|
281
|
+
self._runtime_id = None
|
|
282
|
+
self._runtime_url = None
|
|
283
|
+
self._session_api_key = None
|
|
284
|
+
try:
|
|
285
|
+
self.client.close()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
def __del__(self) -> None:
|
|
290
|
+
self.cleanup()
|
|
291
|
+
|
|
292
|
+
def __enter__(self) -> "APIRemoteWorkspace":
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
296
|
+
self.cleanup()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
openhands/workspace/__init__.py,sha256=0UHu2lU1tjXhVcJSU6zQUaa3MLbqOLgGF8WdPSmjCPY,227
|
|
2
|
+
openhands/workspace/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
openhands/workspace/docker/__init__.py,sha256=axwSNLItKWxyxbZ1VuKKTrvnVAxALC9b2ZlTatnBJRE,111
|
|
4
|
+
openhands/workspace/docker/workspace.py,sha256=H1QiQqr_xig0Xcgc4_sDjeTng7XYGNj4aGPpfsS8dx8,11499
|
|
5
|
+
openhands/workspace/remote_api/__init__.py,sha256=RxDLFcveTGG-uLm-PInfxFnYwYPdehRmLf1r0_KTaP4,122
|
|
6
|
+
openhands/workspace/remote_api/workspace.py,sha256=zWXoq80wxIYA-BORl1ebqLzOTSEdJYan4QEqsk_YY0g,11363
|
|
7
|
+
openhands_workspace-1.0.0.dist-info/METADATA,sha256=PylvdehS6-WarDQNKn7SfmZZ6i0RnRq5F6nmTcwE0Fk,232
|
|
8
|
+
openhands_workspace-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
openhands_workspace-1.0.0.dist-info/top_level.txt,sha256=jHgVu9I0Blam8BXFgedoGKfglPF8XvW1TsJFIjcgP4E,10
|
|
10
|
+
openhands_workspace-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openhands
|