launcher.py
python
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83
chore(governance): sync handover+roadmap to 84db8c8 (drift:…
Human
2 days ago
| 1 | """Desktop launcher that invokes the canonical ``ok app`` entrypoint.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import os |
| 6 | import subprocess |
| 7 | import threading |
| 8 | import time |
| 9 | import urllib.error |
| 10 | import urllib.request |
| 11 | from dataclasses import dataclass |
| 12 | from pathlib import Path |
| 13 | |
| 14 | from cli.kit_root import kit_root |
| 15 | from tools.app.bind import DEFAULT_PORT |
| 16 | from tools.desktop.banner import StartupBanner, parse_startup_stderr |
| 17 | from tools.desktop.constants import ( |
| 18 | CANONICAL_LAUNCHER, |
| 19 | CANONICAL_SUBCOMMAND, |
| 20 | DEFAULT_BIND, |
| 21 | KIT_ROOT_ENV, |
| 22 | REPO_ROOT_ENV, |
| 23 | STARTUP_TIMEOUT_SECONDS, |
| 24 | ) |
| 25 | |
| 26 | |
| 27 | def resolve_kit_root(explicit: Path | None = None) -> Path: |
| 28 | """Resolve the kit checkout root for dev or bundled desktop runs.""" |
| 29 | if explicit is not None: |
| 30 | return explicit.resolve() |
| 31 | env = os.environ.get(KIT_ROOT_ENV) |
| 32 | if env: |
| 33 | return Path(env).resolve() |
| 34 | return kit_root() |
| 35 | |
| 36 | |
| 37 | def resolve_repo_root(*, kit: Path, explicit: Path | None = None, cwd: Path | None = None) -> Path: |
| 38 | """Resolve the governance repo root the desktop shell should bind.""" |
| 39 | if explicit is not None: |
| 40 | return explicit.resolve() |
| 41 | env = os.environ.get(REPO_ROOT_ENV) |
| 42 | if env: |
| 43 | return Path(env).resolve() |
| 44 | if cwd is not None: |
| 45 | return cwd.resolve() |
| 46 | return kit.resolve() |
| 47 | |
| 48 | |
| 49 | def build_launch_argv( |
| 50 | *, |
| 51 | kit_root_path: Path, |
| 52 | repo_root: Path, |
| 53 | port: int = DEFAULT_PORT, |
| 54 | bind: str = DEFAULT_BIND, |
| 55 | ) -> list[str]: |
| 56 | """Build argv for the canonical POSIX shim → ``ok app`` (§Q2a / §Q0.13).""" |
| 57 | ok_shim = kit_root_path / "cli" / CANONICAL_LAUNCHER |
| 58 | if not ok_shim.is_file(): |
| 59 | raise FileNotFoundError(f"missing canonical launcher: {ok_shim}") |
| 60 | return [ |
| 61 | str(ok_shim), |
| 62 | CANONICAL_SUBCOMMAND, |
| 63 | "--repo", |
| 64 | str(repo_root), |
| 65 | "--port", |
| 66 | str(port), |
| 67 | "--bind", |
| 68 | bind, |
| 69 | ] |
| 70 | |
| 71 | |
| 72 | @dataclass |
| 73 | class DesktopLauncher: |
| 74 | """Spawn ``ok app`` and capture the one-time startup banner.""" |
| 75 | |
| 76 | kit_root_path: Path |
| 77 | repo_root: Path |
| 78 | port: int = DEFAULT_PORT |
| 79 | bind: str = DEFAULT_BIND |
| 80 | timeout_seconds: float = STARTUP_TIMEOUT_SECONDS |
| 81 | |
| 82 | _process: subprocess.Popen[str] | None = None |
| 83 | _stderr_lines: list[str] = None # type: ignore[assignment] |
| 84 | _stderr_thread: threading.Thread | None = None |
| 85 | |
| 86 | def __post_init__(self) -> None: |
| 87 | self.kit_root_path = self.kit_root_path.resolve() |
| 88 | self.repo_root = self.repo_root.resolve() |
| 89 | self._stderr_lines = [] |
| 90 | |
| 91 | @property |
| 92 | def argv(self) -> list[str]: |
| 93 | return build_launch_argv( |
| 94 | kit_root_path=self.kit_root_path, |
| 95 | repo_root=self.repo_root, |
| 96 | port=self.port, |
| 97 | bind=self.bind, |
| 98 | ) |
| 99 | |
| 100 | def start(self) -> StartupBanner: |
| 101 | """Start ``ok app`` and block until the startup banner is complete.""" |
| 102 | env = os.environ.copy() |
| 103 | env["PYTHONPATH"] = str(self.kit_root_path) + ( |
| 104 | f":{env['PYTHONPATH']}" if env.get("PYTHONPATH") else "" |
| 105 | ) |
| 106 | self._process = subprocess.Popen( |
| 107 | self.argv, |
| 108 | cwd=self.kit_root_path, |
| 109 | env=env, |
| 110 | stdout=subprocess.DEVNULL, |
| 111 | stderr=subprocess.PIPE, |
| 112 | text=True, |
| 113 | ) |
| 114 | assert self._process.stderr is not None |
| 115 | |
| 116 | def _reader() -> None: |
| 117 | for line in self._process.stderr: # type: ignore[union-attr] |
| 118 | self._stderr_lines.append(line) |
| 119 | |
| 120 | self._stderr_thread = threading.Thread(target=_reader, name="ok-app-stderr", daemon=True) |
| 121 | self._stderr_thread.start() |
| 122 | |
| 123 | deadline = time.monotonic() + self.timeout_seconds |
| 124 | while time.monotonic() < deadline: |
| 125 | banner = parse_startup_stderr(self._stderr_lines) |
| 126 | if banner is not None: |
| 127 | self._wait_until_healthy(banner) |
| 128 | return banner |
| 129 | if self._process.poll() is not None: |
| 130 | break |
| 131 | time.sleep(0.05) |
| 132 | |
| 133 | code = self._process.poll() |
| 134 | tail = "".join(self._stderr_lines[-20:]) |
| 135 | raise RuntimeError(f"ok app failed to start (exit={code}): {tail}") |
| 136 | |
| 137 | def stop(self) -> None: |
| 138 | """Terminate the background ``ok app`` process.""" |
| 139 | if self._process is None: |
| 140 | return |
| 141 | if self._process.poll() is None: |
| 142 | self._process.terminate() |
| 143 | try: |
| 144 | self._process.wait(timeout=5) |
| 145 | except subprocess.TimeoutExpired: |
| 146 | self._process.kill() |
| 147 | self._process.wait(timeout=5) |
| 148 | if self._stderr_thread is not None: |
| 149 | self._stderr_thread.join(timeout=2) |
| 150 | self._process = None |
| 151 | |
| 152 | def _wait_until_healthy(self, banner: StartupBanner) -> None: |
| 153 | health_url = banner.url.rstrip("/") + "/api/health" |
| 154 | request = urllib.request.Request( |
| 155 | health_url, |
| 156 | headers={"Authorization": f"Bearer {banner.session_credential}"}, |
| 157 | ) |
| 158 | deadline = time.monotonic() + self.timeout_seconds |
| 159 | while time.monotonic() < deadline: |
| 160 | try: |
| 161 | with urllib.request.urlopen(request, timeout=1) as response: |
| 162 | if response.status == 200: |
| 163 | return |
| 164 | except (urllib.error.URLError, TimeoutError): |
| 165 | time.sleep(0.05) |
| 166 | raise RuntimeError(f"health check timed out for {health_url}") |
File History
1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199
fix(ISR): default require_independent_second_reviewer to require
Human
minor
⚠
2 days ago