Coverage for integrations / social / livekit_supervisor.py: 0.0%
309 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2LiveKit supervisor — self-hosted SFU lifecycle, baked into HARTOS.
4PURPOSE
5-------
6HARTOS is mostly P2P (PeerLink + WebRTC mesh signaling). LiveKit is the
7FALLBACK SFU that kicks in when:
8 - Call has > 4 participants (mesh inefficient)
9 - One participant is an AgentVoiceBridge (needs a stable rendezvous URL)
11ARCHITECTURE INTENT (per user clarification 2026-05-07)
12-------------------------------------------------------
13* **regional** — multi-tenant SFU host. Runs `livekit-server` locally,
14 signing JWTs with locally-generated dev keys. This module spawns the
15 binary as a managed subprocess.
16* **flat** (single device) — same as regional but typically only one
17 tenant. Supervisor still runs so >4-participant calls work.
18* **central** — sync / federation / backup-restore only. Does NOT run
19 an SFU. This module is a no-op when LIVEKIT_DISABLE=1 or
20 HEVOLVE_DEPLOY_MODE=central.
21* **embedded** — bundled mobile node. No SFU; same as central.
23ZERO-CONFIG GOAL
24----------------
25`pip install -e .` plus first start of HARTOS should produce a working
26SFU on regional/flat with no manual setup. This module:
27 1. Generates AES-256-grade API key + secret on first start, persists
28 them in ~/.hevolve/livekit/dev_keys.json (mode 0600).
29 2. Lazy-downloads the official `livekit-server` Go binary from the
30 LiveKit GitHub release that matches the pinned version, verifies
31 the SHA-256 against an embedded checksum, and installs to
32 ~/.hevolve/livekit/livekit-server (or .exe on Windows).
33 3. Generates a config file (~/.hevolve/livekit/livekit.yaml) wiring
34 the dev keys, port (default 7880), TURN/TCP fallback, and Redis
35 when HEVOLVE_REDIS_URL is set (multi-node regional clusters).
36 4. Spawns the binary as a daemon-thread-managed subprocess; restarts
37 on crash with exponential backoff capped at 60s.
38 5. Exposes runtime status via .info() so /health endpoints can report.
40The token issuer (livekit_service.py) reads the same dev keys file, so
41both sides share a single source of truth — no copy-paste configuration.
43This module is INTENTIONALLY decoupled from the binary fetch URL: the
44default GitHub release URL can be overridden via LIVEKIT_BINARY_URL for
45air-gapped installs, ISO builds (which pre-stage the binary), or
46proxied environments.
47"""
49from __future__ import annotations
51import hashlib
52import json
53import logging
54import os
55import platform
56import secrets
57import shutil
58import socket
59import stat
60import subprocess
61import threading
62import time
63import urllib.request
64from pathlib import Path
65from typing import Any, Dict, Optional
67logger = logging.getLogger('hevolve_social')
70# ── Pinned binary version ──────────────────────────────────────────────
71# Bump together: VERSION + SHA-256 table. When upgrading:
72# curl -sL https://github.com/livekit/livekit/releases/download/v$VERSION/checksums.txt
73# and copy the hashes for the platforms we ship.
74LIVEKIT_VERSION = '1.7.2'
76# SHA-256 of the official release artifacts from
77# https://github.com/livekit/livekit/releases/download/v1.7.2/checksums.txt
78# These are pinned for supply-chain integrity — a download whose hash
79# doesn't match is rejected by ensure_binary().
80#
81# Note: LiveKit does NOT ship darwin (macOS) builds in this release
82# series. Operators on Apple Silicon must either install via Homebrew
83# (`brew install livekit-server`) and let the supervisor's PATH lookup
84# find it, or set LIVEKIT_BINARY_PATH explicitly.
85_LIVEKIT_SHA256 = {
86 'linux-amd64': '7669b1a112449e71ff80cb82460dae7e526e92b3d81e15c70f66a030fac62f4a',
87 'linux-arm64': '482ced7026cbf4c661ab262d04e2d1ba4a723a478bd87028cd27a8a4bcf38035',
88 'linux-armv7': '68a48cf10b2641aaca449ec61018922a2e3294b2682ce0eb9d40ad7fb5e14c2e',
89 'windows-amd64': '9589bd307b4a908beaf65c6887f675090a8299f47979447e49a3b2a78d07a1d8',
90 'windows-arm64': '746adc54325d82e080c32501e17f66cd1830e937bc496026eb155c06cc6fd257',
91 'windows-armv7': '855007017fd5c2043ada6d43d21eb74e1cad8d496a74476be8af9e33bce296bc',
92}
95# ── Filesystem layout ─────────────────────────────────────────────────
96def _hevolve_home() -> Path:
97 """`~/.hevolve` — shared HARTOS data dir. Override via HEVOLVE_HOME."""
98 base = os.environ.get('HEVOLVE_HOME')
99 if base:
100 return Path(base).expanduser()
101 return Path.home() / '.hevolve'
104def _livekit_home() -> Path:
105 return _hevolve_home() / 'livekit'
108DEV_KEYS_FILE = 'dev_keys.json' # {api_key, api_secret, generated_at}
109CONFIG_FILE = 'livekit.yaml'
110BINARY_NAME = 'livekit-server.exe' if os.name == 'nt' else 'livekit-server'
113# ── Deploy-mode detection ─────────────────────────────────────────────
114def _deploy_mode() -> str:
115 """Return one of `flat | regional | central | embedded`.
117 Resolution order:
118 1. `HEVOLVE_DEPLOY_MODE` env var.
119 2. `LIVEKIT_DISABLE=1` → forces 'central' (skip everything).
120 3. Default 'flat' (laptop dev / single-device install).
121 """
122 if os.environ.get('LIVEKIT_DISABLE') == '1':
123 return 'central'
124 return os.environ.get('HEVOLVE_DEPLOY_MODE', 'flat').lower().strip()
127def supervisor_should_run() -> bool:
128 """True iff this deploy mode hosts the SFU itself.
130 Central + embedded skip everything (sync/federation/backup-only).
131 Regional + flat run the supervised binary.
133 Override: set `LIVEKIT_AUTOSTART=0` to force-disable, or
134 `LIVEKIT_AUTOSTART=1` to force-enable regardless of deploy mode.
135 """
136 forced = os.environ.get('LIVEKIT_AUTOSTART')
137 if forced == '0':
138 return False
139 if forced == '1':
140 return True
141 return _deploy_mode() in ('flat', 'regional')
144# ── Dev-key bootstrap (shared with livekit_service token issuer) ──────
145def ensure_dev_keys() -> Dict[str, str]:
146 """Return {api_key, api_secret} — generates + persists on first call.
148 Idempotent: same keys returned across restarts. Safe to call from
149 multiple processes (atomic write via temp + rename). Permissions:
150 0700 dir, 0600 file (POSIX). On Windows we rely on user-profile
151 ACLs (CreateDirectoryW restricts to the current user by default).
153 Env override: if LIVEKIT_API_KEY + LIVEKIT_API_SECRET are both set,
154 those win — we don't write the dev_keys.json. This is the path
155 operators use when deploying with managed LiveKit Cloud or a
156 central-issued key/secret pair.
157 """
158 env_key = os.environ.get('LIVEKIT_API_KEY')
159 env_secret = os.environ.get('LIVEKIT_API_SECRET')
160 if env_key and env_secret:
161 return {'api_key': env_key, 'api_secret': env_secret}
163 home = _livekit_home()
164 home.mkdir(parents=True, exist_ok=True)
165 try:
166 os.chmod(home, 0o700)
167 except OSError:
168 pass # Windows / restricted FS — best effort.
170 keys_path = home / DEV_KEYS_FILE
171 if keys_path.exists():
172 try:
173 with keys_path.open('r', encoding='utf-8') as fp:
174 data = json.load(fp)
175 if data.get('api_key') and data.get('api_secret'):
176 return data
177 except (OSError, json.JSONDecodeError) as e:
178 logger.warning(
179 "livekit_supervisor: could not read %s (%s); regenerating",
180 keys_path, e)
182 # First boot — generate. LiveKit accepts an arbitrary string for
183 # api_key (commonly prefixed `API`); we use a 16-byte random hex for
184 # easy diffing in logs. Secret is 32 bytes (256-bit HMAC key).
185 new_keys = {
186 'api_key': 'API' + secrets.token_hex(8),
187 'api_secret': secrets.token_urlsafe(32),
188 'generated_at': int(time.time()),
189 'generated_by': 'livekit_supervisor.ensure_dev_keys',
190 }
191 tmp_path = keys_path.with_suffix('.tmp')
192 with tmp_path.open('w', encoding='utf-8') as fp:
193 json.dump(new_keys, fp, indent=2)
194 try:
195 os.chmod(tmp_path, 0o600)
196 except OSError:
197 pass
198 os.replace(tmp_path, keys_path)
199 logger.info(
200 "livekit_supervisor: generated dev keys (api_key=%s) at %s",
201 new_keys['api_key'], keys_path)
202 return new_keys
205def get_livekit_url() -> str:
206 """Resolve the URL clients should connect to.
208 Order:
209 1. `LIVEKIT_URL` env var (managed cloud or operator override).
210 2. `ws://localhost:<LIVEKIT_PORT>` (default 7880) for self-hosted.
211 """
212 url = os.environ.get('LIVEKIT_URL')
213 if url:
214 return url
215 port = os.environ.get('LIVEKIT_PORT', '7880')
216 return f'ws://localhost:{port}'
219# ── Binary download + verify ──────────────────────────────────────────
220def _platform_tag() -> str:
221 sys_name = platform.system().lower() # 'linux'/'darwin'/'windows'
222 machine = platform.machine().lower()
223 if machine in ('x86_64', 'amd64'):
224 arch = 'amd64'
225 elif machine in ('aarch64', 'arm64'):
226 arch = 'arm64'
227 else:
228 arch = machine
229 if sys_name == 'darwin':
230 return f'darwin-{arch}'
231 if sys_name == 'windows':
232 return f'windows-{arch}'
233 return f'linux-{arch}'
236def _binary_url() -> str:
237 """Resolve the binary archive URL. `LIVEKIT_BINARY_URL` lets ISO /
238 air-gapped builds point at a local mirror or cached file://.
240 LiveKit's release filenames use underscore between os and arch
241 (`livekit_1.7.2_linux_amd64.tar.gz`), but our internal platform
242 tag uses hyphen (`linux-amd64`) since it doubles as a dict key in
243 _LIVEKIT_SHA256. The translation happens here.
244 """
245 override = os.environ.get('LIVEKIT_BINARY_URL')
246 if override:
247 return override
248 tag = _platform_tag() # e.g. 'linux-amd64'
249 url_tag = tag.replace('-', '_') # e.g. 'linux_amd64'
250 ext = 'zip' if tag.startswith('windows-') else 'tar.gz'
251 return (
252 f'https://github.com/livekit/livekit/releases/download/'
253 f'v{LIVEKIT_VERSION}/livekit_{LIVEKIT_VERSION}_{url_tag}.{ext}'
254 )
257def _verify_sha256(path: Path, tag: str) -> None:
258 expected = _LIVEKIT_SHA256.get(tag, '')
259 h = hashlib.sha256()
260 with path.open('rb') as fp:
261 for chunk in iter(lambda: fp.read(1 << 16), b''):
262 h.update(chunk)
263 actual = h.hexdigest()
264 if not expected:
265 logger.info(
266 "livekit_supervisor: download checksum (%s) = %s — pinning "
267 "deferred; set _LIVEKIT_SHA256[%r] in livekit_supervisor.py "
268 "to lock this version", tag, actual, tag)
269 return
270 if expected.lower() != actual.lower():
271 raise RuntimeError(
272 f'LiveKit binary checksum mismatch for {tag}: '
273 f'expected {expected}, got {actual}')
276def _find_prestaged_binary() -> Optional[Path]:
277 """Look for an already-installed livekit-server in standard
278 locations. Used by Docker (Dockerfile installs to /usr/local/bin)
279 and ISO builds (apt/manual install) so the supervisor doesn't
280 re-download a binary already present.
282 Order: explicit `LIVEKIT_BINARY_PATH` env > `~/.hevolve/livekit/`
283 > shutil.which() (PATH lookup).
284 """
285 explicit = os.environ.get('LIVEKIT_BINARY_PATH')
286 if explicit:
287 p = Path(explicit).expanduser()
288 if p.exists():
289 return p
291 home_path = _livekit_home() / BINARY_NAME
292 if home_path.exists():
293 return home_path
295 on_path = shutil.which('livekit-server')
296 if on_path:
297 return Path(on_path)
298 return None
301def ensure_binary() -> Optional[Path]:
302 """Download + extract the livekit-server binary if missing.
304 Returns the absolute path to the binary, or None if the download
305 couldn't be completed (logged; supervisor will degrade to
306 p2p_mesh-only mode).
308 Safe to call repeatedly: existence check short-circuits. Will
309 prefer a pre-staged binary (Dockerfile / ISO / apt install) over
310 a fresh download.
311 """
312 pre = _find_prestaged_binary()
313 if pre:
314 return pre
316 home = _livekit_home()
317 binary_path = home / BINARY_NAME
318 if binary_path.exists():
319 return binary_path
321 home.mkdir(parents=True, exist_ok=True)
322 tag = _platform_tag()
323 url = _binary_url()
325 logger.info(
326 "livekit_supervisor: fetching livekit-server v%s for %s from %s",
327 LIVEKIT_VERSION, tag, url)
329 archive_ext = 'zip' if url.endswith('.zip') else 'tar.gz'
330 archive_path = home / f'livekit-server-{LIVEKIT_VERSION}.{archive_ext}'
332 try:
333 # Use urllib only — no extra deps. Set a UA so GitHub's CDN
334 # doesn't 403 on default Python signature.
335 req = urllib.request.Request(
336 url, headers={'User-Agent': 'HARTOS-livekit-supervisor/1.0'})
337 with urllib.request.urlopen(req, timeout=120) as resp:
338 with archive_path.open('wb') as fp:
339 shutil.copyfileobj(resp, fp)
340 except Exception as e: # network / 404 / permissions
341 logger.warning(
342 "livekit_supervisor: download failed (%s); SFU will not "
343 "start. Calls fall back to P2P mesh. To install manually: "
344 "download %s and extract to %s",
345 e, url, home)
346 return None
348 _verify_sha256(archive_path, tag)
350 # Extract binary out of the archive. LiveKit ships either a .tar.gz
351 # (Linux/macOS) or .zip (Windows) containing a single
352 # `livekit-server` (or `.exe`) at the root.
353 try:
354 if archive_ext == 'zip':
355 import zipfile
356 with zipfile.ZipFile(archive_path) as z:
357 for name in z.namelist():
358 base = os.path.basename(name)
359 if base in ('livekit-server', 'livekit-server.exe'):
360 with z.open(name) as src, binary_path.open('wb') as dst:
361 shutil.copyfileobj(src, dst)
362 break
363 else:
364 import tarfile
365 with tarfile.open(archive_path, 'r:gz') as t:
366 for member in t.getmembers():
367 base = os.path.basename(member.name)
368 if base == 'livekit-server':
369 f = t.extractfile(member)
370 if f is None:
371 continue
372 with binary_path.open('wb') as dst:
373 shutil.copyfileobj(f, dst)
374 break
375 finally:
376 try:
377 archive_path.unlink()
378 except OSError:
379 pass
381 if not binary_path.exists():
382 logger.warning(
383 "livekit_supervisor: archive extraction did not produce a "
384 "livekit-server binary at %s; SFU disabled", binary_path)
385 return None
387 # 0755 so it's runnable by the current user.
388 try:
389 st = binary_path.stat()
390 os.chmod(binary_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP)
391 except OSError:
392 pass
393 logger.info(
394 "livekit_supervisor: livekit-server v%s installed at %s",
395 LIVEKIT_VERSION, binary_path)
396 return binary_path
399# ── Config generation ─────────────────────────────────────────────────
400def _bind_addresses_for_mode() -> list:
401 """Resolve which interface(s) the SFU should listen on.
403 Default policy is **silent install** — flat mode binds loopback only
404 so first start never triggers a Windows / macOS firewall prompt
405 (binding 0.0.0.0 is what triggers the OS dialog; loopback never
406 does). Regional mode binds all interfaces because LAN peers must
407 reach the SFU; that single first-start prompt is acceptable for
408 the deploy-mode that's explicitly intended to host other users.
410 Override priority (highest first):
411 1. `LIVEKIT_BIND_HOST` env — single literal address.
412 "127.0.0.1" → loopback (silent)
413 "0.0.0.0" → all interfaces (firewall-prompts)
414 "192.168.1.50" → specific NIC
415 2. Mode-aware default:
416 flat / embedded → loopback only (no prompt)
417 regional → all interfaces (one-time prompt)
418 """
419 override = os.environ.get('LIVEKIT_BIND_HOST', '').strip()
420 if override:
421 # 0.0.0.0 → empty string = LiveKit "all interfaces" sentinel
422 return [''] if override == '0.0.0.0' else [override]
423 mode = _deploy_mode()
424 if mode == 'regional':
425 return [''] # all interfaces; LAN peers reach us
426 # flat / embedded / unknown → loopback (silent first-start)
427 return ['127.0.0.1']
430def _use_external_ip_for_mode() -> bool:
431 """Loopback-bound SFU has no external IP to advertise. ICE
432 candidates from a loopback bind would only confuse remote clients.
433 Disable external-IP auto-detection on flat mode; enable on regional.
434 """
435 bind = _bind_addresses_for_mode()
436 # If we're listening on any non-loopback interface, advertise it.
437 return not (len(bind) == 1 and bind[0] in ('127.0.0.1', '::1'))
440def _generate_config(keys: Dict[str, str]) -> Path:
441 """Emit `livekit.yaml` with the dev keys + standard ports.
443 Uses the official LiveKit config schema. Operators can override
444 fields by editing the file or setting env vars HARTOS reads on
445 next start.
446 """
447 home = _livekit_home()
448 home.mkdir(parents=True, exist_ok=True)
449 cfg_path = home / CONFIG_FILE
451 port = int(os.environ.get('LIVEKIT_PORT', '7880'))
452 rtc_tcp_port = int(os.environ.get('LIVEKIT_RTC_TCP_PORT', '7881'))
453 rtc_udp_min = int(os.environ.get('LIVEKIT_RTC_UDP_MIN', '50000'))
454 rtc_udp_max = int(os.environ.get('LIVEKIT_RTC_UDP_MAX', '60000'))
455 redis_url = os.environ.get('HEVOLVE_REDIS_URL') or os.environ.get(
456 'LIVEKIT_REDIS_URL')
458 bind_addresses = _bind_addresses_for_mode()
459 use_external_ip = _use_external_ip_for_mode()
461 yaml_lines = [
462 '# Auto-generated by HARTOS livekit_supervisor. Edit if you',
463 '# need to override; HARTOS will not overwrite an existing',
464 '# config — delete the file to regenerate from current env.',
465 f'port: {port}',
466 'bind_addresses:',
467 ]
468 # YAML list — quote each value so empty-string ("all interfaces")
469 # round-trips correctly to LiveKit's parser.
470 for addr in bind_addresses:
471 yaml_lines.append(f" - '{addr}'")
472 yaml_lines += [
473 'rtc:',
474 f' tcp_port: {rtc_tcp_port}',
475 f' port_range_start: {rtc_udp_min}',
476 f' port_range_end: {rtc_udp_max}',
477 f' use_external_ip: {str(use_external_ip).lower()}',
478 'keys:',
479 f' {keys["api_key"]}: {keys["api_secret"]}',
480 'logging:',
481 ' level: info',
482 ' json: false',
483 ]
484 if redis_url:
485 yaml_lines += [
486 'redis:',
487 f' address: {redis_url}',
488 ]
490 if not cfg_path.exists():
491 with cfg_path.open('w', encoding='utf-8') as fp:
492 fp.write('\n'.join(yaml_lines) + '\n')
493 try:
494 os.chmod(cfg_path, 0o600)
495 except OSError:
496 pass
497 logger.info(
498 "livekit_supervisor: wrote default config at %s", cfg_path)
499 else:
500 logger.info(
501 "livekit_supervisor: keeping existing config at %s "
502 "(delete to regenerate)", cfg_path)
503 return cfg_path
506def _port_in_use(port: int) -> bool:
507 try:
508 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
509 s.settimeout(0.5)
510 return s.connect_ex(('127.0.0.1', port)) == 0
511 except OSError:
512 return False
515# ── Supervisor lifecycle ──────────────────────────────────────────────
516class _Supervisor:
517 """Single instance per HARTOS process. Started by start_supervisor()
518 and lives for the lifetime of the process. Daemon thread → exits
519 cleanly when the parent dies.
520 """
522 def __init__(self) -> None:
523 self.proc: Optional[subprocess.Popen] = None
524 self.binary: Optional[Path] = None
525 self.config: Optional[Path] = None
526 self.thread: Optional[threading.Thread] = None
527 self.stop_event = threading.Event()
528 self.last_error: Optional[str] = None
529 self.last_started: Optional[float] = None
530 self.restart_count = 0
531 self.url: str = get_livekit_url()
532 self.lock = threading.Lock()
534 def start(self) -> Dict[str, Any]:
535 """Provision keys + binary + config; spawn the supervisor thread.
537 Returns an info dict with status the caller can log or surface
538 on a health endpoint.
539 """
540 if self.thread is not None and self.thread.is_alive():
541 return self.info()
543 keys = ensure_dev_keys()
544 self.binary = ensure_binary()
545 self.config = _generate_config(keys)
547 if self.binary is None:
548 self.last_error = (
549 'binary unavailable — calls fall back to P2P mesh only')
550 logger.warning(
551 "livekit_supervisor: %s", self.last_error)
552 return self.info()
554 port = int(os.environ.get('LIVEKIT_PORT', '7880'))
555 if _port_in_use(port):
556 self.last_error = (
557 f'port {port} already in use; assuming an operator-'
558 f'managed livekit-server is running and skipping spawn')
559 logger.info("livekit_supervisor: %s", self.last_error)
560 return self.info()
562 self.thread = threading.Thread(
563 target=self._run, daemon=True, name='livekit-supervisor')
564 self.thread.start()
565 return self.info()
567 def stop(self) -> None:
568 self.stop_event.set()
569 with self.lock:
570 if self.proc and self.proc.poll() is None:
571 try:
572 self.proc.terminate()
573 try:
574 self.proc.wait(timeout=5)
575 except subprocess.TimeoutExpired:
576 self.proc.kill()
577 except OSError:
578 pass
580 def _run(self) -> None:
581 backoff = 1.0
582 while not self.stop_event.is_set():
583 try:
584 cmd = [str(self.binary), '--config', str(self.config)]
585 logger.info("livekit_supervisor: spawning %s", ' '.join(cmd))
586 # Hide the cmd console window on Windows — the supervisor
587 # already streams stdout to logger.info above. Routes
588 # through core.subprocess_safe.hidden_popen_kwargs so
589 # every site uses the same canonical helper instead of
590 # drifting inline `os.name == 'nt'` checks.
591 from core.subprocess_safe import hidden_popen_kwargs
592 with self.lock:
593 self.proc = subprocess.Popen(
594 cmd,
595 stdout=subprocess.PIPE,
596 stderr=subprocess.STDOUT,
597 cwd=str(_livekit_home()),
598 **hidden_popen_kwargs(),
599 )
600 self.last_started = time.time()
601 # Stream output to logger so operators see what's happening
602 # without needing to tail the binary's own log file.
603 if self.proc.stdout is not None:
604 for raw in self.proc.stdout:
605 if self.stop_event.is_set():
606 break
607 line = raw.decode('utf-8', errors='replace').rstrip()
608 if line:
609 logger.info('livekit-server: %s', line)
610 rc = self.proc.wait()
611 if self.stop_event.is_set():
612 return
613 self.last_error = f'livekit-server exited rc={rc}'
614 logger.warning("livekit_supervisor: %s", self.last_error)
615 except Exception as e:
616 self.last_error = f'spawn failed: {e}'
617 logger.error("livekit_supervisor: %s", self.last_error,
618 exc_info=True)
620 # Exponential backoff capped at 60s.
621 self.restart_count += 1
622 wait = min(backoff, 60.0)
623 backoff = min(backoff * 2.0, 60.0)
624 if self.stop_event.wait(wait):
625 return
627 def info(self) -> Dict[str, Any]:
628 running = (
629 self.proc is not None
630 and self.proc.poll() is None
631 and self.thread is not None
632 and self.thread.is_alive()
633 )
634 return {
635 'mode': _deploy_mode(),
636 'should_run': supervisor_should_run(),
637 'binary_path': str(self.binary) if self.binary else None,
638 'config_path': str(self.config) if self.config else None,
639 'url': self.url,
640 'running': running,
641 'restart_count': self.restart_count,
642 'last_started': self.last_started,
643 'last_error': self.last_error,
644 }
647_INSTANCE: Optional[_Supervisor] = None
648_INSTANCE_LOCK = threading.Lock()
651def start_supervisor() -> Dict[str, Any]:
652 """Idempotent entrypoint — call once during HARTOS bootstrap.
654 No-op when the deploy mode shouldn't host an SFU (central /
655 embedded), preserving the architectural intent that central
656 instances do sync/federation/backup-restore only.
657 """
658 global _INSTANCE
659 if not supervisor_should_run():
660 return {
661 'mode': _deploy_mode(),
662 'should_run': False,
663 'reason':
664 'deploy mode does not host an SFU; calls use P2P mesh '
665 'and any operator-managed external LiveKit URL',
666 }
668 with _INSTANCE_LOCK:
669 if _INSTANCE is None:
670 _INSTANCE = _Supervisor()
671 return _INSTANCE.start()
674def stop_supervisor() -> None:
675 """Process-shutdown hook. Safe to call when supervisor never ran."""
676 global _INSTANCE
677 with _INSTANCE_LOCK:
678 if _INSTANCE is not None:
679 _INSTANCE.stop()
680 _INSTANCE = None
683def supervisor_info() -> Dict[str, Any]:
684 """Read-only state — useful for /health, debug pages, tests."""
685 with _INSTANCE_LOCK:
686 if _INSTANCE is None:
687 return {
688 'mode': _deploy_mode(),
689 'should_run': supervisor_should_run(),
690 'running': False,
691 'reason': 'not yet started',
692 }
693 return _INSTANCE.info()
696__all__ = [
697 'LIVEKIT_VERSION',
698 'ensure_dev_keys',
699 'get_livekit_url',
700 'ensure_binary',
701 'supervisor_should_run',
702 'start_supervisor',
703 'stop_supervisor',
704 'supervisor_info',
705]