asbox 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.
Files changed (47) hide show
  1. agent_sandbox/__init__.py +29 -0
  2. agent_sandbox/_bundled_guest/Dockerfile +28 -0
  3. agent_sandbox/_bundled_guest/agentd/__init__.py +8 -0
  4. agent_sandbox/_bundled_guest/agentd/app.py +120 -0
  5. agent_sandbox/_bundled_guest/agentd/exec.py +55 -0
  6. agent_sandbox/_bundled_guest/agentd/hooks.py +102 -0
  7. agent_sandbox/_bundled_guest/agentd/identity.py +66 -0
  8. agent_sandbox/_bundled_guest/agentd/ports.py +17 -0
  9. agent_sandbox/_bundled_guest/agentd/serve.py +58 -0
  10. agent_sandbox/_bundled_guest/agentd/state.py +58 -0
  11. agent_sandbox/_bundled_guest/requirements.txt +3 -0
  12. agent_sandbox/agent_client.py +140 -0
  13. agent_sandbox/cli/__init__.py +1 -0
  14. agent_sandbox/cli/main.py +1162 -0
  15. agent_sandbox/cli/state.py +78 -0
  16. agent_sandbox/control_plane.py +277 -0
  17. agent_sandbox/errors.py +19 -0
  18. agent_sandbox/guest_source.py +83 -0
  19. agent_sandbox/infra/__init__.py +31 -0
  20. agent_sandbox/infra/archive.py +107 -0
  21. agent_sandbox/infra/config.py +310 -0
  22. agent_sandbox/infra/provisioner.py +379 -0
  23. agent_sandbox/infra/resources.py +639 -0
  24. agent_sandbox/infra/state.py +148 -0
  25. agent_sandbox/models.py +99 -0
  26. agent_sandbox/ports.py +22 -0
  27. agent_sandbox/sandbox.py +235 -0
  28. agent_sandbox_mcp/__init__.py +11 -0
  29. agent_sandbox_mcp/__main__.py +8 -0
  30. agent_sandbox_mcp/config.py +145 -0
  31. agent_sandbox_mcp/envelope.py +71 -0
  32. agent_sandbox_mcp/resources.py +91 -0
  33. agent_sandbox_mcp/server.py +62 -0
  34. agent_sandbox_mcp/session.py +235 -0
  35. agent_sandbox_mcp/tools/__init__.py +32 -0
  36. agent_sandbox_mcp/tools/execution.py +64 -0
  37. agent_sandbox_mcp/tools/filesystem.py +193 -0
  38. agent_sandbox_mcp/tools/images.py +97 -0
  39. agent_sandbox_mcp/tools/lifecycle.py +232 -0
  40. agent_sandbox_mcp/tools/logs.py +102 -0
  41. agent_sandbox_mcp/tools/metrics.py +93 -0
  42. agent_sandbox_mcp/tools/runtime.py +85 -0
  43. asbox-0.1.0.dist-info/METADATA +807 -0
  44. asbox-0.1.0.dist-info/RECORD +47 -0
  45. asbox-0.1.0.dist-info/WHEEL +4 -0
  46. asbox-0.1.0.dist-info/entry_points.txt +3 -0
  47. asbox-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,29 @@
