1use serde::{Deserialize, Serialize};
7use tokio::sync::broadcast;
8use ts_rs::TS;
9
10use crate::agent::HaltReason;
11
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
13#[serde(tag = "kind")]
14pub enum AgentEvent {
15 Run { data: RunEvent },
16 Ai { data: AiEvent },
17 Sandbox { data: SandboxEvent },
18 Finding { data: FindingEvent },
19 Budget { data: BudgetEvent },
20 Quarantine { data: QuarantineEvent },
21 Repro { data: ReproEvent },
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
25#[serde(tag = "kind")]
26pub enum RunEvent {
27 Heartbeat {
28 #[ts(type = "number")]
29 ts: i64,
30 },
31 RunStarted {
34 run_id: String,
35 project_id: String,
36 repos: Vec<String>,
37 #[ts(type = "number")]
38 started_at_ms: i64,
39 },
40 ProjectStarted {
46 run_id: String,
47 project_id: String,
48 project_name: String,
49 #[ts(type = "number")]
50 started_at_ms: i64,
51 },
52 PhaseStarted {
53 run_id: String,
54 project_id: String,
55 phase: String,
56 #[ts(type = "number")]
57 started_at_ms: i64,
58 },
59 PhaseFinished {
60 run_id: String,
61 project_id: String,
62 phase: String,
63 status: String,
64 message: Option<String>,
65 #[ts(type = "number")]
66 finished_at_ms: i64,
67 },
68 EnvironmentStatus {
69 run_id: String,
70 project_id: String,
71 environment_run_id: String,
72 status: String,
73 message: Option<String>,
74 target_urls: Vec<String>,
75 #[ts(type = "number")]
76 ts_ms: i64,
77 },
78 AuthSessionStatus {
79 run_id: String,
80 project_id: String,
81 role: String,
82 status: String,
83 acquired_by: String,
84 message: Option<String>,
85 #[ts(type = "number")]
86 ts_ms: i64,
87 },
88 LiveVerificationCapabilities {
89 run_id: String,
90 project_id: String,
91 #[ts(type = "unknown")]
92 report: serde_json::Value,
93 #[ts(type = "number")]
94 ts_ms: i64,
95 },
96 RepoStarted {
99 run_id: String,
100 project_id: String,
101 repo: String,
102 #[ts(type = "number")]
103 started_at_ms: i64,
104 },
105 RepoStaticDone {
107 run_id: String,
108 project_id: String,
109 repo: String,
110 n_diags: u32,
111 #[ts(type = "number")]
112 elapsed_ms: i64,
113 },
114 RepoDynamicDone {
119 run_id: String,
120 project_id: String,
121 repo: String,
122 #[ts(type = "number")]
123 elapsed_ms: i64,
124 },
125 RepoFailed {
128 run_id: String,
129 project_id: String,
130 repo: String,
131 message: String,
132 #[ts(type = "number")]
133 elapsed_ms: i64,
134 },
135 RepoIngestFailed { run_id: String, project_id: String, repo: String, message: String },
143 RepoFinished {
147 run_id: String,
148 project_id: String,
149 repo: String,
150 outcome: RepoOutcomeTag,
151 #[ts(type = "number")]
152 elapsed_ms: i64,
153 },
154 ProjectFinished {
158 run_id: String,
159 project_id: String,
160 #[ts(type = "number")]
161 finished_at_ms: i64,
162 },
163 RunFinished {
166 run_id: String,
167 project_id: String,
168 #[ts(type = "number")]
169 finished_at_ms: i64,
170 #[ts(type = "number")]
171 wall_clock_ms: i64,
172 succeeded: u32,
173 inconclusive: u32,
174 failed: u32,
175 },
176}
177
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
182pub enum RepoOutcomeTag {
183 Success,
184 Inconclusive,
186 Failed,
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
196#[serde(tag = "kind")]
197pub enum AiEvent {
198 TokenReceived {
199 task_id: String,
200 token: String,
201 },
202 ToolCallStarted {
203 task_id: String,
204 name: String,
205 },
206 ToolCallFinished {
207 task_id: String,
208 name: String,
209 ok: bool,
210 },
211 CacheHit {
212 task_id: String,
213 tokens: u32,
214 },
215 CacheMiss {
216 task_id: String,
217 tokens: u32,
218 },
219 BudgetTick {
220 task_id: String,
221 run_id: String,
222 #[ts(type = "number")]
223 spent_usd_micros: i64,
224 },
225 TaskHalted {
226 task_id: String,
227 reason: HaltReason,
228 },
229}
230
231#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
237#[serde(tag = "kind")]
238pub enum SandboxEvent {
239 VerifierStarted {
243 run_id: String,
244 finding_id: String,
245 repo: String,
246 #[ts(type = "number")]
247 started_at_ms: i64,
248 },
249 VerifierFinished {
255 run_id: String,
256 finding_id: String,
257 repo: String,
258 verdict: String,
259 replay_stable: Option<bool>,
260 #[ts(type = "number")]
261 elapsed_ms: i64,
262 },
263}
264
265#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
266pub struct FindingEvent {}
267
268#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
269pub struct BudgetEvent {}
270
271#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
272pub struct QuarantineEvent {}
273
274#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
279#[serde(tag = "kind")]
280#[allow(clippy::enum_variant_names)]
281pub enum ReproEvent {
282 ReplayStarted {
285 finding_id: String,
286 bundle_path: String,
287 #[ts(type = "number")]
288 started_at_ms: i64,
289 },
290 ReplayStdout { finding_id: String, line: String },
292 ReplayStderr { finding_id: String, line: String },
294 ReplayError { finding_id: String, message: String },
297 ReplayFinished {
300 finding_id: String,
301 status: String,
302 #[ts(type = "number")]
303 exit_code: i32,
304 #[ts(type = "number")]
305 started_at_ms: i64,
306 #[ts(type = "number")]
307 finished_at_ms: i64,
308 #[ts(type = "number")]
309 duration_ms: i64,
310 },
311}
312
313pub type EventSink = broadcast::Sender<AgentEvent>;
314
315#[derive(Debug)]
316pub struct EventStream(pub broadcast::Receiver<AgentEvent>);
317
318impl EventStream {
319 pub fn new(rx: broadcast::Receiver<AgentEvent>) -> Self {
320 Self(rx)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[tokio::test]
329 async fn heartbeat_roundtrips_via_broadcast() {
330 let (tx, rx) = broadcast::channel::<AgentEvent>(8);
331 let mut stream = EventStream::new(rx);
332 let original = AgentEvent::Run { data: RunEvent::Heartbeat { ts: 42 } };
333 tx.send(original.clone()).expect("send");
334 let received = stream.0.recv().await.expect("recv");
335 assert_eq!(received, original);
336 }
337
338 #[test]
339 fn heartbeat_serde_roundtrip() {
340 let original = AgentEvent::Run { data: RunEvent::Heartbeat { ts: 7 } };
341 let json = serde_json::to_string(&original).expect("serialize");
342 let back: AgentEvent = serde_json::from_str(&json).expect("deserialize");
343 assert_eq!(back, original);
344 }
345}