levain-runtime 0.1.4__tar.gz → 0.1.6__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.6}/PKG-INFO +1 -1
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/cli.py +31 -7
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/server.py +18 -2
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/sessions.py +19 -7
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/pyproject.toml +1 -1
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/.gitignore +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/LICENSE +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/README.md +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/__init__.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/__main__.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/chisel.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/config.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/heartbeat.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/platform_api.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/process.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/protocol.py +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/sandbox/node_runner.py.vendored +0 -0
- {levain_runtime-0.1.4 → levain_runtime-0.1.6}/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.6
|
|
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
|
|
@@ -12,7 +12,7 @@ import sys
|
|
|
12
12
|
import time
|
|
13
13
|
from importlib import resources
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Annotated
|
|
15
|
+
from typing import Annotated, Any
|
|
16
16
|
|
|
17
17
|
import httpx
|
|
18
18
|
import typer
|
|
@@ -371,18 +371,39 @@ def up() -> None:
|
|
|
371
371
|
_serve(config)
|
|
372
372
|
|
|
373
373
|
|
|
374
|
+
def _live_health() -> dict[str, Any] | None:
|
|
375
|
+
try:
|
|
376
|
+
resp = httpx.get(
|
|
377
|
+
f"http://127.0.0.1:{RUNNER_HTTP_PORT}/v1/health", timeout=2.0
|
|
378
|
+
)
|
|
379
|
+
resp.raise_for_status()
|
|
380
|
+
body = resp.json()
|
|
381
|
+
except (httpx.HTTPError, ValueError):
|
|
382
|
+
return None
|
|
383
|
+
return body if isinstance(body, dict) else None
|
|
384
|
+
|
|
385
|
+
|
|
374
386
|
@app.command()
|
|
375
387
|
def status() -> None:
|
|
376
388
|
"""Show the configuration and the sessions on this runtime."""
|
|
377
389
|
config = _load_config()
|
|
378
|
-
|
|
390
|
+
# Read-only: the startup reset belongs to the running service.
|
|
391
|
+
manager = SessionManager(config, restore=False)
|
|
392
|
+
live = _live_health()
|
|
379
393
|
print(f"home: {config.root}")
|
|
380
394
|
print(
|
|
381
395
|
f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
|
|
382
396
|
)
|
|
397
|
+
print("service: " + ("running" if live else "not running"))
|
|
383
398
|
if config.credentials:
|
|
384
399
|
print(f"runtime: {config.credentials.runtime_id}")
|
|
385
|
-
|
|
400
|
+
if live and live.get("gateway"):
|
|
401
|
+
print(f"gateway: {live['gateway']}")
|
|
402
|
+
else:
|
|
403
|
+
print(
|
|
404
|
+
f"gateway: {chisel.gateway(config.credentials)}"
|
|
405
|
+
" (from this shell)"
|
|
406
|
+
)
|
|
386
407
|
else:
|
|
387
408
|
print("runtime: not connected")
|
|
388
409
|
if config.mode == "direct":
|
|
@@ -392,11 +413,14 @@ def status() -> None:
|
|
|
392
413
|
f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
|
|
393
414
|
)
|
|
394
415
|
sessions = manager.store.all()
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
416
|
+
if live:
|
|
417
|
+
active = set(live.get("active_sessions") or [])
|
|
418
|
+
else:
|
|
419
|
+
active = {m.sid for m in sessions if m.state == "active"}
|
|
420
|
+
print(f"sessions: {len(sessions)} ({len(active)} active)")
|
|
398
421
|
for meta in sessions:
|
|
399
|
-
|
|
422
|
+
state = "active" if meta.sid in active else "paused"
|
|
423
|
+
print(f" {meta.sid} {state} {meta.workspace_dir}")
|
|
400
424
|
|
|
401
425
|
|
|
402
426
|
def _serve(config: RuntimeConfig) -> None:
|
|
@@ -13,6 +13,7 @@ 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,
|
|
@@ -58,8 +59,16 @@ def _env_of(body: dict[str, Any]) -> dict[str, str]:
|
|
|
58
59
|
|
|
59
60
|
async def health(request: Request) -> Response:
|
|
60
61
|
m = _manager(request)
|
|
62
|
+
creds = m.config.credentials
|
|
61
63
|
return JSONResponse(
|
|
62
|
-
{
|
|
64
|
+
{
|
|
65
|
+
"ok": True,
|
|
66
|
+
"mode": m.mode,
|
|
67
|
+
"active_sessions": m.active_sessions(),
|
|
68
|
+
# Resolved in the service's own environment, which may
|
|
69
|
+
# override the configured gateway.
|
|
70
|
+
"gateway": chisel.gateway(creds) if creds else None,
|
|
71
|
+
}
|
|
63
72
|
)
|
|
64
73
|
|
|
65
74
|
|
|
@@ -184,6 +193,13 @@ def _error(status: int) -> Any:
|
|
|
184
193
|
return handler
|
|
185
194
|
|
|
186
195
|
|
|
196
|
+
async def _busy(request: Request, exc: Exception) -> Response:
|
|
197
|
+
assert isinstance(exc, RuntimeBusy)
|
|
198
|
+
return JSONResponse(
|
|
199
|
+
{"detail": str(exc), "session_id": exc.session_id}, status_code=409
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
187
203
|
def create_app(config: RuntimeConfig) -> Starlette:
|
|
188
204
|
app = Starlette(
|
|
189
205
|
routes=[
|
|
@@ -219,7 +235,7 @@ def create_app(config: RuntimeConfig) -> Starlette:
|
|
|
219
235
|
ValueError: _error(400),
|
|
220
236
|
UnknownSession: _error(404),
|
|
221
237
|
MissingTarget: _error(404),
|
|
222
|
-
RuntimeBusy:
|
|
238
|
+
RuntimeBusy: _busy,
|
|
223
239
|
},
|
|
224
240
|
)
|
|
225
241
|
app.state.manager = SessionManager(config)
|
|
@@ -31,7 +31,9 @@ Runner = Callable[..., Awaitable[tuple[int, bytes, bytes]]]
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class RuntimeBusy(Exception):
|
|
34
|
-
|
|
34
|
+
def __init__(self, message: str, *, session_id: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.session_id = session_id
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
class UnknownSession(Exception):
|
|
@@ -212,8 +214,9 @@ class DirectBackend:
|
|
|
212
214
|
os.replace(tmp, link)
|
|
213
215
|
|
|
214
216
|
async def activate(self, meta: SessionMeta) -> None:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
holder = self.active
|
|
218
|
+
if holder is not None and holder != meta.sid:
|
|
219
|
+
raise RuntimeBusy("runtime busy", session_id=holder)
|
|
217
220
|
self._point_workspace(Path(meta.workspace_dir))
|
|
218
221
|
self.active = meta.sid
|
|
219
222
|
|
|
@@ -552,7 +555,7 @@ class DockerBackend:
|
|
|
552
555
|
class SessionManager:
|
|
553
556
|
"""Routes session operations to the configured backend."""
|
|
554
557
|
|
|
555
|
-
def __init__(self, config: RuntimeConfig) -> None:
|
|
558
|
+
def __init__(self, config: RuntimeConfig, *, restore: bool = True) -> None:
|
|
556
559
|
self.config = config
|
|
557
560
|
self.store = SessionStore(config.root)
|
|
558
561
|
self.backend: DirectBackend | DockerBackend
|
|
@@ -566,7 +569,8 @@ class SessionManager:
|
|
|
566
569
|
)
|
|
567
570
|
else:
|
|
568
571
|
self.backend = DirectBackend(config.root)
|
|
569
|
-
|
|
572
|
+
if restore:
|
|
573
|
+
self._restore_direct_slot()
|
|
570
574
|
|
|
571
575
|
def _restore_direct_slot(self) -> None:
|
|
572
576
|
assert isinstance(self.backend, DirectBackend)
|
|
@@ -588,6 +592,8 @@ class SessionManager:
|
|
|
588
592
|
return meta
|
|
589
593
|
|
|
590
594
|
def active_sessions(self) -> list[str]:
|
|
595
|
+
if isinstance(self.backend, DirectBackend):
|
|
596
|
+
return [self.backend.active] if self.backend.active else []
|
|
591
597
|
return [m.sid for m in self.store.all() if m.state == "active"]
|
|
592
598
|
|
|
593
599
|
async def provision(
|
|
@@ -702,9 +708,15 @@ class SessionManager:
|
|
|
702
708
|
scope="user" if user else "agent",
|
|
703
709
|
user=user,
|
|
704
710
|
)
|
|
711
|
+
live = set(self.active_sessions())
|
|
705
712
|
for meta in self.store.all():
|
|
706
|
-
if meta.workspace_dir == str(target) and
|
|
707
|
-
|
|
713
|
+
if meta.workspace_dir == str(target) and (
|
|
714
|
+
meta.state == "active" or meta.sid in live
|
|
715
|
+
):
|
|
716
|
+
raise RuntimeBusy(
|
|
717
|
+
f"workspace in use by session {meta.sid}",
|
|
718
|
+
session_id=meta.sid,
|
|
719
|
+
)
|
|
708
720
|
shutil.rmtree(target, ignore_errors=True)
|
|
709
721
|
target.mkdir(parents=True, exist_ok=True)
|
|
710
722
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "levain-runtime"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/sandbox/node_runner.py.vendored
RENAMED
|
File without changes
|
{levain_runtime-0.1.4 → levain_runtime-0.1.6}/levain_runtime/sandbox/run_logging.py.vendored
RENAMED
|
File without changes
|