[Story-753] WebSocket agent bridge handlers

Story 753Epic 749๐Ÿ”— Chain of CustodyAgent Platform6 files ยท +984/โˆ’0

PR #187 ยท pramaan-io/pramaan-functions ยท merged 2026-05-20

How it flows

sequenceDiagram
  autonumber
  participant client as Client
  participant apigw as API Gateway
  participant lambda as Lambda
  participant db as DB
  participant sqs as SQS
  client->>apigw: connect
  apigw->>lambda: handler_ws_connect
  lambda->>db: write ws_connection
  client->>apigw: message / ping
  apigw->>lambda: handler_ws_default
  lambda->>sqs: enqueue run
  lambda-->>client: pong
  client-->>apigw: disconnect
  apigw->>lambda: handler_ws_disconnect
  lambda->>db: mark abandoned

src/app_api/router_ws.py ยท src/shared/auth/clerk.py

Methodin โ†’ outwhat it does
verify_clerk_jwt_claims(token, settings) โ†’ dictverifies query-string Clerk token at connect
handler_ws_connect(event, context) โ†’ dict[str, Any]pins user to firm and writes the WebSocket connection row
handler_ws_default(event, context) โ†’ dict[str, Any]validates message, enqueues run onto SQS, replies to pings
handler_ws_disconnect(event, context) โ†’ dict[str, Any]marks in-flight runs tied to that connection as abandoned

What changed, file by file

The WebSocket bridge โ€” three API Gateway handlers plus the SQS adapter. Adds auth, firm scoping, enqueue, and abandoned-run cleanup to the live channel.

new classsignature
WebSocketClientMessage(class)
AgentsRunQueueClient(class)
from_settings(settings) โ†’ AgentsRunQueueClient
enqueue(payload) โ†’ None
_queue_client() โ†’ AgentsRunQueueClient
_json_response(status_code, body) โ†’ dict[str, Any]
_empty_response(status_code) โ†’ dict[str, Any]
_error_response(status_code, code, message) โ†’ dict[str, Any]
_request_context(event) โ†’ dict[str, str | None]
_correlation_id(event) โ†’ str
_connect_token(event) โ†’ str
_user_and_claims_from_connect_event(event) โ†’ tuple[CurrentUser, dict[str, Any]]
_resolve_app_firm_id(session, current_user) โ†’ UUID
_set_firm_scope(session, firm_id, user_id) โ†’ None
_connection_row(session, connection_id) โ†’ dict[str, Any]
handler_ws_connect(event, context) โ†’ dict[str, Any]
handler_ws_disconnect(event, context) โ†’ dict[str, Any]
_management_client(event, settings) โ†’ Any
post_to_connection(event, connection_id, payload, client, settings) โ†’ None
post_heartbeat(event, connection_id, client, settings) โ†’ None
handler_ws_default(event, context) โ†’ dict[str, Any]
_dispatch_ws_event(event, context) โ†’ dict[str, Any]
lambda_handler(event, context) โ†’ dict[str, Any]
New fields: matter_id : UUID | Nonechat_session_id : UUID | Nonemessage : str | None

Alembic migration that adds the WebSocket connections table, plus the abandoned status and connection-id column on agent runs.

new methodsignature
upgrade() โ†’ None
downgrade() โ†’ None
New fields: revision : strdown_revision : str | Sequence[str] | Nonebranch_labels : str | Sequence[str] | Nonedepends_on : str | Sequence[str] | None

Shared Clerk helpers โ€” verify a JWT and turn its claims into a CurrentUser โ€” so the connect handler can authenticate the query-string token.

new methodsignature
verify_clerk_jwt_claims(token, settings) โ†’ dict
current_user_from_verified_claims(claims) โ†’ CurrentUser

Settings additions โ€” Clerk JWT issuer, audience, leeway, and the resolved agent runs queue URL โ€” wiring the bridge into runtime config.

new methodsignature
clerk_jwks_url() โ†’ str
resolved_agents_runs_queue_url() โ†’ str
New fields: clerk_jwt_issuer : strclerk_jwt_audience : strclerk_jwt_leeway_seconds : intagents_runs_queue_url : str | None

What doesn't happen

connect: JWT gated
messages: firm-scoped
disconnect: abandoned, not lost
ping โ†’ pong always
pre-existing lint/type noise outside this patch
new unit tests pass clean

Files changed (6)

Fileยฑ
alembic/versions/99b7d4c6a2f1_0045_agents_ws_connections.py โ˜…+142
src/app_api/router_ws.py โ˜…+516
src/shared/agents_db_models.py+13
src/shared/auth/clerk.py โ˜…+37
src/shared/settings.py+31
tests/unit/test_router_ws.py+245