@startuml
!theme plain
title Turnstone — Console Dashboard Data Flow

skinparam sequenceArrowThickness 1.5

participant "Browser" as Browser
participant "Console\nStarlette App" as Server
participant "ClusterCollector" as CC
collections "Redis" as Redis
participant "Node-A Bridge" as BridgeA
participant "Node-A\n(real server)" as NodeA
participant "Node-B\n(sim node)" as NodeB

== Thread 1: Cluster Event Subscriber (real-time) ==

CC -> Redis : SUBSCRIBE turnstone:events:cluster
activate CC #E1BEE7

Redis --> CC : ClusterStateEvent\n{ws_id, state:"thinking",\nnode_id:"nodeA", tokens:500,\ncontext_ratio:0.05}
CC -> CC : Update NodeSnapshot["nodeA"]\n.workstreams["ws123"].state = "thinking"
CC -> CC : _fanout(event) → all SSE listeners

Redis --> CC : {"type":"ws_created",\nws_id:"ws456", name:"task-1",\nnode_id:"sim-0003"}
CC -> CC : Add workstream to\nNodeSnapshot["sim-0003"]
CC -> CC : _fanout(event)

Redis --> CC : ClusterStateEvent\n{ws_id:"ws456", state:"idle"}
CC -> CC : Update workstream state

note right of CC
  Handles: cluster_state,
  ws_created, ws_closed, ws_rename

  Thread runs continuously.
  All updates are thread-safe
  via threading.Lock.
end note

deactivate CC

== Thread 2: Node Discovery (every 15s) ==

CC -> Redis : SCAN 0 MATCH turnstone:node:*
activate CC #B2EBF2
Redis --> CC : [turnstone:node:nodeA, turnstone:node:sim-0003, ...]

loop for each discovered key
    CC -> Redis : GET turnstone:node:{id}
    Redis --> CC : JSON: {server_url, started, max_ws, sim:true/false}
end

CC -> CC : Create new NodeSnapshot\nfor newly discovered nodes
CC -> CC : Remove NodeSnapshot\nfor disappeared nodes

CC -> CC : _fanout({type: "node_joined", ...})\n_fanout({type: "node_lost", ...})

deactivate CC

== Thread 3: HTTP Polling (every 10s, real nodes only) ==

CC -> CC : Filter nodes where\nserver_url.startswith("http")
activate CC #C8E6C9

note right of CC
  sim:// nodes are SKIPPED.
  Their data comes exclusively
  from the cluster event channel.
end note

CC -> NodeA : GET /v1/api/dashboard
activate NodeA
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
deactivate NodeA

CC -> NodeA : GET /health
activate NodeA
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
deactivate NodeA

CC -> CC : Diff old vs new workstream IDs
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
CC -> CC : _fanout(ws_closed) for\nremoved workstreams

note right of CC
  Poll-diff fanout ensures
  browser SSE clients learn
  about workstreams that
  appeared without a real-time
  cluster event (e.g. bridge
  startup recovery).
end note

CC -x NodeB : (SKIPPED: sim:// URL)

deactivate CC

== Browser SSE Stream ==

Browser -> Server : GET /v1/api/cluster/events
activate Server

Server -> CC : get_snapshot()
CC --> Server : ClusterSnapshot\n(full current state)

Server -> CC : register_listener(queue)
note right : Per-client queue.Queue(maxsize=2000)\nSSE via EventSourceResponse + run_in_executor()

Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)

loop continuous (incremental updates)
    CC -> Server : event via listener queue\n(from any of the 3 threads)
    Server -> Browser : data: {"type":"cluster_state",...}\n\n
end

alt keepalive (sse-starlette ping=5)
    Server -> Browser : : ping\n\n
end

Browser -> Server : connection closed
Server -> CC : unregister_listener(queue)
deactivate Server

== Browser REST: Snapshot ==

Browser -> Server : GET /v1/api/cluster/snapshot
Server -> CC : get_snapshot()
CC --> Server : ClusterSnapshot\n(full current state)
Server --> Browser : JSON response

