"""Entry point for the Web Dashboard and StarWiki.
Runs the FastAPI web interface and acts as a lightweight surrogate for the legacy BotRunner,
providing the state required by the web application without booting monolithic message processing.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import uuid
import os
from typing import Any
import uvicorn
from fastapi import FastAPI
from config import Config
from core.service_base import StargazerService
from media_cache import MediaCache
from tools import ToolRegistry
from openrouter_client import OpenRouterClient
from message_cache import MessageCache
from knowledge_graph import KnowledgeGraphManager
from task_manager import TaskManager
from starwiki.service import StarwikiService
from sword.factory import build_sword_system
from web import app as web_app, set_bot_runner, set_config
logger = logging.getLogger("web_main")
[docs]
class WebService(StargazerService):
"""Stargazer service that hosts the FastAPI dashboard and StarWiki.
The ``"web"`` microservice in the five-service split (gateway / inference /
agents / consolidation / web). It runs the uvicorn-hosted FastAPI app from
:mod:`web` and doubles as a lightweight, duck-typed stand-in for the retired
``BotRunner``: it exposes the attributes the dashboard routes reach for
(``adapters``, ``tool_registry``, ``kg_manager``, ``task_manager``, ...)
without booting any message-processing loop. Because the real platform
adapters live in the Gateway service, the platform-lifecycle methods here are
deliberate no-op stubs.
It subclasses :class:`~core.service_base.StargazerService`, so the base
drives its lifecycle: :meth:`on_start` builds dependencies and starts uvicorn,
:meth:`run` blocks until shutdown, and :meth:`on_stop` tears the server down.
Instantiated by :func:`main` and injected into the FastAPI app via
:func:`web.set_bot_runner` so route handlers can reach bot state.
"""
[docs]
def __init__(self, cfg: Config, redis_client: Any, instance_id: str) -> None:
"""Construct the Web service and pre-build its BotRunner-surrogate state.
Initializes the :class:`~core.service_base.StargazerService` base as the
``"web"`` service (with a health server) and pre-creates the duck-typed
``BotRunner`` API the dashboard expects: ``adapters`` (empty here — the real
platform adapters live in the Gateway service), a :class:`~tools.ToolRegistry`,
and the stop event / uvicorn handles used by :meth:`run` and :meth:`on_stop`.
Heavy dependencies (KG manager, SWORD, task manager) are deferred to
:meth:`on_start`. Called by :func:`main` when launching the service standalone.
Args:
cfg: Loaded :class:`~config.Config` (tool permissions, web host/port, ...).
redis_client: Async Redis client shared with the base service.
instance_id: Unique id for this process (e.g. ``"web-ab12cd34"``).
"""
super().__init__("web", instance_id, redis_client=redis_client, use_health_server=True)
self.cfg = cfg
self._stop_event = asyncio.Event()
self._web_task: asyncio.Task | None = None
self.server: uvicorn.Server | None = None
self.shutdown_timeout = 5.0
# --- Duck-Type BotRunner API ---
# The web dashboard (bot_admin.py, platforms_api.py, etc.) expects these properties on _bot_runner.
self.adapters: list[Any] = [] # Distributed platforms run in GatewayService
self.tool_registry = ToolRegistry()
if cfg.tool_permissions:
self.tool_registry.set_permissions(cfg.tool_permissions)
# Shared core components
self.media_cache = MediaCache(
cache_dir=cfg.media_cache_dir,
max_size_mb=cfg.media_cache_max_mb,
)
self.openrouter = OpenRouterClient(
api_key=cfg.api_key,
model=cfg.model,
temperature=cfg.temperature,
max_tokens=cfg.max_tokens,
top_p=cfg.top_p,
tool_registry=self.tool_registry,
base_url=cfg.llm_base_url,
gemini_api_key=cfg.gemini_api_key,
http_connect_timeout=cfg.openrouter_http_connect_timeout_seconds,
http_read_timeout=cfg.openrouter_http_read_timeout_seconds,
http_write_timeout=cfg.openrouter_http_write_timeout_seconds,
http_pool_timeout=cfg.openrouter_http_pool_timeout_seconds,
)
ssl_kw = cfg.redis_ssl_kwargs() if cfg.redis_sentinels else cfg.redis_connection_kwargs_for_url(cfg.redis_url)
self.message_cache = MessageCache(
redis_url=cfg.redis_url,
openrouter_client=self.openrouter,
embedding_model=cfg.embedding_model,
ssl_kwargs=ssl_kw,
redis_sentinels=cfg.redis_sentinels,
redis_sentinel_master=cfg.redis_sentinel_master,
resilience_kwargs=cfg.redis_resilience_kwargs(),
)
from observability import set_observability_redis
set_observability_redis(self.message_cache.redis_client)
self.kg_manager = None
self.task_manager = None
self.starwiki_service = None
[docs]
def get_adapter(self, platform_name: str) -> Any | None:
"""Return the platform adapter for ``platform_name`` — always ``None`` here.
Part of the duck-typed ``BotRunner`` surface the dashboard expects. In the
distributed architecture the real platform adapters live in the Gateway
service, not in the web process, so this surrogate has no adapters to hand
out and unconditionally returns ``None``. Has no side effects. Dashboard
routes that call it (e.g. ``web/bot_admin.py``, ``web/platforms_api.py``,
``web/deps.py``, ``web/ncm_chart_api.py``, ``web/sillytavern.py``) must
therefore tolerate a missing adapter when running against the web service.
Args:
platform_name: Platform identifier such as ``"discord"`` or ``"matrix"``.
Returns:
Always ``None`` in this surrogate.
"""
return None
[docs]
async def on_start(self) -> None:
"""Build the dashboard's runtime dependencies and register as bot_runner.
The Web service's startup phase (invoked by
:meth:`~core.service_base.StargazerService.boot`). Loads tools into
``self.tool_registry`` via :func:`tool_loader.load_tools` (off-thread),
constructs the :class:`KnowledgeGraphManager`, boots the SWORD subsystem
via :func:`sword.factory.build_sword_system`, and wires a :class:`TaskManager`.
The assembled service is injected as the FastAPI app's ``bot_runner`` surrogate
(:func:`web.set_bot_runner`) so dashboard routes can reach bot state without
booting message processing.
"""
logger.info("Injecting WebService as bot_runner surrogate...")
# Surrogate tool registry (read-only — web lists tools but never executes
# them). Prefer the catalog published by the dedicated tools service so
# web need not import handler modules; fall back to loading locally when
# no catalog has been published yet (dormant deployment).
from core.tool_catalog import load_catalog
from core.remote_tool_registry import RemoteToolRegistry
_catalog = None
try:
_catalog = await load_catalog(self.message_cache.redis_raw_client)
except Exception:
logger.debug("catalog read failed; loading tools locally", exc_info=True)
if _catalog is not None:
remote = RemoteToolRegistry(
event_bus=None,
redis=self.message_cache.redis_raw_client,
config=self.cfg,
local_registry=self.tool_registry,
worker_id=self.instance_id,
)
await remote.reload_catalog() # catalog read surface; no background tasks
self.tool_registry = remote
self.openrouter.tool_registry = remote
logger.info("WebService using published tool catalog (%d tools)", len(remote))
else:
from tool_loader import load_tools
logger.info("No tool catalog; loading tools locally from: %s", self.cfg.tools_dir)
await asyncio.to_thread(load_tools, self.cfg.tools_dir, self.tool_registry)
self.kg_manager = KnowledgeGraphManager(
redis_client=self.redis,
openrouter=self.openrouter,
embedding_model=self.cfg.embedding_model,
admin_user_ids=set(self.cfg.admin_user_ids) if self.cfg.admin_user_ids else None,
)
sword_monitor, _ = build_sword_system(
cfg=self.cfg,
redis_client=self.redis,
openrouter=self.openrouter,
kg_manager=self.kg_manager,
)
if sword_monitor is not None:
self.kg_manager._sword_monitor = sword_monitor
self.task_manager = TaskManager(timeout=10.0, redis=self.redis)
self.tool_registry.task_manager = self.task_manager
if getattr(self.cfg, "starwiki_enabled", False):
self.starwiki_service = StarwikiService(
self.cfg,
self.message_cache.redis_client,
self.openrouter,
)
set_config(self.cfg)
set_bot_runner(self)
if self.starwiki_service:
await self.starwiki_service.ensure_ready()
logger.info("Starting Uvicorn web server on %s:%s...", self.cfg.web_host, self.cfg.web_port)
config = uvicorn.Config(
app=web_app,
host=self.cfg.web_host,
port=self.cfg.web_port,
log_level="info",
access_log=False,
timeout_keep_alive=30,
)
self.server = uvicorn.Server(config)
self._web_task = asyncio.create_task(self.server.serve(), name="uvicorn_server")
[docs]
async def run(self) -> None:
"""Block the service until uvicorn exits or a shutdown is requested.
The service's main loop, called by the base
:class:`~core.service_base.StargazerService` after :meth:`on_start` (see
:func:`main`, which awaits ``service.boot()`` then ``service.run()``).
It races the long-lived ``self._web_task`` (the uvicorn ``serve()`` task,
wrapped in :func:`asyncio.shield` so it is not cancelled here) against a
waiter on ``self._stop_event`` (set by :meth:`on_stop`); whichever
completes first wins, and the still-pending waiter is cancelled. Returns
immediately if uvicorn was never started.
"""
if self._web_task:
web_waiter = asyncio.shield(self._web_task)
stop_waiter = asyncio.create_task(self._stop_event.wait())
done, pending = await asyncio.wait(
[web_waiter, stop_waiter],
return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
[docs]
async def on_stop(self) -> None:
"""Gracefully shut the uvicorn web server down.
The service's teardown hook, invoked by the base
:class:`~core.service_base.StargazerService` during shutdown (ultimately
triggered by the SIGINT/SIGTERM handlers installed in :func:`main`). It
sets ``self._stop_event`` to release :meth:`run`, asks the uvicorn server
to exit by flipping ``self.server.should_exit``, and waits up to
``self.shutdown_timeout`` seconds for the serve task to finish. If uvicorn
overruns that budget the task is cancelled and the resulting
:class:`asyncio.CancelledError` is swallowed so shutdown still completes.
"""
logger.info("Shutting down WebService...")
self._stop_event.set()
if self.server:
self.server.should_exit = True
if self._web_task and not self._web_task.done():
try:
# Wait for uvicorn to shutdown gracefully
await asyncio.wait_for(self._web_task, timeout=self.shutdown_timeout)
except asyncio.TimeoutError:
logger.warning("Uvicorn did not exit within timeout. Task was cancelled.")
try:
await self._web_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
pass
[docs]
async def main() -> None:
"""Async entry point: build and run the Web service until signalled.
Loads :class:`~config.Config`, mints an instance id, opens an async Redis
client, constructs :class:`WebService`, installs SIGINT/SIGTERM handlers that
trigger a graceful :meth:`~core.service_base.StargazerService.shutdown`, then
boots and runs the service, closing the Redis client on exit. Executed via
``asyncio.run(main())`` when ``web_main.py`` is run standalone (the
``stargazer-web`` systemd unit).
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
cfg = Config.load()
instance_id = f"web-{uuid.uuid4().hex[:8]}"
redis_client = cfg.build_async_redis_client(decode_responses=False)
service = WebService(cfg, redis_client, instance_id)
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: asyncio.create_task(service.shutdown()))
try:
await service.boot()
await service.run()
finally:
if redis_client:
await redis_client.aclose()
if __name__ == "__main__":
asyncio.run(main())