use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EngineEvent {
ClassifierDecided {
intent: Intent,
confidence: f32,
latency_ms: u32,
reasoning: String,
},
RouterEscalating {
intent: Intent,
target_mode: ActiveMode,
preflight: bool,
},
WorkerStarted {
worker_id: String,
kind: String,
task: String,
},
WorkerProgress {
worker_id: String,
percent: Option<f32>,
message: Option<String>,
},
WorkerCompleted {
worker_id: String,
files_touched: u32,
ok: bool,
},
GoalCreated {
goal_id: String,
parent_session: String,
plan: Vec<String>,
},
GoalPlanUpdated {
goal_id: String,
revision: u32,
nodes: Vec<PlanNode>,
},
GoalGateTransition {
goal_id: String,
gate: String,
from: String,
to: String,
},
GoalProofReady { goal_id: String, path: PathBuf },
SliceOpened {
goal_id: String,
slice_id: String,
worktree: PathBuf,
pr_url: Option<String>,
},
CostDelta {
source: String,
tokens_in: u32,
tokens_out: u32,
usd: f32,
},
SessionTick { now: DateTime<Utc> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Intent {
Trivial,
Small,
Medium,
Large,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ActiveMode {
#[default]
Idle,
DirectLlm,
WireWorker,
PlannerWorkers,
GoalRun,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanNode {
pub id: String,
pub label: String,
pub status: PlanNodeStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlanNodeStatus {
Pending,
Running,
Done,
Failed,
}
#[derive(Debug)]
pub struct EngineSubscriber {
rx: tokio::sync::broadcast::Receiver<EngineEvent>,
}
impl EngineSubscriber {
pub fn new(rx: tokio::sync::broadcast::Receiver<EngineEvent>) -> Self {
Self { rx }
}
pub async fn next(&mut self) -> Option<EngineEvent> {
loop {
match self.rx.recv().await {
Ok(ev) => return Some(ev),
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_event_roundtrips_via_json() {
let ev = EngineEvent::ClassifierDecided {
intent: Intent::Small,
confidence: 0.92,
latency_ms: 287,
reasoning: "rename symbol".into(),
};
let json = serde_json::to_string(&ev).unwrap();
let back: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(back, EngineEvent::ClassifierDecided { .. }));
}
#[test]
fn engine_subscriber_lagged_skips() {
let (tx, rx) = tokio::sync::broadcast::channel(2);
tx.send(EngineEvent::SessionTick { now: Utc::now() })
.unwrap();
tx.send(EngineEvent::SessionTick { now: Utc::now() })
.unwrap();
tx.send(EngineEvent::SessionTick { now: Utc::now() })
.unwrap();
let mut sub = EngineSubscriber::new(rx);
let rt = tokio::runtime::Runtime::new().unwrap();
let ev = rt.block_on(sub.next());
assert!(ev.is_some());
}
}