levain-runtime 0.1.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.
- levain_runtime/__init__.py +5 -0
- levain_runtime/__main__.py +3 -0
- levain_runtime/chisel.py +153 -0
- levain_runtime/cli.py +365 -0
- levain_runtime/config.py +117 -0
- levain_runtime/heartbeat.py +47 -0
- levain_runtime/platform_api.py +83 -0
- levain_runtime/process.py +172 -0
- levain_runtime/protocol.py +9 -0
- levain_runtime/sandbox/node_runner.py.vendored +304 -0
- levain_runtime/sandbox/run_logging.py.vendored +153 -0
- levain_runtime/server.py +226 -0
- levain_runtime/sessions.py +721 -0
- levain_runtime-0.1.2.dist-info/METADATA +81 -0
- levain_runtime-0.1.2.dist-info/RECORD +18 -0
- levain_runtime-0.1.2.dist-info/WHEEL +4 -0
- levain_runtime-0.1.2.dist-info/entry_points.txt +2 -0
- levain_runtime-0.1.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""Session registry and the two execution backends (direct host, docker)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import tarfile
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import IO, Any, Literal
|
|
16
|
+
|
|
17
|
+
from .config import RuntimeConfig
|
|
18
|
+
from .process import host_env, run_process, stream_process
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("levain_runtime")
|
|
21
|
+
|
|
22
|
+
State = Literal["active", "paused"]
|
|
23
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
24
|
+
_SESSION_FILE = "session.json"
|
|
25
|
+
# Trees under these roots are host bind mounts in docker mode, so they
|
|
26
|
+
# are extracted on the host for both backends.
|
|
27
|
+
_HOST_TREE_ROOTS = ("/recipes", "/sdk")
|
|
28
|
+
_CLAUDE_READY_SENTINEL = "/tmp/.levain-runtime-ready"
|
|
29
|
+
|
|
30
|
+
Runner = Callable[..., Awaitable[tuple[int, bytes, bytes]]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuntimeBusy(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnknownSession(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MissingTarget(Exception):
|
|
42
|
+
"""``link`` asked for a target tree that is not on this runtime."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SessionMeta:
|
|
47
|
+
sid: str
|
|
48
|
+
workspace_id: str
|
|
49
|
+
agent_id: str | None
|
|
50
|
+
workspace_scope: str | None
|
|
51
|
+
workspace_user: str | None
|
|
52
|
+
env: dict[str, str]
|
|
53
|
+
workspace_dir: str
|
|
54
|
+
state: State = "paused"
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return asdict(self)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, raw: dict[str, Any]) -> SessionMeta:
|
|
61
|
+
return cls(**raw)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_abs_path(path: str) -> str:
|
|
65
|
+
if not path.startswith("/") or ".." in Path(path).parts:
|
|
66
|
+
raise ValueError("path must be absolute and contain no '..'")
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_id(value: str, what: str) -> str:
|
|
71
|
+
if not _ID_RE.match(value):
|
|
72
|
+
raise ValueError(f"invalid {what}")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_tar(src: IO[bytes], dest: Path) -> None:
|
|
77
|
+
"""Unpack ``src`` at ``dest`` atomically; keep ``dest`` if it exists.
|
|
78
|
+
|
|
79
|
+
Trees are content-addressed, so an existing ``dest`` already holds
|
|
80
|
+
these bytes. Staging next to it means a failed extraction never
|
|
81
|
+
leaves a half tree that a later probe would mistake for a full one.
|
|
82
|
+
"""
|
|
83
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = dest.parent / f".{dest.name}.{uuid.uuid4().hex[:8]}"
|
|
85
|
+
tmp.mkdir()
|
|
86
|
+
try:
|
|
87
|
+
with tarfile.open(fileobj=src, mode="r:") as tar:
|
|
88
|
+
# The data filter rejects absolute members and ``..`` escapes.
|
|
89
|
+
tar.extractall(tmp, filter="data")
|
|
90
|
+
try:
|
|
91
|
+
os.rename(tmp, dest)
|
|
92
|
+
except OSError:
|
|
93
|
+
if not dest.is_dir():
|
|
94
|
+
raise
|
|
95
|
+
finally:
|
|
96
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def flip_link(link: Path, target: str, *, exists_at: Path) -> None:
|
|
100
|
+
"""Point ``link`` at ``target`` in one ``rename(2)``.
|
|
101
|
+
|
|
102
|
+
``target`` is the string stored in the symlink (the path as nodes
|
|
103
|
+
see it); ``exists_at`` is where that tree lives from here, which
|
|
104
|
+
differs in docker mode where ``/recipes`` is a bind mount.
|
|
105
|
+
"""
|
|
106
|
+
if not exists_at.is_dir():
|
|
107
|
+
raise MissingTarget(f"{target} is not on this runtime")
|
|
108
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
tmp = link.parent / f".{link.name}.{uuid.uuid4().hex[:8]}"
|
|
110
|
+
os.symlink(target, tmp)
|
|
111
|
+
try:
|
|
112
|
+
os.replace(tmp, link)
|
|
113
|
+
except OSError:
|
|
114
|
+
tmp.unlink(missing_ok=True)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SessionStore:
|
|
119
|
+
"""On-disk session metadata under ``<root>/sessions/<sid>/``."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, root: Path) -> None:
|
|
122
|
+
self.root = root
|
|
123
|
+
self._cache: dict[str, SessionMeta] = {}
|
|
124
|
+
|
|
125
|
+
def dir(self, sid: str) -> Path:
|
|
126
|
+
return self.root / "sessions" / sid
|
|
127
|
+
|
|
128
|
+
def get(self, sid: str) -> SessionMeta | None:
|
|
129
|
+
if sid in self._cache:
|
|
130
|
+
return self._cache[sid]
|
|
131
|
+
path = self.dir(sid) / _SESSION_FILE
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return None
|
|
134
|
+
meta = SessionMeta.from_dict(json.loads(path.read_text()))
|
|
135
|
+
self._cache[sid] = meta
|
|
136
|
+
return meta
|
|
137
|
+
|
|
138
|
+
def save(self, meta: SessionMeta) -> None:
|
|
139
|
+
self.dir(meta.sid).mkdir(parents=True, exist_ok=True)
|
|
140
|
+
path = self.dir(meta.sid) / _SESSION_FILE
|
|
141
|
+
tmp = path.with_suffix(".tmp")
|
|
142
|
+
tmp.write_text(json.dumps(meta.to_dict()))
|
|
143
|
+
os.replace(tmp, path)
|
|
144
|
+
self._cache[meta.sid] = meta
|
|
145
|
+
|
|
146
|
+
def delete(self, sid: str, *, keep_workspace: bool) -> None:
|
|
147
|
+
self._cache.pop(sid, None)
|
|
148
|
+
sdir = self.dir(sid)
|
|
149
|
+
if not sdir.exists():
|
|
150
|
+
return
|
|
151
|
+
if keep_workspace:
|
|
152
|
+
(sdir / _SESSION_FILE).unlink(missing_ok=True)
|
|
153
|
+
return
|
|
154
|
+
shutil.rmtree(sdir, ignore_errors=True)
|
|
155
|
+
|
|
156
|
+
def all(self) -> list[SessionMeta]:
|
|
157
|
+
base = self.root / "sessions"
|
|
158
|
+
if not base.is_dir():
|
|
159
|
+
return []
|
|
160
|
+
out: list[SessionMeta] = []
|
|
161
|
+
for child in base.iterdir():
|
|
162
|
+
if child.name.startswith("."):
|
|
163
|
+
continue
|
|
164
|
+
meta = self.get(child.name)
|
|
165
|
+
if meta is not None:
|
|
166
|
+
out.append(meta)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
def workspace_dir(
|
|
170
|
+
self,
|
|
171
|
+
sid: str,
|
|
172
|
+
*,
|
|
173
|
+
agent_id: str | None,
|
|
174
|
+
scope: str | None,
|
|
175
|
+
user: str | None,
|
|
176
|
+
) -> Path:
|
|
177
|
+
if scope == "agent" and agent_id:
|
|
178
|
+
return (
|
|
179
|
+
self.root
|
|
180
|
+
/ "agents"
|
|
181
|
+
/ _check_id(agent_id, "agent_id")
|
|
182
|
+
/ "workspace"
|
|
183
|
+
)
|
|
184
|
+
if scope == "user" and agent_id and user:
|
|
185
|
+
return (
|
|
186
|
+
self.root
|
|
187
|
+
/ "agents"
|
|
188
|
+
/ _check_id(agent_id, "agent_id")
|
|
189
|
+
/ "users"
|
|
190
|
+
/ _check_id(user, "workspace_user")
|
|
191
|
+
/ "workspace"
|
|
192
|
+
)
|
|
193
|
+
return self.dir(sid) / "workspace"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class DirectBackend:
|
|
197
|
+
"""Nodes run as host processes; one active session owns ``/workspace``."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, root: Path) -> None:
|
|
200
|
+
self.root = root
|
|
201
|
+
self.active: str | None = None
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def idle_workspace(self) -> Path:
|
|
205
|
+
return self.root / "sessions" / ".idle" / "workspace"
|
|
206
|
+
|
|
207
|
+
def _point_workspace(self, target: Path) -> None:
|
|
208
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
link = self.root / "workspace"
|
|
210
|
+
tmp = self.root / f".workspace.{uuid.uuid4().hex[:8]}"
|
|
211
|
+
os.symlink(target, tmp)
|
|
212
|
+
os.replace(tmp, link)
|
|
213
|
+
|
|
214
|
+
async def activate(self, meta: SessionMeta) -> None:
|
|
215
|
+
if self.active not in (None, meta.sid):
|
|
216
|
+
raise RuntimeBusy("runtime busy")
|
|
217
|
+
self._point_workspace(Path(meta.workspace_dir))
|
|
218
|
+
self.active = meta.sid
|
|
219
|
+
|
|
220
|
+
async def release(self, meta: SessionMeta) -> None:
|
|
221
|
+
if self.active == meta.sid:
|
|
222
|
+
self.active = None
|
|
223
|
+
self._point_workspace(self.idle_workspace)
|
|
224
|
+
|
|
225
|
+
async def destroy(self, meta: SessionMeta) -> None:
|
|
226
|
+
await self.release(meta)
|
|
227
|
+
|
|
228
|
+
def status(self, meta: SessionMeta) -> State:
|
|
229
|
+
return "active" if self.active == meta.sid else "paused"
|
|
230
|
+
|
|
231
|
+
def _env(self, meta: SessionMeta, extra: dict[str, str]) -> dict[str, str]:
|
|
232
|
+
return {**host_env(), **meta.env, **extra}
|
|
233
|
+
|
|
234
|
+
def exec(
|
|
235
|
+
self,
|
|
236
|
+
meta: SessionMeta,
|
|
237
|
+
cmd: str,
|
|
238
|
+
env: dict[str, str],
|
|
239
|
+
*,
|
|
240
|
+
stdin: str | None,
|
|
241
|
+
timeout: float,
|
|
242
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
243
|
+
return stream_process(
|
|
244
|
+
["/bin/sh", "-c", cmd],
|
|
245
|
+
env=self._env(meta, env),
|
|
246
|
+
stdin=stdin.encode() if stdin is not None else None,
|
|
247
|
+
timeout=timeout,
|
|
248
|
+
cwd=meta.workspace_dir
|
|
249
|
+
if Path(meta.workspace_dir).is_dir()
|
|
250
|
+
else None,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def capture(
|
|
254
|
+
self,
|
|
255
|
+
meta: SessionMeta,
|
|
256
|
+
cmd: str,
|
|
257
|
+
env: dict[str, str],
|
|
258
|
+
*,
|
|
259
|
+
timeout: float,
|
|
260
|
+
) -> tuple[str, int]:
|
|
261
|
+
rc, out, _ = await run_process(
|
|
262
|
+
["/bin/sh", "-c", cmd],
|
|
263
|
+
env=self._env(meta, env),
|
|
264
|
+
timeout=timeout,
|
|
265
|
+
cwd=meta.workspace_dir
|
|
266
|
+
if Path(meta.workspace_dir).is_dir()
|
|
267
|
+
else None,
|
|
268
|
+
)
|
|
269
|
+
return out.decode("utf-8", "replace"), rc
|
|
270
|
+
|
|
271
|
+
async def put_file(
|
|
272
|
+
self, meta: SessionMeta, path: str, data: bytes
|
|
273
|
+
) -> None:
|
|
274
|
+
p = Path(path)
|
|
275
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
p.write_bytes(data)
|
|
277
|
+
|
|
278
|
+
async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
|
|
279
|
+
p = Path(path)
|
|
280
|
+
return p.read_bytes() if p.is_file() else None
|
|
281
|
+
|
|
282
|
+
async def put_tree(
|
|
283
|
+
self, meta: SessionMeta, path: str, src: IO[bytes]
|
|
284
|
+
) -> None:
|
|
285
|
+
extract_tar(src, Path(path))
|
|
286
|
+
|
|
287
|
+
async def link(self, meta: SessionMeta, path: str, target: str) -> None:
|
|
288
|
+
flip_link(Path(path), target, exists_at=Path(target))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class DockerBackend:
|
|
292
|
+
"""One container per session; the runtime home is bind-mounted in."""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
root: Path,
|
|
297
|
+
image: str,
|
|
298
|
+
*,
|
|
299
|
+
share_claude_login: bool = True,
|
|
300
|
+
runner: Runner = run_process,
|
|
301
|
+
streamer: Callable[
|
|
302
|
+
..., AsyncIterator[dict[str, Any]]
|
|
303
|
+
] = stream_process,
|
|
304
|
+
) -> None:
|
|
305
|
+
self.root = root
|
|
306
|
+
self.image = image
|
|
307
|
+
self.share_claude_login = share_claude_login
|
|
308
|
+
self._run = runner
|
|
309
|
+
self._stream = streamer
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def container_name(sid: str) -> str:
|
|
313
|
+
return "levain-rt-" + sid.replace("-", "")[:12]
|
|
314
|
+
|
|
315
|
+
def _claude_binds(self) -> list[str]:
|
|
316
|
+
if not self.share_claude_login:
|
|
317
|
+
return []
|
|
318
|
+
binds: list[str] = []
|
|
319
|
+
cfg = Path(
|
|
320
|
+
os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"
|
|
321
|
+
).expanduser()
|
|
322
|
+
if cfg.is_dir():
|
|
323
|
+
binds += ["-v", f"{cfg}:/mnt/host-claude:ro"]
|
|
324
|
+
cfg_json = Path("~/.claude.json").expanduser()
|
|
325
|
+
if cfg_json.is_file():
|
|
326
|
+
binds += ["-v", f"{cfg_json}:/mnt/host-claude.json:ro"]
|
|
327
|
+
return binds
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def _startup_script() -> str:
|
|
331
|
+
# Copy the host login into a writable $HOME; the CLI refreshes
|
|
332
|
+
# tokens and writes session state there. Skip the bulky,
|
|
333
|
+
# host-specific directories.
|
|
334
|
+
return "\n".join(
|
|
335
|
+
[
|
|
336
|
+
"if [ -d /mnt/host-claude ]; then",
|
|
337
|
+
' mkdir -p "$HOME/.claude"',
|
|
338
|
+
" for entry in /mnt/host-claude/* /mnt/host-claude/.[!.]*; do",
|
|
339
|
+
' [ -e "$entry" ] || continue',
|
|
340
|
+
' case "$(basename "$entry")" in '
|
|
341
|
+
"sessions|projects|cache|debug|backups|statsig|"
|
|
342
|
+
"shell-snapshots|todos|history) continue ;; esac",
|
|
343
|
+
' cp -r "$entry" "$HOME/.claude/" || echo "warning: copy failed" >&2',
|
|
344
|
+
" done",
|
|
345
|
+
"fi",
|
|
346
|
+
'[ -f /mnt/host-claude.json ] && cp /mnt/host-claude.json "$HOME/.claude.json"',
|
|
347
|
+
f"touch {_CLAUDE_READY_SENTINEL}",
|
|
348
|
+
"exec sleep infinity",
|
|
349
|
+
]
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def create_argv(self, meta: SessionMeta) -> list[str]:
|
|
353
|
+
argv = [
|
|
354
|
+
"docker",
|
|
355
|
+
"run",
|
|
356
|
+
"-d",
|
|
357
|
+
"--name",
|
|
358
|
+
self.container_name(meta.sid),
|
|
359
|
+
"-v",
|
|
360
|
+
f"{meta.workspace_dir}:/workspace",
|
|
361
|
+
"-v",
|
|
362
|
+
f"{self.root / 'recipes'}:/recipes",
|
|
363
|
+
"-v",
|
|
364
|
+
f"{self.root / 'sdk'}:/sdk",
|
|
365
|
+
"-v",
|
|
366
|
+
f"{self.root / 'sandbox'}:/sandbox:ro",
|
|
367
|
+
"-e",
|
|
368
|
+
"PYTHONUNBUFFERED=1",
|
|
369
|
+
]
|
|
370
|
+
for k, v in meta.env.items():
|
|
371
|
+
argv += ["-e", f"{k}={v}"]
|
|
372
|
+
argv += self._claude_binds()
|
|
373
|
+
argv += [self.image, "/bin/sh", "-c", self._startup_script()]
|
|
374
|
+
return argv
|
|
375
|
+
|
|
376
|
+
async def _inspect(self, name: str) -> str | None:
|
|
377
|
+
rc, out, _ = await self._run(
|
|
378
|
+
["docker", "inspect", "-f", "{{.State.Running}}", name],
|
|
379
|
+
timeout=30,
|
|
380
|
+
)
|
|
381
|
+
if rc != 0:
|
|
382
|
+
return None
|
|
383
|
+
return "running" if out.strip() == b"true" else "stopped"
|
|
384
|
+
|
|
385
|
+
async def _wait_ready(self, name: str) -> None:
|
|
386
|
+
rc, _, _ = await self._run(
|
|
387
|
+
[
|
|
388
|
+
"docker",
|
|
389
|
+
"exec",
|
|
390
|
+
name,
|
|
391
|
+
"/bin/sh",
|
|
392
|
+
"-c",
|
|
393
|
+
f"i=0; until [ -f {_CLAUDE_READY_SENTINEL} ]; do"
|
|
394
|
+
" i=$((i+1)); [ $i -ge 300 ] && exit 1; sleep 0.1; done",
|
|
395
|
+
],
|
|
396
|
+
timeout=45,
|
|
397
|
+
)
|
|
398
|
+
if rc != 0:
|
|
399
|
+
raise RuntimeError(f"container {name} did not become ready")
|
|
400
|
+
|
|
401
|
+
async def activate(self, meta: SessionMeta) -> None:
|
|
402
|
+
Path(meta.workspace_dir).mkdir(parents=True, exist_ok=True)
|
|
403
|
+
name = self.container_name(meta.sid)
|
|
404
|
+
state = await self._inspect(name)
|
|
405
|
+
if state == "running":
|
|
406
|
+
return
|
|
407
|
+
if state == "stopped":
|
|
408
|
+
rc, _, err = await self._run(["docker", "start", name], timeout=60)
|
|
409
|
+
if rc == 0:
|
|
410
|
+
return
|
|
411
|
+
log.warning("docker start %s failed: %s; recreating", name, err)
|
|
412
|
+
await self._run(["docker", "rm", "-f", name], timeout=60)
|
|
413
|
+
rc, _, err = await self._run(self.create_argv(meta), timeout=300)
|
|
414
|
+
if rc != 0:
|
|
415
|
+
raise RuntimeError(
|
|
416
|
+
f"docker run failed: {err.decode(errors='replace')}"
|
|
417
|
+
)
|
|
418
|
+
await self._wait_ready(name)
|
|
419
|
+
|
|
420
|
+
async def release(self, meta: SessionMeta) -> None:
|
|
421
|
+
await self._run(
|
|
422
|
+
["docker", "stop", self.container_name(meta.sid)], timeout=60
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
async def destroy(self, meta: SessionMeta) -> None:
|
|
426
|
+
await self._run(
|
|
427
|
+
["docker", "rm", "-f", self.container_name(meta.sid)], timeout=60
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
async def status_async(self, meta: SessionMeta) -> State:
|
|
431
|
+
state = await self._inspect(self.container_name(meta.sid))
|
|
432
|
+
return "active" if state == "running" else "paused"
|
|
433
|
+
|
|
434
|
+
def exec(
|
|
435
|
+
self,
|
|
436
|
+
meta: SessionMeta,
|
|
437
|
+
cmd: str,
|
|
438
|
+
env: dict[str, str],
|
|
439
|
+
*,
|
|
440
|
+
stdin: str | None,
|
|
441
|
+
timeout: float,
|
|
442
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
443
|
+
argv = ["docker", "exec", "-i"]
|
|
444
|
+
for k, v in env.items():
|
|
445
|
+
argv += ["-e", f"{k}={v}"]
|
|
446
|
+
argv.append(self.container_name(meta.sid))
|
|
447
|
+
if timeout and timeout > 0:
|
|
448
|
+
# Kill inside the container too; the host watchdog only
|
|
449
|
+
# reaches the docker client process.
|
|
450
|
+
argv += ["timeout", "-s", "KILL", str(int(timeout) + 1)]
|
|
451
|
+
argv += ["/bin/sh", "-c", cmd]
|
|
452
|
+
return self._stream(
|
|
453
|
+
argv,
|
|
454
|
+
env=host_env(),
|
|
455
|
+
stdin=stdin.encode() if stdin is not None else None,
|
|
456
|
+
timeout=timeout,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def capture(
|
|
460
|
+
self,
|
|
461
|
+
meta: SessionMeta,
|
|
462
|
+
cmd: str,
|
|
463
|
+
env: dict[str, str],
|
|
464
|
+
*,
|
|
465
|
+
timeout: float,
|
|
466
|
+
) -> tuple[str, int]:
|
|
467
|
+
argv = ["docker", "exec"]
|
|
468
|
+
for k, v in env.items():
|
|
469
|
+
argv += ["-e", f"{k}={v}"]
|
|
470
|
+
argv += [self.container_name(meta.sid), "/bin/sh", "-c", cmd]
|
|
471
|
+
rc, out, _ = await self._run(argv, timeout=timeout or 600)
|
|
472
|
+
return out.decode("utf-8", "replace"), rc
|
|
473
|
+
|
|
474
|
+
def put_file_argv(self, meta: SessionMeta, path: str) -> list[str]:
|
|
475
|
+
return [
|
|
476
|
+
"docker",
|
|
477
|
+
"exec",
|
|
478
|
+
"-i",
|
|
479
|
+
self.container_name(meta.sid),
|
|
480
|
+
"/bin/sh",
|
|
481
|
+
"-c",
|
|
482
|
+
'mkdir -p "$(dirname -- "$1")" && cat > "$1"',
|
|
483
|
+
"sh",
|
|
484
|
+
path,
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
async def put_file(
|
|
488
|
+
self, meta: SessionMeta, path: str, data: bytes
|
|
489
|
+
) -> None:
|
|
490
|
+
rc, _, err = await self._run(
|
|
491
|
+
self.put_file_argv(meta, path), stdin=data, timeout=120
|
|
492
|
+
)
|
|
493
|
+
if rc != 0:
|
|
494
|
+
raise RuntimeError(
|
|
495
|
+
f"write {path} failed: {err.decode(errors='replace')}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
|
|
499
|
+
rc, out, _ = await self._run(
|
|
500
|
+
["docker", "exec", self.container_name(meta.sid), "cat", path],
|
|
501
|
+
timeout=120,
|
|
502
|
+
)
|
|
503
|
+
return out if rc == 0 else None
|
|
504
|
+
|
|
505
|
+
def host_path(self, path: str) -> Path | None:
|
|
506
|
+
"""Where a container path lives on the host, for bind-mounted roots."""
|
|
507
|
+
for prefix in _HOST_TREE_ROOTS:
|
|
508
|
+
if path == prefix or path.startswith(prefix + "/"):
|
|
509
|
+
return self.root / path.lstrip("/")
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
async def put_tree(
|
|
513
|
+
self, meta: SessionMeta, path: str, src: IO[bytes]
|
|
514
|
+
) -> None:
|
|
515
|
+
host = self.host_path(path)
|
|
516
|
+
if host is not None:
|
|
517
|
+
extract_tar(src, host)
|
|
518
|
+
return
|
|
519
|
+
rc, _, err = await self._run(
|
|
520
|
+
[
|
|
521
|
+
"docker",
|
|
522
|
+
"exec",
|
|
523
|
+
"-i",
|
|
524
|
+
self.container_name(meta.sid),
|
|
525
|
+
"/bin/sh",
|
|
526
|
+
"-c",
|
|
527
|
+
'mkdir -p "$1" && tar -x -C "$1"',
|
|
528
|
+
"sh",
|
|
529
|
+
path,
|
|
530
|
+
],
|
|
531
|
+
stdin=src.read(),
|
|
532
|
+
timeout=600,
|
|
533
|
+
)
|
|
534
|
+
if rc != 0:
|
|
535
|
+
raise RuntimeError(
|
|
536
|
+
f"extract into {path} failed: {err.decode(errors='replace')}"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
async def link(self, meta: SessionMeta, path: str, target: str) -> None:
|
|
540
|
+
# The symlink is created on the host but read inside the
|
|
541
|
+
# container, so it stores the container path and the existence
|
|
542
|
+
# check uses the host one.
|
|
543
|
+
host_link = self.host_path(path)
|
|
544
|
+
host_target = self.host_path(target)
|
|
545
|
+
if host_link is None or host_target is None:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
f"link is limited to {', '.join(_HOST_TREE_ROOTS)} in docker mode"
|
|
548
|
+
)
|
|
549
|
+
flip_link(host_link, target, exists_at=host_target)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class SessionManager:
|
|
553
|
+
"""Routes session operations to the configured backend."""
|
|
554
|
+
|
|
555
|
+
def __init__(self, config: RuntimeConfig) -> None:
|
|
556
|
+
self.config = config
|
|
557
|
+
self.store = SessionStore(config.root)
|
|
558
|
+
self.backend: DirectBackend | DockerBackend
|
|
559
|
+
if config.mode == "docker":
|
|
560
|
+
if not config.image:
|
|
561
|
+
raise ValueError("docker mode requires an image")
|
|
562
|
+
self.backend = DockerBackend(
|
|
563
|
+
config.root,
|
|
564
|
+
config.image,
|
|
565
|
+
share_claude_login=config.share_claude_login,
|
|
566
|
+
)
|
|
567
|
+
else:
|
|
568
|
+
self.backend = DirectBackend(config.root)
|
|
569
|
+
self._restore_direct_slot()
|
|
570
|
+
|
|
571
|
+
def _restore_direct_slot(self) -> None:
|
|
572
|
+
assert isinstance(self.backend, DirectBackend)
|
|
573
|
+
active = [m for m in self.store.all() if m.state == "active"]
|
|
574
|
+
# Only one session may own /workspace; after a restart, any
|
|
575
|
+
# recorded holder is stale until the platform resumes it.
|
|
576
|
+
for meta in active:
|
|
577
|
+
meta.state = "paused"
|
|
578
|
+
self.store.save(meta)
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def mode(self) -> str:
|
|
582
|
+
return self.config.mode
|
|
583
|
+
|
|
584
|
+
def require(self, sid: str) -> SessionMeta:
|
|
585
|
+
meta = self.store.get(sid)
|
|
586
|
+
if meta is None:
|
|
587
|
+
raise UnknownSession(sid)
|
|
588
|
+
return meta
|
|
589
|
+
|
|
590
|
+
def active_sessions(self) -> list[str]:
|
|
591
|
+
return [m.sid for m in self.store.all() if m.state == "active"]
|
|
592
|
+
|
|
593
|
+
async def provision(
|
|
594
|
+
self,
|
|
595
|
+
sid: str,
|
|
596
|
+
*,
|
|
597
|
+
workspace_id: str,
|
|
598
|
+
agent_id: str | None,
|
|
599
|
+
workspace_scope: str | None,
|
|
600
|
+
workspace_user: str | None,
|
|
601
|
+
env: dict[str, str],
|
|
602
|
+
) -> tuple[str, bool]:
|
|
603
|
+
meta = self.store.get(sid)
|
|
604
|
+
created = meta is None
|
|
605
|
+
if meta is None:
|
|
606
|
+
ws = self.store.workspace_dir(
|
|
607
|
+
sid,
|
|
608
|
+
agent_id=agent_id,
|
|
609
|
+
scope=workspace_scope,
|
|
610
|
+
user=workspace_user,
|
|
611
|
+
)
|
|
612
|
+
meta = SessionMeta(
|
|
613
|
+
sid=sid,
|
|
614
|
+
workspace_id=workspace_id,
|
|
615
|
+
agent_id=agent_id,
|
|
616
|
+
workspace_scope=workspace_scope,
|
|
617
|
+
workspace_user=workspace_user,
|
|
618
|
+
env=dict(env),
|
|
619
|
+
workspace_dir=str(ws),
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
meta.env = dict(env)
|
|
623
|
+
Path(meta.workspace_dir).mkdir(parents=True, exist_ok=True)
|
|
624
|
+
await self.backend.activate(meta)
|
|
625
|
+
meta.state = "active"
|
|
626
|
+
self.store.save(meta)
|
|
627
|
+
return meta.sid, created
|
|
628
|
+
|
|
629
|
+
async def status(self, sid: str) -> State:
|
|
630
|
+
meta = self.require(sid)
|
|
631
|
+
if isinstance(self.backend, DockerBackend):
|
|
632
|
+
return await self.backend.status_async(meta)
|
|
633
|
+
return self.backend.status(meta)
|
|
634
|
+
|
|
635
|
+
async def pause(self, sid: str) -> None:
|
|
636
|
+
meta = self.require(sid)
|
|
637
|
+
await self.backend.release(meta)
|
|
638
|
+
meta.state = "paused"
|
|
639
|
+
self.store.save(meta)
|
|
640
|
+
|
|
641
|
+
async def resume(self, sid: str) -> None:
|
|
642
|
+
meta = self.require(sid)
|
|
643
|
+
await self.backend.activate(meta)
|
|
644
|
+
meta.state = "active"
|
|
645
|
+
self.store.save(meta)
|
|
646
|
+
|
|
647
|
+
async def delete(self, sid: str, *, keep_workspace: bool) -> None:
|
|
648
|
+
meta = self.store.get(sid)
|
|
649
|
+
if meta is None:
|
|
650
|
+
return
|
|
651
|
+
await self.backend.destroy(meta)
|
|
652
|
+
self.store.delete(sid, keep_workspace=keep_workspace)
|
|
653
|
+
|
|
654
|
+
def exec(
|
|
655
|
+
self,
|
|
656
|
+
sid: str,
|
|
657
|
+
cmd: str,
|
|
658
|
+
env: dict[str, str],
|
|
659
|
+
*,
|
|
660
|
+
stdin: str | None,
|
|
661
|
+
timeout: float,
|
|
662
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
663
|
+
meta = self.require(sid)
|
|
664
|
+
return self.backend.exec(meta, cmd, env, stdin=stdin, timeout=timeout)
|
|
665
|
+
|
|
666
|
+
async def capture(
|
|
667
|
+
self, sid: str, cmd: str, env: dict[str, str], *, timeout: float
|
|
668
|
+
) -> tuple[str, int]:
|
|
669
|
+
meta = self.require(sid)
|
|
670
|
+
return await self.backend.capture(meta, cmd, env, timeout=timeout)
|
|
671
|
+
|
|
672
|
+
async def put_file(self, sid: str, path: str, data: bytes) -> None:
|
|
673
|
+
await self.backend.put_file(
|
|
674
|
+
self.require(sid), validate_abs_path(path), data
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
async def get_file(self, sid: str, path: str) -> bytes | None:
|
|
678
|
+
return await self.backend.get_file(
|
|
679
|
+
self.require(sid), validate_abs_path(path)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
async def put_tree(self, sid: str, path: str, src: IO[bytes]) -> None:
|
|
683
|
+
await self.backend.put_tree(
|
|
684
|
+
self.require(sid), validate_abs_path(path), src
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
async def link(self, sid: str, path: str, target: str) -> None:
|
|
688
|
+
await self.backend.link(
|
|
689
|
+
self.require(sid),
|
|
690
|
+
validate_abs_path(path),
|
|
691
|
+
validate_abs_path(target),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
async def reset_agent_workspace(
|
|
695
|
+
self, agent_id: str, *, user: str | None
|
|
696
|
+
) -> None:
|
|
697
|
+
"""Empty a durable workspace; refused while a session is using it."""
|
|
698
|
+
_check_id(agent_id, "agent_id")
|
|
699
|
+
target = self.store.workspace_dir(
|
|
700
|
+
"",
|
|
701
|
+
agent_id=agent_id,
|
|
702
|
+
scope="user" if user else "agent",
|
|
703
|
+
user=user,
|
|
704
|
+
)
|
|
705
|
+
for meta in self.store.all():
|
|
706
|
+
if meta.workspace_dir == str(target) and meta.state == "active":
|
|
707
|
+
raise RuntimeBusy(f"workspace in use by session {meta.sid}")
|
|
708
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
709
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
__all__ = [
|
|
713
|
+
"DirectBackend",
|
|
714
|
+
"DockerBackend",
|
|
715
|
+
"MissingTarget",
|
|
716
|
+
"RuntimeBusy",
|
|
717
|
+
"SessionManager",
|
|
718
|
+
"SessionMeta",
|
|
719
|
+
"UnknownSession",
|
|
720
|
+
"validate_abs_path",
|
|
721
|
+
]
|