Files
OpenOPC/opc/plugins/office_ui/server.py
T
2026-07-01 17:56:31 +08:00

266 lines
11 KiB
Python

"""Office-UI Server — aiohttp server that bridges OPC Engine with the React+Phaser frontend.
Initializes OPCEngine, opens ui_state.db for agent/chat persistence,
sets up the event adapter pipeline, and serves static files + WebSocket.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from opc.core.windows_ssl import sanitize_windows_sslkeylogfile
sanitize_windows_sslkeylogfile()
import aiohttp.web
import aiosqlite
from loguru import logger
from opc.core.config import OPCConfig, get_opc_home
from opc.engine import OPCEngine
from opc.plugins.office_ui.agent_store import AgentStore
from opc.plugins.office_ui.chat_store import ChatStore
from opc.plugins.office_ui.event_adapter import EventAdapter
from opc.plugins.office_ui.terminal import server_banner
from opc.plugins.office_ui.terminal import status as terminal_status
from opc.plugins.office_ui.ws_handler import WSHandler
# ── Static file paths ────────────────────────────────────────────────────
# Pre-built frontend lives alongside this file
_STATIC_DIR = Path(__file__).parent / "frontend_dist"
_FRONTEND_NO_STORE_HEADERS = {
"Cache-Control": "no-store, no-cache, max-age=0, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
def _is_under_path(path: Path, base: Path) -> bool:
try:
path.relative_to(base)
return True
except ValueError:
return False
# ── Application factory ──────────────────────────────────────────────────
async def create_app(
config: OPCConfig | None = None,
project_id: str | None = None,
) -> aiohttp.web.Application:
"""Build and return a fully-wired aiohttp Application."""
app = aiohttp.web.Application()
# ── Load config from standard location if not provided ─────────
if config is None:
config_dir = get_opc_home() / "config"
if config_dir.is_dir():
try:
config = OPCConfig.load(config_dir)
logger.info(f"Loaded config from {config_dir}")
except Exception as e:
logger.warning(f"Failed to load config from {config_dir}: {e}")
# ── OPC Engine ────────────────────────────────────────────────────
engine = OPCEngine(config=config, project_id=project_id)
# ── UI-state database (agents + chat) ─────────────────────────────
opc_home = engine.opc_home
db_path = opc_home / "ui_state.db"
db = await aiosqlite.connect(str(db_path))
agent_store = AgentStore(db)
await agent_store.initialize()
chat_store = ChatStore(db)
await chat_store.initialize()
event_adapter = EventAdapter()
# ── Initialize engine (this starts all OPC layers) ────────────────
await engine.initialize()
# ── WSHandler ─────────────────────────────────────────────────────
ws_handler = WSHandler(engine, agent_store, chat_store, event_adapter)
# Wire engine callbacks through project-bound wrappers so project switches
# do not retarget in-flight progress/runtime events to the active view.
ws_handler._wire_engine_callbacks(engine)
# Wire EventBus → ws_handler.on_opc_event (subscribe to ALL events),
# preserving the root project context even when the active UI view changes.
async def _root_engine_event(event: Any) -> None:
await ws_handler.on_opc_event(
event,
runtime_engine=engine,
project_id=engine.project_id or "default",
)
engine.event_bus.subscribe_all(_root_engine_event)
# ── Runtime crash recovery (pluggable, no engine modifications) ──
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
recovery_manager = RuntimeRecoveryManager(engine, ws_handler.broadcast)
ws_handler.recovery_manager = recovery_manager
# ── Startup self-heal for tasks abandoned by a prior process ─────
# Must run before restoring persisted mode and before any WS client can
# connect, so orphaned running/locked rows do not block new Continue /
# session_send acquisitions.
await ws_handler.heal_orphan_tasks_on_boot()
# ── Restore persisted mode and load matching agents on startup ───
await ws_handler.restore_persisted_mode()
startup_preset = ws_handler._resolve_preset_name()
agents = await agent_store.load_preset(startup_preset, engine.org_engine)
logger.info(f"Loaded {len(agents)} preset agents (mode={ws_handler._exec_mode}, preset={startup_preset})")
# ── Ensure activity + secretary channels (session channels are created on demand)
await chat_store.ensure_activity_channel()
await chat_store.ensure_secretary_channel()
# ── Store references for cleanup ──────────────────────────────────
app["engine"] = engine
app["db"] = db
app["ws_handler"] = ws_handler
# ── Routes ────────────────────────────────────────────────────────
app.router.add_get("/ws", ws_handler.handle_ws)
# Attachment download (must be registered before the SPA catch-all)
app.router.add_get(
"/api/attachments/{attachment_id}/{filename}",
_make_attachment_handler(engine),
)
# SPA: serve static files, fallback to index.html
if _STATIC_DIR.is_dir():
app.router.add_get("/", _serve_index)
app.router.add_get("/assets/{path:.*}", _serve_asset)
# Catch-all for SPA client-side routing
app.router.add_get("/{path:.*}", _serve_spa_fallback)
else:
app.router.add_get("/", _serve_no_build)
logger.warning(f"Frontend not built: {_STATIC_DIR} does not exist")
# ── Cleanup on shutdown ───────────────────────────────────────────
app.on_shutdown.append(_on_shutdown)
return app
# ── Route handlers ────────────────────────────────────────────────────
async def _serve_index(request: aiohttp.web.Request) -> aiohttp.web.FileResponse:
return aiohttp.web.FileResponse(_STATIC_DIR / "index.html", headers=_FRONTEND_NO_STORE_HEADERS)
async def _serve_asset(request: aiohttp.web.Request) -> aiohttp.web.Response:
"""Serve built frontend assets without allowing stale UI protocol bundles."""
path = request.match_info.get("path", "")
file_path = (_STATIC_DIR / "assets" / path).resolve()
assets_dir = (_STATIC_DIR / "assets").resolve()
if not _is_under_path(file_path, assets_dir):
return aiohttp.web.Response(status=403, text="Forbidden")
if not file_path.is_file():
return aiohttp.web.Response(status=404, text="Not found")
return aiohttp.web.FileResponse(file_path, headers=_FRONTEND_NO_STORE_HEADERS)
async def _serve_spa_fallback(request: aiohttp.web.Request) -> aiohttp.web.Response:
"""Serve static file if exists, otherwise fall back to index.html for SPA routing."""
path = request.match_info.get("path", "")
file_path = (_STATIC_DIR / path).resolve()
if not _is_under_path(file_path, _STATIC_DIR.resolve()):
return aiohttp.web.Response(status=403, text="Forbidden")
if file_path.is_file():
return aiohttp.web.FileResponse(file_path, headers=_FRONTEND_NO_STORE_HEADERS)
# SPA fallback
return aiohttp.web.FileResponse(_STATIC_DIR / "index.html", headers=_FRONTEND_NO_STORE_HEADERS)
def _make_attachment_handler(engine: OPCEngine):
"""Factory that returns an HTTP handler for serving stored attachments."""
import mimetypes as _mt
async def _handle(request: aiohttp.web.Request) -> aiohttp.web.Response:
attachment_id = request.match_info["attachment_id"]
filename = request.match_info["filename"]
att_store = getattr(engine, "attachment_store", None)
if not att_store:
return aiohttp.web.Response(status=503, text="Attachment store not available")
file_path = att_store.base_dir / attachment_id / filename
if not file_path.is_file():
return aiohttp.web.Response(status=404, text="Not found")
# Path-traversal guard
if not _is_under_path(file_path.resolve(), att_store.base_dir.resolve()):
return aiohttp.web.Response(status=403, text="Forbidden")
ct, _ = _mt.guess_type(filename)
headers = {"Cache-Control": "public, max-age=86400"}
return aiohttp.web.FileResponse(file_path, headers=headers)
return _handle
async def _serve_no_build(request: aiohttp.web.Request) -> aiohttp.web.Response:
return aiohttp.web.Response(
text=(
"<h1>OpenOPC Office UI</h1>"
"<p>Frontend not built. Run <code>cd opc/plugins/office_ui/frontend_src && npm install && npm run build</code></p>"
),
content_type="text/html",
)
async def _on_shutdown(app: aiohttp.web.Application) -> None:
"""Graceful cleanup."""
ws_handler = app.get("ws_handler")
if ws_handler:
await ws_handler.shutdown()
await ws_handler.flush_all_progress()
engine = app.get("engine")
db = app.get("db")
if engine:
await engine.shutdown()
if db:
await db.close()
logger.info("Office-UI server shut down")
# ── Entry point ───────────────────────────────────────────────────────
def run_server(
host: str = "0.0.0.0",
port: int = 8765,
config: OPCConfig | None = None,
project_id: str | None = None,
) -> None:
"""Create and run the office-UI server (blocking)."""
async def _start() -> None:
app = await create_app(config=config, project_id=project_id)
runner = aiohttp.web.AppRunner(app)
await runner.setup()
site = aiohttp.web.TCPSite(runner, host, port)
await site.start()
logger.info(f"Office-UI running at http://{host}:{port}")
server_banner(host=host, port=port, project_id=project_id)
# Keep running until interrupted
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await runner.cleanup()
try:
asyncio.run(_start())
except KeyboardInterrupt:
terminal_status("Shutting down Office UI", kind="warning")