Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
96
backend/app/services/events.py
Normal file
96
backend/app/services/events.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Task event bus: persist events to DB and publish to Redis for live streaming."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.task import TaskEvent
|
||||
|
||||
_redis: aioredis.Redis | None = None
|
||||
|
||||
# Redis keys
|
||||
TASK_QUEUE = "agentic:task_queue"
|
||||
|
||||
# High-frequency live-only event kinds: streamed to the UI but not written to
|
||||
# Postgres, to avoid a separate transaction per progress/tool-start tick.
|
||||
EPHEMERAL_KINDS = frozenset({"progress", "tool_start"})
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
global _redis
|
||||
if _redis is None:
|
||||
_redis = aioredis.from_url(
|
||||
settings.redis_url, decode_responses=True, max_connections=32
|
||||
)
|
||||
return _redis
|
||||
|
||||
|
||||
def _channel(task_id: int) -> str:
|
||||
return f"agentic:task:{task_id}:events"
|
||||
|
||||
|
||||
async def dedupe_task_queue(task_id: int) -> int:
|
||||
"""Remove stale duplicate entries for a task id from the Redis queue."""
|
||||
redis = get_redis()
|
||||
needle = str(task_id)
|
||||
items = await redis.lrange(TASK_QUEUE, 0, -1)
|
||||
removed = sum(1 for x in items if x == needle)
|
||||
if removed:
|
||||
await redis.delete(TASK_QUEUE)
|
||||
for item in items:
|
||||
if item != needle:
|
||||
await redis.rpush(TASK_QUEUE, item)
|
||||
return removed
|
||||
|
||||
|
||||
async def enqueue_task(task_id: int) -> None:
|
||||
await dedupe_task_queue(task_id)
|
||||
await get_redis().rpush(TASK_QUEUE, str(task_id))
|
||||
|
||||
|
||||
async def publish_event(
|
||||
task_id: int,
|
||||
kind: str,
|
||||
message: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
persist: bool | None = None,
|
||||
) -> None:
|
||||
"""Persist an event and publish it to the task's Redis channel.
|
||||
|
||||
When ``persist`` is None, high-frequency ephemeral kinds (progress,
|
||||
tool_start) are streamed live but not written to the DB, while all other
|
||||
kinds are persisted. Pass an explicit bool to override.
|
||||
"""
|
||||
if persist is None:
|
||||
persist = kind not in EPHEMERAL_KINDS
|
||||
if persist:
|
||||
async with SessionLocal() as db:
|
||||
db.add(TaskEvent(task_id=task_id, kind=kind, message=message, payload=payload))
|
||||
await db.commit()
|
||||
|
||||
event = {
|
||||
"task_id": task_id,
|
||||
"kind": kind,
|
||||
"message": message,
|
||||
"payload": payload,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await get_redis().publish(_channel(task_id), json.dumps(event))
|
||||
|
||||
|
||||
async def subscribe_events(task_id: int):
|
||||
"""Async generator yielding decoded events for a task."""
|
||||
pubsub = get_redis().pubsub()
|
||||
await pubsub.subscribe(_channel(task_id))
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
yield json.loads(message["data"])
|
||||
finally:
|
||||
await pubsub.unsubscribe(_channel(task_id))
|
||||
await pubsub.close()
|
||||
Reference in New Issue
Block a user