Files
ai-agent/backend/core/routes.py
Gabriel Vidal d8263a93b1 fix(backend): deploy-status resolves the conversation's real title
deploy.sh only reads the sidecar's custom title override, which most
conversations never set, so the banner always said "Untitled conversation".
/api/deploy-status now looks the session up in the index: the title the list
shows (override, else ai-title) and the in-app conversationId. Hidden from a
guest who can't see that conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:33:37 +02:00

112 lines
3.9 KiB
Python

"""App-level routes: the SSE event stream, deploy status and health."""
import queue
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import schemas
from conversations.cards import _conv_title
from conversations.tree import _conv_id, _is_sidechain_path
from core.http import _r
from core.state import State
from deploy_status import read_status as read_deploy_status
from users import access
from users.caller import CallerDep
router = APIRouter()
# What a guest's stream carries: the pings about conversations they can see
# (and the cross-cutting ones that only make a client refetch a filtered list).
_GUEST_PASS = {"deploy", "users"}
def _guest_filter(deps, caller):
def flt(ev: dict):
kind = ev.get("type")
if kind in _GUEST_PASS:
return ev
if kind == "transcript":
ids = [i for i in ev.get("ids") or []
if access.visible(deps, caller, i)]
return {**ev, "ids": ids} if ids else None
if kind == "meta":
cid = ev.get("id")
return ev if not cid or access.visible(deps, caller, cid) else None
if kind == "form":
sid = ev.get("sessionId")
return ev if sid and access.visible(deps, caller, sid) else None
if kind == "notification":
return ev # an id only; the feed it refetches is filtered
return None
return flt
@router.get("/api/events")
def events(deps: State, caller: CallerDep):
"""Server-Sent Events: ``meta`` / ``transcript`` change pings for the UI."""
q = deps.hub.subscribe(None if caller.is_owner else _guest_filter(deps, caller))
def gen():
try:
yield "retry: 3000\n\n"
while True:
try:
data = q.get(timeout=15)
yield f"data: {data}\n\n"
except queue.Empty:
yield ": ping\n\n" # keep proxies from closing the stream
finally:
deps.hub.unsubscribe(q)
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no",
"Connection": "keep-alive"})
@router.get("/api/deploy-status", responses=_r(schemas.DeployStatus))
def deploy_status(deps: State, caller: CallerDep):
"""Current blue-green deploy snapshot (or ``null`` when none is in flight).
Written by ``services/ai-agent/deploy.sh`` into the data dir; polled by the
:class:`DeployWatcher`, which pings a ``deploy`` SSE event on any change.
"""
snap = read_deploy_status()
if snap:
_attach_conversation(deps, caller, snap)
return snap
def _attach_conversation(deps, caller, snap: dict) -> None:
"""Resolve the deploying conversation from the snapshot's ``sessionId``.
The script can only stamp the sidecar's custom ``title`` override, which
most conversations never set — the title the viewer shows is Claude's
``ai-title`` (else the first prompt), parsed from the transcript. So the
title and the in-app ``conversationId`` are looked up here, live; the
script's own ``convTitle`` is only the fallback. A guest who can't see the
conversation gets neither (nor the script's link).
"""
sid = snap.get("sessionId")
if not sid:
return
if not access.visible(deps, caller, sid):
snap["convTitle"] = snap["convUrl"] = None
return
best = None
for path, s in deps.store.all_summaries():
if s.get("sessionId") != sid or _is_sidechain_path(path):
continue
if best is None or (s.get("endedAt") or "") > (best[1].get("endedAt") or ""):
best = (path, s)
if best:
snap["conversationId"] = _conv_id(best[0])
snap["convTitle"] = _conv_title(deps, best[1]) or snap.get("convTitle")
@router.get("/api/health")
def health():
return {"ok": True}