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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: levain-runtime
3
- Version: 0.1.4
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
- manager = SessionManager(config)
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
- print(f"gateway: {chisel.gateway(config.credentials)}")
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
- print(
396
- f"sessions: {len(sessions)} ({len(manager.active_sessions())} active)"
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
- print(f" {meta.sid} {meta.state} {meta.workspace_dir}")
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
- {"ok": True, "mode": m.mode, "active_sessions": m.active_sessions()}
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: _error(409),
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
- pass
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
- if self.active not in (None, meta.sid):
216
- raise RuntimeBusy("runtime busy")
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
- self._restore_direct_slot()
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 meta.state == "active":
707
- raise RuntimeBusy(f"workspace in use by session {meta.sid}")
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.4"
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