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.
- levain_runtime/__init__.py +5 -0
- levain_runtime/__main__.py +3 -0
- levain_runtime/chisel.py +153 -0
- levain_runtime/cli.py +365 -0
- levain_runtime/config.py +117 -0
- levain_runtime/heartbeat.py +47 -0
- levain_runtime/platform_api.py +83 -0
- levain_runtime/process.py +172 -0
- levain_runtime/protocol.py +9 -0
- levain_runtime/sandbox/node_runner.py.vendored +304 -0
- levain_runtime/sandbox/run_logging.py.vendored +153 -0
- levain_runtime/server.py +226 -0
- levain_runtime/sessions.py +721 -0
- levain_runtime-0.1.2.dist-info/METADATA +81 -0
- levain_runtime-0.1.2.dist-info/RECORD +18 -0
- levain_runtime-0.1.2.dist-info/WHEEL +4 -0
- levain_runtime-0.1.2.dist-info/entry_points.txt +2 -0
- levain_runtime-0.1.2.dist-info/licenses/LICENSE +202 -0
levain_runtime/chisel.py
ADDED
|
@@ -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
|
+
)
|
levain_runtime/config.py
ADDED
|
@@ -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
|