"""Socket.io server instance and event handlers. Events (see AGENTS.md): Server -> Client: job:start, job:log, job:progress, job:complete Client -> Server: job:subscribe, job:unsubscribe, job:cancel """ from typing import Any import socketio from app.core.config import get_settings from app.core.logging import get_logger from app.websocket.manager import ws_manager settings = get_settings() logger = get_logger(__name__) sio = socketio.AsyncServer( async_mode="asgi", cors_allowed_origins=settings.cors_origin_list or "*", # Redis manager for multi-worker pub/sub; set in main.py when Redis is up ) @sio.event async def connect(sid: str, environ: dict, auth: dict | None) -> None: # noqa: ARG001 # TODO: validate JWT from auth payload before accepting ws_manager.register(sid) @sio.event async def disconnect(sid: str) -> None: ws_manager.unregister(sid) @sio.event async def subscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]: """Client subscribes to a job's live log room.""" job_id = int(data.get("job_id", 0)) room = ws_manager.subscribe(sid, job_id) await sio.enter_room(sid, room) logger.info("ws.subscribed", sid=sid, room=room) return {"ok": True, "room": room} @sio.event async def unsubscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]: job_id = int(data.get("job_id", 0)) room = ws_manager.unsubscribe(sid, job_id) await sio.leave_room(sid, room) return {"ok": True} @sio.event async def cancel_job(sid: str, data: dict[str, Any]) -> dict[str, Any]: """Client requests job cancellation.""" from app.services.job_runner import job_runner job_id = int(data.get("job_id", 0)) cancelled = await job_runner.cancel(job_id) return {"ok": cancelled} # --------------------------------------------------------------------------- # Emit helpers used by services / job runner # --------------------------------------------------------------------------- async def emit_job_start(job_id: int, server_id: int, job_type: str) -> None: await sio.emit( "job:start", {"job_id": job_id, "server_id": server_id, "type": job_type}, room=ws_manager.room_for(job_id), ) async def emit_job_log(job_id: int, line: str, level: str = "info") -> None: from datetime import UTC, datetime await sio.emit( "job:log", {"job_id": job_id, "line": line, "level": level, "timestamp": datetime.now(UTC).isoformat()}, room=ws_manager.room_for(job_id), ) async def emit_job_progress( job_id: int, percent: int, phase: str, node: str | None = None ) -> None: await sio.emit( "job:progress", {"job_id": job_id, "percent": percent, "phase": phase, "node": node}, room=ws_manager.room_for(job_id), ) async def emit_job_complete(job_id: int, status: str, duration: float | None) -> None: await sio.emit( "job:complete", {"job_id": job_id, "status": status, "duration": duration}, room=ws_manager.room_for(job_id), )