mod envelope;
mod forwarders;
use std::collections::HashMap;
use std::time::Duration;
use actix_web::{web, HttpRequest, Responder};
use actix_ws::Message;
use futures::StreamExt;
use tokio::sync::mpsc;
use serde::Deserialize;
use self::envelope::{Channel, ClientFrame};
use self::forwarders::{spawn_agent_forwarder, spawn_feed_forwarder, OutboundTx};
use crate::app_state::AppState;
use crate::handlers::agent::events::MAX_BATCH_MS;
use crate::handlers::agent::stop::cancel_session;
const OUTBOUND_BUFFER: usize = 256;
const PING_INTERVAL: Duration = Duration::from_secs(15);
#[derive(Debug, Default, Deserialize)]
pub struct StreamQuery {
#[serde(default)]
pub batch_ms: u64,
}
pub async fn handler(
state: web::Data<AppState>,
query: web::Query<StreamQuery>,
req: HttpRequest,
body: web::Payload,
) -> actix_web::Result<impl Responder> {
let batch_ms = query.batch_ms.min(MAX_BATCH_MS);
let (response, session, msg_stream) = actix_ws::handle(&req, body)?;
actix_web::rt::spawn(drive(state, session, msg_stream, batch_ms));
Ok(response)
}
async fn drive(
state: web::Data<AppState>,
mut session: actix_ws::Session,
mut msg_stream: actix_ws::MessageStream,
batch_ms: u64,
) {
let (out_tx, mut out_rx) = mpsc::channel::<String>(OUTBOUND_BUFFER);
let mut forwarders: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
let mut ping = tokio::time::interval(PING_INTERVAL);
ping.tick().await;
loop {
tokio::select! {
Some(text) = out_rx.recv() => {
if session.text(text).await.is_err() {
break;
}
}
_ = ping.tick() => {
if session.ping(b"").await.is_err() {
break;
}
}
msg = msg_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let keep_open = handle_client_text(
&state, &out_tx, &mut forwarders, &mut session, batch_ms, &text,
)
.await;
if !keep_open {
break;
}
}
Some(Ok(Message::Ping(bytes))) => {
if session.pong(&bytes).await.is_err() {
break;
}
}
Some(Ok(Message::Pong(_)))
| Some(Ok(Message::Binary(_)))
| Some(Ok(Message::Continuation(_)))
| Some(Ok(Message::Nop)) => {}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::debug!("ws_v2: message stream error: {e}");
break;
}
}
}
else => break,
}
}
for (_ch, handle) in forwarders.drain() {
handle.abort();
}
let _ = session.close(None).await;
}
async fn handle_client_text(
state: &web::Data<AppState>,
out_tx: &OutboundTx,
forwarders: &mut HashMap<String, tokio::task::JoinHandle<()>>,
_session: &mut actix_ws::Session,
batch_ms: u64,
text: &str,
) -> bool {
let frame: ClientFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
tracing::debug!("ws_v2: ignoring malformed client frame: {e}");
return true;
}
};
match frame {
ClientFrame::Hello { device_id, token } => {
match (device_id, token) {
(Some(device_id), Some(token)) => {
let config = state.config.read().await.clone();
if crate::handlers::settings::verify_device_token(&config, &device_id, &token) {
tracing::debug!("ws_v2: hello verified for device {device_id}");
} else {
tracing::warn!(
"ws_v2: hello rejected — invalid device credential for {device_id}; closing"
);
return false;
}
}
_ => {
tracing::debug!("ws_v2: hello accepted without device token");
}
}
}
ClientFrame::Subscribe { ch, since } => {
subscribe(state, out_tx, forwarders, batch_ms, &ch, since).await;
}
ClientFrame::Unsubscribe { ch } => {
if let Some(handle) = forwarders.remove(&ch) {
handle.abort();
tracing::debug!("ws_v2: unsubscribed {ch}");
}
}
ClientFrame::Stop { session_id } => {
let cancelled = cancel_session(state, &session_id).await;
tracing::debug!("ws_v2: stop {session_id} -> cancelled={cancelled}");
}
ClientFrame::Unknown => {
tracing::debug!("ws_v2: ignoring unknown client frame type");
}
}
true
}
async fn subscribe(
state: &web::Data<AppState>,
out_tx: &OutboundTx,
forwarders: &mut HashMap<String, tokio::task::JoinHandle<()>>,
batch_ms: u64,
ch: &str,
since: Option<u64>,
) {
let Some(channel) = Channel::parse(ch) else {
tracing::debug!("ws_v2: ignoring subscribe to unknown channel {ch}");
return;
};
if let Some(old) = forwarders.remove(ch) {
old.abort();
}
let handle = match channel {
Channel::Feed => {
let receiver = state.account_sink.subscribe();
let latest_at_start = state.account_sink.latest_seq();
let events_dir = state.account_sink.events_dir().to_path_buf();
let since = since.unwrap_or(0);
spawn_feed_forwarder(out_tx.clone(), receiver, events_dir, since, latest_at_start)
}
Channel::Agent(sid) => {
if state.session_store.get_index_entry(&sid).await.is_none() {
tracing::debug!("ws_v2: ignoring subscribe to unknown session {sid}");
return;
}
let sender = state.get_session_event_sender(&sid).await;
let receiver = sender.subscribe();
state.ensure_notification_relay(&sid, sender.clone());
let runner_snapshot = {
let runners = state.agent_runners.read().await;
runners.get(&sid).cloned()
};
let budget_event_to_replay = runner_snapshot
.as_ref()
.and_then(|runner| runner.last_budget_event.clone());
let critical_events_to_replay: Vec<_> = runner_snapshot
.as_ref()
.map(|runner| runner.last_critical_events.clone())
.unwrap_or_default();
spawn_agent_forwarder(
state.clone(),
sid.clone(),
out_tx.clone(),
ch.to_string(),
receiver,
budget_event_to_replay,
critical_events_to_replay,
batch_ms,
)
}
};
forwarders.insert(ch.to_string(), handle);
tracing::debug!("ws_v2: subscribed {ch} (since={since:?})");
}