levain-runtime 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Self-hosted runtime for Levain agents.
2
+
3
+ Keep this module light: the CLI reads the package version from
4
+ metadata and the server imports its own modules directly.
5
+ """
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -0,0 +1,153 @@
1
+ """The tunnel client: a pinned chisel binary run under supervision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import gzip
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import platform
11
+ import stat
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+
16
+ from .config import Credentials
17
+ from .process import host_env
18
+ from .protocol import CHISEL_VERSION, RUNNER_HTTP_PORT
19
+
20
+ log = logging.getLogger("levain_runtime.chisel")
21
+
22
+ _RELEASES = "https://github.com/jpillora/chisel/releases/download"
23
+ _BACKOFF_MAX_S = 30.0
24
+
25
+
26
+ def _asset_name() -> str:
27
+ system = platform.system().lower()
28
+ machine = platform.machine().lower()
29
+ arch = {
30
+ "x86_64": "amd64",
31
+ "amd64": "amd64",
32
+ "aarch64": "arm64",
33
+ "arm64": "arm64",
34
+ }.get(machine)
35
+ if system not in ("linux", "darwin") or arch is None:
36
+ raise RuntimeError(
37
+ f"no chisel build for {system}/{machine}; set CHISEL_BIN"
38
+ )
39
+ return f"chisel_{CHISEL_VERSION}_{system}_{arch}"
40
+
41
+
42
+ def download(root: Path) -> Path:
43
+ """Fetch the pinned release into ``<root>/bin`` after verifying its checksum."""
44
+ name = _asset_name()
45
+ base = f"{_RELEASES}/v{CHISEL_VERSION}"
46
+ with httpx.Client(follow_redirects=True, timeout=120) as client:
47
+ sums = client.get(f"{base}/chisel_{CHISEL_VERSION}_checksums.txt")
48
+ sums.raise_for_status()
49
+ expected = None
50
+ for line in sums.text.splitlines():
51
+ parts = line.split()
52
+ if len(parts) == 2 and parts[1] == f"{name}.gz":
53
+ expected = parts[0]
54
+ if expected is None:
55
+ raise RuntimeError(f"{name}.gz missing from the release checksums")
56
+ blob = client.get(f"{base}/{name}.gz")
57
+ blob.raise_for_status()
58
+ actual = hashlib.sha256(blob.content).hexdigest()
59
+ if actual != expected:
60
+ raise RuntimeError(f"chisel checksum mismatch: {actual} != {expected}")
61
+ target = root / "bin" / "chisel"
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ tmp = target.with_suffix(".tmp")
64
+ tmp.write_bytes(gzip.decompress(blob.content))
65
+ tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
66
+ os.replace(tmp, target)
67
+ return target
68
+
69
+
70
+ def resolve_binary(root: Path) -> Path:
71
+ override = os.environ.get("CHISEL_BIN")
72
+ if override:
73
+ return Path(override)
74
+ local = root / "bin" / "chisel"
75
+ if local.is_file():
76
+ return local
77
+ log.info("downloading chisel %s", CHISEL_VERSION)
78
+ return download(root)
79
+
80
+
81
+ def server_url(gateway_url: str) -> str:
82
+ """The gateway as chisel wants it: an http(s) URL it upgrades itself.
83
+
84
+ Chisel prepends ``http://`` to any other scheme, so a ``wss://`` origin
85
+ would be dialled as host ``wss:`` on port 80.
86
+ """
87
+ for ws, http in (("wss://", "https://"), ("ws://", "http://")):
88
+ if gateway_url.startswith(ws):
89
+ return http + gateway_url[len(ws) :]
90
+ return gateway_url
91
+
92
+
93
+ def client_argv(binary: Path, creds: Credentials) -> list[str]:
94
+ remotes = [f"R:{creds.reverse_port}:127.0.0.1:{RUNNER_HTTP_PORT}"]
95
+ remotes += [f"{f.local_port}:{f.remote}" for f in creds.forwards]
96
+ return [
97
+ str(binary),
98
+ "client",
99
+ "--keepalive",
100
+ "25s",
101
+ # The gateway pod is replaced on every platform deploy; chisel's
102
+ # default lets the reconnect wait grow to five minutes.
103
+ "--max-retry-interval",
104
+ "30s",
105
+ server_url(creds.gateway_url),
106
+ *remotes,
107
+ ]
108
+
109
+
110
+ async def supervise(
111
+ binary: Path, creds: Credentials, stop: asyncio.Event
112
+ ) -> None:
113
+ """Keep the tunnel client alive; the secret travels via the AUTH env var."""
114
+ env = {**host_env(), "AUTH": f"{creds.user}:{creds.secret}"}
115
+ backoff = 1.0
116
+ while not stop.is_set():
117
+ started = asyncio.get_running_loop().time()
118
+ proc = await asyncio.create_subprocess_exec(
119
+ *client_argv(binary, creds),
120
+ env=env,
121
+ stdout=asyncio.subprocess.PIPE,
122
+ stderr=asyncio.subprocess.STDOUT,
123
+ )
124
+ waiter = asyncio.create_task(stop.wait())
125
+ assert proc.stdout is not None
126
+ try:
127
+ while True:
128
+ reader = asyncio.create_task(proc.stdout.readline())
129
+ done, _ = await asyncio.wait(
130
+ {reader, waiter}, return_when=asyncio.FIRST_COMPLETED
131
+ )
132
+ if waiter in done:
133
+ reader.cancel()
134
+ proc.terminate()
135
+ break
136
+ line = reader.result()
137
+ if not line:
138
+ break
139
+ log.info("chisel: %s", line.decode(errors="replace").rstrip())
140
+ finally:
141
+ waiter.cancel()
142
+ rc = await proc.wait()
143
+ if stop.is_set():
144
+ return
145
+ # A long-lived connection that died resets the backoff.
146
+ if asyncio.get_running_loop().time() - started > 60:
147
+ backoff = 1.0
148
+ log.warning("chisel exited with %s; restarting in %.0fs", rc, backoff)
149
+ try:
150
+ await asyncio.wait_for(stop.wait(), timeout=backoff)
151
+ return
152
+ except TimeoutError:
153
+ backoff = min(backoff * 2, _BACKOFF_MAX_S)
levain_runtime/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """``levain-runtime`` — set up, connect and run a self-hosted runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import httpx
17
+ import typer
18
+ import uvicorn
19
+
20
+ from . import chisel, platform_api
21
+ from .config import Credentials, Mode, RuntimeConfig, runtime_home
22
+ from .heartbeat import package_version, run_heartbeat
23
+ from .protocol import RUNNER_HTTP_PORT
24
+ from .server import create_app
25
+ from .sessions import SessionManager
26
+
27
+ app = typer.Typer(
28
+ name="levain-runtime",
29
+ help="Run Levain agents on your own machine or container.",
30
+ add_completion=False,
31
+ )
32
+
33
+ _HOME_DIRS = ("recipes", "sdk", "sandbox", "sessions", "agents", "bin")
34
+ _SYSTEM_LINKS = ("recipes", "sdk", "sandbox", "workspace")
35
+ _SYNTHETIC_CONF = Path("/etc/synthetic.conf")
36
+ _VENDORED = ("node_runner.py", "run_logging.py")
37
+
38
+
39
+ def _version_callback(value: bool) -> None:
40
+ if value:
41
+ print(f"levain-runtime {package_version()}")
42
+ raise typer.Exit()
43
+
44
+
45
+ @app.callback()
46
+ def _main(
47
+ version: Annotated[
48
+ bool,
49
+ typer.Option("--version", callback=_version_callback, is_eager=True),
50
+ ] = False,
51
+ ) -> None:
52
+ """Self-hosted runtime for Levain agents."""
53
+
54
+
55
+ def _load_config() -> RuntimeConfig:
56
+ home = runtime_home()
57
+ if not (home / "config.json").is_file():
58
+ raise typer.BadParameter(
59
+ f"no runtime at {home}; run `levain-runtime init` first"
60
+ )
61
+ return RuntimeConfig.load(home)
62
+
63
+
64
+ def write_sandbox_files(root: Path) -> None:
65
+ """Materialise the node entrypoint the platform invokes per node."""
66
+ pkg = resources.files("levain_runtime").joinpath("sandbox")
67
+ (root / "sandbox").mkdir(parents=True, exist_ok=True)
68
+ # Shipped with a non-module suffix so linters treat them as data.
69
+ for name in _VENDORED:
70
+ data = pkg.joinpath(f"{name}.vendored").read_bytes()
71
+ (root / "sandbox" / name).write_bytes(data)
72
+
73
+
74
+ def prepare_home(root: Path) -> None:
75
+ for name in _HOME_DIRS:
76
+ (root / name).mkdir(parents=True, exist_ok=True)
77
+ write_sandbox_files(root)
78
+ idle = root / "sessions" / ".idle" / "workspace"
79
+ idle.mkdir(parents=True, exist_ok=True)
80
+ link = root / "workspace"
81
+ if not link.is_symlink():
82
+ link.symlink_to(idle)
83
+
84
+
85
+ def system_link_commands(root: Path) -> list[list[str]]:
86
+ """``ln`` commands still needed for ``/recipes`` & co.; empty when done."""
87
+ cmds: list[list[str]] = []
88
+ for name in _SYSTEM_LINKS:
89
+ path = Path("/") / name
90
+ target = root / name
91
+ if path.is_symlink():
92
+ if os.readlink(path) == str(target):
93
+ continue
94
+ elif path.exists():
95
+ raise typer.BadParameter(
96
+ f"{path} exists and is not a symlink; move it aside first"
97
+ )
98
+ cmds.append(["ln", "-sfn", str(target), str(path)])
99
+ return cmds
100
+
101
+
102
+ def synthetic_conf_lines(root: Path, existing: str) -> list[str]:
103
+ """Entries ``/etc/synthetic.conf`` still needs for the system links."""
104
+ present: dict[str, str] = {}
105
+ for raw in existing.splitlines():
106
+ parts = raw.split("\t")
107
+ if parts[0].strip():
108
+ present[parts[0].strip()] = (
109
+ parts[1].strip() if len(parts) > 1 else ""
110
+ )
111
+ lines: list[str] = []
112
+ for name in _SYSTEM_LINKS:
113
+ target = str(root / name)
114
+ if name in present:
115
+ if present[name] != target:
116
+ raise typer.BadParameter(
117
+ f"{_SYNTHETIC_CONF} maps /{name} to "
118
+ f"{present[name] or 'a directory'}; change it to {target}"
119
+ )
120
+ continue
121
+ lines.append(f"{name}\t{target}")
122
+ return lines
123
+
124
+
125
+ def ensure_system_links(root: Path) -> None:
126
+ if sys.platform == "darwin":
127
+ _ensure_synthetic_links(root)
128
+ return
129
+ sudo = [] if os.access("/", os.W_OK) else ["sudo"]
130
+ for cmd in system_link_commands(root):
131
+ full = [*sudo, *cmd]
132
+ print(shlex.join(full))
133
+ subprocess.run(full, check=True)
134
+
135
+
136
+ def _ensure_synthetic_links(root: Path) -> None:
137
+ # The macOS system volume is read-only: root-level links come from
138
+ # /etc/synthetic.conf and appear after a restart.
139
+ if not system_link_commands(root):
140
+ return
141
+ existing = _SYNTHETIC_CONF.read_text() if _SYNTHETIC_CONF.is_file() else ""
142
+ lines = synthetic_conf_lines(root, existing)
143
+ if lines:
144
+ payload = "".join(line + "\n" for line in lines)
145
+ subprocess.run(
146
+ [
147
+ "sudo",
148
+ "sh",
149
+ "-c",
150
+ f"cat >> {shlex.quote(str(_SYNTHETIC_CONF))}",
151
+ ],
152
+ input=payload.encode(),
153
+ check=True,
154
+ )
155
+ print(
156
+ "Restart the machine to activate /recipes, /sdk, /sandbox and "
157
+ "/workspace, then run `levain-runtime connect`."
158
+ )
159
+
160
+
161
+ @app.command()
162
+ def init(
163
+ mode: Annotated[Mode, typer.Option(help="direct or docker")] = "direct",
164
+ image: Annotated[
165
+ str | None, typer.Option(help="Container image (docker mode)")
166
+ ] = None,
167
+ share_claude_login: Annotated[
168
+ bool,
169
+ typer.Option(
170
+ "--share-claude-login/--no-share-claude-login",
171
+ help="Copy this host's Claude login into docker sessions",
172
+ ),
173
+ ] = True,
174
+ ) -> None:
175
+ """Prepare the runtime home and, in direct mode, the system links."""
176
+ if mode == "docker" and not image:
177
+ raise typer.BadParameter("--image is required in docker mode")
178
+ home = runtime_home()
179
+ prepare_home(home)
180
+ existing = (
181
+ RuntimeConfig.load(home) if (home / "config.json").is_file() else None
182
+ )
183
+ config = RuntimeConfig(
184
+ root=home,
185
+ mode=mode,
186
+ image=image,
187
+ share_claude_login=share_claude_login,
188
+ credentials=existing.credentials if existing else None,
189
+ )
190
+ config.save()
191
+ if mode == "direct":
192
+ ensure_system_links(home)
193
+ print(f"runtime ready at {home} ({mode})")
194
+
195
+
196
+ @app.command()
197
+ def connect(
198
+ credentials: Annotated[
199
+ str, typer.Option(help="Credentials JSON file, or - for stdin")
200
+ ],
201
+ no_start: Annotated[
202
+ bool, typer.Option("--no-start", help="Store credentials only")
203
+ ] = False,
204
+ ) -> None:
205
+ """Store the platform credentials and start the runtime."""
206
+ config = _load_config()
207
+ raw = (
208
+ sys.stdin.read()
209
+ if credentials == "-"
210
+ else Path(credentials).read_text()
211
+ )
212
+ config.credentials = Credentials.from_dict(json.loads(raw))
213
+ config.save()
214
+ print(f"connected runtime {config.credentials.runtime_id}")
215
+ if not no_start:
216
+ _serve(config)
217
+
218
+
219
+ @app.command()
220
+ def register(
221
+ name: Annotated[
222
+ str, typer.Option(help="Runtime name, unique per workspace")
223
+ ],
224
+ mode: Annotated[
225
+ Mode | None,
226
+ typer.Option(
227
+ help="direct or docker; defaults to the initialised mode"
228
+ ),
229
+ ] = None,
230
+ image: Annotated[
231
+ str | None, typer.Option(help="Container image (docker mode)")
232
+ ] = None,
233
+ model_auth: Annotated[
234
+ str,
235
+ typer.Option(
236
+ help="platform: model calls on your Levain plan; "
237
+ "local: the provider login on this machine"
238
+ ),
239
+ ] = "platform",
240
+ api_url: Annotated[
241
+ str | None,
242
+ typer.Option(help="Levain API base URL", envvar="LEVAIN_API"),
243
+ ] = None,
244
+ ) -> None:
245
+ """Register this machine with Levain and store its credentials.
246
+
247
+ Needs a workspace API key in LEVAIN_API_KEY (prompted when unset).
248
+ """
249
+ config = _load_config()
250
+ if mode is not None and mode != config.mode:
251
+ raise typer.BadParameter(
252
+ f"this runtime was initialised in {config.mode} mode; "
253
+ f"run `levain-runtime init --mode {mode}` first"
254
+ )
255
+ if model_auth not in ("platform", "local"):
256
+ raise typer.BadParameter("--model-auth must be platform or local")
257
+ if config.credentials is not None:
258
+ raise typer.BadParameter(
259
+ f"already registered as runtime {config.credentials.runtime_id}; "
260
+ "delete it on Levain and remove credentials from config.json "
261
+ "to register again"
262
+ )
263
+ api_key = os.environ.get("LEVAIN_API_KEY") or typer.prompt(
264
+ "Workspace API key", hide_input=True
265
+ )
266
+ body: dict[str, object] = {
267
+ "name": name,
268
+ "mode": config.mode,
269
+ "model_auth": model_auth,
270
+ }
271
+ if config.mode == "docker":
272
+ body["image"] = image or config.image
273
+ base = api_url or platform_api.DEFAULT_API_URL
274
+ try:
275
+ created = platform_api.register_runtime(base, api_key, body)
276
+ except (httpx.HTTPError, ValueError) as exc:
277
+ raise typer.BadParameter(f"registration failed: {exc}") from None
278
+ creds = dict(created["credentials"])
279
+ creds.setdefault("api_url", base)
280
+ config.credentials = Credentials.from_dict(creds)
281
+ config.save()
282
+ print(f"registered runtime {name} ({config.credentials.runtime_id})")
283
+ print("next: levain-runtime up")
284
+
285
+
286
+ @app.command()
287
+ def up() -> None:
288
+ """Start the node server, the tunnel and the heartbeat."""
289
+ config = _load_config()
290
+ if config.credentials is None:
291
+ raise typer.BadParameter(
292
+ "no credentials; run `levain-runtime connect`"
293
+ )
294
+ _serve(config)
295
+
296
+
297
+ @app.command()
298
+ def status() -> None:
299
+ """Show the configuration and the sessions on this runtime."""
300
+ config = _load_config()
301
+ manager = SessionManager(config)
302
+ print(f"home: {config.root}")
303
+ print(
304
+ f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
305
+ )
306
+ if config.credentials:
307
+ print(f"runtime: {config.credentials.runtime_id}")
308
+ print(f"gateway: {config.credentials.gateway_url}")
309
+ else:
310
+ print("runtime: not connected")
311
+ if config.mode == "direct":
312
+ pending = system_link_commands(config.root)
313
+ print("system links: " + ("ok" if not pending else "missing"))
314
+ print(
315
+ f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
316
+ )
317
+ sessions = manager.store.all()
318
+ print(
319
+ f"sessions: {len(sessions)} ({len(manager.active_sessions())} active)"
320
+ )
321
+ for meta in sessions:
322
+ print(f" {meta.sid} {meta.state} {meta.workspace_dir}")
323
+
324
+
325
+ def _serve(config: RuntimeConfig) -> None:
326
+ logging.basicConfig(
327
+ level=logging.INFO, format="%(levelname)s %(name)s %(message)s"
328
+ )
329
+ creds = config.credentials
330
+ assert creds is not None
331
+ # The forward list lives on the platform; pick up changes on start.
332
+ if platform_api.refresh_connection(creds):
333
+ config.save()
334
+ binary = chisel.resolve_binary(config.root)
335
+ asyncio.run(_run_all(config, creds, binary))
336
+
337
+
338
+ async def _run_all(
339
+ config: RuntimeConfig, creds: Credentials, binary: Path
340
+ ) -> None:
341
+ application = create_app(config)
342
+ manager: SessionManager = application.state.manager
343
+ server = uvicorn.Server(
344
+ uvicorn.Config(
345
+ application,
346
+ host="127.0.0.1",
347
+ port=RUNNER_HTTP_PORT,
348
+ log_level="warning",
349
+ lifespan="off",
350
+ )
351
+ )
352
+ stop = asyncio.Event()
353
+
354
+ async def follow_server() -> None:
355
+ # uvicorn owns the signal handlers; mirror its exit flag.
356
+ while not server.should_exit:
357
+ await asyncio.sleep(0.5)
358
+ stop.set()
359
+
360
+ await asyncio.gather(
361
+ server.serve(),
362
+ follow_server(),
363
+ chisel.supervise(binary, creds, stop),
364
+ run_heartbeat(creds, manager, stop),
365
+ )
@@ -0,0 +1,117 @@
1
+ """Runtime configuration: home directory, mode, and platform credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ Mode = Literal["direct", "docker"]
12
+
13
+ CONFIG_FILE = "config.json"
14
+
15
+
16
+ def runtime_home() -> Path:
17
+ raw = os.environ.get("LEVAIN_RUNTIME_HOME")
18
+ return (
19
+ Path(raw).expanduser()
20
+ if raw
21
+ else Path("~/.levain/runtime").expanduser()
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Forward:
27
+ local_port: int
28
+ remote: str
29
+
30
+
31
+ @dataclass
32
+ class Credentials:
33
+ runtime_id: str
34
+ api_url: str
35
+ gateway_url: str
36
+ user: str
37
+ secret: str
38
+ reverse_port: int
39
+ forwards: list[Forward] = field(default_factory=list)
40
+ heartbeat_seconds: int = 30
41
+
42
+ @classmethod
43
+ def from_dict(cls, raw: dict[str, Any]) -> Credentials:
44
+ forwards = [
45
+ Forward(local_port=int(f["local_port"]), remote=str(f["remote"]))
46
+ for f in raw.get("forwards") or []
47
+ ]
48
+ return cls(
49
+ runtime_id=str(raw["runtime_id"]),
50
+ api_url=str(raw["api_url"]).rstrip("/"),
51
+ gateway_url=str(raw["gateway_url"]),
52
+ user=str(raw["user"]),
53
+ secret=str(raw["secret"]),
54
+ reverse_port=int(raw["reverse_port"]),
55
+ forwards=forwards,
56
+ heartbeat_seconds=int(raw.get("heartbeat_seconds") or 30),
57
+ )
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ return asdict(self)
61
+
62
+ def apply_connection(self, raw: dict[str, Any]) -> bool:
63
+ """Take the platform's current connection settings; True if changed."""
64
+ fresh = Credentials.from_dict(
65
+ {
66
+ **self.to_dict(),
67
+ **{k: v for k, v in raw.items() if k != "secret"},
68
+ }
69
+ )
70
+ changed = fresh != self
71
+ self.gateway_url = fresh.gateway_url
72
+ self.user = fresh.user
73
+ self.reverse_port = fresh.reverse_port
74
+ self.forwards = fresh.forwards
75
+ self.heartbeat_seconds = fresh.heartbeat_seconds
76
+ return changed
77
+
78
+
79
+ @dataclass
80
+ class RuntimeConfig:
81
+ root: Path
82
+ mode: Mode = "direct"
83
+ image: str | None = None
84
+ share_claude_login: bool = True
85
+ credentials: Credentials | None = None
86
+
87
+ @property
88
+ def path(self) -> Path:
89
+ return self.root / CONFIG_FILE
90
+
91
+ @classmethod
92
+ def load(cls, root: Path) -> RuntimeConfig:
93
+ raw = json.loads((root / CONFIG_FILE).read_text())
94
+ creds = raw.get("credentials")
95
+ return cls(
96
+ root=root,
97
+ mode=raw.get("mode", "direct"),
98
+ image=raw.get("image"),
99
+ share_claude_login=bool(raw.get("share_claude_login", True)),
100
+ credentials=Credentials.from_dict(creds) if creds else None,
101
+ )
102
+
103
+ def save(self) -> None:
104
+ self.root.mkdir(parents=True, exist_ok=True)
105
+ payload = {
106
+ "mode": self.mode,
107
+ "image": self.image,
108
+ "share_claude_login": self.share_claude_login,
109
+ "credentials": self.credentials.to_dict()
110
+ if self.credentials
111
+ else None,
112
+ }
113
+ tmp = self.path.with_suffix(".json.tmp")
114
+ tmp.write_text(json.dumps(payload, indent=2))
115
+ # Credentials live here; keep the file private.
116
+ os.chmod(tmp, 0o600)
117
+ os.replace(tmp, self.path)
@@ -0,0 +1,47 @@
1
+ """Periodic liveness report to the platform API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ import httpx
10
+
11
+ from .config import Credentials
12
+ from .sessions import SessionManager
13
+
14
+ log = logging.getLogger("levain_runtime.heartbeat")
15
+
16
+
17
+ def package_version() -> str:
18
+ try:
19
+ return version("levain-runtime")
20
+ except PackageNotFoundError:
21
+ return "dev"
22
+
23
+
24
+ async def run_heartbeat(
25
+ creds: Credentials, manager: SessionManager, stop: asyncio.Event
26
+ ) -> None:
27
+ url = f"{creds.api_url}/api/v1/runtimes/{creds.runtime_id}/heartbeat"
28
+ headers = {"Authorization": f"Bearer {creds.secret}"}
29
+ async with httpx.AsyncClient(timeout=15) as client:
30
+ while not stop.is_set():
31
+ payload = {
32
+ "version": package_version(),
33
+ "mode": manager.mode,
34
+ "active_sessions": len(manager.active_sessions()),
35
+ }
36
+ try:
37
+ resp = await client.post(url, json=payload, headers=headers)
38
+ if resp.status_code >= 400:
39
+ log.warning("heartbeat rejected: %s", resp.status_code)
40
+ except httpx.HTTPError as exc:
41
+ log.warning("heartbeat failed: %s", exc)
42
+ try:
43
+ await asyncio.wait_for(
44
+ stop.wait(), timeout=creds.heartbeat_seconds
45
+ )
46
+ except TimeoutError:
47
+ continue