launcher.py python
166 lines 5.2 KB
Raw
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