levain-runtime 0.1.2__tar.gz → 0.1.10__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.2 → levain_runtime-0.1.10}/.gitignore +7 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/PKG-INFO +11 -5
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/README.md +10 -4
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/chisel.py +18 -4
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/cli.py +200 -11
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/config.py +10 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/heartbeat.py +1 -0
- levain_runtime-0.1.10/levain_runtime/jobs.py +177 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/platform_api.py +29 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/protocol.py +5 -1
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/server.py +68 -7
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sessions.py +160 -35
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/pyproject.toml +1 -1
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/LICENSE +0 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/__init__.py +0 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/__main__.py +0 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/process.py +0 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/node_runner.py.vendored +0 -0
- {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/run_logging.py.vendored +0 -0
|
@@ -18,6 +18,10 @@ build/
|
|
|
18
18
|
!**/platform_agents/build/
|
|
19
19
|
!**/src/build/
|
|
20
20
|
|
|
21
|
+
# Maven build output
|
|
22
|
+
services/flink/target/
|
|
23
|
+
services/flink/dependency-reduced-pom.xml
|
|
24
|
+
|
|
21
25
|
# Binaries
|
|
22
26
|
bin/
|
|
23
27
|
|
|
@@ -136,3 +140,6 @@ assets/video/*/renders/
|
|
|
136
140
|
# www/public/videos/, the composition that made them does not.
|
|
137
141
|
assets/video/updates-*/
|
|
138
142
|
assets/video/*/snapshots/
|
|
143
|
+
|
|
144
|
+
# Maven build output (Flink job); the image build compiles from source.
|
|
145
|
+
services/flink/target/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: levain-runtime
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
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)
|
|
@@ -90,6 +95,15 @@ def server_url(gateway_url: str) -> str:
|
|
|
90
95
|
return gateway_url
|
|
91
96
|
|
|
92
97
|
|
|
98
|
+
def gateway(creds: Credentials) -> str:
|
|
99
|
+
"""The gateway to dial: the platform's, unless this host overrides it.
|
|
100
|
+
|
|
101
|
+
A runner on the platform cluster's own node dials the gateway Service
|
|
102
|
+
directly instead of looping out through the public edge and back.
|
|
103
|
+
"""
|
|
104
|
+
return os.environ.get("LEVAIN_RUNTIME_GATEWAY_URL") or creds.gateway_url
|
|
105
|
+
|
|
106
|
+
|
|
93
107
|
def client_argv(binary: Path, creds: Credentials) -> list[str]:
|
|
94
108
|
remotes = [f"R:{creds.reverse_port}:127.0.0.1:{RUNNER_HTTP_PORT}"]
|
|
95
109
|
remotes += [f"{f.local_port}:{f.remote}" for f in creds.forwards]
|
|
@@ -102,7 +116,7 @@ def client_argv(binary: Path, creds: Credentials) -> list[str]:
|
|
|
102
116
|
# default lets the reconnect wait grow to five minutes.
|
|
103
117
|
"--max-retry-interval",
|
|
104
118
|
"30s",
|
|
105
|
-
server_url(creds
|
|
119
|
+
server_url(gateway(creds)),
|
|
106
120
|
*remotes,
|
|
107
121
|
]
|
|
108
122
|
|
|
@@ -7,11 +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
|
|
13
|
+
import time
|
|
12
14
|
from importlib import resources
|
|
13
15
|
from pathlib import Path
|
|
14
|
-
from typing import Annotated
|
|
16
|
+
from typing import Annotated, Any
|
|
15
17
|
|
|
16
18
|
import httpx
|
|
17
19
|
import typer
|
|
@@ -22,7 +24,7 @@ from .config import Credentials, Mode, RuntimeConfig, runtime_home
|
|
|
22
24
|
from .heartbeat import package_version, run_heartbeat
|
|
23
25
|
from .protocol import RUNNER_HTTP_PORT
|
|
24
26
|
from .server import create_app
|
|
25
|
-
from .sessions import SessionManager
|
|
27
|
+
from .sessions import BWRAP, SessionManager
|
|
26
28
|
|
|
27
29
|
app = typer.Typer(
|
|
28
30
|
name="levain-runtime",
|
|
@@ -34,6 +36,9 @@ _HOME_DIRS = ("recipes", "sdk", "sandbox", "sessions", "agents", "bin")
|
|
|
34
36
|
_SYSTEM_LINKS = ("recipes", "sdk", "sandbox", "workspace")
|
|
35
37
|
_SYNTHETIC_CONF = Path("/etc/synthetic.conf")
|
|
36
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")
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
def _version_callback(value: bool) -> None:
|
|
@@ -71,10 +76,29 @@ def write_sandbox_files(root: Path) -> None:
|
|
|
71
76
|
(root / "sandbox" / name).write_bytes(data)
|
|
72
77
|
|
|
73
78
|
|
|
74
|
-
def prepare_home(root: Path) -> None:
|
|
79
|
+
def prepare_home(root: Path, *, sessions: int | None = 1) -> None:
|
|
75
80
|
for name in _HOME_DIRS:
|
|
76
81
|
(root / name).mkdir(parents=True, exist_ok=True)
|
|
77
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
|
|
78
102
|
idle = root / "sessions" / ".idle" / "workspace"
|
|
79
103
|
idle.mkdir(parents=True, exist_ok=True)
|
|
80
104
|
link = root / "workspace"
|
|
@@ -82,6 +106,47 @@ def prepare_home(root: Path) -> None:
|
|
|
82
106
|
link.symlink_to(idle)
|
|
83
107
|
|
|
84
108
|
|
|
109
|
+
def check_sandbox(sessions: int | None) -> int | None:
|
|
110
|
+
"""The sessions a direct runtime here serves at once.
|
|
111
|
+
|
|
112
|
+
``None`` asks for no cap, which needs Linux and bubblewrap: a
|
|
113
|
+
machine without them serves one, and the reason is printed. An
|
|
114
|
+
explicit count above one must be servable or the command fails.
|
|
115
|
+
"""
|
|
116
|
+
if sessions is not None and sessions < 1:
|
|
117
|
+
raise typer.BadParameter("--sessions must be at least 1")
|
|
118
|
+
if sessions == 1:
|
|
119
|
+
return 1
|
|
120
|
+
problem = _sandbox_problem()
|
|
121
|
+
if problem is None:
|
|
122
|
+
return sessions
|
|
123
|
+
if sessions is None:
|
|
124
|
+
print(f"serving one session at a time: {problem}")
|
|
125
|
+
return 1
|
|
126
|
+
raise typer.BadParameter(problem)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sandbox_problem() -> str | None:
|
|
130
|
+
if sys.platform != "linux":
|
|
131
|
+
return "several sessions at once need Linux; macOS serves one"
|
|
132
|
+
if shutil.which(BWRAP) is None:
|
|
133
|
+
return (
|
|
134
|
+
"several sessions at once need bubblewrap; "
|
|
135
|
+
"install it (apt install bubblewrap) and retry"
|
|
136
|
+
)
|
|
137
|
+
probe = subprocess.run(
|
|
138
|
+
[BWRAP, "--dev-bind", "/", "/", "--die-with-parent", "--", "true"],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
check=False,
|
|
142
|
+
)
|
|
143
|
+
if probe.returncode != 0:
|
|
144
|
+
return "bubblewrap cannot start a sandbox here: " + (
|
|
145
|
+
probe.stderr.strip() or f"exit {probe.returncode}"
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
85
150
|
def system_link_commands(root: Path) -> list[list[str]]:
|
|
86
151
|
"""``ln`` commands still needed for ``/recipes`` & co.; empty when done."""
|
|
87
152
|
cmds: list[list[str]] = []
|
|
@@ -171,12 +236,24 @@ def init(
|
|
|
171
236
|
help="Copy this host's Claude login into docker sessions",
|
|
172
237
|
),
|
|
173
238
|
] = True,
|
|
239
|
+
sessions: Annotated[
|
|
240
|
+
int | None,
|
|
241
|
+
typer.Option(
|
|
242
|
+
help="Cap on sessions served at once in direct mode; by default "
|
|
243
|
+
"as many as the machine can isolate (above 1 needs Linux and "
|
|
244
|
+
"bubblewrap)"
|
|
245
|
+
),
|
|
246
|
+
] = None,
|
|
174
247
|
) -> None:
|
|
175
248
|
"""Prepare the runtime home and, in direct mode, the system links."""
|
|
176
249
|
if mode == "docker" and not image:
|
|
177
250
|
raise typer.BadParameter("--image is required in docker mode")
|
|
251
|
+
if mode == "direct":
|
|
252
|
+
sessions = check_sandbox(sessions)
|
|
253
|
+
else:
|
|
254
|
+
sessions = 1
|
|
178
255
|
home = runtime_home()
|
|
179
|
-
prepare_home(home)
|
|
256
|
+
prepare_home(home, sessions=sessions)
|
|
180
257
|
existing = (
|
|
181
258
|
RuntimeConfig.load(home) if (home / "config.json").is_file() else None
|
|
182
259
|
)
|
|
@@ -185,12 +262,23 @@ def init(
|
|
|
185
262
|
mode=mode,
|
|
186
263
|
image=image,
|
|
187
264
|
share_claude_login=share_claude_login,
|
|
265
|
+
sessions=sessions,
|
|
188
266
|
credentials=existing.credentials if existing else None,
|
|
189
267
|
)
|
|
190
268
|
config.save()
|
|
191
269
|
if mode == "direct":
|
|
192
270
|
ensure_system_links(home)
|
|
193
|
-
|
|
271
|
+
served = (
|
|
272
|
+
"no cap on sessions at once"
|
|
273
|
+
if sessions is None
|
|
274
|
+
else f"{sessions} sessions at once"
|
|
275
|
+
if sessions > 1
|
|
276
|
+
else ""
|
|
277
|
+
)
|
|
278
|
+
print(
|
|
279
|
+
f"runtime ready at {home} ({mode}"
|
|
280
|
+
+ (f", {served})" if served else ")")
|
|
281
|
+
)
|
|
194
282
|
|
|
195
283
|
|
|
196
284
|
@app.command()
|
|
@@ -283,6 +371,82 @@ def register(
|
|
|
283
371
|
print("next: levain-runtime up")
|
|
284
372
|
|
|
285
373
|
|
|
374
|
+
def _detach_and_send(
|
|
375
|
+
root: Path,
|
|
376
|
+
creds: Credentials,
|
|
377
|
+
event_type: str,
|
|
378
|
+
payload: object,
|
|
379
|
+
agent: str | None,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Deliver an event from a detached child; the caller returns now.
|
|
382
|
+
|
|
383
|
+
A hook must never wait on the network, so the parent forks and
|
|
384
|
+
exits while the child drops the inherited stdio (a caller reading
|
|
385
|
+
our pipes would otherwise block on the child) and posts the event.
|
|
386
|
+
Failures land in ``emit.log`` under the runtime home.
|
|
387
|
+
"""
|
|
388
|
+
if os.fork() > 0:
|
|
389
|
+
return
|
|
390
|
+
os.setsid()
|
|
391
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
392
|
+
for fd in (0, 1, 2):
|
|
393
|
+
os.dup2(devnull, fd)
|
|
394
|
+
try:
|
|
395
|
+
platform_api.post_event(creds, event_type, payload, agent)
|
|
396
|
+
except Exception as exc: # nothing above us to handle it
|
|
397
|
+
with (root / "emit.log").open("a") as fh:
|
|
398
|
+
fh.write(
|
|
399
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {event_type}: {exc}\n"
|
|
400
|
+
)
|
|
401
|
+
finally:
|
|
402
|
+
os._exit(0)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@app.command()
|
|
406
|
+
def emit(
|
|
407
|
+
event_type: Annotated[
|
|
408
|
+
str,
|
|
409
|
+
typer.Argument(help="Event name, e.g. runtime.claude_code.stop"),
|
|
410
|
+
],
|
|
411
|
+
agent: Annotated[
|
|
412
|
+
str | None,
|
|
413
|
+
typer.Option(help="Only dispatch this agent (slug)"),
|
|
414
|
+
] = None,
|
|
415
|
+
payload: Annotated[
|
|
416
|
+
str | None,
|
|
417
|
+
typer.Option(help="JSON payload, or - to read it from stdin"),
|
|
418
|
+
] = None,
|
|
419
|
+
wait: Annotated[
|
|
420
|
+
bool,
|
|
421
|
+
typer.Option(
|
|
422
|
+
"--wait", help="Send in the foreground and print the result"
|
|
423
|
+
),
|
|
424
|
+
] = False,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Raise an event for agents subscribed to it on this runtime.
|
|
427
|
+
|
|
428
|
+
Reads the payload, then returns immediately while a detached child
|
|
429
|
+
delivers it — safe to call from hooks. Use --wait to send in the
|
|
430
|
+
foreground and see the platform's response.
|
|
431
|
+
"""
|
|
432
|
+
config = _load_config()
|
|
433
|
+
creds = config.credentials
|
|
434
|
+
if creds is None:
|
|
435
|
+
raise typer.BadParameter(
|
|
436
|
+
"no credentials; run `levain-runtime connect`"
|
|
437
|
+
)
|
|
438
|
+
raw = sys.stdin.read() if payload == "-" else payload
|
|
439
|
+
try:
|
|
440
|
+
parsed = json.loads(raw) if raw and raw.strip() else None
|
|
441
|
+
except json.JSONDecodeError as exc:
|
|
442
|
+
raise typer.BadParameter(f"payload is not valid JSON: {exc}") from None
|
|
443
|
+
if wait or not hasattr(os, "fork"):
|
|
444
|
+
result = platform_api.post_event(creds, event_type, parsed, agent)
|
|
445
|
+
print(json.dumps(result))
|
|
446
|
+
return
|
|
447
|
+
_detach_and_send(config.root, creds, event_type, parsed, agent)
|
|
448
|
+
|
|
449
|
+
|
|
286
450
|
@app.command()
|
|
287
451
|
def up() -> None:
|
|
288
452
|
"""Start the node server, the tunnel and the heartbeat."""
|
|
@@ -294,32 +458,57 @@ def up() -> None:
|
|
|
294
458
|
_serve(config)
|
|
295
459
|
|
|
296
460
|
|
|
461
|
+
def _live_health() -> dict[str, Any] | None:
|
|
462
|
+
try:
|
|
463
|
+
resp = httpx.get(
|
|
464
|
+
f"http://127.0.0.1:{RUNNER_HTTP_PORT}/v1/health", timeout=2.0
|
|
465
|
+
)
|
|
466
|
+
resp.raise_for_status()
|
|
467
|
+
body = resp.json()
|
|
468
|
+
except (httpx.HTTPError, ValueError):
|
|
469
|
+
return None
|
|
470
|
+
return body if isinstance(body, dict) else None
|
|
471
|
+
|
|
472
|
+
|
|
297
473
|
@app.command()
|
|
298
474
|
def status() -> None:
|
|
299
475
|
"""Show the configuration and the sessions on this runtime."""
|
|
300
476
|
config = _load_config()
|
|
301
|
-
|
|
477
|
+
# Read-only: the startup reset belongs to the running service.
|
|
478
|
+
manager = SessionManager(config, restore=False)
|
|
479
|
+
live = _live_health()
|
|
302
480
|
print(f"home: {config.root}")
|
|
303
481
|
print(
|
|
304
482
|
f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
|
|
305
483
|
)
|
|
484
|
+
print("service: " + ("running" if live else "not running"))
|
|
306
485
|
if config.credentials:
|
|
307
486
|
print(f"runtime: {config.credentials.runtime_id}")
|
|
308
|
-
|
|
487
|
+
if live and live.get("gateway"):
|
|
488
|
+
print(f"gateway: {live['gateway']}")
|
|
489
|
+
else:
|
|
490
|
+
print(
|
|
491
|
+
f"gateway: {chisel.gateway(config.credentials)}"
|
|
492
|
+
" (from this shell)"
|
|
493
|
+
)
|
|
309
494
|
else:
|
|
310
495
|
print("runtime: not connected")
|
|
311
496
|
if config.mode == "direct":
|
|
497
|
+
print(f"sessions at once: {config.sessions or 'no cap'}")
|
|
312
498
|
pending = system_link_commands(config.root)
|
|
313
499
|
print("system links: " + ("ok" if not pending else "missing"))
|
|
314
500
|
print(
|
|
315
501
|
f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
|
|
316
502
|
)
|
|
317
503
|
sessions = manager.store.all()
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
504
|
+
if live:
|
|
505
|
+
active = set(live.get("active_sessions") or [])
|
|
506
|
+
else:
|
|
507
|
+
active = {m.sid for m in sessions if m.state == "active"}
|
|
508
|
+
print(f"sessions: {len(sessions)} ({len(active)} active)")
|
|
321
509
|
for meta in sessions:
|
|
322
|
-
|
|
510
|
+
state = "active" if meta.sid in active else "paused"
|
|
511
|
+
print(f" {meta.sid} {state} {meta.workspace_dir}")
|
|
323
512
|
|
|
324
513
|
|
|
325
514
|
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, ``None`` for no cap.
|
|
86
|
+
# Unless it is one, each node runs under bubblewrap with its
|
|
87
|
+
# session's folders at /workspace, /recipes/active and /sdk/active.
|
|
88
|
+
sessions: int | None = 1
|
|
85
89
|
credentials: Credentials | None = None
|
|
86
90
|
|
|
87
91
|
@property
|
|
@@ -97,6 +101,11 @@ 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
|
+
# Absent (an older runner wrote the file) means one; an
|
|
105
|
+
# explicit null means no cap.
|
|
106
|
+
sessions=None
|
|
107
|
+
if (count := raw.get("sessions", 1)) is None
|
|
108
|
+
else max(1, int(count or 1)),
|
|
100
109
|
credentials=Credentials.from_dict(creds) if creds else None,
|
|
101
110
|
)
|
|
102
111
|
|
|
@@ -106,6 +115,7 @@ class RuntimeConfig:
|
|
|
106
115
|
"mode": self.mode,
|
|
107
116
|
"image": self.image,
|
|
108
117
|
"share_claude_login": self.share_claude_login,
|
|
118
|
+
"sessions": self.sessions,
|
|
109
119
|
"credentials": self.credentials.to_dict()
|
|
110
120
|
if self.credentials
|
|
111
121
|
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)
|
|
@@ -50,6 +50,35 @@ def refresh_connection(creds: Credentials) -> bool:
|
|
|
50
50
|
return True
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def post_event(
|
|
54
|
+
creds: Credentials,
|
|
55
|
+
event_type: str,
|
|
56
|
+
payload: Any,
|
|
57
|
+
agent: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""``POST /api/v1/runtimes/{id}/events``; returns the response body.
|
|
60
|
+
|
|
61
|
+
Raises ``httpx.HTTPStatusError`` with the API's message on refusal.
|
|
62
|
+
"""
|
|
63
|
+
body: dict[str, Any] = {"event_type": event_type, "payload": payload}
|
|
64
|
+
if agent is not None:
|
|
65
|
+
body["agent"] = agent
|
|
66
|
+
with _client() as client:
|
|
67
|
+
resp = client.post(
|
|
68
|
+
f"{creds.api_url}/api/v1/runtimes/{creds.runtime_id}/events",
|
|
69
|
+
json=body,
|
|
70
|
+
headers={"Authorization": f"Bearer {creds.secret}"},
|
|
71
|
+
)
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
raise httpx.HTTPStatusError(
|
|
74
|
+
_error_message(resp), request=resp.request, response=resp
|
|
75
|
+
)
|
|
76
|
+
data = resp.json()
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
raise ValueError("unexpected response from the platform")
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
53
82
|
def register_runtime(
|
|
54
83
|
api_url: str, api_key: str, body: dict[str, Any]
|
|
55
84
|
) -> dict[str, Any]:
|
|
@@ -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,25 @@ class SessionStore:
|
|
|
194
206
|
|
|
195
207
|
|
|
196
208
|
class DirectBackend:
|
|
197
|
-
"""Nodes run as host processes
|
|
209
|
+
"""Nodes run as host processes.
|
|
210
|
+
|
|
211
|
+
With a capacity of one, the active session owns the root-level
|
|
212
|
+
``/workspace`` link. Above one, or with no cap (``None``), each
|
|
213
|
+
node runs under bubblewrap,
|
|
214
|
+
which binds the session's own folders over ``/workspace``,
|
|
215
|
+
``/recipes/active`` and ``/sdk/active`` in a private mount
|
|
216
|
+
namespace; the rest of the host looks unchanged.
|
|
217
|
+
"""
|
|
198
218
|
|
|
199
|
-
def __init__(self, root: Path) -> None:
|
|
219
|
+
def __init__(self, root: Path, *, capacity: int | None = 1) -> None:
|
|
200
220
|
self.root = root
|
|
201
|
-
self.
|
|
221
|
+
self.capacity = None if capacity is None else max(1, capacity)
|
|
222
|
+
# Holders, oldest first.
|
|
223
|
+
self.active: list[str] = []
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def sandboxed(self) -> bool:
|
|
227
|
+
return self.capacity != 1
|
|
202
228
|
|
|
203
229
|
@property
|
|
204
230
|
def idle_workspace(self) -> Path:
|
|
@@ -212,24 +238,81 @@ class DirectBackend:
|
|
|
212
238
|
os.replace(tmp, link)
|
|
213
239
|
|
|
214
240
|
async def activate(self, meta: SessionMeta) -> None:
|
|
215
|
-
if
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
if meta.sid not in self.active:
|
|
242
|
+
if self.capacity is not None and len(self.active) >= self.capacity:
|
|
243
|
+
raise RuntimeBusy(
|
|
244
|
+
f"runtime full ({self.capacity} sessions)"
|
|
245
|
+
if self.sandboxed
|
|
246
|
+
else "runtime busy",
|
|
247
|
+
session_id=self.active[0],
|
|
248
|
+
)
|
|
249
|
+
self.active.append(meta.sid)
|
|
250
|
+
if not self.sandboxed:
|
|
251
|
+
self._point_workspace(Path(meta.workspace_dir))
|
|
219
252
|
|
|
220
253
|
async def release(self, meta: SessionMeta) -> None:
|
|
221
|
-
if
|
|
222
|
-
|
|
254
|
+
if meta.sid not in self.active:
|
|
255
|
+
return
|
|
256
|
+
self.active.remove(meta.sid)
|
|
257
|
+
if not self.sandboxed:
|
|
223
258
|
self._point_workspace(self.idle_workspace)
|
|
224
259
|
|
|
225
260
|
async def destroy(self, meta: SessionMeta) -> None:
|
|
226
261
|
await self.release(meta)
|
|
227
262
|
|
|
228
263
|
def status(self, meta: SessionMeta) -> State:
|
|
229
|
-
return "active" if
|
|
264
|
+
return "active" if meta.sid in self.active else "paused"
|
|
265
|
+
|
|
266
|
+
def views(self, meta: SessionMeta) -> dict[str, str]:
|
|
267
|
+
"""Node-visible path -> the session's folder behind it."""
|
|
268
|
+
return {WORKSPACE_PATH: meta.workspace_dir, **meta.trees}
|
|
269
|
+
|
|
270
|
+
def session_path(self, meta: SessionMeta, path: str) -> Path:
|
|
271
|
+
"""Where ``path`` lives on the host for this session.
|
|
272
|
+
|
|
273
|
+
Only differs from ``path`` in sandboxed mode, where the views
|
|
274
|
+
exist inside each node's mount namespace.
|
|
275
|
+
"""
|
|
276
|
+
if self.sandboxed:
|
|
277
|
+
for view, real in self.views(meta).items():
|
|
278
|
+
if path == view or path.startswith(view + "/"):
|
|
279
|
+
return Path(real + path[len(view) :])
|
|
280
|
+
return Path(path)
|
|
281
|
+
|
|
282
|
+
def _tmp_dir(self, meta: SessionMeta) -> Path:
|
|
283
|
+
return self.root / "sessions" / meta.sid / "tmp"
|
|
230
284
|
|
|
231
285
|
def _env(self, meta: SessionMeta, extra: dict[str, str]) -> dict[str, str]:
|
|
232
|
-
|
|
286
|
+
env = {**host_env(), **meta.env, **extra}
|
|
287
|
+
if self.sandboxed:
|
|
288
|
+
# Daemons outside the sandbox (docker, a devcontainer CLI)
|
|
289
|
+
# resolve paths on the host, so recipes hand them this.
|
|
290
|
+
env["LEVAIN_HOST_WORKSPACE"] = meta.workspace_dir
|
|
291
|
+
# /tmp stays shared (host tools keep sockets there); the
|
|
292
|
+
# session's own scratch keeps file names from clashing.
|
|
293
|
+
tmp = self._tmp_dir(meta)
|
|
294
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
env["TMPDIR"] = str(tmp)
|
|
296
|
+
return env
|
|
297
|
+
|
|
298
|
+
def argv(self, meta: SessionMeta, cmd: str) -> list[str]:
|
|
299
|
+
shell = ["/bin/sh", "-c", cmd]
|
|
300
|
+
if not self.sandboxed:
|
|
301
|
+
return shell
|
|
302
|
+
argv = [BWRAP, "--dev-bind", "/", "/"]
|
|
303
|
+
for view, real in self.views(meta).items():
|
|
304
|
+
# bubblewrap mounts on the resolved host path, never on a
|
|
305
|
+
# symlink; ``init`` made these real directories.
|
|
306
|
+
argv += ["--bind", real, os.path.realpath(view)]
|
|
307
|
+
argv += ["--chdir", WORKSPACE_PATH, "--die-with-parent", "--", *shell]
|
|
308
|
+
return argv
|
|
309
|
+
|
|
310
|
+
def _cwd(self, meta: SessionMeta) -> str | None:
|
|
311
|
+
if self.sandboxed:
|
|
312
|
+
return None
|
|
313
|
+
return (
|
|
314
|
+
meta.workspace_dir if Path(meta.workspace_dir).is_dir() else None
|
|
315
|
+
)
|
|
233
316
|
|
|
234
317
|
def exec(
|
|
235
318
|
self,
|
|
@@ -241,13 +324,11 @@ class DirectBackend:
|
|
|
241
324
|
timeout: float,
|
|
242
325
|
) -> AsyncIterator[dict[str, Any]]:
|
|
243
326
|
return stream_process(
|
|
244
|
-
|
|
327
|
+
self.argv(meta, cmd),
|
|
245
328
|
env=self._env(meta, env),
|
|
246
329
|
stdin=stdin.encode() if stdin is not None else None,
|
|
247
330
|
timeout=timeout,
|
|
248
|
-
cwd=meta
|
|
249
|
-
if Path(meta.workspace_dir).is_dir()
|
|
250
|
-
else None,
|
|
331
|
+
cwd=self._cwd(meta),
|
|
251
332
|
)
|
|
252
333
|
|
|
253
334
|
async def capture(
|
|
@@ -259,33 +340,37 @@ class DirectBackend:
|
|
|
259
340
|
timeout: float,
|
|
260
341
|
) -> tuple[str, int]:
|
|
261
342
|
rc, out, _ = await run_process(
|
|
262
|
-
|
|
343
|
+
self.argv(meta, cmd),
|
|
263
344
|
env=self._env(meta, env),
|
|
264
345
|
timeout=timeout,
|
|
265
|
-
cwd=meta
|
|
266
|
-
if Path(meta.workspace_dir).is_dir()
|
|
267
|
-
else None,
|
|
346
|
+
cwd=self._cwd(meta),
|
|
268
347
|
)
|
|
269
348
|
return out.decode("utf-8", "replace"), rc
|
|
270
349
|
|
|
271
350
|
async def put_file(
|
|
272
351
|
self, meta: SessionMeta, path: str, data: bytes
|
|
273
352
|
) -> None:
|
|
274
|
-
p =
|
|
353
|
+
p = self.session_path(meta, path)
|
|
275
354
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
276
355
|
p.write_bytes(data)
|
|
277
356
|
|
|
278
357
|
async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
|
|
279
|
-
p =
|
|
358
|
+
p = self.session_path(meta, path)
|
|
280
359
|
return p.read_bytes() if p.is_file() else None
|
|
281
360
|
|
|
282
361
|
async def put_tree(
|
|
283
362
|
self, meta: SessionMeta, path: str, src: IO[bytes]
|
|
284
363
|
) -> None:
|
|
285
|
-
extract_tar(src,
|
|
364
|
+
extract_tar(src, self.session_path(meta, path))
|
|
286
365
|
|
|
287
366
|
async def link(self, meta: SessionMeta, path: str, target: str) -> None:
|
|
288
|
-
|
|
367
|
+
if not self.sandboxed:
|
|
368
|
+
flip_link(Path(path), target, exists_at=Path(target))
|
|
369
|
+
return
|
|
370
|
+
# The view is per session: recorded here, bound at each exec.
|
|
371
|
+
if not Path(target).is_dir():
|
|
372
|
+
raise MissingTarget(f"{target} is not on this runtime")
|
|
373
|
+
meta.trees[path] = target
|
|
289
374
|
|
|
290
375
|
|
|
291
376
|
class DockerBackend:
|
|
@@ -552,9 +637,10 @@ class DockerBackend:
|
|
|
552
637
|
class SessionManager:
|
|
553
638
|
"""Routes session operations to the configured backend."""
|
|
554
639
|
|
|
555
|
-
def __init__(self, config: RuntimeConfig) -> None:
|
|
640
|
+
def __init__(self, config: RuntimeConfig, *, restore: bool = True) -> None:
|
|
556
641
|
self.config = config
|
|
557
642
|
self.store = SessionStore(config.root)
|
|
643
|
+
self.jobs = JobRegistry()
|
|
558
644
|
self.backend: DirectBackend | DockerBackend
|
|
559
645
|
if config.mode == "docker":
|
|
560
646
|
if not config.image:
|
|
@@ -565,14 +651,15 @@ class SessionManager:
|
|
|
565
651
|
share_claude_login=config.share_claude_login,
|
|
566
652
|
)
|
|
567
653
|
else:
|
|
568
|
-
self.backend = DirectBackend(config.root)
|
|
569
|
-
|
|
654
|
+
self.backend = DirectBackend(config.root, capacity=config.sessions)
|
|
655
|
+
if restore:
|
|
656
|
+
self._restore_direct_slots()
|
|
570
657
|
|
|
571
|
-
def
|
|
658
|
+
def _restore_direct_slots(self) -> None:
|
|
572
659
|
assert isinstance(self.backend, DirectBackend)
|
|
573
660
|
active = [m for m in self.store.all() if m.state == "active"]
|
|
574
|
-
#
|
|
575
|
-
#
|
|
661
|
+
# Slots live in memory; after a restart, any recorded holder is
|
|
662
|
+
# stale until the platform resumes it.
|
|
576
663
|
for meta in active:
|
|
577
664
|
meta.state = "paused"
|
|
578
665
|
self.store.save(meta)
|
|
@@ -587,7 +674,16 @@ class SessionManager:
|
|
|
587
674
|
raise UnknownSession(sid)
|
|
588
675
|
return meta
|
|
589
676
|
|
|
677
|
+
@property
|
|
678
|
+
def capacity(self) -> int | None:
|
|
679
|
+
"""Sessions served at once; ``None`` when nothing bounds it."""
|
|
680
|
+
if isinstance(self.backend, DirectBackend):
|
|
681
|
+
return self.backend.capacity
|
|
682
|
+
return None
|
|
683
|
+
|
|
590
684
|
def active_sessions(self) -> list[str]:
|
|
685
|
+
if isinstance(self.backend, DirectBackend):
|
|
686
|
+
return list(self.backend.active)
|
|
591
687
|
return [m.sid for m in self.store.all() if m.state == "active"]
|
|
592
688
|
|
|
593
689
|
async def provision(
|
|
@@ -648,6 +744,7 @@ class SessionManager:
|
|
|
648
744
|
meta = self.store.get(sid)
|
|
649
745
|
if meta is None:
|
|
650
746
|
return
|
|
747
|
+
await self.jobs.cancel_for(sid)
|
|
651
748
|
await self.backend.destroy(meta)
|
|
652
749
|
self.store.delete(sid, keep_workspace=keep_workspace)
|
|
653
750
|
|
|
@@ -663,6 +760,26 @@ class SessionManager:
|
|
|
663
760
|
meta = self.require(sid)
|
|
664
761
|
return self.backend.exec(meta, cmd, env, stdin=stdin, timeout=timeout)
|
|
665
762
|
|
|
763
|
+
def start_job(
|
|
764
|
+
self,
|
|
765
|
+
sid: str,
|
|
766
|
+
cmd: str,
|
|
767
|
+
env: dict[str, str],
|
|
768
|
+
*,
|
|
769
|
+
stdin: str | None,
|
|
770
|
+
timeout: float,
|
|
771
|
+
) -> Job:
|
|
772
|
+
return self.jobs.start(
|
|
773
|
+
sid, self.exec(sid, cmd, env, stdin=stdin, timeout=timeout)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def job(self, sid: str, job_id: str) -> Job:
|
|
777
|
+
self.require(sid)
|
|
778
|
+
job = self.jobs.get(sid, job_id)
|
|
779
|
+
if job is None:
|
|
780
|
+
raise UnknownJob(job_id)
|
|
781
|
+
return job
|
|
782
|
+
|
|
666
783
|
async def capture(
|
|
667
784
|
self, sid: str, cmd: str, env: dict[str, str], *, timeout: float
|
|
668
785
|
) -> tuple[str, int]:
|
|
@@ -685,11 +802,11 @@ class SessionManager:
|
|
|
685
802
|
)
|
|
686
803
|
|
|
687
804
|
async def link(self, sid: str, path: str, target: str) -> None:
|
|
805
|
+
meta = self.require(sid)
|
|
688
806
|
await self.backend.link(
|
|
689
|
-
|
|
690
|
-
validate_abs_path(path),
|
|
691
|
-
validate_abs_path(target),
|
|
807
|
+
meta, validate_abs_path(path), validate_abs_path(target)
|
|
692
808
|
)
|
|
809
|
+
self.store.save(meta)
|
|
693
810
|
|
|
694
811
|
async def reset_agent_workspace(
|
|
695
812
|
self, agent_id: str, *, user: str | None
|
|
@@ -702,20 +819,28 @@ class SessionManager:
|
|
|
702
819
|
scope="user" if user else "agent",
|
|
703
820
|
user=user,
|
|
704
821
|
)
|
|
822
|
+
live = set(self.active_sessions())
|
|
705
823
|
for meta in self.store.all():
|
|
706
|
-
if meta.workspace_dir == str(target) and
|
|
707
|
-
|
|
824
|
+
if meta.workspace_dir == str(target) and (
|
|
825
|
+
meta.state == "active" or meta.sid in live
|
|
826
|
+
):
|
|
827
|
+
raise RuntimeBusy(
|
|
828
|
+
f"workspace in use by session {meta.sid}",
|
|
829
|
+
session_id=meta.sid,
|
|
830
|
+
)
|
|
708
831
|
shutil.rmtree(target, ignore_errors=True)
|
|
709
832
|
target.mkdir(parents=True, exist_ok=True)
|
|
710
833
|
|
|
711
834
|
|
|
712
835
|
__all__ = [
|
|
836
|
+
"BWRAP",
|
|
713
837
|
"DirectBackend",
|
|
714
838
|
"DockerBackend",
|
|
715
839
|
"MissingTarget",
|
|
716
840
|
"RuntimeBusy",
|
|
717
841
|
"SessionManager",
|
|
718
842
|
"SessionMeta",
|
|
843
|
+
"UnknownJob",
|
|
719
844
|
"UnknownSession",
|
|
720
845
|
"validate_abs_path",
|
|
721
846
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "levain-runtime"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.10"
|
|
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
|
{levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/node_runner.py.vendored
RENAMED
|
File without changes
|
{levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/run_logging.py.vendored
RENAMED
|
File without changes
|