1use std::collections::HashMap;
18use std::sync::Arc;
19
20use car_ir::{ToolHandle, ToolStatus, ToolStreamChunk, ToolStreamEvent};
21use serde::{Deserialize, Serialize};
22use tokio::sync::{broadcast, Mutex};
23use tokio_util::sync::CancellationToken;
24
25const EVENT_CHANNEL_CAP: usize = 256;
29
30const MAX_BUFFERED_CHUNKS: usize = 1024;
36
37const TERMINAL_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ToolPollResult {
48 pub handle: String,
49 pub tool: String,
50 pub action_id: String,
51 pub status: ToolStatus,
52 pub chunks: Vec<ToolStreamChunk>,
55 #[serde(default, skip_serializing_if = "is_zero")]
59 pub dropped_chunks: u64,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub result: Option<serde_json::Value>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub error: Option<String>,
66}
67
68fn is_zero(n: &u64) -> bool {
69 *n == 0
70}
71
72struct HandleEntry {
73 tool: String,
74 action_id: String,
75 status: ToolStatus,
76 buffered: Vec<ToolStreamChunk>,
77 dropped_chunks: u64,
79 result: Option<serde_json::Value>,
80 error: Option<String>,
81 cancel: CancellationToken,
82 drained_terminal: bool,
86 sealed_at: Option<std::time::Instant>,
88}
89
90pub struct ToolHandleRegistry {
93 entries: Mutex<HashMap<String, HandleEntry>>,
94 events: broadcast::Sender<ToolStreamEvent>,
95}
96
97impl Default for ToolHandleRegistry {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl ToolHandleRegistry {
104 pub fn new() -> Self {
105 let (events, _) = broadcast::channel(EVENT_CHANNEL_CAP);
106 Self {
107 entries: Mutex::new(HashMap::new()),
108 events,
109 }
110 }
111
112 pub fn subscribe(&self) -> broadcast::Receiver<ToolStreamEvent> {
116 self.events.subscribe()
117 }
118
119 pub async fn register(&self, tool: &str, action_id: &str) -> (ToolHandle, CancellationToken) {
122 let id = uuid::Uuid::new_v4().simple().to_string();
126 let cancel = CancellationToken::new();
127 let entry = HandleEntry {
128 tool: tool.to_string(),
129 action_id: action_id.to_string(),
130 status: ToolStatus::Running,
131 buffered: Vec::new(),
132 dropped_chunks: 0,
133 result: None,
134 error: None,
135 cancel: cancel.clone(),
136 drained_terminal: false,
137 sealed_at: None,
138 };
139 let mut entries = self.entries.lock().await;
140 Self::reap_expired(&mut entries);
141 entries.insert(id.clone(), entry);
142 drop(entries);
143 (ToolHandle::new(id), cancel)
144 }
145
146 fn reap_expired(entries: &mut HashMap<String, HandleEntry>) {
151 entries.retain(|_, e| match e.sealed_at {
152 Some(at) => at.elapsed() < TERMINAL_TTL,
153 None => true,
154 });
155 }
156
157 pub async fn push_chunk(&self, handle_id: &str, chunk: ToolStreamChunk) {
163 let mut entries = self.entries.lock().await;
164 let Some(entry) = entries.get_mut(handle_id) else {
165 return;
166 };
167 if entry.status.is_terminal() {
168 return;
169 }
170 match &chunk {
171 ToolStreamChunk::Done { result } => {
172 entry.status = ToolStatus::Succeeded;
173 entry.result = result.clone();
174 entry.sealed_at = Some(std::time::Instant::now());
175 }
176 ToolStreamChunk::Error { message } => {
177 entry.status = ToolStatus::Failed;
178 entry.error = Some(message.clone());
179 entry.sealed_at = Some(std::time::Instant::now());
180 }
181 _ => {}
182 }
183 if entry.buffered.len() >= MAX_BUFFERED_CHUNKS {
187 entry.buffered.remove(0);
188 entry.dropped_chunks += 1;
189 }
190 entry.buffered.push(chunk.clone());
191 let _ = self.events.send(ToolStreamEvent {
194 handle: ToolHandle::new(handle_id.to_string()),
195 chunk,
196 });
197 }
198
199 pub async fn mark_stream_closed(&self, handle_id: &str) {
203 let mut entries = self.entries.lock().await;
204 if let Some(entry) = entries.get_mut(handle_id) {
205 if !entry.status.is_terminal() {
206 entry.status = ToolStatus::Failed;
207 entry.error = Some("tool stream closed without a terminal chunk".to_string());
208 entry.sealed_at = Some(std::time::Instant::now());
209 }
210 }
211 }
212
213 pub async fn cancel(&self, handle_id: &str) -> bool {
218 let mut entries = self.entries.lock().await;
219 Self::reap_expired(&mut entries);
220 let Some(entry) = entries.get_mut(handle_id) else {
221 return false;
222 };
223 entry.cancel.cancel();
224 if !entry.status.is_terminal() {
225 entry.status = ToolStatus::Cancelled;
226 entry.sealed_at = Some(std::time::Instant::now());
227 }
228 true
229 }
230
231 pub async fn cancel_all(&self) -> usize {
238 let mut entries = self.entries.lock().await;
239 let mut n = 0;
240 for e in entries.values_mut() {
241 if !e.status.is_terminal() {
242 e.cancel.cancel();
243 e.status = ToolStatus::Cancelled;
244 e.sealed_at = Some(std::time::Instant::now());
245 n += 1;
246 }
247 }
248 n
249 }
250
251 pub async fn poll(&self, handle_id: &str) -> Option<ToolPollResult> {
257 let mut entries = self.entries.lock().await;
258 Self::reap_expired(&mut entries);
259 let entry = entries.get_mut(handle_id)?;
260 let chunks = std::mem::take(&mut entry.buffered);
261 let dropped = std::mem::take(&mut entry.dropped_chunks);
262 let res = ToolPollResult {
263 handle: handle_id.to_string(),
264 tool: entry.tool.clone(),
265 action_id: entry.action_id.clone(),
266 status: entry.status,
267 chunks,
268 dropped_chunks: dropped,
269 result: entry.result.clone(),
270 error: entry.error.clone(),
271 };
272 if entry.status.is_terminal() {
273 if entry.drained_terminal {
274 entries.remove(handle_id);
275 } else {
276 entry.drained_terminal = true;
277 }
278 }
279 Some(res)
280 }
281
282 pub async fn status(&self, handle_id: &str) -> Option<ToolStatus> {
284 self.entries.lock().await.get(handle_id).map(|e| e.status)
285 }
286
287 pub async fn live_count(&self) -> usize {
289 self.entries
290 .lock()
291 .await
292 .values()
293 .filter(|e| !e.status.is_terminal())
294 .count()
295 }
296}
297
298pub fn spawn_drain(
302 registry: Arc<ToolHandleRegistry>,
303 handle_id: String,
304 rx: tokio::sync::mpsc::Receiver<ToolStreamChunk>,
305 cancel: CancellationToken,
306) {
307 drop(spawn_drain_task(registry, handle_id, rx, cancel));
308}
309
310fn spawn_drain_task(
311 registry: Arc<ToolHandleRegistry>,
312 handle_id: String,
313 mut rx: tokio::sync::mpsc::Receiver<ToolStreamChunk>,
314 cancel: CancellationToken,
315) -> tokio::task::JoinHandle<()> {
316 tokio::spawn(async move {
317 loop {
318 tokio::select! {
319 _ = cancel.cancelled() => {
320 break;
323 }
324 chunk = rx.recv() => match chunk {
325 Some(c) => {
326 let terminal = c.is_terminal();
327 registry.push_chunk(&handle_id, c).await;
328 if terminal {
329 break;
330 }
331 }
332 None => {
333 registry.mark_stream_closed(&handle_id).await;
334 break;
335 }
336 }
337 }
338 }
339 })
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[tokio::test]
347 async fn chunks_buffer_and_drain_in_order() {
348 let reg = ToolHandleRegistry::new();
349 let (h, _tok) = reg.register("tail_log", "a1").await;
350 reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "one".into() })
351 .await;
352 reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "two".into() })
353 .await;
354 let poll = reg.poll(&h.id).await.expect("known handle");
355 assert_eq!(poll.status, ToolStatus::Running);
356 assert_eq!(poll.chunks.len(), 2);
357 let poll2 = reg.poll(&h.id).await.expect("still live");
359 assert!(poll2.chunks.is_empty());
360 }
361
362 #[tokio::test]
363 async fn done_chunk_seals_success_with_result() {
364 let reg = ToolHandleRegistry::new();
365 let (h, _tok) = reg.register("build", "a2").await;
366 reg.push_chunk(
367 &h.id,
368 ToolStreamChunk::Done {
369 result: Some(serde_json::json!({"exit": 0})),
370 },
371 )
372 .await;
373 let poll = reg.poll(&h.id).await.unwrap();
374 assert_eq!(poll.status, ToolStatus::Succeeded);
375 assert_eq!(poll.result, Some(serde_json::json!({"exit": 0})));
376 reg.push_chunk(
378 &h.id,
379 ToolStreamChunk::Text {
380 text: "late".into(),
381 },
382 )
383 .await;
384 let poll2 = reg.poll(&h.id).await.unwrap();
385 assert!(poll2.chunks.is_empty());
386 assert!(reg.poll(&h.id).await.is_none());
388 }
389
390 #[tokio::test]
391 async fn cancel_seals_cancelled_and_fires_token() {
392 let reg = ToolHandleRegistry::new();
393 let (h, tok) = reg.register("watch", "a3").await;
394 assert!(reg.cancel(&h.id).await);
395 assert!(tok.is_cancelled());
396 assert_eq!(reg.status(&h.id).await, Some(ToolStatus::Cancelled));
397 reg.push_chunk(&h.id, ToolStreamChunk::Done { result: None })
399 .await;
400 assert_eq!(reg.status(&h.id).await, Some(ToolStatus::Cancelled));
401 assert!(!reg.cancel("nope").await);
402 }
403
404 #[tokio::test]
405 async fn drain_task_forwards_until_terminal() {
406 let reg = Arc::new(ToolHandleRegistry::new());
407 let (h, tok) = reg.register("gen", "a4").await;
408 let (tx, rx) = tokio::sync::mpsc::channel(8);
409 let drain = spawn_drain_task(reg.clone(), h.id.clone(), rx, tok);
410 tx.send(ToolStreamChunk::Progress {
411 fraction: 0.5,
412 message: Some("half".into()),
413 })
414 .await
415 .unwrap();
416 tx.send(ToolStreamChunk::Done { result: None })
417 .await
418 .unwrap();
419 drain.await.expect("drain task must observe terminal chunk");
420 let poll = reg.poll(&h.id).await.unwrap();
421 assert_eq!(poll.status, ToolStatus::Succeeded);
422 assert_eq!(poll.chunks.len(), 2);
423 }
424
425 #[tokio::test]
426 async fn dropped_stream_without_terminal_is_failure() {
427 let reg = Arc::new(ToolHandleRegistry::new());
428 let (h, tok) = reg.register("flaky", "a5").await;
429 let (tx, rx) = tokio::sync::mpsc::channel(8);
430 let drain = spawn_drain_task(reg.clone(), h.id.clone(), rx, tok);
431 tx.send(ToolStreamChunk::Text {
432 text: "partial".into(),
433 })
434 .await
435 .unwrap();
436 drop(tx);
437 drain
438 .await
439 .expect("drain task must observe stream closure without terminal chunk");
440 let poll = reg.poll(&h.id).await.unwrap();
441 assert_eq!(poll.status, ToolStatus::Failed);
442 assert!(poll.error.unwrap().contains("without a terminal chunk"));
443 }
444
445 #[tokio::test]
446 async fn events_broadcast_to_subscribers() {
447 let reg = ToolHandleRegistry::new();
448 let mut sub = reg.subscribe();
449 let (h, _tok) = reg.register("emit", "a6").await;
450 reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "x".into() })
451 .await;
452 let ev = sub.recv().await.expect("event delivered");
453 assert_eq!(ev.handle.id, h.id);
454 assert!(matches!(ev.chunk, ToolStreamChunk::Text { .. }));
455 }
456}