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.
This commit is contained in:
@@ -301,6 +301,23 @@ class ApiHookClient:
|
|||||||
"""
|
"""
|
||||||
return self._make_request('GET', '/api/performance') or {}
|
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
|
#endregion: Diagnostics
|
||||||
|
|
||||||
#region: Project
|
#region: Project
|
||||||
|
|||||||
@@ -321,6 +321,49 @@ class HookHandler(BaseHTTPRequestHandler):
|
|||||||
queue = _get_app_attr(app, "_api_event_queue")
|
queue = _get_app_attr(app, "_api_event_queue")
|
||||||
if queue: queue_size = len(queue)
|
if queue: queue_size = len(queue)
|
||||||
self.wfile.write(json.dumps({"threads": threads, "event_queue_size": queue_size}).encode("utf-8"))
|
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:
|
else:
|
||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user