== Browser REST Requests ==

Browser -> Server : GET /v1/api/cluster/overview
Server -> CC : get_overview()
CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.3.0"]}
Server --> Browser : JSON response

Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
Server -> CC : get_nodes(sort_by="activity")
CC --> Server : {nodes: [...], total: 10}
Server --> Browser : JSON response

Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=sim-0003
Server -> CC : get_workstreams(state="running",\nnode="sim-0003")
CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1}
Server --> Browser : JSON response

== Workstream Creation (via MQ) ==

Browser -> Server : POST /v1/api/cluster/workstreams/new\n{node_id:"nodeA", name:"new-task"}
activate Server #FFECB3

Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
CC --> Server : node validated

Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task",\nuser_id: from auth_result}

Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
deactivate Server

note right of Redis
  Bridge on Node-A picks up the
  message from its directed queue,
  POSTs to /v1/api/workstreams/new
  (forwarding user_id in payload),
  registers ownership, publishes
  ws_created to cluster channel.
end note

Redis --> BridgeA : BLPOP turnstone:inbound:nodeA
activate BridgeA
BridgeA -> NodeA : POST /v1/api/workstreams/new\n{name:"new-task"}
NodeA --> BridgeA : {ws_id:"ws789", name:"new-task"}
BridgeA -> Redis : SET turnstone:ws:ws789 = nodeA
BridgeA -> Redis : PUBLISH turnstone:events:cluster\n{type:"ws_created", ws_id:"ws789",\nnode_id:"nodeA", name:"new-task"}
deactivate BridgeA

Redis --> CC : ws_created event
CC -> CC : Add workstream to\nNodeSnapshot["nodeA"]
CC -> CC : _fanout(event)
Server -> Browser : SSE: data: {"type":"ws_created",...}

== Reverse Proxy (server UI through console port) ==

Browser -> Server : GET /node/nodeA/
activate Server #FFF9C4

Server -> CC : get_node_detail("nodeA")\n→ server_url = "http://10.0.1.1:8080"

Server -> NodeA : GET http://10.0.1.1:8080/\n(via httpx.AsyncClient)
activate NodeA
NodeA --> Server : index.html
deactivate NodeA

Server -> Server : Rewrite static paths:\nhref="/static/" → "/node/nodeA/static/"\nInject console-return banner\nafter <body>

Server --> Browser : Rewritten HTML
deactivate Server

Browser -> Server : GET /node/nodeA/static/app.js
activate Server #FFF9C4

Server -> NodeA : GET http://10.0.1.1:8080/static/app.js
activate NodeA
NodeA --> Server : app.js
deactivate NodeA

Server -> Server : Prepend JS proxy shim:\nOverride fetch() and EventSource()\nto prepend "/node/nodeA" prefix

Server --> Browser : Shimmed app.js
deactivate Server

note right of Browser
  All fetch("/v1/api/send") calls in the
  server UI now become fetch("/node/nodeA/v1/api/send"),
  routed through the console proxy.
end note

Browser -> Server : GET /node/nodeA/v1/api/events?ws_id=ws789
activate Server #FFF9C4

Server -> NodeA : GET http://10.0.1.1:8080/v1/api/events?ws_id=ws789\n(SSE stream via httpx.AsyncClient timeout=None)
activate NodeA

loop SSE streaming
    NodeA --> Server : data: {"type":"content","text":"..."}\n\n
    Server --> Browser : data: {"type":"content","text":"..."}\n\n
end

deactivate NodeA
deactivate Server

Browser -> Server : POST /node/nodeA/v1/api/send\n{message:"hello", ws_id:"ws789"}
activate Server #FFF9C4

Server -> NodeA : POST http://10.0.1.1:8080/v1/api/send\n(body forwarded)
activate NodeA
NodeA --> Server : {status:"ok"}
deactivate NodeA

Server --> Browser : {status:"ok"}
deactivate Server

@enduml
