levain-runtime 0.1.4__tar.gz → 0.1.9__tar.gz
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-0.1.4 → levain_runtime-0.1.9}/PKG-INFO +11 -5
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/README.md +10 -4
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/chisel.py +8 -3
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/cli.py +102 -11
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/config.py +6 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/heartbeat.py +1 -0
- levain_runtime-0.1.9/levain_runtime/jobs.py +177 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/protocol.py +5 -1
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/server.py +68 -7
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/sessions.py +159 -35
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/pyproject.toml +1 -1
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/.gitignore +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/LICENSE +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/__init__.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/__main__.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/platform_api.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/process.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/sandbox/node_runner.py.vendored +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/sandbox/run_logging.py.vendored +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: levain-runtime
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Self-hosted runtime for Levain agents: run an agent's nodes on your own machine or container while the platform drives the recipe
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -34,9 +34,12 @@ uv tool install levain-runtime
|
|
|
34
34
|
## Set up
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
# Run nodes directly on this host
|
|
37
|
+
# Run nodes directly on this host:
|
|
38
38
|
levain-runtime init --mode direct
|
|
39
39
|
|
|
40
|
+
# Direct mode serving four sessions at once (Linux with bubblewrap):
|
|
41
|
+
levain-runtime init --mode direct --sessions 4
|
|
42
|
+
|
|
40
43
|
# Or run each session in a container built from your own image:
|
|
41
44
|
levain-runtime init --mode docker --image ghcr.io/you/agent-runtime:latest
|
|
42
45
|
```
|
|
@@ -76,6 +79,9 @@ and `/sdk`, then runs each node as a short-lived process with the session's
|
|
|
76
79
|
environment. Everything a node prints is streamed back and lands in the run's
|
|
77
80
|
logs on the platform.
|
|
78
81
|
|
|
79
|
-
Direct mode
|
|
80
|
-
active session.
|
|
81
|
-
|
|
82
|
+
Direct mode serves one session at a time by default: `/workspace` points at
|
|
83
|
+
the active session. With `--sessions N` it serves up to N at once, each node
|
|
84
|
+
running under bubblewrap with its session's folders mounted at `/workspace`,
|
|
85
|
+
`/recipes/active` and `/sdk/active`; the real workspace path is in
|
|
86
|
+
`LEVAIN_HOST_WORKSPACE` for tools outside the sandbox such as Docker. Docker
|
|
87
|
+
mode starts one container per session and has no such limit.
|
|
@@ -19,9 +19,12 @@ uv tool install levain-runtime
|
|
|
19
19
|
## Set up
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
# Run nodes directly on this host
|
|
22
|
+
# Run nodes directly on this host:
|
|
23
23
|
levain-runtime init --mode direct
|
|
24
24
|
|
|
25
|
+
# Direct mode serving four sessions at once (Linux with bubblewrap):
|
|
26
|
+
levain-runtime init --mode direct --sessions 4
|
|
27
|
+
|
|
25
28
|
# Or run each session in a container built from your own image:
|
|
26
29
|
levain-runtime init --mode docker --image ghcr.io/you/agent-runtime:latest
|
|
27
30
|
```
|
|
@@ -61,6 +64,9 @@ and `/sdk`, then runs each node as a short-lived process with the session's
|
|
|
61
64
|
environment. Everything a node prints is streamed back and lands in the run's
|
|
62
65
|
logs on the platform.
|
|
63
66
|
|
|
64
|
-
Direct mode
|
|
65
|
-
active session.
|
|
66
|
-
|
|
67
|
+
Direct mode serves one session at a time by default: `/workspace` points at
|
|
68
|
+
the active session. With `--sessions N` it serves up to N at once, each node
|
|
69
|
+
running under bubblewrap with its session's folders mounted at `/workspace`,
|
|
70
|
+
`/recipes/active` and `/sdk/active`; the real workspace path is in
|
|
71
|
+
`LEVAIN_HOST_WORKSPACE` for tools outside the sandbox such as Docker. Docker
|
|
72
|
+
mode starts one container per session and has no such limit.
|
|
@@ -40,7 +40,7 @@ def _asset_name() -> str:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def download(root: Path) -> Path:
|
|
43
|
-
"""Fetch the pinned release
|
|
43
|
+
"""Fetch the pinned release to :func:`binary_path` after verifying its checksum."""
|
|
44
44
|
name = _asset_name()
|
|
45
45
|
base = f"{_RELEASES}/v{CHISEL_VERSION}"
|
|
46
46
|
with httpx.Client(follow_redirects=True, timeout=120) as client:
|
|
@@ -58,7 +58,7 @@ def download(root: Path) -> Path:
|
|
|
58
58
|
actual = hashlib.sha256(blob.content).hexdigest()
|
|
59
59
|
if actual != expected:
|
|
60
60
|
raise RuntimeError(f"chisel checksum mismatch: {actual} != {expected}")
|
|
61
|
-
target = root
|
|
61
|
+
target = binary_path(root)
|
|
62
62
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
63
|
tmp = target.with_suffix(".tmp")
|
|
64
64
|
tmp.write_bytes(gzip.decompress(blob.content))
|
|
@@ -67,11 +67,16 @@ def download(root: Path) -> Path:
|
|
|
67
67
|
return target
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def binary_path(root: Path) -> Path:
|
|
71
|
+
"""Where the pinned release lives; named by version so a bump is fetched."""
|
|
72
|
+
return root / "bin" / f"chisel-{CHISEL_VERSION}"
|
|
73
|
+
|
|
74
|
+
|
|
70
75
|
def resolve_binary(root: Path) -> Path:
|
|
71
76
|
override = os.environ.get("CHISEL_BIN")
|
|
72
77
|
if override:
|
|
73
78
|
return Path(override)
|
|
74
|
-
local = root
|
|
79
|
+
local = binary_path(root)
|
|
75
80
|
if local.is_file():
|
|
76
81
|
return local
|
|
77
82
|
log.info("downloading chisel %s", CHISEL_VERSION)
|
|
@@ -7,12 +7,13 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import shlex
|
|
10
|
+
import shutil
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
import time
|
|
13
14
|
from importlib import resources
|
|
14
15
|
from pathlib import Path
|
|
15
|
-
from typing import Annotated
|
|
16
|
+
from typing import Annotated, Any
|
|
16
17
|
|
|
17
18
|
import httpx
|
|
18
19
|
import typer
|
|
@@ -23,7 +24,7 @@ from .config import Credentials, Mode, RuntimeConfig, runtime_home
|
|
|
23
24
|
from .heartbeat import package_version, run_heartbeat
|
|
24
25
|
from .protocol import RUNNER_HTTP_PORT
|
|
25
26
|
from .server import create_app
|
|
26
|
-
from .sessions import SessionManager
|
|
27
|
+
from .sessions import BWRAP, SessionManager
|
|
27
28
|
|
|
28
29
|
app = typer.Typer(
|
|
29
30
|
name="levain-runtime",
|
|
@@ -35,6 +36,9 @@ _HOME_DIRS = ("recipes", "sdk", "sandbox", "sessions", "agents", "bin")
|
|
|
35
36
|
_SYSTEM_LINKS = ("recipes", "sdk", "sandbox", "workspace")
|
|
36
37
|
_SYNTHETIC_CONF = Path("/etc/synthetic.conf")
|
|
37
38
|
_VENDORED = ("node_runner.py", "run_logging.py")
|
|
39
|
+
# Paths a sandboxed direct runtime binds per session, relative to the
|
|
40
|
+
# home; the root-level links point into it.
|
|
41
|
+
_SESSION_VIEWS = ("workspace", "recipes/active", "sdk/active")
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def _version_callback(value: bool) -> None:
|
|
@@ -72,10 +76,29 @@ def write_sandbox_files(root: Path) -> None:
|
|
|
72
76
|
(root / "sandbox" / name).write_bytes(data)
|
|
73
77
|
|
|
74
78
|
|
|
75
|
-
def prepare_home(root: Path) -> None:
|
|
79
|
+
def prepare_home(root: Path, *, sessions: int = 1) -> None:
|
|
76
80
|
for name in _HOME_DIRS:
|
|
77
81
|
(root / name).mkdir(parents=True, exist_ok=True)
|
|
78
82
|
write_sandbox_files(root)
|
|
83
|
+
if sessions > 1:
|
|
84
|
+
# bubblewrap mounts over real directories only, never a link.
|
|
85
|
+
for name in _SESSION_VIEWS:
|
|
86
|
+
path = root / name
|
|
87
|
+
if path.is_symlink():
|
|
88
|
+
path.unlink()
|
|
89
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return
|
|
91
|
+
for name in _SESSION_VIEWS:
|
|
92
|
+
path = root / name
|
|
93
|
+
if path.is_dir() and not path.is_symlink():
|
|
94
|
+
# Left by a sandboxed setup; flip_link() can't replace a
|
|
95
|
+
# real directory, so clear it back to empty here.
|
|
96
|
+
try:
|
|
97
|
+
path.rmdir()
|
|
98
|
+
except OSError:
|
|
99
|
+
raise typer.BadParameter(
|
|
100
|
+
f"{path} is a directory with content; move it aside first"
|
|
101
|
+
) from None
|
|
79
102
|
idle = root / "sessions" / ".idle" / "workspace"
|
|
80
103
|
idle.mkdir(parents=True, exist_ok=True)
|
|
81
104
|
link = root / "workspace"
|
|
@@ -83,6 +106,34 @@ def prepare_home(root: Path) -> None:
|
|
|
83
106
|
link.symlink_to(idle)
|
|
84
107
|
|
|
85
108
|
|
|
109
|
+
def check_sandbox(sessions: int) -> None:
|
|
110
|
+
"""Fail early when ``sessions`` above one cannot be served here."""
|
|
111
|
+
if sessions < 1:
|
|
112
|
+
raise typer.BadParameter("--sessions must be at least 1")
|
|
113
|
+
if sessions == 1:
|
|
114
|
+
return
|
|
115
|
+
if sys.platform != "linux":
|
|
116
|
+
raise typer.BadParameter(
|
|
117
|
+
"several sessions at once need Linux; macOS serves one"
|
|
118
|
+
)
|
|
119
|
+
if shutil.which(BWRAP) is None:
|
|
120
|
+
raise typer.BadParameter(
|
|
121
|
+
"several sessions at once need bubblewrap; "
|
|
122
|
+
"install it (apt install bubblewrap) and retry"
|
|
123
|
+
)
|
|
124
|
+
probe = subprocess.run(
|
|
125
|
+
[BWRAP, "--dev-bind", "/", "/", "--die-with-parent", "--", "true"],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
check=False,
|
|
129
|
+
)
|
|
130
|
+
if probe.returncode != 0:
|
|
131
|
+
raise typer.BadParameter(
|
|
132
|
+
"bubblewrap cannot start a sandbox here: "
|
|
133
|
+
+ (probe.stderr.strip() or f"exit {probe.returncode}")
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
86
137
|
def system_link_commands(root: Path) -> list[list[str]]:
|
|
87
138
|
"""``ln`` commands still needed for ``/recipes`` & co.; empty when done."""
|
|
88
139
|
cmds: list[list[str]] = []
|
|
@@ -172,12 +223,23 @@ def init(
|
|
|
172
223
|
help="Copy this host's Claude login into docker sessions",
|
|
173
224
|
),
|
|
174
225
|
] = True,
|
|
226
|
+
sessions: Annotated[
|
|
227
|
+
int,
|
|
228
|
+
typer.Option(
|
|
229
|
+
help="Sessions served at once in direct mode "
|
|
230
|
+
"(above 1 needs Linux and bubblewrap)"
|
|
231
|
+
),
|
|
232
|
+
] = 1,
|
|
175
233
|
) -> None:
|
|
176
234
|
"""Prepare the runtime home and, in direct mode, the system links."""
|
|
177
235
|
if mode == "docker" and not image:
|
|
178
236
|
raise typer.BadParameter("--image is required in docker mode")
|
|
237
|
+
if mode == "direct":
|
|
238
|
+
check_sandbox(sessions)
|
|
239
|
+
else:
|
|
240
|
+
sessions = 1
|
|
179
241
|
home = runtime_home()
|
|
180
|
-
prepare_home(home)
|
|
242
|
+
prepare_home(home, sessions=sessions)
|
|
181
243
|
existing = (
|
|
182
244
|
RuntimeConfig.load(home) if (home / "config.json").is_file() else None
|
|
183
245
|
)
|
|
@@ -186,12 +248,16 @@ def init(
|
|
|
186
248
|
mode=mode,
|
|
187
249
|
image=image,
|
|
188
250
|
share_claude_login=share_claude_login,
|
|
251
|
+
sessions=sessions,
|
|
189
252
|
credentials=existing.credentials if existing else None,
|
|
190
253
|
)
|
|
191
254
|
config.save()
|
|
192
255
|
if mode == "direct":
|
|
193
256
|
ensure_system_links(home)
|
|
194
|
-
print(
|
|
257
|
+
print(
|
|
258
|
+
f"runtime ready at {home} ({mode}"
|
|
259
|
+
+ (f", {sessions} sessions at once)" if sessions > 1 else ")")
|
|
260
|
+
)
|
|
195
261
|
|
|
196
262
|
|
|
197
263
|
@app.command()
|
|
@@ -371,32 +437,57 @@ def up() -> None:
|
|
|
371
437
|
_serve(config)
|
|
372
438
|
|
|
373
439
|
|
|
440
|
+
def _live_health() -> dict[str, Any] | None:
|
|
441
|
+
try:
|
|
442
|
+
resp = httpx.get(
|
|
443
|
+
f"http://127.0.0.1:{RUNNER_HTTP_PORT}/v1/health", timeout=2.0
|
|
444
|
+
)
|
|
445
|
+
resp.raise_for_status()
|
|
446
|
+
body = resp.json()
|
|
447
|
+
except (httpx.HTTPError, ValueError):
|
|
448
|
+
return None
|
|
449
|
+
return body if isinstance(body, dict) else None
|
|
450
|
+
|
|
451
|
+
|
|
374
452
|
@app.command()
|
|
375
453
|
def status() -> None:
|
|
376
454
|
"""Show the configuration and the sessions on this runtime."""
|
|
377
455
|
config = _load_config()
|
|
378
|
-
|
|
456
|
+
# Read-only: the startup reset belongs to the running service.
|
|
457
|
+
manager = SessionManager(config, restore=False)
|
|
458
|
+
live = _live_health()
|
|
379
459
|
print(f"home: {config.root}")
|
|
380
460
|
print(
|
|
381
461
|
f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
|
|
382
462
|
)
|
|
463
|
+
print("service: " + ("running" if live else "not running"))
|
|
383
464
|
if config.credentials:
|
|
384
465
|
print(f"runtime: {config.credentials.runtime_id}")
|
|
385
|
-
|
|
466
|
+
if live and live.get("gateway"):
|
|
467
|
+
print(f"gateway: {live['gateway']}")
|
|
468
|
+
else:
|
|
469
|
+
print(
|
|
470
|
+
f"gateway: {chisel.gateway(config.credentials)}"
|
|
471
|
+
" (from this shell)"
|
|
472
|
+
)
|
|
386
473
|
else:
|
|
387
474
|
print("runtime: not connected")
|
|
388
475
|
if config.mode == "direct":
|
|
476
|
+
print(f"sessions at once: {config.sessions}")
|
|
389
477
|
pending = system_link_commands(config.root)
|
|
390
478
|
print("system links: " + ("ok" if not pending else "missing"))
|
|
391
479
|
print(
|
|
392
480
|
f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
|
|
393
481
|
)
|
|
394
482
|
sessions = manager.store.all()
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
483
|
+
if live:
|
|
484
|
+
active = set(live.get("active_sessions") or [])
|
|
485
|
+
else:
|
|
486
|
+
active = {m.sid for m in sessions if m.state == "active"}
|
|
487
|
+
print(f"sessions: {len(sessions)} ({len(active)} active)")
|
|
398
488
|
for meta in sessions:
|
|
399
|
-
|
|
489
|
+
state = "active" if meta.sid in active else "paused"
|
|
490
|
+
print(f" {meta.sid} {state} {meta.workspace_dir}")
|
|
400
491
|
|
|
401
492
|
|
|
402
493
|
def _serve(config: RuntimeConfig) -> None:
|
|
@@ -82,6 +82,10 @@ class RuntimeConfig:
|
|
|
82
82
|
mode: Mode = "direct"
|
|
83
83
|
image: str | None = None
|
|
84
84
|
share_claude_login: bool = True
|
|
85
|
+
# Sessions a direct runtime serves at once; above one, each node
|
|
86
|
+
# runs under bubblewrap with its session's folders at /workspace,
|
|
87
|
+
# /recipes/active and /sdk/active.
|
|
88
|
+
sessions: int = 1
|
|
85
89
|
credentials: Credentials | None = None
|
|
86
90
|
|
|
87
91
|
@property
|
|
@@ -97,6 +101,7 @@ class RuntimeConfig:
|
|
|
97
101
|
mode=raw.get("mode", "direct"),
|
|
98
102
|
image=raw.get("image"),
|
|
99
103
|
share_claude_login=bool(raw.get("share_claude_login", True)),
|
|
104
|
+
sessions=max(1, int(raw.get("sessions") or 1)),
|
|
100
105
|
credentials=Credentials.from_dict(creds) if creds else None,
|
|
101
106
|
)
|
|
102
107
|
|
|
@@ -106,6 +111,7 @@ class RuntimeConfig:
|
|
|
106
111
|
"mode": self.mode,
|
|
107
112
|
"image": self.image,
|
|
108
113
|
"share_claude_login": self.share_claude_login,
|
|
114
|
+
"sessions": self.sessions,
|
|
109
115
|
"credentials": self.credentials.to_dict()
|
|
110
116
|
if self.credentials
|
|
111
117
|
else None,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Execs that outlive the HTTP stream that started them (#2760).
|
|
2
|
+
|
|
3
|
+
A job drains ``stream_process`` into a numbered record list, so a
|
|
4
|
+
reader whose tunnel dropped can attach again at the record it last
|
|
5
|
+
saw. The process is killed on cancel, on its own timeout, or once no
|
|
6
|
+
reader has been attached for ``ORPHAN_SECONDS``: a node never outlives
|
|
7
|
+
the platform's interest in it, and a finished job nobody collects is
|
|
8
|
+
forgotten the same way.
|
|
9
|
+
|
|
10
|
+
A reattach whose last-seen record has already been trimmed from the
|
|
11
|
+
buffer is a gap the server can no longer fill; ``Job.has_gap`` lets
|
|
12
|
+
the caller refuse it rather than silently resuming past missing
|
|
13
|
+
records.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import AsyncIterator, Callable
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .protocol import JOB_ORPHAN_SECONDS
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
ORPHAN_SECONDS: float = JOB_ORPHAN_SECONDS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Job:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
job_id: str,
|
|
36
|
+
sid: str,
|
|
37
|
+
stream: AsyncIterator[dict[str, Any]],
|
|
38
|
+
*,
|
|
39
|
+
orphan_seconds: float,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.id = job_id
|
|
42
|
+
self.sid = sid
|
|
43
|
+
self.records: list[dict[str, Any]] = []
|
|
44
|
+
self.done = False
|
|
45
|
+
self.readers = 0
|
|
46
|
+
self._trimmed = 0 # highest seq ever dropped from the front
|
|
47
|
+
self._generation = 0 # bumped on each attach; only the latest trims
|
|
48
|
+
self._idle_since = time.monotonic()
|
|
49
|
+
self._orphan = orphan_seconds
|
|
50
|
+
self._changed = asyncio.Condition()
|
|
51
|
+
self._drain = asyncio.create_task(self._run(stream))
|
|
52
|
+
|
|
53
|
+
def _next_seq(self) -> int:
|
|
54
|
+
return self._trimmed + len(self.records) + 1
|
|
55
|
+
|
|
56
|
+
async def _run(self, stream: AsyncIterator[dict[str, Any]]) -> None:
|
|
57
|
+
try:
|
|
58
|
+
async for item in stream:
|
|
59
|
+
async with self._changed:
|
|
60
|
+
self.records.append({"seq": self._next_seq(), **item})
|
|
61
|
+
self._changed.notify_all()
|
|
62
|
+
except asyncio.CancelledError:
|
|
63
|
+
raise
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
logger.exception("job %s stream failed", self.id)
|
|
66
|
+
async with self._changed:
|
|
67
|
+
self.records.append(
|
|
68
|
+
{
|
|
69
|
+
"seq": self._next_seq(),
|
|
70
|
+
"exit": -1,
|
|
71
|
+
"error": str(exc),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
self._changed.notify_all()
|
|
75
|
+
finally:
|
|
76
|
+
async with self._changed:
|
|
77
|
+
self.done = True
|
|
78
|
+
self._changed.notify_all()
|
|
79
|
+
|
|
80
|
+
def _trim(self, through: int) -> None:
|
|
81
|
+
"""Drop buffered records with ``seq <= through``."""
|
|
82
|
+
drop = min(max(through, 0) - self._trimmed, len(self.records))
|
|
83
|
+
if drop > 0:
|
|
84
|
+
del self.records[:drop]
|
|
85
|
+
self._trimmed += drop
|
|
86
|
+
|
|
87
|
+
def has_gap(self, after: int) -> bool:
|
|
88
|
+
"""True if a resuming reader's ``after`` can't be served.
|
|
89
|
+
|
|
90
|
+
Records are trimmed as soon as they're yielded to a reader,
|
|
91
|
+
not once that reader's tunnel has actually delivered them, so
|
|
92
|
+
a reattach can ask for a ``after`` that's already gone from
|
|
93
|
+
the buffer: the records between would be silently skipped.
|
|
94
|
+
A fresh attach always starts while ``_trimmed`` is still 0,
|
|
95
|
+
so ``after < self._trimmed`` already excludes it without
|
|
96
|
+
special-casing ``after == 0``.
|
|
97
|
+
"""
|
|
98
|
+
return after < self._trimmed
|
|
99
|
+
|
|
100
|
+
async def attach(self, after: int) -> AsyncIterator[dict[str, Any]]:
|
|
101
|
+
"""Records with ``seq > after``, live until the exit record.
|
|
102
|
+
|
|
103
|
+
Acknowledged records are dropped from the buffer as they're
|
|
104
|
+
yielded, so a chatty, long-running job doesn't grow memory
|
|
105
|
+
without bound even over a single unbroken stream; ``seq`` stays
|
|
106
|
+
monotonic across the trim. Only the most recently attached
|
|
107
|
+
reader trims: a reattach (the reader's tunnel dropped) bumps
|
|
108
|
+
the generation, so a prior reader whose disconnect hasn't been
|
|
109
|
+
noticed yet stops deleting records the new one still needs.
|
|
110
|
+
"""
|
|
111
|
+
self.readers += 1
|
|
112
|
+
self._generation += 1
|
|
113
|
+
generation = self._generation
|
|
114
|
+
try:
|
|
115
|
+
async with self._changed:
|
|
116
|
+
self._trim(after)
|
|
117
|
+
i = 0
|
|
118
|
+
while True:
|
|
119
|
+
while i < len(self.records):
|
|
120
|
+
yield self.records[i]
|
|
121
|
+
i += 1
|
|
122
|
+
async with self._changed:
|
|
123
|
+
if i > 0 and self._generation == generation:
|
|
124
|
+
self._trim(self._trimmed + i)
|
|
125
|
+
i = 0
|
|
126
|
+
if self.done and i >= len(self.records):
|
|
127
|
+
return
|
|
128
|
+
await self._changed.wait_for(self._grew_past(i))
|
|
129
|
+
finally:
|
|
130
|
+
self.readers -= 1
|
|
131
|
+
self._idle_since = time.monotonic()
|
|
132
|
+
|
|
133
|
+
def _grew_past(self, n: int) -> Callable[[], bool]:
|
|
134
|
+
return lambda: self.done or len(self.records) > n
|
|
135
|
+
|
|
136
|
+
def orphaned(self) -> bool:
|
|
137
|
+
return (
|
|
138
|
+
self.readers == 0
|
|
139
|
+
and time.monotonic() - self._idle_since >= self._orphan
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def cancel(self) -> None:
|
|
143
|
+
# Cancelling the drain closes the generator, whose ``finally``
|
|
144
|
+
# kills the process group.
|
|
145
|
+
self._drain.cancel()
|
|
146
|
+
await asyncio.wait({self._drain})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class JobRegistry:
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
self._jobs: dict[str, Job] = {}
|
|
152
|
+
self._reaper: asyncio.Task[None] | None = None
|
|
153
|
+
|
|
154
|
+
def start(self, sid: str, stream: AsyncIterator[dict[str, Any]]) -> Job:
|
|
155
|
+
job = Job(uuid.uuid4().hex, sid, stream, orphan_seconds=ORPHAN_SECONDS)
|
|
156
|
+
self._jobs[job.id] = job
|
|
157
|
+
if self._reaper is None or self._reaper.done():
|
|
158
|
+
self._reaper = asyncio.create_task(self._reap())
|
|
159
|
+
return job
|
|
160
|
+
|
|
161
|
+
def get(self, sid: str, job_id: str) -> Job | None:
|
|
162
|
+
job = self._jobs.get(job_id)
|
|
163
|
+
return job if job is not None and job.sid == sid else None
|
|
164
|
+
|
|
165
|
+
async def cancel(self, job: Job) -> None:
|
|
166
|
+
self._jobs.pop(job.id, None)
|
|
167
|
+
await job.cancel()
|
|
168
|
+
|
|
169
|
+
async def cancel_for(self, sid: str) -> None:
|
|
170
|
+
for job in [j for j in self._jobs.values() if j.sid == sid]:
|
|
171
|
+
await self.cancel(job)
|
|
172
|
+
|
|
173
|
+
async def _reap(self) -> None:
|
|
174
|
+
while self._jobs:
|
|
175
|
+
await asyncio.sleep(min(ORPHAN_SECONDS / 4, 5.0))
|
|
176
|
+
for job in [j for j in self._jobs.values() if j.orphaned()]:
|
|
177
|
+
await self.cancel(job)
|
|
@@ -6,4 +6,8 @@ RUNNER_HTTP_PORT = 17777
|
|
|
6
6
|
FORWARD_SANDBOX_API_PORT = 18010
|
|
7
7
|
FORWARD_PROXY_PORT = 14100
|
|
8
8
|
|
|
9
|
-
CHISEL_VERSION = "1.
|
|
9
|
+
CHISEL_VERSION = "1.12.0"
|
|
10
|
+
|
|
11
|
+
# A job whose reader has been gone this long is killed; the platform
|
|
12
|
+
# gives up reattaching after the same window (``remote.py``).
|
|
13
|
+
JOB_ORPHAN_SECONDS = 300
|
|
@@ -13,11 +13,13 @@ from starlette.requests import Request
|
|
|
13
13
|
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
14
14
|
from starlette.routing import Mount, Route
|
|
15
15
|
|
|
16
|
+
from . import chisel
|
|
16
17
|
from .config import RuntimeConfig
|
|
17
18
|
from .sessions import (
|
|
18
19
|
MissingTarget,
|
|
19
20
|
RuntimeBusy,
|
|
20
21
|
SessionManager,
|
|
22
|
+
UnknownJob,
|
|
21
23
|
UnknownSession,
|
|
22
24
|
)
|
|
23
25
|
|
|
@@ -58,8 +60,19 @@ def _env_of(body: dict[str, Any]) -> dict[str, str]:
|
|
|
58
60
|
|
|
59
61
|
async def health(request: Request) -> Response:
|
|
60
62
|
m = _manager(request)
|
|
63
|
+
creds = m.config.credentials
|
|
61
64
|
return JSONResponse(
|
|
62
|
-
{
|
|
65
|
+
{
|
|
66
|
+
"ok": True,
|
|
67
|
+
"mode": m.mode,
|
|
68
|
+
"capacity": m.capacity,
|
|
69
|
+
"active_sessions": m.active_sessions(),
|
|
70
|
+
# Execs can run as jobs the platform reattaches to (#2760).
|
|
71
|
+
"jobs": True,
|
|
72
|
+
# Resolved in the service's own environment, which may
|
|
73
|
+
# override the configured gateway.
|
|
74
|
+
"gateway": chisel.gateway(creds) if creds else None,
|
|
75
|
+
}
|
|
63
76
|
)
|
|
64
77
|
|
|
65
78
|
|
|
@@ -94,9 +107,7 @@ async def resume(request: Request) -> Response:
|
|
|
94
107
|
return Response(status_code=204)
|
|
95
108
|
|
|
96
109
|
|
|
97
|
-
async def
|
|
98
|
-
m = _manager(request)
|
|
99
|
-
sid = _sid(request)
|
|
110
|
+
async def _exec_body(request: Request) -> dict[str, Any]:
|
|
100
111
|
body = await _json_body(request)
|
|
101
112
|
cmd = body.get("cmd")
|
|
102
113
|
if not isinstance(cmd, str) or not cmd:
|
|
@@ -104,9 +115,15 @@ async def exec_(request: Request) -> Response:
|
|
|
104
115
|
stdin = body.get("stdin")
|
|
105
116
|
if stdin is not None and not isinstance(stdin, str):
|
|
106
117
|
raise ValueError("stdin must be a string")
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
return {
|
|
119
|
+
"cmd": cmd,
|
|
120
|
+
"env": _env_of(body),
|
|
121
|
+
"stdin": stdin,
|
|
122
|
+
"timeout": float(body.get("timeout") or 0),
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
|
|
126
|
+
def _ndjson(stream: AsyncIterator[dict[str, Any]]) -> StreamingResponse:
|
|
110
127
|
async def lines() -> AsyncIterator[bytes]:
|
|
111
128
|
async for item in stream:
|
|
112
129
|
yield (json.dumps(item) + "\n").encode()
|
|
@@ -114,6 +131,36 @@ async def exec_(request: Request) -> Response:
|
|
|
114
131
|
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
|
115
132
|
|
|
116
133
|
|
|
134
|
+
async def exec_(request: Request) -> Response:
|
|
135
|
+
m = _manager(request)
|
|
136
|
+
sid = _sid(request)
|
|
137
|
+
args = await _exec_body(request)
|
|
138
|
+
return _ndjson(m.exec(sid, **args))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def jobs(request: Request) -> Response:
|
|
142
|
+
m = _manager(request)
|
|
143
|
+
sid = _sid(request)
|
|
144
|
+
args = await _exec_body(request)
|
|
145
|
+
job = m.start_job(sid, **args)
|
|
146
|
+
return JSONResponse({"job_id": job.id}, status_code=201)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def job(request: Request) -> Response:
|
|
150
|
+
m = _manager(request)
|
|
151
|
+
job = m.job(_sid(request), request.path_params["job_id"])
|
|
152
|
+
if request.method == "DELETE":
|
|
153
|
+
await m.jobs.cancel(job)
|
|
154
|
+
return Response(status_code=204)
|
|
155
|
+
try:
|
|
156
|
+
after = int(request.query_params.get("after", "0"))
|
|
157
|
+
except ValueError as exc:
|
|
158
|
+
raise ValueError("after must be an integer") from exc
|
|
159
|
+
if job.has_gap(after):
|
|
160
|
+
return Response(status_code=410)
|
|
161
|
+
return _ndjson(job.attach(after))
|
|
162
|
+
|
|
163
|
+
|
|
117
164
|
async def capture(request: Request) -> Response:
|
|
118
165
|
m = _manager(request)
|
|
119
166
|
sid = _sid(request)
|
|
@@ -184,6 +231,13 @@ def _error(status: int) -> Any:
|
|
|
184
231
|
return handler
|
|
185
232
|
|
|
186
233
|
|
|
234
|
+
async def _busy(request: Request, exc: Exception) -> Response:
|
|
235
|
+
assert isinstance(exc, RuntimeBusy)
|
|
236
|
+
return JSONResponse(
|
|
237
|
+
{"detail": str(exc), "session_id": exc.session_id}, status_code=409
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
187
241
|
def create_app(config: RuntimeConfig) -> Starlette:
|
|
188
242
|
app = Starlette(
|
|
189
243
|
routes=[
|
|
@@ -199,6 +253,12 @@ def create_app(config: RuntimeConfig) -> Starlette:
|
|
|
199
253
|
Route("/sessions/{sid}/pause", pause, methods=["POST"]),
|
|
200
254
|
Route("/sessions/{sid}/resume", resume, methods=["POST"]),
|
|
201
255
|
Route("/sessions/{sid}/exec", exec_, methods=["POST"]),
|
|
256
|
+
Route("/sessions/{sid}/jobs", jobs, methods=["POST"]),
|
|
257
|
+
Route(
|
|
258
|
+
"/sessions/{sid}/jobs/{job_id}",
|
|
259
|
+
job,
|
|
260
|
+
methods=["GET", "DELETE"],
|
|
261
|
+
),
|
|
202
262
|
Route(
|
|
203
263
|
"/sessions/{sid}/capture", capture, methods=["POST"]
|
|
204
264
|
),
|
|
@@ -218,8 +278,9 @@ def create_app(config: RuntimeConfig) -> Starlette:
|
|
|
218
278
|
exception_handlers={
|
|
219
279
|
ValueError: _error(400),
|
|
220
280
|
UnknownSession: _error(404),
|
|
281
|
+
UnknownJob: _error(404),
|
|
221
282
|
MissingTarget: _error(404),
|
|
222
|
-
RuntimeBusy:
|
|
283
|
+
RuntimeBusy: _busy,
|
|
223
284
|
},
|
|
224
285
|
)
|
|
225
286
|
app.state.manager = SessionManager(config)
|
|
@@ -10,11 +10,12 @@ import shutil
|
|
|
10
10
|
import tarfile
|
|
11
11
|
import uuid
|
|
12
12
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
13
|
-
from dataclasses import asdict, dataclass
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import IO, Any, Literal
|
|
16
16
|
|
|
17
17
|
from .config import RuntimeConfig
|
|
18
|
+
from .jobs import Job, JobRegistry
|
|
18
19
|
from .process import host_env, run_process, stream_process
|
|
19
20
|
|
|
20
21
|
log = logging.getLogger("levain_runtime")
|
|
@@ -25,12 +26,20 @@ _SESSION_FILE = "session.json"
|
|
|
25
26
|
# Trees under these roots are host bind mounts in docker mode, so they
|
|
26
27
|
# are extracted on the host for both backends.
|
|
27
28
|
_HOST_TREE_ROOTS = ("/recipes", "/sdk")
|
|
29
|
+
WORKSPACE_PATH = "/workspace"
|
|
30
|
+
BWRAP = "bwrap"
|
|
28
31
|
_CLAUDE_READY_SENTINEL = "/tmp/.levain-runtime-ready"
|
|
29
32
|
|
|
30
33
|
Runner = Callable[..., Awaitable[tuple[int, bytes, bytes]]]
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
class RuntimeBusy(Exception):
|
|
37
|
+
def __init__(self, message: str, *, session_id: str) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.session_id = session_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnknownJob(Exception):
|
|
34
43
|
pass
|
|
35
44
|
|
|
36
45
|
|
|
@@ -52,6 +61,9 @@ class SessionMeta:
|
|
|
52
61
|
env: dict[str, str]
|
|
53
62
|
workspace_dir: str
|
|
54
63
|
state: State = "paused"
|
|
64
|
+
# Per-session views (``/recipes/active`` -> ``/recipes/<fp>``) on a
|
|
65
|
+
# sandboxed direct runtime; the host symlink is flipped elsewhere.
|
|
66
|
+
trees: dict[str, str] = field(default_factory=dict)
|
|
55
67
|
|
|
56
68
|
def to_dict(self) -> dict[str, Any]:
|
|
57
69
|
return asdict(self)
|
|
@@ -194,11 +206,24 @@ class SessionStore:
|
|
|
194
206
|
|
|
195
207
|
|
|
196
208
|
class DirectBackend:
|
|
197
|
-
"""Nodes run as host processes
|
|
209
|
+
"""Nodes run as host processes.
|
|
198
210
|
|
|
199
|
-
|
|
211
|
+
With a capacity of one, the active session owns the root-level
|
|
212
|
+
``/workspace`` link. Above one, each node runs under bubblewrap,
|
|
213
|
+
which binds the session's own folders over ``/workspace``,
|
|
214
|
+
``/recipes/active`` and ``/sdk/active`` in a private mount
|
|
215
|
+
namespace; the rest of the host looks unchanged.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, root: Path, *, capacity: int = 1) -> None:
|
|
200
219
|
self.root = root
|
|
201
|
-
self.
|
|
220
|
+
self.capacity = max(1, capacity)
|
|
221
|
+
# Holders, oldest first.
|
|
222
|
+
self.active: list[str] = []
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def sandboxed(self) -> bool:
|
|
226
|
+
return self.capacity > 1
|
|
202
227
|
|
|
203
228
|
@property
|
|
204
229
|
def idle_workspace(self) -> Path:
|
|
@@ -212,24 +237,81 @@ class DirectBackend:
|
|
|
212
237
|
os.replace(tmp, link)
|
|
213
238
|
|
|
214
239
|
async def activate(self, meta: SessionMeta) -> None:
|
|
215
|
-
if
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
240
|
+
if meta.sid not in self.active:
|
|
241
|
+
if len(self.active) >= self.capacity:
|
|
242
|
+
raise RuntimeBusy(
|
|
243
|
+
f"runtime full ({self.capacity} sessions)"
|
|
244
|
+
if self.sandboxed
|
|
245
|
+
else "runtime busy",
|
|
246
|
+
session_id=self.active[0],
|
|
247
|
+
)
|
|
248
|
+
self.active.append(meta.sid)
|
|
249
|
+
if not self.sandboxed:
|
|
250
|
+
self._point_workspace(Path(meta.workspace_dir))
|
|
219
251
|
|
|
220
252
|
async def release(self, meta: SessionMeta) -> None:
|
|
221
|
-
if
|
|
222
|
-
|
|
253
|
+
if meta.sid not in self.active:
|
|
254
|
+
return
|
|
255
|
+
self.active.remove(meta.sid)
|
|
256
|
+
if not self.sandboxed:
|
|
223
257
|
self._point_workspace(self.idle_workspace)
|
|
224
258
|
|
|
225
259
|
async def destroy(self, meta: SessionMeta) -> None:
|
|
226
260
|
await self.release(meta)
|
|
227
261
|
|
|
228
262
|
def status(self, meta: SessionMeta) -> State:
|
|
229
|
-
return "active" if
|
|
263
|
+
return "active" if meta.sid in self.active else "paused"
|
|
264
|
+
|
|
265
|
+
def views(self, meta: SessionMeta) -> dict[str, str]:
|
|
266
|
+
"""Node-visible path -> the session's folder behind it."""
|
|
267
|
+
return {WORKSPACE_PATH: meta.workspace_dir, **meta.trees}
|
|
268
|
+
|
|
269
|
+
def session_path(self, meta: SessionMeta, path: str) -> Path:
|
|
270
|
+
"""Where ``path`` lives on the host for this session.
|
|
271
|
+
|
|
272
|
+
Only differs from ``path`` in sandboxed mode, where the views
|
|
273
|
+
exist inside each node's mount namespace.
|
|
274
|
+
"""
|
|
275
|
+
if self.sandboxed:
|
|
276
|
+
for view, real in self.views(meta).items():
|
|
277
|
+
if path == view or path.startswith(view + "/"):
|
|
278
|
+
return Path(real + path[len(view) :])
|
|
279
|
+
return Path(path)
|
|
280
|
+
|
|
281
|
+
def _tmp_dir(self, meta: SessionMeta) -> Path:
|
|
282
|
+
return self.root / "sessions" / meta.sid / "tmp"
|
|
230
283
|
|
|
231
284
|
def _env(self, meta: SessionMeta, extra: dict[str, str]) -> dict[str, str]:
|
|
232
|
-
|
|
285
|
+
env = {**host_env(), **meta.env, **extra}
|
|
286
|
+
if self.sandboxed:
|
|
287
|
+
# Daemons outside the sandbox (docker, a devcontainer CLI)
|
|
288
|
+
# resolve paths on the host, so recipes hand them this.
|
|
289
|
+
env["LEVAIN_HOST_WORKSPACE"] = meta.workspace_dir
|
|
290
|
+
# /tmp stays shared (host tools keep sockets there); the
|
|
291
|
+
# session's own scratch keeps file names from clashing.
|
|
292
|
+
tmp = self._tmp_dir(meta)
|
|
293
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
env["TMPDIR"] = str(tmp)
|
|
295
|
+
return env
|
|
296
|
+
|
|
297
|
+
def argv(self, meta: SessionMeta, cmd: str) -> list[str]:
|
|
298
|
+
shell = ["/bin/sh", "-c", cmd]
|
|
299
|
+
if not self.sandboxed:
|
|
300
|
+
return shell
|
|
301
|
+
argv = [BWRAP, "--dev-bind", "/", "/"]
|
|
302
|
+
for view, real in self.views(meta).items():
|
|
303
|
+
# bubblewrap mounts on the resolved host path, never on a
|
|
304
|
+
# symlink; ``init`` made these real directories.
|
|
305
|
+
argv += ["--bind", real, os.path.realpath(view)]
|
|
306
|
+
argv += ["--chdir", WORKSPACE_PATH, "--die-with-parent", "--", *shell]
|
|
307
|
+
return argv
|
|
308
|
+
|
|
309
|
+
def _cwd(self, meta: SessionMeta) -> str | None:
|
|
310
|
+
if self.sandboxed:
|
|
311
|
+
return None
|
|
312
|
+
return (
|
|
313
|
+
meta.workspace_dir if Path(meta.workspace_dir).is_dir() else None
|
|
314
|
+
)
|
|
233
315
|
|
|
234
316
|
def exec(
|
|
235
317
|
self,
|
|
@@ -241,13 +323,11 @@ class DirectBackend:
|
|
|
241
323
|
timeout: float,
|
|
242
324
|
) -> AsyncIterator[dict[str, Any]]:
|
|
243
325
|
return stream_process(
|
|
244
|
-
|
|
326
|
+
self.argv(meta, cmd),
|
|
245
327
|
env=self._env(meta, env),
|
|
246
328
|
stdin=stdin.encode() if stdin is not None else None,
|
|
247
329
|
timeout=timeout,
|
|
248
|
-
cwd=meta
|
|
249
|
-
if Path(meta.workspace_dir).is_dir()
|
|
250
|
-
else None,
|
|
330
|
+
cwd=self._cwd(meta),
|
|
251
331
|
)
|
|
252
332
|
|
|
253
333
|
async def capture(
|
|
@@ -259,33 +339,37 @@ class DirectBackend:
|
|
|
259
339
|
timeout: float,
|
|
260
340
|
) -> tuple[str, int]:
|
|
261
341
|
rc, out, _ = await run_process(
|
|
262
|
-
|
|
342
|
+
self.argv(meta, cmd),
|
|
263
343
|
env=self._env(meta, env),
|
|
264
344
|
timeout=timeout,
|
|
265
|
-
cwd=meta
|
|
266
|
-
if Path(meta.workspace_dir).is_dir()
|
|
267
|
-
else None,
|
|
345
|
+
cwd=self._cwd(meta),
|
|
268
346
|
)
|
|
269
347
|
return out.decode("utf-8", "replace"), rc
|
|
270
348
|
|
|
271
349
|
async def put_file(
|
|
272
350
|
self, meta: SessionMeta, path: str, data: bytes
|
|
273
351
|
) -> None:
|
|
274
|
-
p =
|
|
352
|
+
p = self.session_path(meta, path)
|
|
275
353
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
276
354
|
p.write_bytes(data)
|
|
277
355
|
|
|
278
356
|
async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
|
|
279
|
-
p =
|
|
357
|
+
p = self.session_path(meta, path)
|
|
280
358
|
return p.read_bytes() if p.is_file() else None
|
|
281
359
|
|
|
282
360
|
async def put_tree(
|
|
283
361
|
self, meta: SessionMeta, path: str, src: IO[bytes]
|
|
284
362
|
) -> None:
|
|
285
|
-
extract_tar(src,
|
|
363
|
+
extract_tar(src, self.session_path(meta, path))
|
|
286
364
|
|
|
287
365
|
async def link(self, meta: SessionMeta, path: str, target: str) -> None:
|
|
288
|
-
|
|
366
|
+
if not self.sandboxed:
|
|
367
|
+
flip_link(Path(path), target, exists_at=Path(target))
|
|
368
|
+
return
|
|
369
|
+
# The view is per session: recorded here, bound at each exec.
|
|
370
|
+
if not Path(target).is_dir():
|
|
371
|
+
raise MissingTarget(f"{target} is not on this runtime")
|
|
372
|
+
meta.trees[path] = target
|
|
289
373
|
|
|
290
374
|
|
|
291
375
|
class DockerBackend:
|
|
@@ -552,9 +636,10 @@ class DockerBackend:
|
|
|
552
636
|
class SessionManager:
|
|
553
637
|
"""Routes session operations to the configured backend."""
|
|
554
638
|
|
|
555
|
-
def __init__(self, config: RuntimeConfig) -> None:
|
|
639
|
+
def __init__(self, config: RuntimeConfig, *, restore: bool = True) -> None:
|
|
556
640
|
self.config = config
|
|
557
641
|
self.store = SessionStore(config.root)
|
|
642
|
+
self.jobs = JobRegistry()
|
|
558
643
|
self.backend: DirectBackend | DockerBackend
|
|
559
644
|
if config.mode == "docker":
|
|
560
645
|
if not config.image:
|
|
@@ -565,14 +650,15 @@ class SessionManager:
|
|
|
565
650
|
share_claude_login=config.share_claude_login,
|
|
566
651
|
)
|
|
567
652
|
else:
|
|
568
|
-
self.backend = DirectBackend(config.root)
|
|
569
|
-
|
|
653
|
+
self.backend = DirectBackend(config.root, capacity=config.sessions)
|
|
654
|
+
if restore:
|
|
655
|
+
self._restore_direct_slots()
|
|
570
656
|
|
|
571
|
-
def
|
|
657
|
+
def _restore_direct_slots(self) -> None:
|
|
572
658
|
assert isinstance(self.backend, DirectBackend)
|
|
573
659
|
active = [m for m in self.store.all() if m.state == "active"]
|
|
574
|
-
#
|
|
575
|
-
#
|
|
660
|
+
# Slots live in memory; after a restart, any recorded holder is
|
|
661
|
+
# stale until the platform resumes it.
|
|
576
662
|
for meta in active:
|
|
577
663
|
meta.state = "paused"
|
|
578
664
|
self.store.save(meta)
|
|
@@ -587,7 +673,16 @@ class SessionManager:
|
|
|
587
673
|
raise UnknownSession(sid)
|
|
588
674
|
return meta
|
|
589
675
|
|
|
676
|
+
@property
|
|
677
|
+
def capacity(self) -> int | None:
|
|
678
|
+
"""Sessions served at once; ``None`` when nothing bounds it."""
|
|
679
|
+
if isinstance(self.backend, DirectBackend):
|
|
680
|
+
return self.backend.capacity
|
|
681
|
+
return None
|
|
682
|
+
|
|
590
683
|
def active_sessions(self) -> list[str]:
|
|
684
|
+
if isinstance(self.backend, DirectBackend):
|
|
685
|
+
return list(self.backend.active)
|
|
591
686
|
return [m.sid for m in self.store.all() if m.state == "active"]
|
|
592
687
|
|
|
593
688
|
async def provision(
|
|
@@ -648,6 +743,7 @@ class SessionManager:
|
|
|
648
743
|
meta = self.store.get(sid)
|
|
649
744
|
if meta is None:
|
|
650
745
|
return
|
|
746
|
+
await self.jobs.cancel_for(sid)
|
|
651
747
|
await self.backend.destroy(meta)
|
|
652
748
|
self.store.delete(sid, keep_workspace=keep_workspace)
|
|
653
749
|
|
|
@@ -663,6 +759,26 @@ class SessionManager:
|
|
|
663
759
|
meta = self.require(sid)
|
|
664
760
|
return self.backend.exec(meta, cmd, env, stdin=stdin, timeout=timeout)
|
|
665
761
|
|
|
762
|
+
def start_job(
|
|
763
|
+
self,
|
|
764
|
+
sid: str,
|
|
765
|
+
cmd: str,
|
|
766
|
+
env: dict[str, str],
|
|
767
|
+
*,
|
|
768
|
+
stdin: str | None,
|
|
769
|
+
timeout: float,
|
|
770
|
+
) -> Job:
|
|
771
|
+
return self.jobs.start(
|
|
772
|
+
sid, self.exec(sid, cmd, env, stdin=stdin, timeout=timeout)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def job(self, sid: str, job_id: str) -> Job:
|
|
776
|
+
self.require(sid)
|
|
777
|
+
job = self.jobs.get(sid, job_id)
|
|
778
|
+
if job is None:
|
|
779
|
+
raise UnknownJob(job_id)
|
|
780
|
+
return job
|
|
781
|
+
|
|
666
782
|
async def capture(
|
|
667
783
|
self, sid: str, cmd: str, env: dict[str, str], *, timeout: float
|
|
668
784
|
) -> tuple[str, int]:
|
|
@@ -685,11 +801,11 @@ class SessionManager:
|
|
|
685
801
|
)
|
|
686
802
|
|
|
687
803
|
async def link(self, sid: str, path: str, target: str) -> None:
|
|
804
|
+
meta = self.require(sid)
|
|
688
805
|
await self.backend.link(
|
|
689
|
-
|
|
690
|
-
validate_abs_path(path),
|
|
691
|
-
validate_abs_path(target),
|
|
806
|
+
meta, validate_abs_path(path), validate_abs_path(target)
|
|
692
807
|
)
|
|
808
|
+
self.store.save(meta)
|
|
693
809
|
|
|
694
810
|
async def reset_agent_workspace(
|
|
695
811
|
self, agent_id: str, *, user: str | None
|
|
@@ -702,20 +818,28 @@ class SessionManager:
|
|
|
702
818
|
scope="user" if user else "agent",
|
|
703
819
|
user=user,
|
|
704
820
|
)
|
|
821
|
+
live = set(self.active_sessions())
|
|
705
822
|
for meta in self.store.all():
|
|
706
|
-
if meta.workspace_dir == str(target) and
|
|
707
|
-
|
|
823
|
+
if meta.workspace_dir == str(target) and (
|
|
824
|
+
meta.state == "active" or meta.sid in live
|
|
825
|
+
):
|
|
826
|
+
raise RuntimeBusy(
|
|
827
|
+
f"workspace in use by session {meta.sid}",
|
|
828
|
+
session_id=meta.sid,
|
|
829
|
+
)
|
|
708
830
|
shutil.rmtree(target, ignore_errors=True)
|
|
709
831
|
target.mkdir(parents=True, exist_ok=True)
|
|
710
832
|
|
|
711
833
|
|
|
712
834
|
__all__ = [
|
|
835
|
+
"BWRAP",
|
|
713
836
|
"DirectBackend",
|
|
714
837
|
"DockerBackend",
|
|
715
838
|
"MissingTarget",
|
|
716
839
|
"RuntimeBusy",
|
|
717
840
|
"SessionManager",
|
|
718
841
|
"SessionMeta",
|
|
842
|
+
"UnknownJob",
|
|
719
843
|
"UnknownSession",
|
|
720
844
|
"validate_abs_path",
|
|
721
845
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "levain-runtime"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.9"
|
|
4
4
|
description = "Self-hosted runtime for Levain agents: run an agent's nodes on your own machine or container while the platform drives the recipe"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/sandbox/node_runner.py.vendored
RENAMED
|
File without changes
|
{levain_runtime-0.1.4 → levain_runtime-0.1.9}/levain_runtime/sandbox/run_logging.py.vendored
RENAMED
|
File without changes
|