from __future__ import annotations import asyncio import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from threading import Lock from typing import Any from fastapi import FastAPI, Response, status from scripts.reconcile_gitea_public_surface import reconcile_gitea_public_surface from services.control_api.contextforge import ContextForgeClient, ContextForgeSettings from services.control_api.persistence import create_engine_from_url, session_factory from .common import hub_database_url, run_asgi from .state_sync import reconcile_runtime_state from .synthetic_monitor import GiteaSyntheticMonitor class WorkerState: def __init__(self) -> None: self._lock = Lock() self.ready = False self.last_error = "bootstrap pending" def set(self, ready: bool, detail: str) -> None: with self._lock: self.ready = ready self.last_error = detail def snapshot(self) -> tuple[bool, str]: with self._lock: return self.ready, self.last_error def _safe_error_detail(exc: Exception) -> str: """Return a bounded operator hint without exposing exception payloads.""" message = str(exc).lower() if "tool inventory parity failed" in message: reason = "tool-inventory-parity" elif "input schema parity failed" in message: reason = "input-schema-parity" elif "failed connector preflight" in message: reason = "connector-preflight" elif "public surface reconciliation did not pass" in message: reason = "public-surface-readback" else: reason = type(exc).__name__ return f"runtime-reconciliation:{reason}" def _sync_once( client: ContextForgeClient, sessions: Any, catalog_root: Path, monitor: GiteaSyntheticMonitor, ) -> None: reconcile_runtime_state(client, sessions, catalog_root, os.environ["HUB_OIDC_ISSUER"]) surface = reconcile_gitea_public_surface(client, catalog_root) if surface.get("status") != "pass": raise RuntimeError("Gitea public surface reconciliation did not pass") monitor.probe() def build_app() -> FastAPI: catalog_root = Path(os.environ.get("HUB_CATALOG_ROOT", "/opt/hub/catalog")) engine = create_engine_from_url(hub_database_url()) sessions = session_factory(engine) contextforge = ContextForgeClient(ContextForgeSettings.from_env()) monitor = GiteaSyntheticMonitor(catalog_root) state = WorkerState() async def reconcile_forever() -> None: while True: try: await asyncio.to_thread(_sync_once, contextforge, sessions, catalog_root, monitor) state.set(True, "runtime state, gateways and Gitea synthetic checks passed") except Exception as exc: state.set(False, _safe_error_detail(exc)) await asyncio.sleep(30) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: task = asyncio.create_task(reconcile_forever()) try: yield finally: task.cancel() await asyncio.gather(task, return_exceptions=True) contextforge.close() engine.dispose() app = FastAPI( title="ITWorx MCP Hub worker", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan, ) @app.get("/healthz") @app.get("/health") def health() -> dict[str, str]: return {"status": "healthy", "component": "worker"} @app.get("/ready") @app.get("/health/ready") def ready(response: Response) -> dict[str, object]: is_ready, detail = state.snapshot() if not is_ready: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "ready" if is_ready else "not-ready", "detail": detail} return app def run() -> None: run_asgi(build_app())