feat: add WS Gateway and Chat Service (Step 2)
WS Gateway:
- WebSocket lifecycle handler with RS256 JWT auth
- Redis bridge: device registry, frame publishing, tool_result routing
- Inbound routing: tool_result→LPUSH, home/floating→chat pub/sub
- Outbound: subscribes to ws:out:{user_id}, forwards to Electron
- Single-worker Dockerfile (long-lived WS connections)
Chat Service:
- Redis consumer: subscribes to chat:request:* pattern
- Redis-based ws_context: tool_call→publish, BRPOP tool_result (30s timeout)
- deep_agent: single-agent runner with home/floating/stream variants
- memory_middleware: core/associative/episodic/proactive memory with Fernet
- Domain agents: task (8 tools), note (5), project (6), timeline (4)
- LLM factory via LiteLLM (100+ providers)
- Output formatter (StreamFormatter)
- POST /chat REST fallback with Traefik header auth
- Multi-worker Dockerfile with 120s timeout for LLM calls
This commit is contained in:
104
services/ws-gateway/app/redis_bridge.py
Normal file
104
services/ws-gateway/app/redis_bridge.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Redis bridge — device registry + pub/sub routing.
|
||||
|
||||
All inter-service communication passes through Redis:
|
||||
- Device registry: HSET/HDEL ws:devices:{user_id}
|
||||
- Outbound frames: Subscribe ws:out:{user_id}
|
||||
- Chat requests: Publish chat:request:{user_id}
|
||||
- Batch requests: Publish batch:request:{user_id}
|
||||
- Tool results: LPUSH tool:result:{call_id}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from shared.redis import (
|
||||
batch_request_channel,
|
||||
chat_request_channel,
|
||||
device_key,
|
||||
redis_client,
|
||||
tool_result_key,
|
||||
ws_out_channel,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Instance ID for this gateway replica (set on startup)
|
||||
_GATEWAY_ID: str = ""
|
||||
|
||||
|
||||
def set_gateway_id(gid: str) -> None:
|
||||
global _GATEWAY_ID
|
||||
_GATEWAY_ID = gid
|
||||
|
||||
|
||||
def get_gateway_id() -> str:
|
||||
return _GATEWAY_ID
|
||||
|
||||
|
||||
# ── Device Registry ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def register_device(user_id: str, device_id: str) -> None:
|
||||
"""Register a connected device in Redis."""
|
||||
key = device_key(user_id)
|
||||
await redis_client.hset(key, mapping={
|
||||
"device_id": device_id,
|
||||
"gateway_id": _GATEWAY_ID,
|
||||
})
|
||||
logger.info("redis_bridge: registered user=%s device=%s gateway=%s", user_id, device_id, _GATEWAY_ID)
|
||||
|
||||
|
||||
async def unregister_device(user_id: str) -> None:
|
||||
"""Remove device registration from Redis."""
|
||||
key = device_key(user_id)
|
||||
await redis_client.delete(key)
|
||||
logger.info("redis_bridge: unregistered user=%s", user_id)
|
||||
|
||||
|
||||
async def is_device_online(user_id: str) -> bool:
|
||||
"""Check if a device is registered."""
|
||||
key = device_key(user_id)
|
||||
return await redis_client.exists(key) > 0
|
||||
|
||||
|
||||
# ── Frame Routing ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def publish_chat_request(user_id: str, frame: dict) -> None:
|
||||
"""Forward a chat request frame to the Chat Service via Redis."""
|
||||
channel = chat_request_channel(user_id)
|
||||
await redis_client.publish(channel, json.dumps(frame))
|
||||
logger.debug("redis_bridge: published chat_request user=%s", user_id)
|
||||
|
||||
|
||||
async def publish_batch_request(user_id: str, frame: dict) -> None:
|
||||
"""Forward a batch request frame to the Batch Agent Service via Redis."""
|
||||
channel = batch_request_channel(user_id)
|
||||
await redis_client.publish(channel, json.dumps(frame))
|
||||
logger.debug("redis_bridge: published batch_request user=%s", user_id)
|
||||
|
||||
|
||||
async def push_tool_result(call_id: str, result: dict) -> None:
|
||||
"""Push a tool_result to the Redis list for the waiting service.
|
||||
|
||||
Chat/Batch services do BRPOP on this key with a 30s timeout.
|
||||
"""
|
||||
key = tool_result_key(call_id)
|
||||
await redis_client.lpush(key, json.dumps(result))
|
||||
# Auto-expire after 60s to prevent stale keys
|
||||
await redis_client.expire(key, 60)
|
||||
logger.debug("redis_bridge: pushed tool_result call_id=%s", call_id)
|
||||
|
||||
|
||||
async def subscribe_outbound(user_id: str):
|
||||
"""Return an async pubsub subscription for frames to send to Electron.
|
||||
|
||||
Chat/Batch services publish to ws:out:{user_id} and this gateway
|
||||
forwards them to the connected WebSocket.
|
||||
"""
|
||||
channel = ws_out_channel(user_id)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
return pubsub
|
||||
Reference in New Issue
Block a user