1
+ """agent_sandbox: embeddable Python SDK for microVM sandboxes on AWS Lambda MicroVMs.
2
+
3
+ A Python fork of the microsandbox developer experience. The local ``libkrun``
4
+ runtime is replaced by AWS Lambda MicroVMs (managed Firecracker); the SDK talks
5
+ to the ``lambda-microvms`` control plane and to a guest agent (``agentd``) baked
6
+ into the MicroVM image over its dedicated HTTPS endpoint.
7
+ """
8
+
9
+ from agent_sandbox.errors import (
10
+ AgentError,
11
+ ControlPlaneError,
12
+ SandboxError,
13
+ SandboxTimeoutError,
14
+ )
15
+ from agent_sandbox.models import ExecResult, SandboxConfig, SandboxState
16
+ from agent_sandbox.sandbox import Sandbox
17
+
18
+ __all__ = [
19
+ "Sandbox",
20
+ "SandboxConfig",
21
+ "SandboxState",
22
+ "ExecResult",
23
+ "SandboxError",
24
+ "ControlPlaneError",
25
+ "AgentError",
26
+ "SandboxTimeoutError",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,28 @@
1
+ # MicroVM image for AWS Lambda MicroVMs.
2
+ #
3
+ # Lambda MicroVMs runs this Dockerfile, starts the app, and snapshots the fully
4
+ # initialized environment. Every launched MicroVM resumes from that snapshot, so
5
+ # agentd is already listening the moment run-microvm returns.
6
+ #
7
+ # Lambda MicroVMs is ARM64-only.
8
+ FROM --platform=linux/arm64 python:3.12-slim
9
+
10
+ ENV PYTHONUNBUFFERED=1 \
11
+ PYTHONDONTWRITEBYTECODE=1 \
12
+ AGENTD_PORT=8080 \
13
+ AGENTD_HOOK_PORT=9000
14
+
15
+ WORKDIR /opt/agentd
16
+
17
+ COPY requirements.txt ./
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY agentd ./agentd
21
+
22
+ # 8080: agentd application API (proxied to clients). 9000: lifecycle hooks
23
+ # (platform-only). Both must be exposed so the platform can reach the hook port.
24
+ EXPOSE 8080 9000
25
+
26
+ # serve.py starts both listeners in one process: agentd on AGENTD_PORT and the
27
+ # lifecycle-hook server on AGENTD_HOOK_PORT. User workloads are launched via exec.
28
+ CMD ["python", "-m", "agentd.serve"]
@@ -0,0 +1,8 @@
1
+ """agentd: the in-VM guest agent.
2
+
3
+ Runs inside the Lambda MicroVM image and exposes a small HTTP API that the SDK's
4
+ ``AgentClient`` calls to execute commands and read/write files. This is the
5
+ Python analogue of microsandbox's ``agentd`` guest agent.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ """FastAPI application for the guest agent.
2
+
3
+ Auth note: authentication is handled by the Lambda MicroVMs ingress proxy, which
4
+ validates the ``X-aws-proxy-auth`` token before traffic reaches this process.
5
+ agentd therefore trusts inbound requests and does not re-validate the token.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+
13
+ from contextlib import asynccontextmanager
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from pydantic import BaseModel, Field
17
+
18
+ from agentd import state
19
+ from agentd.exec import run_command
20
+
21
+
22
+ @asynccontextmanager
23
+ async def _lifespan(_app: FastAPI):
24
+ # Signals the /ready lifecycle hook that agentd has fully started, so the
25
+ # platform snapshots a serving agentd rather than a half-booted one.
26
+ state.ready.set()
27
+ yield
28
+
29
+
30
+ app = FastAPI(title="agentd", version="0.1.0", lifespan=_lifespan)
31
+
32
+
33
+ class ExecRequest(BaseModel):
34
+ command: str
35
+ args: list[str] = Field(default_factory=list)
36
+ cwd: str | None = None
37
+ env: dict[str, str] | None = None
38
+ timeout: float | None = None
39
+
40
+
41
+ class ExecResponse(BaseModel):
42
+ exit_code: int
43
+ stdout: str
44
+ stderr: str
45
+ encoding: str = "base64"
46
+
47
+
48
+ class ReadRequest(BaseModel):
49
+ path: str
50
+
51
+
52
+ class ReadResponse(BaseModel):
53
+ content: str
54
+ encoding: str = "base64"
55
+
56
+
57
+ class WriteRequest(BaseModel):
58
+ path: str
59
+ content: str
60
+ encoding: str = "base64"
61
+
62
+
63
+ @app.get("/healthz")
64
+ async def healthz() -> dict[str, str]:
65
+ return {"status": "ok", "version": app.version}
66
+
67
+
68
+ @app.post("/v1/exec", response_model=ExecResponse)
69
+ async def exec_command(req: ExecRequest) -> ExecResponse:
70
+ try:
71
+ with state.track():
72
+ outcome = await run_command(
73
+ req.command,
74
+ req.args,
75
+ cwd=req.cwd,
76
+ env=req.env,
77
+ timeout=req.timeout,
78
+ )
79
+ except TimeoutError as exc:
80
+ raise HTTPException(status_code=504, detail="command timed out") from exc
81
+ except FileNotFoundError as exc:
82
+ raise HTTPException(status_code=400, detail=f"command not found: {req.command}") from exc
83
+ return ExecResponse(
84
+ exit_code=outcome.exit_code,
85
+ stdout=base64.b64encode(outcome.stdout).decode("ascii"),
86
+ stderr=base64.b64encode(outcome.stderr).decode("ascii"),
87
+ )
88
+
89
+
90
+ @app.post("/v1/fs/read", response_model=ReadResponse)
91
+ async def read_file(req: ReadRequest) -> ReadResponse:
92
+ try:
93
+ with open(req.path, "rb") as fh:
94
+ data = fh.read()
95
+ except FileNotFoundError as exc:
96
+ raise HTTPException(status_code=404, detail=f"no such file: {req.path}") from exc
97
+ except OSError as exc:
98
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
99
+ return ReadResponse(content=base64.b64encode(data).decode("ascii"))
100
+
101
+
102
+ @app.post("/v1/fs/write")
103
+ async def write_file(req: WriteRequest) -> dict[str, int]:
104
+ if req.encoding == "base64":
105
+ try:
106
+ data = base64.b64decode(req.content)
107
+ except (ValueError, TypeError) as exc:
108
+ raise HTTPException(status_code=400, detail="invalid base64 content") from exc
109
+ else:
110
+ data = req.content.encode("utf-8")
111
+
112
+ directory = os.path.dirname(req.path)
113
+ if directory:
114
+ os.makedirs(directory, exist_ok=True)
115
+ try:
116
+ with open(req.path, "wb") as fh:
117
+ fh.write(data)
118
+ except OSError as exc:
119
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
120
+ return {"bytes_written": len(data)}
@@ -0,0 +1,55 @@
1
+ """Command execution inside the guest.
2
+
3
+ Captures stdout, stderr, and the exit code. A dedicated module (mirroring
4
+ microsandbox's split between the agent loop and exec/session handling) so PTY
5
+ support can be added here later without touching the HTTP layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ExecOutcome:
17
+ exit_code: int
18
+ stdout: bytes
19
+ stderr: bytes
20
+
21
+
22
+ async def run_command(
23
+ command: str,
24
+ args: list[str] | None = None,
25
+ *,
26
+ cwd: str | None = None,
27
+ env: dict[str, str] | None = None,
28
+ timeout: float | None = None,
29
+ ) -> ExecOutcome:
30
+ """Run ``command`` with ``args`` and capture its output.
31
+
32
+ ``env`` is merged onto the current process environment rather than replacing
33
+ it, so PATH and friends remain intact.
34
+ """
35
+ argv = [command, *(args or [])]
36
+ merged_env = {**os.environ, **(env or {})}
37
+
38
+ proc = await asyncio.create_subprocess_exec(
39
+ *argv,
40
+ stdout=asyncio.subprocess.PIPE,
41
+ stderr=asyncio.subprocess.PIPE,
42
+ cwd=cwd,
43
+ env=merged_env,
44
+ )
45
+ try:
46
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
47
+ except asyncio.TimeoutError:
48
+ proc.kill()
49
+ await proc.wait()
50
+ raise
51
+ return ExecOutcome(
52
+ exit_code=proc.returncode if proc.returncode is not None else -1,
53
+ stdout=stdout or b"",
54
+ stderr=stderr or b"",
55
+ )
@@ -0,0 +1,102 @@
1
+ """Lifecycle-hook HTTP server, called by the AWS Lambda MicroVMs platform.
2
+
3
+ This is the *lifecycle plane*: a separate FastAPI app (served on ``HOOK_PORT`` by
4
+ ``serve.py``) that the platform invokes at image build and MicroVM state
5
+ transitions. It is never reached by application clients — the ``forward`` CLI
6
+ guard keeps auth tokens from ever being scoped to the hook port.
7
+
8
+ Contract: return 200 on success; return 503 from ``/ready`` and ``/validate``
9
+ when the app needs more time (the platform keeps polling until the configured
10
+ timeout). Handlers are kept fast and idempotent per the platform guidance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import sys
18
+ import threading
19
+
20
+ _PYTHON = sys.executable or "python3"
21
+
22
+ from fastapi import FastAPI, Request, Response
23
+
24
+ from agentd import identity, state
25
+ from agentd.exec import run_command
26
+
27
+ log = logging.getLogger("agentd.hooks")
28
+
29
+ hooks_app = FastAPI(title="agentd-hooks", version="0.1.0")
30
+
31
+ # The platform fixes these paths; only the port is ours to choose.
32
+ PREFIX = "/aws/lambda-microvms/runtime/v1"
33
+
34
+ # Suspend drain budget: how long /suspend waits for in-flight exec to finish.
35
+ # Must stay under the suspendTimeoutInSeconds configured in the image's --hooks
36
+ # (see agent_sandbox.infra.resources._default_hooks) or the platform gives up
37
+ # first. Overridable so it can track that value without editing code.
38
+ _SUSPEND_DRAIN_SECONDS = float(os.getenv("AGENTD_SUSPEND_DRAIN_SECONDS", "8"))
39
+
40
+ # /run fires once per boot-from-snapshot; guard against duplicate delivery so a
41
+ # stray call can't rotate identity out from under a running workload.
42
+ _run_lock = threading.Lock()
43
+ _run_done = False
44
+
45
+
46
+ @hooks_app.post(f"{PREFIX}/ready")
47
+ async def ready() -> Response:
48
+ """Build hook: 200 once agentd has started, else 503 so the platform retries."""
49
+ return Response(status_code=200 if state.ready.is_set() else 503)
50
+
51
+
52
+ @hooks_app.post(f"{PREFIX}/validate")
53
+ async def validate() -> Response:
54
+ """Build hook: smoke-test the snapshot by running a trivial exec in-process."""
55
+ try:
56
+ outcome = await run_command(_PYTHON, ["-c", "pass"], timeout=10.0)
57
+ except Exception as exc: # noqa: BLE001 - any failure means "not valid yet"
58
+ log.warning("validate: exec failed: %s", exc)
59
+ return Response(status_code=503)
60
+ return Response(status_code=200 if outcome.exit_code == 0 else 503)
61
+
62
+
63
+ @hooks_app.post(f"{PREFIX}/run")
64
+ async def run(request: Request) -> Response:
65
+ """Runtime hook: fires once after run-from-snapshot. Mint per-VM identity."""
66
+ global _run_done
67
+ with _run_lock:
68
+ if _run_done:
69
+ return Response(status_code=200) # idempotent: already ran this boot
70
+ payload = await request.body()
71
+ identity.regenerate("run", run_payload=payload or None)
72
+ # Mark done only AFTER a successful regenerate, so a transient failure leaves
73
+ # the flag clear and the platform's retry still gets a fresh identity.
74
+ with _run_lock:
75
+ _run_done = True
76
+ return Response(status_code=200)
77
+
78
+
79
+ @hooks_app.post(f"{PREFIX}/resume")
80
+ async def resume() -> Response:
81
+ """Runtime hook: fires after SUSPENDED -> RUNNING. Reseed randomness/identity."""
82
+ identity.regenerate("resume")
83
+ return Response(status_code=200)
84
+
85
+
86
+ @hooks_app.post(f"{PREFIX}/suspend")
87
+ async def suspend() -> Response:
88
+ """Runtime hook: fires before RUNNING -> SUSPENDED. Drain in-flight exec."""
89
+ state.wait_drain(_SUSPEND_DRAIN_SECONDS)
90
+ return Response(status_code=200)
91
+
92
+
93
+ @hooks_app.post(f"{PREFIX}/terminate")
94
+ async def terminate() -> Response:
95
+ """Runtime hook: fires before termination. Flush logs; best-effort cleanup."""
96
+ for stream in (sys.stdout, sys.stderr):
97
+ try:
98
+ stream.flush()
99
+ except Exception: # noqa: BLE001 - best-effort
100
+ pass
101
+ logging.shutdown()
102
+ return Response(status_code=200)
@@ -0,0 +1,66 @@
1
+ """Per-VM uniqueness state, driven by the ``/run`` and ``/resume`` hooks.
2
+
3
+ Every MicroVM resumes from the SAME memory+disk snapshot, so anything derived
4
+ from randomness at image-build time is IDENTICAL across VMs (the "snapshot
5
+ uniqueness" pitfall). :func:`regenerate` mints fresh, CSPRNG-backed identity and
6
+ reseeds the stdlib non-CSPRNG so each running VM diverges. It is called on
7
+ ``/run`` (first boot from snapshot) and ``/resume``, and is written to a tmpfs
8
+ file so processes launched via ``/v1/exec`` can read it.
9
+
10
+ Both agentd and the hooks server share this module (same process), so reseeding
11
+ here is visible to agentd's request handlers too.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import random
18
+ import secrets
19
+ import threading
20
+ import time
21
+
22
+ # tmpfs path (per-VM, not part of the shared on-disk snapshot layout).
23
+ IDENTITY_PATH = os.environ.get("AGENTD_IDENTITY_PATH", "/run/agentd/identity")
24
+
25
+ _lock = threading.Lock()
26
+ _state: dict[str, object] = {
27
+ "vm_id": None,
28
+ "generation": 0,
29
+ "last_event": None,
30
+ "booted_at": None,
31
+ "run_payload": None,
32
+ }
33
+
34
+
35
+ def regenerate(event: str, *, run_payload: bytes | None = None) -> dict[str, object]:
36
+ """Mint a fresh per-VM identity and reseed randomness.
37
+
38
+ Uses :mod:`secrets` (a CSPRNG pulling fresh kernel entropy) so the new id is
39
+ unique per VM even though the snapshot is shared, then reseeds :mod:`random`
40
+ so later non-CSPRNG use also diverges. Persisting is best-effort.
41
+ """
42
+ with _lock:
43
+ _state["vm_id"] = secrets.token_hex(16)
44
+ _state["generation"] = int(_state["generation"]) + 1
45
+ _state["last_event"] = event
46
+ _state["booted_at"] = time.time()
47
+ if run_payload is not None:
48
+ _state["run_payload"] = run_payload.decode("utf-8", errors="replace")
49
+ random.seed(secrets.randbits(256))
50
+ _persist(str(_state["vm_id"]))
51
+ return dict(_state)
52
+
53
+
54
+ def current() -> dict[str, object]:
55
+ with _lock:
56
+ return dict(_state)
57
+
58
+
59
+ def _persist(vm_id: str) -> None:
60
+ try:
61
+ os.makedirs(os.path.dirname(IDENTITY_PATH), exist_ok=True)
62
+ with open(IDENTITY_PATH, "w", encoding="utf-8") as fh:
63
+ fh.write(vm_id)
64
+ except OSError:
65
+ # Best-effort: identity is still available in-process via current().
66
+ pass
@@ -0,0 +1,17 @@
1
+ """Well-known ports for the guest, as a single source of truth.
2
+
3
+ ``AGENT_PORT`` is agentd's application API (exec/fs), fronted by the platform's
4
+ auth-token proxy. ``HOOK_PORT`` is the lifecycle-hook server, called only by the
5
+ platform. Both are overridable via env; the Dockerfile bakes the defaults.
6
+
7
+ Invariant: ``HOOK_PORT`` here (what ``serve.py`` binds) must equal the SDK's
8
+ ``agent_sandbox.ports.HOOK_PORT`` (what ``--hooks`` tells the platform). Both
9
+ default to 9000; override consistently on both sides.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ AGENT_PORT: int = int(os.getenv("AGENTD_PORT", "8080"))
17
+ HOOK_PORT: int = int(os.getenv("AGENTD_HOOK_PORT", "9000"))
@@ -0,0 +1,58 @@
1
+ """Process entrypoint: serve agentd and the lifecycle hooks side by side.
2
+
3
+ One container runs one ``CMD``, so this single process starts both listeners:
4
+
5
+ * agentd's application API on ``AGENT_PORT`` (main thread) — fronted by the
6
+ platform's auth-token proxy, this is what the SDK/CLI talk to.
7
+ * the lifecycle-hook server on ``HOOK_PORT`` (daemon thread) — called only by the
8
+ platform. Bound to ``0.0.0.0`` because the platform reaches it over the guest's
9
+ network interface, not loopback.
10
+
11
+ Running both in one process lets the hooks reseed state agentd shares (see
12
+ ``identity``/``state``); running the hooks on their own thread keeps ``/suspend``
13
+ responsive even while agentd is busy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import threading
21
+
22
+ import uvicorn
23
+
24
+ from agentd.ports import AGENT_PORT, HOOK_PORT
25
+
26
+ log = logging.getLogger("agentd.serve")
27
+
28
+
29
+ def _serve_hooks() -> None:
30
+ # The hook server is required: the platform's /ready gates image build, and
31
+ # /suspend etc. gate runtime transitions. If it ever exits — failed bind,
32
+ # import error — don't let agentd keep the container alive without it (which
33
+ # would surface only as an opaque /ready timeout). Crash the process instead.
34
+ try:
35
+ uvicorn.run(
36
+ "agentd.hooks:hooks_app",
37
+ host="0.0.0.0",
38
+ port=HOOK_PORT,
39
+ log_level="warning",
40
+ )
41
+ except BaseException:
42
+ log.exception("hook server crashed")
43
+ finally:
44
+ os._exit(1)
45
+
46
+
47
+ def main() -> None:
48
+ threading.Thread(target=_serve_hooks, name="agentd-hooks", daemon=True).start()
49
+ uvicorn.run(
50
+ "agentd.app:app",
51
+ host="0.0.0.0",
52
+ port=AGENT_PORT,
53
+ log_level="info",
54
+ )
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,58 @@
1
+ """Shared in-process state, visible to both server threads.
2
+
3
+ agentd (app plane) and the lifecycle hooks (:mod:`agentd.hooks`) run in the same
4
+ process but on separate threads/event loops (see ``serve.py``), so this module
5
+ uses plain :mod:`threading` primitives rather than asyncio ones:
6
+
7
+ * :data:`ready` — set once agentd's app has started; the ``/ready`` hook reads it
8
+ so the platform snapshots only a fully-booted agentd.
9
+ * :data:`inflight` — count of currently-running ``exec`` calls, so the
10
+ ``/suspend`` hook can drain in-flight work before the VM is frozen.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from collections.abc import Iterator
17
+ from contextlib import contextmanager
18
+
19
+ # Set by agentd's startup event; read by the /ready hook.
20
+ ready = threading.Event()
21
+
22
+ _lock = threading.Lock()
23
+ _inflight = 0
24
+ _drained = threading.Condition(_lock)
25
+
26
+
27
+ @contextmanager
28
+ def track() -> Iterator[None]:
29
+ """Count one in-flight exec for the duration of the ``with`` block."""
30
+ global _inflight
31
+ with _lock:
32
+ _inflight += 1
33
+ try:
34
+ yield
35
+ finally:
36
+ with _lock:
37
+ _inflight -= 1
38
+ if _inflight == 0:
39
+ _drained.notify_all()
40
+
41
+
42
+ def inflight() -> int:
43
+ with _lock:
44
+ return _inflight
45
+
46
+
47
+ def wait_drain(timeout: float) -> bool:
48
+ """Block until no exec is in flight, or ``timeout`` elapses.
49
+
50
+ Returns ``True`` if fully drained, ``False`` on timeout. Used by ``/suspend``
51
+ so the snapshot isn't taken mid-command.
52
+ """
53
+ with _drained:
54
+ if _inflight == 0:
55
+ return True
56
+ # wait_for returns True when the predicate held (drained), False on timeout.
57
+ drained = _drained.wait_for(lambda: _inflight == 0, timeout=timeout)
58
+ return drained
@@ -0,0 +1,3 @@
1
+ fastapi>=0.111
2
+ uvicorn[standard]>=0.30
3
+ pydantic>=2.7