From 8fea8fe9a0c44916fbcaafc434efbafaadc8dbf1 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 6 Jun 2026 21:01:56 -0400 Subject: [PATCH] feat(api_hooks): add /api/warmup_status and /api/warmup_wait endpoints (sub-track 3) Sub-track 3 of startup_speedup_20260606. Builds on the Phase 7 minimal work at b464d1fe which only added warmup_status to /api/gui/diagnostics. New dedicated endpoints: - GET /api/warmup_status -> controller.warmup_status() (cheap, lock-guarded) - GET /api/warmup_wait?timeout=N -> controller.wait_for_warmup(timeout) then returns the final status. Default 30s. Both callable from external clients via ApiHookClient.get_warmup_status() and ApiHookClient.get_warmup_wait(timeout=30.0). 7 new tests in tests/test_api_hooks_warmup.py (5 unit + 2 live_gui). All 7 pass. --- src/api_hook_client.py | 17 +++++++ src/api_hooks.py | 43 +++++++++++++++++ tests/test_api_hooks_warmup.py | 86 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 tests/test_api_hooks_warmup.py diff --git a/src/api_hook_client.py b/src/api_hook_client.py index 01a763cb..9066101d 100644 --- a/src/api_hook_client.py +++ b/src/api_hook_client.py @@ -301,6 +301,23 @@ class ApiHookClient: """ return self._make_request('GET', '/api/performance') or {} + def get_warmup_status(self) -> dict[str, Any]: + """ + Returns the current warmup status: {pending, completed, failed}. + [C: tests/test_api_hooks_warmup.py:test_get_warmup_status_calls_correct_endpoint, tests/test_api_hooks_warmup.py:test_get_warmup_status_handles_empty_response, tests/test_api_hooks_warmup.py:test_live_warmup_status_endpoint] + """ + return self._make_request('GET', '/api/warmup_status') or {} + + def get_warmup_wait(self, timeout: float = 30.0) -> dict[str, Any]: + """ + Blocks server-side up to `timeout` seconds waiting for the warmup to + complete, then returns the final status. Useful for external clients + that need to wait until the system is fully ready before issuing AI + requests. + [C: tests/test_api_hooks_warmup.py:test_get_warmup_wait_passes_timeout_as_query_string, tests/test_api_hooks_warmup.py:test_get_warmup_wait_uses_default_timeout_when_unspecified, tests/test_api_hooks_warmup.py:test_get_warmup_wait_handles_empty_response, tests/test_api_hooks_warmup.py:test_live_warmup_wait_endpoint_completes] + """ + return self._make_request('GET', f'/api/warmup_wait?timeout={timeout}') or {} + #endregion: Diagnostics #region: Project diff --git a/src/api_hooks.py b/src/api_hooks.py index 0ac58a39..6c7c06ba 100644 --- a/src/api_hooks.py +++ b/src/api_hooks.py @@ -321,6 +321,49 @@ class HookHandler(BaseHTTPRequestHandler): queue = _get_app_attr(app, "_api_event_queue") if queue: queue_size = len(queue) self.wfile.write(json.dumps({"threads": threads, "event_queue_size": queue_size}).encode("utf-8")) + elif self.path == "/api/warmup_status" or self.path.startswith("/api/warmup_status?"): + # Cheap snapshot of the AppController's warmup progress. + # Thread-safe: WarmupManager.status() returns a lock-guarded copy. + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + controller = _get_app_attr(app, "controller", None) + if controller and hasattr(controller, "warmup_status"): + try: + payload = controller.warmup_status() + except Exception: + payload = {"pending": [], "completed": [], "failed": []} + else: + payload = {"pending": [], "completed": [], "failed": []} + self.wfile.write(json.dumps(payload).encode("utf-8")) + elif self.path == "/api/warmup_wait" or self.path.startswith("/api/warmup_wait?"): + # Blocks the request thread (safe under ThreadingHTTPServer) up + # to `timeout` seconds waiting for warmup to complete, then + # returns the final status. Default timeout: 30s. Useful for + # external clients (scripts, other tools) that need to know when + # the system is fully ready before issuing AI requests. + timeout = 30.0 + if "?" in self.path: + from urllib.parse import parse_qs, urlparse + qs = parse_qs(urlparse(self.path).query) + if "timeout" in qs: + try: timeout = float(qs["timeout"][0]) + except (TypeError, ValueError): timeout = 30.0 + controller = _get_app_attr(app, "controller", None) + if controller and hasattr(controller, "wait_for_warmup"): + try: + controller.wait_for_warmup(timeout=timeout) + except Exception: pass + try: + payload = controller.warmup_status() + except Exception: + payload = {"pending": [], "completed": [], "failed": []} + else: + payload = {"pending": [], "completed": [], "failed": []} + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode("utf-8")) else: self.send_response(404) self.end_headers() diff --git a/tests/test_api_hooks_warmup.py b/tests/test_api_hooks_warmup.py new file mode 100644 index 00000000..1099810f --- /dev/null +++ b/tests/test_api_hooks_warmup.py @@ -0,0 +1,86 @@ +import pytest +import sys +import os +from unittest.mock import patch + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.api_hook_client import ApiHookClient + + +def test_get_warmup_status_calls_correct_endpoint() -> None: + """get_warmup_status() hits GET /api/warmup_status.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"pending": [], "completed": ["a", "b"], "failed": []} + result = client.get_warmup_status() + assert "pending" in result + assert "completed" in result + assert "failed" in result + mock_make.assert_called_once_with("GET", "/api/warmup_status") + + +def test_get_warmup_status_handles_empty_response() -> None: + """get_warmup_status() returns empty dict when server returns None/empty.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = None + result = client.get_warmup_status() + assert result == {} + + +def test_get_warmup_wait_passes_timeout_as_query_string() -> None: + """get_warmup_wait(timeout=N) hits GET /api/warmup_wait?timeout=N.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"pending": [], "completed": ["x"], "failed": []} + result = client.get_warmup_wait(timeout=12.5) + assert "completed" in result + mock_make.assert_called_once_with("GET", "/api/warmup_wait?timeout=12.5") + + +def test_get_warmup_wait_uses_default_timeout_when_unspecified() -> None: + """get_warmup_wait() with no args uses the default timeout=30.0.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"pending": [], "completed": [], "failed": []} + client.get_warmup_wait() + args, _ = mock_make.call_args + assert args[0] == "GET" + assert args[1].startswith("/api/warmup_wait?timeout=") + assert "30" in args[1] + + +def test_get_warmup_wait_handles_empty_response() -> None: + """get_warmup_wait() returns empty dict when server returns None.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = None + result = client.get_warmup_wait(timeout=5.0) + assert result == {} + + +def test_live_warmup_status_endpoint(live_gui) -> None: + """Live: GET /api/warmup_status returns 200 + warmup dict (sub-track 3).""" + client = ApiHookClient() + assert client.wait_for_server(timeout=10) + status = client.get_warmup_status() + assert "pending" in status + assert "completed" in status + assert "failed" in status + assert isinstance(status["pending"], list) + assert isinstance(status["completed"], list) + assert isinstance(status["failed"], list) + + +def test_live_warmup_wait_endpoint_completes(live_gui) -> None: + """Live: GET /api/warmup_wait?timeout=2.0 returns the (likely-completed) status.""" + client = ApiHookClient() + assert client.wait_for_server(timeout=10) + result = client.get_warmup_wait(timeout=2.0) + assert "pending" in result + assert "completed" in result + assert "failed" in result + # In a live session the warmup either already finished (no pending) or + # completed within the 2s window. Either way the response is well-formed. + assert isinstance(result["pending"], list)