mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
466 lines
16 KiB
Python
466 lines
16 KiB
Python
"""
|
|
F10/F13/F46 — Bridge API Endpoints.
|
|
Sidecar-facing endpoints for job submission, delivery, and worker polling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
from aiohttp import web # type: ignore
|
|
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
|
|
web = None # type: ignore
|
|
|
|
try:
|
|
from ..services.async_utils import run_in_thread
|
|
|
|
# CRITICAL: handshake verifier must be imported in package mode;
|
|
# missing this causes NameError at runtime on /bridge/handshake.
|
|
from ..services.bridge_handshake import verify_handshake
|
|
from ..services.cache import TTLCache
|
|
from ..services.execution_budgets import BudgetExceededError
|
|
from ..services.rate_limit import check_rate_limit
|
|
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
|
from ..services.sidecar.bridge_contract import (
|
|
BRIDGE_ENDPOINTS,
|
|
BridgeDeliveryRequest,
|
|
BridgeHealthResponse,
|
|
BridgeJobRequest,
|
|
BridgeScope,
|
|
)
|
|
from ..services.trace import get_effective_trace_id
|
|
from ..services.trace_store import trace_store
|
|
except ImportError:
|
|
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
|
from services.async_utils import run_in_thread
|
|
from services.bridge_handshake import verify_handshake
|
|
from services.cache import TTLCache
|
|
from services.execution_budgets import BudgetExceededError
|
|
from services.rate_limit import check_rate_limit
|
|
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
|
from services.sidecar.bridge_contract import (
|
|
BRIDGE_ENDPOINTS,
|
|
BridgeDeliveryRequest,
|
|
BridgeHealthResponse,
|
|
BridgeJobRequest,
|
|
BridgeScope,
|
|
)
|
|
from services.trace import get_effective_trace_id
|
|
from services.trace_store import trace_store
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.api.bridge")
|
|
|
|
# Payload limits
|
|
MAX_INPUTS_SIZE = 64 * 1024 # 64KB JSON
|
|
MAX_TEXT_LENGTH = 8000 # 8K chars
|
|
MAX_FILES_COUNT = 10
|
|
|
|
# Track startup time for uptime
|
|
_startup_time = time.time()
|
|
|
|
|
|
class BridgeHandlers:
|
|
"""Handlers for bridge API endpoints."""
|
|
|
|
def __init__(self, submit_service=None, delivery_router=None):
|
|
"""
|
|
Args:
|
|
submit_service: Service for job submission (injected)
|
|
delivery_router: Router for delivery requests (injected)
|
|
"""
|
|
self.submit_service = submit_service
|
|
self.delivery_router = delivery_router
|
|
# R22: Bounded Idempotency Store
|
|
# Key: idempotency_key -> Response Dict
|
|
self._idempotency_store = TTLCache[dict](
|
|
max_size=1000, ttl_sec=86400
|
|
) # 24h retention
|
|
# F46: Worker job queue (in-memory stub, production would use persistent store)
|
|
self._worker_job_queue: list = []
|
|
# F46: Worker result store
|
|
self._worker_results: dict = {}
|
|
# F46: Worker heartbeats
|
|
self._worker_heartbeats: dict = {}
|
|
|
|
async def health_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
GET /bridge/health
|
|
Returns bridge health status. Safe, low-sensitivity endpoint.
|
|
"""
|
|
if not is_bridge_enabled():
|
|
return web.json_response({"error": "Bridge not enabled"}, status=403)
|
|
|
|
# Get version from package
|
|
try:
|
|
from services.pack_info import get_pack_info
|
|
|
|
pack = get_pack_info()
|
|
version = pack.get("version", "unknown")
|
|
except Exception:
|
|
version = "unknown"
|
|
|
|
response = BridgeHealthResponse(
|
|
ok=True,
|
|
version=version,
|
|
uptime_sec=time.time() - _startup_time,
|
|
job_queue_depth=0, # TODO: Wire to actual queue
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"ok": response.ok,
|
|
"version": response.version,
|
|
"uptime_sec": response.uptime_sec,
|
|
"job_queue_depth": response.job_queue_depth,
|
|
}
|
|
)
|
|
|
|
async def handshake_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
POST /bridge/handshake
|
|
Negotiate protocol version compatibility.
|
|
"""
|
|
try:
|
|
data = await request.json()
|
|
client_version = int(data.get("version", 0))
|
|
except (ValueError, TypeError, Exception):
|
|
return web.json_response({"error": "Invalid version format"}, status=400)
|
|
|
|
ok, msg, meta = verify_handshake(client_version)
|
|
|
|
status_code = 200 if ok else 409 # 409 Conflict for version mismatch
|
|
|
|
return web.json_response(
|
|
{
|
|
"ok": ok,
|
|
"message": msg,
|
|
"metadata": meta,
|
|
},
|
|
status=status_code,
|
|
)
|
|
|
|
async def submit_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
POST /bridge/submit
|
|
Submit a job via sidecar bridge.
|
|
"""
|
|
# Auth check
|
|
is_valid, error_resp, device_id = require_bridge_auth(
|
|
request, BridgeScope.JOB_SUBMIT
|
|
)
|
|
if not is_valid:
|
|
return error_resp
|
|
|
|
# Rate limit
|
|
if not check_rate_limit(request, "bridge"):
|
|
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
|
|
|
# Parse payload
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
# R25: Trace context
|
|
trace_id = get_effective_trace_id(request.headers, data)
|
|
|
|
# Validate required fields
|
|
template_id = data.get("template_id")
|
|
inputs = data.get("inputs", {})
|
|
idempotency_key = data.get("idempotency_key")
|
|
|
|
if not template_id:
|
|
return web.json_response({"error": "template_id required"}, status=400)
|
|
if not idempotency_key:
|
|
return web.json_response({"error": "idempotency_key required"}, status=400)
|
|
|
|
# Payload size check
|
|
import json
|
|
|
|
inputs_size = len(json.dumps(inputs))
|
|
if inputs_size > MAX_INPUTS_SIZE:
|
|
return web.json_response(
|
|
{"error": f"inputs exceeds {MAX_INPUTS_SIZE // 1024}KB"}, status=400
|
|
)
|
|
|
|
# Idempotency check
|
|
cached = self._idempotency_store.get(idempotency_key)
|
|
if cached:
|
|
logger.info(f"Duplicate bridge submit suppressed: {idempotency_key}")
|
|
return web.json_response(cached)
|
|
|
|
# Build request object
|
|
job_request = BridgeJobRequest(
|
|
template_id=template_id,
|
|
inputs=inputs,
|
|
trace_id=trace_id,
|
|
idempotency_key=idempotency_key,
|
|
session_id=data.get("session_id"),
|
|
device_id=device_id,
|
|
delivery_target=data.get("delivery_target"),
|
|
timeout_sec=data.get("timeout_sec", 300),
|
|
)
|
|
|
|
# Submit to job service
|
|
try:
|
|
if self.submit_service:
|
|
result = await run_in_thread(self.submit_service.submit, job_request)
|
|
prompt_id = result.get("prompt_id", "")
|
|
else:
|
|
# Fail-closed: No submit service wired
|
|
logger.error(
|
|
"BridgeHandlers.submit_service not wired - cannot submit job"
|
|
)
|
|
return web.json_response(
|
|
{"error": "Bridge submit service not configured", "ok": False},
|
|
status=503,
|
|
)
|
|
|
|
response_data = {
|
|
"ok": True,
|
|
"prompt_id": prompt_id,
|
|
"trace_id": trace_id,
|
|
"status": "queued",
|
|
}
|
|
|
|
# R25: Record trace mapping + queued event
|
|
try:
|
|
if prompt_id:
|
|
trace_store.add_event(
|
|
prompt_id, trace_id, "queued", {"source": "bridge"}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Cache response
|
|
self._idempotency_store.put(idempotency_key, response_data)
|
|
|
|
return web.json_response(response_data)
|
|
|
|
except BudgetExceededError as e:
|
|
logger.warning(f"Bridge submit denied by execution budget: {e}")
|
|
return web.json_response(
|
|
{"error": "budget_exceeded", "detail": str(e)},
|
|
status=429,
|
|
headers={"Retry-After": str(getattr(e, "retry_after", 1))},
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Bridge submit failed")
|
|
return web.json_response({"error": "Internal server error"}, status=500)
|
|
|
|
async def deliver_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
POST /bridge/deliver
|
|
Request outbound delivery via sidecar.
|
|
"""
|
|
# Auth check
|
|
is_valid, error_resp, device_id = require_bridge_auth(
|
|
request, BridgeScope.DELIVERY
|
|
)
|
|
if not is_valid:
|
|
return error_resp
|
|
|
|
# Rate limit
|
|
if not check_rate_limit(request, "bridge"):
|
|
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
|
|
|
# Parse payload
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
# R25: Trace context
|
|
trace_id = get_effective_trace_id(request.headers, data)
|
|
|
|
# Validate required fields
|
|
target = data.get("target")
|
|
text = data.get("text", "")
|
|
idempotency_key = data.get("idempotency_key")
|
|
files = data.get("files", [])
|
|
|
|
if not target:
|
|
return web.json_response({"error": "target required"}, status=400)
|
|
if not idempotency_key:
|
|
return web.json_response({"error": "idempotency_key required"}, status=400)
|
|
|
|
# Payload size checks
|
|
if len(text) > MAX_TEXT_LENGTH:
|
|
return web.json_response(
|
|
{"error": f"text exceeds {MAX_TEXT_LENGTH} chars"}, status=400
|
|
)
|
|
if len(files) > MAX_FILES_COUNT:
|
|
return web.json_response(
|
|
{"error": f"files exceeds {MAX_FILES_COUNT}"}, status=400
|
|
)
|
|
|
|
# Build request
|
|
delivery_request = BridgeDeliveryRequest(
|
|
target=target,
|
|
text=text,
|
|
idempotency_key=idempotency_key,
|
|
files=files,
|
|
)
|
|
|
|
# Route to delivery adapter
|
|
try:
|
|
if self.delivery_router:
|
|
success = await self.delivery_router.route(delivery_request)
|
|
else:
|
|
# Stub: No delivery router wired
|
|
logger.warning("BridgeHandlers.delivery_router not wired")
|
|
success = True
|
|
|
|
return web.json_response(
|
|
{
|
|
"ok": success,
|
|
"status": "delivered" if success else "failed",
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Bridge deliver failed")
|
|
return web.json_response({"error": "Internal server error"}, status=500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# F46 — Worker-facing endpoints
|
|
# ------------------------------------------------------------------
|
|
|
|
async def worker_poll_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
GET /bridge/worker/poll
|
|
Worker polls for pending jobs. Returns available jobs or 204 if none.
|
|
"""
|
|
is_valid, error_resp, device_id = require_bridge_auth(
|
|
request, BridgeScope.JOB_STATUS
|
|
)
|
|
if not is_valid:
|
|
return error_resp
|
|
|
|
# Return pending jobs (FIFO, up to 5 per poll)
|
|
try:
|
|
batch_size = max(1, min(int(request.query.get("batch", "1")), 5))
|
|
except (ValueError, TypeError):
|
|
return web.json_response(
|
|
{"error": "batch must be an integer (1-5)"}, status=400
|
|
)
|
|
jobs = []
|
|
for _ in range(batch_size):
|
|
if self._worker_job_queue:
|
|
jobs.append(self._worker_job_queue.pop(0))
|
|
else:
|
|
break
|
|
|
|
if not jobs:
|
|
return web.Response(status=204)
|
|
|
|
return web.json_response({"jobs": jobs})
|
|
|
|
async def worker_result_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
POST /bridge/worker/result/{job_id}
|
|
Worker submits completed job result.
|
|
"""
|
|
is_valid, error_resp, device_id = require_bridge_auth(
|
|
request, BridgeScope.JOB_SUBMIT
|
|
)
|
|
if not is_valid:
|
|
return error_resp
|
|
|
|
job_id = request.match_info.get("job_id", "")
|
|
if not job_id:
|
|
return web.json_response({"error": "job_id required"}, status=400)
|
|
|
|
# Idempotency check
|
|
idempotency_key = request.headers.get("X-Idempotency-Key", "")
|
|
if idempotency_key:
|
|
cached = self._idempotency_store.get(f"wr:{idempotency_key}")
|
|
if cached:
|
|
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
|
|
return web.json_response(cached)
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
# Store result
|
|
self._worker_results[job_id] = {
|
|
"status": data.get("status", "completed"),
|
|
"outputs": data.get("outputs", {}),
|
|
"worker_id": device_id,
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
response_data = {"ok": True, "job_id": job_id, "status": "accepted"}
|
|
|
|
if idempotency_key:
|
|
self._idempotency_store.put(f"wr:{idempotency_key}", response_data)
|
|
|
|
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
|
|
return web.json_response(response_data, status=201)
|
|
|
|
async def worker_heartbeat_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
POST /bridge/worker/heartbeat
|
|
Worker reports its status. Lightweight, no scope required.
|
|
"""
|
|
# Basic auth only (no scope enforcement for heartbeat)
|
|
is_valid, error_resp, device_id = require_bridge_auth(request, None)
|
|
if not is_valid:
|
|
return error_resp
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
data = {}
|
|
|
|
self._worker_heartbeats[device_id] = {
|
|
"status": data.get("status", "alive"),
|
|
"details": data.get("details", {}),
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
def register_bridge_routes(
|
|
app: web.Application, handlers: Optional[BridgeHandlers] = None
|
|
):
|
|
"""
|
|
Register bridge routes with the aiohttp app.
|
|
Uses contract-defined paths from BRIDGE_ENDPOINTS.
|
|
"""
|
|
if handlers is None:
|
|
handlers = BridgeHandlers()
|
|
|
|
# Server-facing endpoints
|
|
app.router.add_get(BRIDGE_ENDPOINTS["health"]["path"], handlers.health_handler)
|
|
app.router.add_post(BRIDGE_ENDPOINTS["submit"]["path"], handlers.submit_handler)
|
|
app.router.add_post(BRIDGE_ENDPOINTS["deliver"]["path"], handlers.deliver_handler)
|
|
app.router.add_post(
|
|
BRIDGE_ENDPOINTS["handshake"]["path"], handlers.handshake_handler
|
|
)
|
|
|
|
# F46: Worker-facing endpoints
|
|
app.router.add_get(
|
|
BRIDGE_ENDPOINTS["worker_poll"]["path"], handlers.worker_poll_handler
|
|
)
|
|
app.router.add_post(
|
|
BRIDGE_ENDPOINTS["worker_result"]["path"] + "/{job_id}",
|
|
handlers.worker_result_handler,
|
|
)
|
|
app.router.add_post(
|
|
BRIDGE_ENDPOINTS["worker_heartbeat"]["path"],
|
|
handlers.worker_heartbeat_handler,
|
|
)
|
|
|
|
logger.info(
|
|
"Bridge routes registered: /bridge/{health,submit,deliver} "
|
|
"+ /bridge/worker/{poll,result,heartbeat}"
|
|
)
|