121 lines
3.9 KiB
Python
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())
|