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