use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use ts_rs::TS;
use crate::agent::HaltReason;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind")]
pub enum AgentEvent {
Run { data: RunEvent },
Ai { data: AiEvent },
Sandbox { data: SandboxEvent },
Finding { data: FindingEvent },
Budget { data: BudgetEvent },
Quarantine { data: QuarantineEvent },
Repro { data: ReproEvent },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind")]
pub enum RunEvent {
Heartbeat {
#[ts(type = "number")]
ts: i64,
},
RunStarted {
run_id: String,
project_id: String,
repos: Vec<String>,
#[ts(type = "number")]
started_at_ms: i64,
},
ProjectStarted {
run_id: String,
project_id: String,
project_name: String,
#[ts(type = "number")]
started_at_ms: i64,
},
PhaseStarted {
run_id: String,
project_id: String,
phase: String,
#[ts(type = "number")]
started_at_ms: i64,
},
PhaseFinished {
run_id: String,
project_id: String,
phase: String,
status: String,
message: Option<String>,
#[ts(type = "number")]
finished_at_ms: i64,
},
EnvironmentStatus {
run_id: String,
project_id: String,
environment_run_id: String,
status: String,
message: Option<String>,
target_urls: Vec<String>,
#[ts(type = "number")]
ts_ms: i64,
},
AuthSessionStatus {
run_id: String,
project_id: String,
role: String,
status: String,
acquired_by: String,
message: Option<String>,
#[ts(type = "number")]
ts_ms: i64,
},
LiveVerificationCapabilities {
run_id: String,
project_id: String,
#[ts(type = "unknown")]
report: serde_json::Value,
#[ts(type = "number")]
ts_ms: i64,
},
RepoStarted {
run_id: String,
project_id: String,
repo: String,
#[ts(type = "number")]
started_at_ms: i64,
},
RepoStaticDone {
run_id: String,
project_id: String,
repo: String,
n_diags: u32,
#[ts(type = "number")]
elapsed_ms: i64,
},
RepoDynamicDone {
run_id: String,
project_id: String,
repo: String,
#[ts(type = "number")]
elapsed_ms: i64,
},
RepoFailed {
run_id: String,
project_id: String,
repo: String,
message: String,
#[ts(type = "number")]
elapsed_ms: i64,
},
RepoIngestFailed { run_id: String, project_id: String, repo: String, message: String },
RepoFinished {
run_id: String,
project_id: String,
repo: String,
outcome: RepoOutcomeTag,
#[ts(type = "number")]
elapsed_ms: i64,
},
ProjectFinished {
run_id: String,
project_id: String,
#[ts(type = "number")]
finished_at_ms: i64,
},
RunFinished {
run_id: String,
project_id: String,
#[ts(type = "number")]
finished_at_ms: i64,
#[ts(type = "number")]
wall_clock_ms: i64,
succeeded: u32,
inconclusive: u32,
failed: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
pub enum RepoOutcomeTag {
Success,
Inconclusive,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind")]
pub enum AiEvent {
TokenReceived {
task_id: String,
token: String,
},
ToolCallStarted {
task_id: String,
name: String,
},
ToolCallFinished {
task_id: String,
name: String,
ok: bool,
},
CacheHit {
task_id: String,
tokens: u32,
},
CacheMiss {
task_id: String,
tokens: u32,
},
BudgetTick {
task_id: String,
run_id: String,
#[ts(type = "number")]
spent_usd_micros: i64,
},
TaskHalted {
task_id: String,
reason: HaltReason,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind")]
pub enum SandboxEvent {
VerifierStarted {
run_id: String,
finding_id: String,
repo: String,
#[ts(type = "number")]
started_at_ms: i64,
},
VerifierFinished {
run_id: String,
finding_id: String,
repo: String,
verdict: String,
replay_stable: Option<bool>,
#[ts(type = "number")]
elapsed_ms: i64,
},
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
pub struct FindingEvent {}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
pub struct BudgetEvent {}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
pub struct QuarantineEvent {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind")]
#[allow(clippy::enum_variant_names)]
pub enum ReproEvent {
ReplayStarted {
finding_id: String,
bundle_path: String,
#[ts(type = "number")]
started_at_ms: i64,
},
ReplayStdout { finding_id: String, line: String },
ReplayStderr { finding_id: String, line: String },
ReplayError { finding_id: String, message: String },
ReplayFinished {
finding_id: String,
status: String,
#[ts(type = "number")]
exit_code: i32,
#[ts(type = "number")]
started_at_ms: i64,
#[ts(type = "number")]
finished_at_ms: i64,
#[ts(type = "number")]
duration_ms: i64,
},
}
pub type EventSink = broadcast::Sender<AgentEvent>;
#[derive(Debug)]
pub struct EventStream(pub broadcast::Receiver<AgentEvent>);
impl EventStream {
pub fn new(rx: broadcast::Receiver<AgentEvent>) -> Self {
Self(rx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn heartbeat_roundtrips_via_broadcast() {
let (tx, rx) = broadcast::channel::<AgentEvent>(8);
let mut stream = EventStream::new(rx);
let original = AgentEvent::Run { data: RunEvent::Heartbeat { ts: 42 } };
tx.send(original.clone()).expect("send");
let received = stream.0.recv().await.expect("recv");
assert_eq!(received, original);
}
#[test]
fn heartbeat_serde_roundtrip() {
let original = AgentEvent::Run { data: RunEvent::Heartbeat { ts: 7 } };
let json = serde_json::to_string(&original).expect("serialize");
let back: AgentEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, original);
}
}