Files
DocsGPT/docsgpt/api/devices/session.py
T
Alex 54b540ea42 feat(api): serve long-lived streams on the event loop
Move GET /api/events, the remote-device command stream and artifact
downloads from Flask to Starlette routes mounted ahead of the Flask
catch-all. On Flask each held an a2wsgi threadpool slot for as long as
its response stayed open, and because uvicorn drops writes after a
client disconnects, a closed tab never released it.

- asgi_auth: one JWT/OIDC gate for Starlette routes; the chat reconnect
  reader uses it too
- ClosingStreamingResponse closes the body iterator and releases the
  SSE slot or file handle even when the client leaves before the first
  frame
- AsyncTopic liveness probe replaces the sync client's socket_timeout
  guard against half-open pub/sub sockets
- ASYNC_REDIS_MAX_CONNECTIONS sizes the async Redis pool; every open
  stream holds a connection and redis-py defaults to 100
2026-09-14 08:33:46 +01:00

199 lines
7.1 KiB
Python

"""Device session endpoints: poll, me, ack, output.
The SSE command stream itself is a native-async route (``session_events.py``).
"""
from __future__ import annotations
import gzip
import io
import json
import logging
from flask import Response, jsonify, make_response, request
from docsgpt.api.devices.auth import verify_device_session
from docsgpt.devices.broker import get_broker
from docsgpt.storage.db.repositories.device_audit_log import (
DeviceAuditLogRepository,
)
from docsgpt.storage.db.session import db_session
logger = logging.getLogger(__name__)
# Window (seconds) the CLI has to upgrade a poll-issued ticket to an SSE
# stream. Advertised to the CLI as ``expires_in`` and used as the broker
# ticket TTL so the two never drift.
_SESSION_TICKET_TTL_SECONDS = 30
def poll() -> Response:
"""``GET /api/devices/poll`` — long-poll for queued invocations.
Returns ``202`` with empty body when nothing is queued and ``200`` with
a session ticket payload when work is waiting.
"""
device, err = verify_device_session()
if err is not None:
return err
broker = get_broker()
ticket = broker.claim_ticket(device["id"], _SESSION_TICKET_TTL_SECONDS)
if ticket is None:
return make_response("", 202)
return make_response(
jsonify(
{
"session_ticket": ticket,
"session_url": f"/api/devices/sessions/{ticket}/events",
"expires_in": _SESSION_TICKET_TTL_SECONDS,
}
),
200,
)
def me() -> Response:
"""``GET /api/devices/me`` — return the calling device's own record.
Auth: device session token (same as ``/poll``). Used by ``docsgpt-cli
host status`` to show live server state.
"""
device, err = verify_device_session()
if err is not None:
return err
out = {
"id": device.get("id"),
"name": device.get("name"),
"hostname": device.get("hostname"),
"os": device.get("os"),
"status": device.get("status"),
"approval_mode": device.get("approval_mode"),
"description": device.get("description"),
"paired_at": device.get("paired_at"),
"last_seen_at": device.get("last_seen_at"),
}
for key in ("paired_at", "last_seen_at"):
value = out.get(key)
if value is not None and not isinstance(value, str):
out[key] = value.isoformat()
return make_response(jsonify(out), 200)
def ack_invocation(session_id: str, invocation_id: str) -> Response:
"""CLI acks (accepted / denied / auto_approved) the invocation."""
device, err = verify_device_session()
if err is not None:
return err
body = request.get_json(silent=True) or {}
decision = body.get("decision")
reason = body.get("reason")
if decision not in {"accepted", "denied", "auto_approved"}:
return make_response(
jsonify({"success": False, "error": "invalid_decision"}), 400
)
broker = get_broker()
inv = broker.get_invocation(invocation_id)
if inv is None or inv.device_id != device["id"]:
return make_response(
jsonify({"success": False, "error": "invocation_not_found"}), 404
)
broker.submit_ack(invocation_id, decision, reason)
if decision == "denied":
# A denial is terminal and produces no device output, so submit_output's
# audit write is never reached. Record the outcome here from locally
# known facts (not re-read Redis state the agent's drain races to clean
# up), so the audit row reflects the denial instead of staying
# "dispatched". Accepted/auto_approved runs record via submit_output.
from datetime import datetime, timezone
try:
with db_session() as conn:
DeviceAuditLogRepository(conn).record_result(
invocation_id,
finished_at=datetime.now(timezone.utc),
error="denied",
)
except Exception:
logger.exception("audit record_result (denied) failed for %s", invocation_id)
return make_response(jsonify({"success": True}), 200)
def submit_output(session_id: str, invocation_id: str) -> Response:
"""CLI streams stdout/stderr/control chunks (NDJSON, gzip-aware)."""
device, err = verify_device_session()
if err is not None:
return err
broker = get_broker()
inv = broker.get_invocation(invocation_id)
if inv is None or inv.device_id != device["id"]:
return make_response(
jsonify({"success": False, "error": "invocation_not_found"}), 404
)
body = request.get_data() or b""
if request.headers.get("Content-Encoding", "").lower() == "gzip":
try:
body = gzip.decompress(body)
except OSError:
return make_response(
jsonify({"success": False, "error": "invalid_gzip"}), 400
)
received = 0
control_chunk = None
for line in io.BytesIO(body):
line = line.strip()
if not line:
continue
try:
chunk = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
continue
if not isinstance(chunk, dict):
continue
if chunk.get("stream") == "control":
control_chunk = chunk
broker.submit_output_chunk(invocation_id, chunk)
received += 1
# Persist the outcome when the closing control chunk arrived in this POST.
# Its fields are captured locally so the audit write survives the draining
# (worker) process racing to delete the invocation's Redis state. Byte
# totals / started_at live in the hash, read best-effort (the functional
# exit_code/error/duration still land even if the hash is already gone).
if control_chunk is not None:
from datetime import datetime, timezone
snap = broker.get_invocation(invocation_id)
try:
with db_session() as conn:
DeviceAuditLogRepository(conn).record_result(
invocation_id,
started_at=(
datetime.fromtimestamp(snap.started_at, tz=timezone.utc)
if snap is not None and snap.started_at else None
),
finished_at=datetime.now(timezone.utc),
exit_code=_as_opt_int(control_chunk.get("exit_code")),
duration_ms=_as_opt_int(control_chunk.get("duration_ms")),
stdout_bytes=(snap.stdout_bytes if snap is not None else 0),
stderr_bytes=(snap.stderr_bytes if snap is not None else 0),
error=control_chunk.get("error"),
)
except Exception:
logger.exception("audit record_result failed for %s", invocation_id)
return make_response(
jsonify({"success": True, "received": received}), 200
)
def _as_opt_int(value) -> int | None:
"""Coerce a CLI-supplied JSON value to int for an INTEGER audit column."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None