Files
ITWorx_MCP_Hub-Public/services/runtime/worker.py
T

121 lines
3.9 KiB
Python

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())