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.
@@ -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
+ ]