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 tokio::time;
298
299 use std::time::Duration;
300
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 = format!("{}😀", "x".repeat(97)); let chunks = split_utf8(&content, 100);
359 assert_eq!(chunks.join(""), content);
360 for c in &chunks {
361 assert!(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!(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 = time::timeout(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 = time::timeout(Duration::from_millis(100), rx_b.recv()).await;
545 assert!(
546 result_b.is_err(),
547 "repo-b should not receive repo-a's output"
548 );
549 }
550
551 #[test]
554 fn conversation_key_without_topic() {
555 let key = conversation_key("telegram", "12345", None);
556 assert_eq!(key, "telegram:12345");
557 }
558
559 #[test]
560 fn conversation_key_with_topic() {
561 let key = conversation_key("telegram", "12345", Some("678"));
562 assert_eq!(key, "telegram:12345|678");
563 }
564
565 #[test]
566 fn conversation_key_empty_topic_treated_as_none() {
567 let key_empty = conversation_key("discord", "abc", Some(""));
569 let key_none = conversation_key("discord", "abc", None);
570 assert_eq!(key_empty, key_none);
571 }
572
573 #[test]
576 fn parse_key_without_topic() {
577 let (channel, thread_id, topic_id) =
578 parse_conversation_key("telegram:12345").expect("valid key");
579 assert_eq!(channel, "telegram");
580 assert_eq!(thread_id, "12345");
581 assert!(topic_id.is_none());
582 }
583
584 #[test]
585 fn parse_key_with_topic() {
586 let (channel, thread_id, topic_id) =
587 parse_conversation_key("telegram:12345|678").expect("valid key");
588 assert_eq!(channel, "telegram");
589 assert_eq!(thread_id, "12345");
590 assert_eq!(topic_id, Some("678"));
591 }
592
593 #[test]
594 fn parse_key_malformed_returns_none() {
595 assert!(parse_conversation_key("no-colon-here").is_none());
596 }
597
598 #[test]
599 fn conversation_key_round_trips() {
600 for (ch, tid, topic) in [
602 ("telegram", "111", Some("222")),
603 ("discord", "abc", None),
604 ("slack", "x", Some("y")),
605 ] {
606 let key = conversation_key(ch, tid, topic);
607 let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
608 assert_eq!(ch2, ch);
609 assert_eq!(tid2, tid);
610 assert_eq!(topic2, topic);
611 }
612 }
613
614 #[tokio::test]
619 async fn different_topics_do_not_collide() {
620 let transport = Transport::new();
621
622 transport
624 .bind(
625 "owner/repo",
626 "10",
627 "orch-proj-10",
628 "telegram",
629 "999",
630 Some("100"),
631 )
632 .await;
633 transport
635 .bind(
636 "owner/repo",
637 "20",
638 "orch-proj-20",
639 "telegram",
640 "999",
641 Some("200"),
642 )
643 .await;
644
645 let msg_topic_100 = IncomingMessage {
646 channel: "telegram".to_string(),
647 id: "m1".to_string(),
648 thread_id: "999".to_string(),
649 author: "user".to_string(),
650 body: "reply for task 10".to_string(),
651 timestamp: chrono::Utc::now(),
652 metadata: serde_json::json!({}),
653 topic_id: Some("100".to_string()),
654 };
655
656 let msg_topic_200 = IncomingMessage {
657 channel: "telegram".to_string(),
658 id: "m2".to_string(),
659 thread_id: "999".to_string(),
660 author: "user".to_string(),
661 body: "reply for task 20".to_string(),
662 timestamp: chrono::Utc::now(),
663 metadata: serde_json::json!({}),
664 topic_id: Some("200".to_string()),
665 };
666
667 match transport.route(&msg_topic_100).await {
668 MessageRoute::TaskSession { session_key } => {
669 assert_eq!(session_key, "owner/repo:10");
670 }
671 other => panic!("expected TaskSession for task 10, got {other:?}"),
672 }
673
674 match transport.route(&msg_topic_200).await {
675 MessageRoute::TaskSession { session_key } => {
676 assert_eq!(session_key, "owner/repo:20");
677 }
678 other => panic!("expected TaskSession for task 20, got {other:?}"),
679 }
680 }
681
682 #[tokio::test]
686 async fn unbind_removes_binding_and_reverse_lookup() {
687 let transport = Transport::new();
688 transport
689 .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
690 .await;
691
692 assert!(transport.get_binding("owner/repo", "99").await.is_some());
694
695 transport.unbind("owner/repo", "99").await;
697
698 assert!(transport.get_binding("owner/repo", "99").await.is_none());
700
701 let msg = IncomingMessage {
703 channel: "telegram".to_string(),
704 id: "m1".to_string(),
705 thread_id: "555".to_string(),
706 author: "user".to_string(),
707 body: "hello".to_string(),
708 timestamp: chrono::Utc::now(),
709 metadata: serde_json::json!({}),
710 topic_id: None,
711 };
712 match transport.route(&msg).await {
713 MessageRoute::NewTask => {} other => panic!("expected NewTask after unbind, got {other:?}"),
715 }
716 }
717
718 #[tokio::test]
720 async fn unbind_nonexistent_does_not_panic() {
721 let transport = Transport::new();
722 transport.unbind("owner/repo", "nonexistent").await;
724 }
725
726 #[tokio::test]
729 async fn wrong_topic_does_not_route_to_bound_task() {
730 let transport = Transport::new();
731
732 transport
734 .bind(
735 "owner/repo",
736 "5",
737 "orch-proj-5",
738 "telegram",
739 "42",
740 Some("10"),
741 )
742 .await;
743
744 let msg = IncomingMessage {
746 channel: "telegram".to_string(),
747 id: "m1".to_string(),
748 thread_id: "42".to_string(),
749 author: "user".to_string(),
750 body: "hello from a different topic".to_string(),
751 timestamp: chrono::Utc::now(),
752 metadata: serde_json::json!({}),
753 topic_id: Some("99".to_string()),
754 };
755
756 match transport.route(&msg).await {
757 MessageRoute::NewTask => {} MessageRoute::TaskSession { session_key } => {
759 panic!("should not have routed to session {session_key}")
760 }
761 other => panic!("unexpected route result: {other:?}"),
762 }
763 }
764}