1use super::notification::TaskNotification;
30use super::{split_utf8, IncomingMessage, OutputChunk};
31use std::collections::HashMap;
32use std::sync::Arc;
33use tokio::sync::{broadcast, RwLock};
34
35const MAX_OUTPUT_CHUNK_BYTES: usize = 64 * 1024;
36
37pub fn session_key(repo: &str, task_id: &str) -> String {
44 if task_id.starts_with("internal:") {
45 task_id.to_string()
46 } else {
47 format!("{repo}:{task_id}")
48 }
49}
50
51pub fn parse_session_key(key: &str) -> Option<(&str, &str)> {
55 if key.starts_with("internal:") {
56 None
59 } else {
60 let (repo, task_id) = key.split_once(':')?;
63 Some((repo, task_id))
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct SessionBinding {
70 pub tmux_session: String,
72 pub connected_threads: Vec<String>,
81 pub output_tx: broadcast::Sender<OutputChunk>,
83}
84
85pub fn conversation_key(channel: &str, thread_id: &str, topic_id: Option<&str>) -> String {
96 match topic_id {
97 Some(tid) if !tid.is_empty() => format!("{channel}:{thread_id}|{tid}"),
98 _ => format!("{channel}:{thread_id}"),
99 }
100}
101
102pub fn parse_conversation_key(key: &str) -> Option<(&str, &str, Option<&str>)> {
107 let (channel, rest) = key.split_once(':')?;
108 let (thread_id, topic_id) = match rest.split_once('|') {
109 Some((tid, topic)) => (tid, Some(topic)),
110 None => (rest, None),
111 };
112 Some((channel, thread_id, topic_id))
113}
114
115pub struct Transport {
117 bindings: Arc<RwLock<HashMap<String, SessionBinding>>>,
119 thread_to_task: Arc<RwLock<HashMap<String, String>>>,
121 notification_tx: broadcast::Sender<TaskNotification>,
123}
124
125impl Transport {
126 pub fn new() -> Self {
127 let (notification_tx, _) = broadcast::channel(64);
128 Self {
129 bindings: Arc::new(RwLock::new(HashMap::new())),
130 thread_to_task: Arc::new(RwLock::new(HashMap::new())),
131 notification_tx,
132 }
133 }
134
135 pub async fn bind(
144 &self,
145 repo: &str,
146 task_id: &str,
147 tmux_session: &str,
148 channel: &str,
149 thread_id: &str,
150 topic_id: Option<&str>,
151 ) {
152 let key = conversation_key(channel, thread_id, topic_id);
153 let skey = session_key(repo, task_id);
154 {
156 let mut bindings = self.bindings.write().await;
157 let binding = bindings.entry(skey.clone()).or_insert_with(|| {
158 let (tx, _) = broadcast::channel(256);
159 SessionBinding {
160 tmux_session: tmux_session.to_string(),
161 connected_threads: Vec::new(),
162 output_tx: tx,
163 }
164 });
165 binding.tmux_session = tmux_session.to_string();
167 if !binding.connected_threads.contains(&key) {
168 binding.connected_threads.push(key.clone());
169 }
170 } self.thread_to_task.write().await.insert(key, skey);
173 }
174
175 pub async fn subscribe(
177 &self,
178 repo: &str,
179 task_id: &str,
180 ) -> Option<broadcast::Receiver<OutputChunk>> {
181 let skey = session_key(repo, task_id);
182 let bindings = self.bindings.read().await;
183 bindings.get(&skey).map(|b| b.output_tx.subscribe())
184 }
185
186 pub async fn push_output(&self, repo: &str, task_id: &str, chunk: OutputChunk) {
188 let skey = session_key(repo, task_id);
189 if chunk.content.is_empty() {
190 let bindings = self.bindings.read().await;
191 if let Some(binding) = bindings.get(&skey) {
192 let _ = binding.output_tx.send(chunk.clone());
193 }
194 return;
195 }
196
197 let parts = split_utf8(&chunk.content, MAX_OUTPUT_CHUNK_BYTES);
198 let last_index = parts.len().saturating_sub(1);
199
200 for (idx, part) in parts.into_iter().enumerate() {
201 let part_chunk = OutputChunk {
202 content: part,
203 is_final: chunk.is_final && idx == last_index,
204 };
205 let bindings = self.bindings.read().await;
206 if let Some(binding) = bindings.get(&skey) {
207 let _ = binding.output_tx.send(part_chunk.clone());
208 }
209 }
210 }
211
212 pub async fn get_binding(&self, repo: &str, task_id: &str) -> Option<SessionBinding> {
214 let skey = session_key(repo, task_id);
215 self.bindings.read().await.get(&skey).cloned()
216 }
217
218 pub async fn route(&self, msg: &IncomingMessage) -> MessageRoute {
221 let key = conversation_key(&msg.channel, &msg.thread_id, msg.topic_id.as_deref());
222
223 if let Some(session_key) = self.thread_to_task.read().await.get(&key) {
225 return MessageRoute::TaskSession {
226 session_key: session_key.clone(),
227 };
228 }
229
230 let body = msg.body.trim();
232 if body.starts_with('/') || body.starts_with("orch ") {
233 return MessageRoute::Command {
234 raw: body.to_string(),
235 };
236 }
237
238 MessageRoute::NewTask
240 }
241
242 pub async fn unbind(&self, repo: &str, task_id: &str) {
248 let skey = session_key(repo, task_id);
249
250 let connected_threads = {
252 let mut bindings = self.bindings.write().await;
253 bindings
254 .remove(&skey)
255 .map(|b| b.connected_threads)
256 .unwrap_or_default()
257 };
258
259 if !connected_threads.is_empty() {
261 let mut t2t = self.thread_to_task.write().await;
262 for key in &connected_threads {
263 t2t.remove(key);
264 }
265 }
266 }
267
268 pub async fn active_sessions(&self) -> Vec<SessionBinding> {
270 self.bindings.read().await.values().cloned().collect()
271 }
272
273 pub fn push_notification(&self, notification: TaskNotification) {
275 let _ = self.notification_tx.send(notification);
277 }
278
279 pub fn subscribe_notifications(&self) -> broadcast::Receiver<TaskNotification> {
281 self.notification_tx.subscribe()
282 }
283}
284
285#[derive(Debug)]
287pub enum MessageRoute {
288 TaskSession { session_key: String },
291 Command { raw: String },
293 ControlSession,
295 NewTask,
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[tokio::test]
304 async fn push_notification_reaches_subscriber() {
305 let transport = Transport::new();
306 let mut rx = transport.subscribe_notifications();
307
308 let notification = TaskNotification {
309 task_id: "42".to_string(),
310 title: "Test task".to_string(),
311 status: "done".to_string(),
312 agent: "claude".to_string(),
313 duration_seconds: 60.0,
314 summary: "Completed successfully".to_string(),
315 repo: None,
316 notify_target: None,
317 pr_number: None,
318 };
319
320 transport.push_notification(notification.clone());
321
322 let received = rx.recv().await.unwrap();
323 assert_eq!(received.task_id, "42");
324 assert_eq!(received.status, "done");
325 assert_eq!(received.agent, "claude");
326 assert_eq!(received.summary, "Completed successfully");
327 }
328
329 #[tokio::test]
330 async fn push_notification_multiple_subscribers() {
331 let transport = Transport::new();
332 let mut rx1 = transport.subscribe_notifications();
333 let mut rx2 = transport.subscribe_notifications();
334
335 transport.push_notification(TaskNotification {
336 task_id: "1".to_string(),
337 title: "Task".to_string(),
338 status: "done".to_string(),
339 agent: "codex".to_string(),
340 duration_seconds: 10.0,
341 summary: "Done".to_string(),
342 repo: None,
343 notify_target: None,
344 pr_number: None,
345 });
346
347 let n1 = rx1.recv().await.unwrap();
348 let n2 = rx2.recv().await.unwrap();
349 assert_eq!(n1.task_id, "1");
350 assert_eq!(n2.task_id, "1");
351 }
352
353 #[test]
354 fn split_chunks_multibyte_at_boundary_does_not_panic() {
355 let content = "x".repeat(97) + "😀"; let chunks = split_utf8(&content, 100);
359 assert_eq!(chunks.join(""), content);
360 for c in &chunks {
361 assert!(std::str::from_utf8(c.as_bytes()).is_ok());
362 }
363 }
364
365 #[test]
366 fn split_chunks_only_emojis() {
367 let content = "😀".repeat(50); let chunks = split_utf8(&content, 10); assert_eq!(chunks.join(""), content);
371 for c in &chunks {
372 assert!(std::str::from_utf8(c.as_bytes()).is_ok());
373 }
374 }
375
376 #[test]
377 fn push_notification_no_subscribers_does_not_panic() {
378 let transport = Transport::new();
379 transport.push_notification(TaskNotification {
381 task_id: "1".to_string(),
382 title: "Task".to_string(),
383 status: "done".to_string(),
384 agent: "claude".to_string(),
385 duration_seconds: 0.0,
386 summary: "Done".to_string(),
387 repo: None,
388 notify_target: None,
389 pr_number: None,
390 });
391 }
392
393 #[tokio::test]
394 async fn bind_and_route_to_session() {
395 let transport = Transport::new();
396 transport
397 .bind(
398 "owner/repo",
399 "42",
400 "orch-myproject-42",
401 "telegram",
402 "12345",
403 None,
404 )
405 .await;
406
407 let msg = IncomingMessage {
408 channel: "telegram".to_string(),
409 id: "msg1".to_string(),
410 thread_id: "12345".to_string(),
411 author: "user".to_string(),
412 body: "hello".to_string(),
413 timestamp: chrono::Utc::now(),
414 metadata: serde_json::json!({}),
415 topic_id: None,
416 };
417
418 match transport.route(&msg).await {
419 MessageRoute::TaskSession { session_key } => {
420 assert_eq!(session_key, "owner/repo:42");
421 }
422 _ => panic!("expected TaskSession"),
423 }
424 }
425
426 #[tokio::test]
427 async fn route_command() {
428 let transport = Transport::new();
429
430 let msg = IncomingMessage {
431 channel: "telegram".to_string(),
432 id: "msg1".to_string(),
433 thread_id: "99".to_string(),
434 author: "user".to_string(),
435 body: "/status".to_string(),
436 timestamp: chrono::Utc::now(),
437 metadata: serde_json::json!({}),
438 topic_id: None,
439 };
440
441 match transport.route(&msg).await {
442 MessageRoute::Command { raw } => assert_eq!(raw, "/status"),
443 _ => panic!("expected Command"),
444 }
445 }
446
447 #[test]
450 fn session_key_external_is_prefixed_with_repo() {
451 assert_eq!(session_key("owner/repo", "42"), "owner/repo:42");
452 }
453
454 #[test]
455 fn session_key_internal_is_unchanged() {
456 assert_eq!(session_key("owner/repo", "internal:99"), "internal:99");
457 }
458
459 #[test]
460 fn session_key_same_id_different_repos_are_unique() {
461 let k1 = session_key("owner/repo-a", "42");
462 let k2 = session_key("owner/repo-b", "42");
463 assert_ne!(k1, k2);
464 assert_eq!(k1, "owner/repo-a:42");
465 assert_eq!(k2, "owner/repo-b:42");
466 }
467
468 #[tokio::test]
472 async fn same_external_id_different_repos_do_not_collide() {
473 let transport = Transport::new();
474
475 transport
477 .bind(
478 "owner/repo-a",
479 "42",
480 "orch-repo-a-42",
481 "telegram",
482 "111",
483 None,
484 )
485 .await;
486 transport
488 .bind(
489 "owner/repo-b",
490 "42",
491 "orch-repo-b-42",
492 "telegram",
493 "222",
494 None,
495 )
496 .await;
497
498 let binding_a = transport.get_binding("owner/repo-a", "42").await.unwrap();
500 let binding_b = transport.get_binding("owner/repo-b", "42").await.unwrap();
501 assert_eq!(binding_a.tmux_session, "orch-repo-a-42");
502 assert_eq!(binding_b.tmux_session, "orch-repo-b-42");
503 assert_ne!(binding_a.tmux_session, binding_b.tmux_session);
504 }
505
506 #[tokio::test]
509 async fn push_output_isolated_across_repos_with_same_task_id() {
510 let transport = Transport::new();
511
512 transport
514 .bind("owner/repo-a", "42", "orch-a-42", "cli", "stream-a", None)
515 .await;
516 let mut rx_a = transport.subscribe("owner/repo-a", "42").await.unwrap();
517
518 transport
520 .bind("owner/repo-b", "42", "orch-b-42", "cli", "stream-b", None)
521 .await;
522 let mut rx_b = transport.subscribe("owner/repo-b", "42").await.unwrap();
523
524 transport
526 .push_output(
527 "owner/repo-a",
528 "42",
529 OutputChunk {
530 content: "hello from repo-a".to_string(),
531 is_final: false,
532 },
533 )
534 .await;
535
536 let chunk_a = tokio::time::timeout(std::time::Duration::from_millis(100), rx_a.recv())
538 .await
539 .unwrap()
540 .unwrap();
541 assert_eq!(chunk_a.content, "hello from repo-a");
542
543 let result_b =
545 tokio::time::timeout(std::time::Duration::from_millis(100), rx_b.recv()).await;
546 assert!(
547 result_b.is_err(),
548 "repo-b should not receive repo-a's output"
549 );
550 }
551
552 #[test]
555 fn conversation_key_without_topic() {
556 let key = conversation_key("telegram", "12345", None);
557 assert_eq!(key, "telegram:12345");
558 }
559
560 #[test]
561 fn conversation_key_with_topic() {
562 let key = conversation_key("telegram", "12345", Some("678"));
563 assert_eq!(key, "telegram:12345|678");
564 }
565
566 #[test]
567 fn conversation_key_empty_topic_treated_as_none() {
568 let key_empty = conversation_key("discord", "abc", Some(""));
570 let key_none = conversation_key("discord", "abc", None);
571 assert_eq!(key_empty, key_none);
572 }
573
574 #[test]
577 fn parse_key_without_topic() {
578 let (channel, thread_id, topic_id) =
579 parse_conversation_key("telegram:12345").expect("valid key");
580 assert_eq!(channel, "telegram");
581 assert_eq!(thread_id, "12345");
582 assert!(topic_id.is_none());
583 }
584
585 #[test]
586 fn parse_key_with_topic() {
587 let (channel, thread_id, topic_id) =
588 parse_conversation_key("telegram:12345|678").expect("valid key");
589 assert_eq!(channel, "telegram");
590 assert_eq!(thread_id, "12345");
591 assert_eq!(topic_id, Some("678"));
592 }
593
594 #[test]
595 fn parse_key_malformed_returns_none() {
596 assert!(parse_conversation_key("no-colon-here").is_none());
597 }
598
599 #[test]
600 fn conversation_key_round_trips() {
601 for (ch, tid, topic) in [
603 ("telegram", "111", Some("222")),
604 ("discord", "abc", None),
605 ("slack", "x", Some("y")),
606 ] {
607 let key = conversation_key(ch, tid, topic);
608 let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
609 assert_eq!(ch2, ch);
610 assert_eq!(tid2, tid);
611 assert_eq!(topic2, topic);
612 }
613 }
614
615 #[tokio::test]
620 async fn different_topics_do_not_collide() {
621 let transport = Transport::new();
622
623 transport
625 .bind(
626 "owner/repo",
627 "10",
628 "orch-proj-10",
629 "telegram",
630 "999",
631 Some("100"),
632 )
633 .await;
634 transport
636 .bind(
637 "owner/repo",
638 "20",
639 "orch-proj-20",
640 "telegram",
641 "999",
642 Some("200"),
643 )
644 .await;
645
646 let msg_topic_100 = IncomingMessage {
647 channel: "telegram".to_string(),
648 id: "m1".to_string(),
649 thread_id: "999".to_string(),
650 author: "user".to_string(),
651 body: "reply for task 10".to_string(),
652 timestamp: chrono::Utc::now(),
653 metadata: serde_json::json!({}),
654 topic_id: Some("100".to_string()),
655 };
656
657 let msg_topic_200 = IncomingMessage {
658 channel: "telegram".to_string(),
659 id: "m2".to_string(),
660 thread_id: "999".to_string(),
661 author: "user".to_string(),
662 body: "reply for task 20".to_string(),
663 timestamp: chrono::Utc::now(),
664 metadata: serde_json::json!({}),
665 topic_id: Some("200".to_string()),
666 };
667
668 match transport.route(&msg_topic_100).await {
669 MessageRoute::TaskSession { session_key } => {
670 assert_eq!(session_key, "owner/repo:10");
671 }
672 other => panic!("expected TaskSession for task 10, got {other:?}"),
673 }
674
675 match transport.route(&msg_topic_200).await {
676 MessageRoute::TaskSession { session_key } => {
677 assert_eq!(session_key, "owner/repo:20");
678 }
679 other => panic!("expected TaskSession for task 20, got {other:?}"),
680 }
681 }
682
683 #[tokio::test]
687 async fn unbind_removes_binding_and_reverse_lookup() {
688 let transport = Transport::new();
689 transport
690 .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
691 .await;
692
693 assert!(transport.get_binding("owner/repo", "99").await.is_some());
695
696 transport.unbind("owner/repo", "99").await;
698
699 assert!(transport.get_binding("owner/repo", "99").await.is_none());
701
702 let msg = IncomingMessage {
704 channel: "telegram".to_string(),
705 id: "m1".to_string(),
706 thread_id: "555".to_string(),
707 author: "user".to_string(),
708 body: "hello".to_string(),
709 timestamp: chrono::Utc::now(),
710 metadata: serde_json::json!({}),
711 topic_id: None,
712 };
713 match transport.route(&msg).await {
714 MessageRoute::NewTask => {} other => panic!("expected NewTask after unbind, got {other:?}"),
716 }
717 }
718
719 #[tokio::test]
721 async fn unbind_nonexistent_does_not_panic() {
722 let transport = Transport::new();
723 transport.unbind("owner/repo", "nonexistent").await;
725 }
726
727 #[tokio::test]
730 async fn wrong_topic_does_not_route_to_bound_task() {
731 let transport = Transport::new();
732
733 transport
735 .bind(
736 "owner/repo",
737 "5",
738 "orch-proj-5",
739 "telegram",
740 "42",
741 Some("10"),
742 )
743 .await;
744
745 let msg = IncomingMessage {
747 channel: "telegram".to_string(),
748 id: "m1".to_string(),
749 thread_id: "42".to_string(),
750 author: "user".to_string(),
751 body: "hello from a different topic".to_string(),
752 timestamp: chrono::Utc::now(),
753 metadata: serde_json::json!({}),
754 topic_id: Some("99".to_string()),
755 };
756
757 match transport.route(&msg).await {
758 MessageRoute::NewTask => {} MessageRoute::TaskSession { session_key } => {
760 panic!("should not have routed to session {session_key}")
761 }
762 other => panic!("unexpected route result: {other:?}"),
763 }
764 }
765}