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 async fn active_sessions(&self) -> Vec<SessionBinding> {
271 self.bindings.read().await.values().cloned().collect()
272 }
273
274 pub fn push_notification(&self, notification: TaskNotification) {
276 let _ = self.notification_tx.send(notification);
278 }
279
280 pub fn subscribe_notifications(&self) -> broadcast::Receiver<TaskNotification> {
282 self.notification_tx.subscribe()
283 }
284}
285
286#[derive(Debug)]
288pub enum MessageRoute {
289 TaskSession { session_key: String },
292 Command { raw: String },
294 ControlSession,
296 NewTask,
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[tokio::test]
305 async fn push_notification_reaches_subscriber() {
306 let transport = Transport::new();
307 let mut rx = transport.subscribe_notifications();
308
309 let notification = TaskNotification {
310 task_id: "42".to_string(),
311 title: "Test task".to_string(),
312 status: "done".to_string(),
313 agent: "claude".to_string(),
314 duration_seconds: 60.0,
315 summary: "Completed successfully".to_string(),
316 repo: None,
317 notify_target: None,
318 pr_number: None,
319 };
320
321 transport.push_notification(notification.clone());
322
323 let received = rx.recv().await.unwrap();
324 assert_eq!(received.task_id, "42");
325 assert_eq!(received.status, "done");
326 assert_eq!(received.agent, "claude");
327 assert_eq!(received.summary, "Completed successfully");
328 }
329
330 #[tokio::test]
331 async fn push_notification_multiple_subscribers() {
332 let transport = Transport::new();
333 let mut rx1 = transport.subscribe_notifications();
334 let mut rx2 = transport.subscribe_notifications();
335
336 transport.push_notification(TaskNotification {
337 task_id: "1".to_string(),
338 title: "Task".to_string(),
339 status: "done".to_string(),
340 agent: "codex".to_string(),
341 duration_seconds: 10.0,
342 summary: "Done".to_string(),
343 repo: None,
344 notify_target: None,
345 pr_number: None,
346 });
347
348 let n1 = rx1.recv().await.unwrap();
349 let n2 = rx2.recv().await.unwrap();
350 assert_eq!(n1.task_id, "1");
351 assert_eq!(n2.task_id, "1");
352 }
353
354 #[test]
355 fn split_chunks_multibyte_at_boundary_does_not_panic() {
356 let content = format!("{}😀", "x".repeat(97)); let chunks = split_utf8(&content, 100);
360 assert_eq!(chunks.join(""), content);
361 for c in &chunks {
362 assert!(std::str::from_utf8(c.as_bytes()).is_ok());
363 }
364 }
365
366 #[test]
367 fn split_chunks_only_emojis() {
368 let content = "😀".repeat(50); let chunks = split_utf8(&content, 10); assert_eq!(chunks.join(""), content);
372 for c in &chunks {
373 assert!(std::str::from_utf8(c.as_bytes()).is_ok());
374 }
375 }
376
377 #[test]
378 fn push_notification_no_subscribers_does_not_panic() {
379 let transport = Transport::new();
380 transport.push_notification(TaskNotification {
382 task_id: "1".to_string(),
383 title: "Task".to_string(),
384 status: "done".to_string(),
385 agent: "claude".to_string(),
386 duration_seconds: 0.0,
387 summary: "Done".to_string(),
388 repo: None,
389 notify_target: None,
390 pr_number: None,
391 });
392 }
393
394 #[tokio::test]
395 async fn bind_and_route_to_session() {
396 let transport = Transport::new();
397 transport
398 .bind(
399 "owner/repo",
400 "42",
401 "orch-myproject-42",
402 "telegram",
403 "12345",
404 None,
405 )
406 .await;
407
408 let msg = IncomingMessage {
409 channel: "telegram".to_string(),
410 id: "msg1".to_string(),
411 thread_id: "12345".to_string(),
412 author: "user".to_string(),
413 body: "hello".to_string(),
414 timestamp: chrono::Utc::now(),
415 metadata: serde_json::json!({}),
416 topic_id: None,
417 };
418
419 match transport.route(&msg).await {
420 MessageRoute::TaskSession { session_key } => {
421 assert_eq!(session_key, "owner/repo:42");
422 }
423 _ => panic!("expected TaskSession"),
424 }
425 }
426
427 #[tokio::test]
428 async fn route_command() {
429 let transport = Transport::new();
430
431 let msg = IncomingMessage {
432 channel: "telegram".to_string(),
433 id: "msg1".to_string(),
434 thread_id: "99".to_string(),
435 author: "user".to_string(),
436 body: "/status".to_string(),
437 timestamp: chrono::Utc::now(),
438 metadata: serde_json::json!({}),
439 topic_id: None,
440 };
441
442 match transport.route(&msg).await {
443 MessageRoute::Command { raw } => assert_eq!(raw, "/status"),
444 _ => panic!("expected Command"),
445 }
446 }
447
448 #[test]
451 fn session_key_external_is_prefixed_with_repo() {
452 assert_eq!(session_key("owner/repo", "42"), "owner/repo:42");
453 }
454
455 #[test]
456 fn session_key_internal_is_unchanged() {
457 assert_eq!(session_key("owner/repo", "internal:99"), "internal:99");
458 }
459
460 #[test]
461 fn session_key_same_id_different_repos_are_unique() {
462 let k1 = session_key("owner/repo-a", "42");
463 let k2 = session_key("owner/repo-b", "42");
464 assert_ne!(k1, k2);
465 assert_eq!(k1, "owner/repo-a:42");
466 assert_eq!(k2, "owner/repo-b:42");
467 }
468
469 #[tokio::test]
473 async fn same_external_id_different_repos_do_not_collide() {
474 let transport = Transport::new();
475
476 transport
478 .bind(
479 "owner/repo-a",
480 "42",
481 "orch-repo-a-42",
482 "telegram",
483 "111",
484 None,
485 )
486 .await;
487 transport
489 .bind(
490 "owner/repo-b",
491 "42",
492 "orch-repo-b-42",
493 "telegram",
494 "222",
495 None,
496 )
497 .await;
498
499 let binding_a = transport.get_binding("owner/repo-a", "42").await.unwrap();
501 let binding_b = transport.get_binding("owner/repo-b", "42").await.unwrap();
502 assert_eq!(binding_a.tmux_session, "orch-repo-a-42");
503 assert_eq!(binding_b.tmux_session, "orch-repo-b-42");
504 assert_ne!(binding_a.tmux_session, binding_b.tmux_session);
505 }
506
507 #[tokio::test]
510 async fn push_output_isolated_across_repos_with_same_task_id() {
511 let transport = Transport::new();
512
513 transport
515 .bind("owner/repo-a", "42", "orch-a-42", "cli", "stream-a", None)
516 .await;
517 let mut rx_a = transport.subscribe("owner/repo-a", "42").await.unwrap();
518
519 transport
521 .bind("owner/repo-b", "42", "orch-b-42", "cli", "stream-b", None)
522 .await;
523 let mut rx_b = transport.subscribe("owner/repo-b", "42").await.unwrap();
524
525 transport
527 .push_output(
528 "owner/repo-a",
529 "42",
530 OutputChunk {
531 content: "hello from repo-a".to_string(),
532 is_final: false,
533 },
534 )
535 .await;
536
537 let chunk_a = tokio::time::timeout(std::time::Duration::from_millis(100), rx_a.recv())
539 .await
540 .unwrap()
541 .unwrap();
542 assert_eq!(chunk_a.content, "hello from repo-a");
543
544 let result_b =
546 tokio::time::timeout(std::time::Duration::from_millis(100), rx_b.recv()).await;
547 assert!(
548 result_b.is_err(),
549 "repo-b should not receive repo-a's output"
550 );
551 }
552
553 #[test]
556 fn conversation_key_without_topic() {
557 let key = conversation_key("telegram", "12345", None);
558 assert_eq!(key, "telegram:12345");
559 }
560
561 #[test]
562 fn conversation_key_with_topic() {
563 let key = conversation_key("telegram", "12345", Some("678"));
564 assert_eq!(key, "telegram:12345|678");
565 }
566
567 #[test]
568 fn conversation_key_empty_topic_treated_as_none() {
569 let key_empty = conversation_key("discord", "abc", Some(""));
571 let key_none = conversation_key("discord", "abc", None);
572 assert_eq!(key_empty, key_none);
573 }
574
575 #[test]
578 fn parse_key_without_topic() {
579 let (channel, thread_id, topic_id) =
580 parse_conversation_key("telegram:12345").expect("valid key");
581 assert_eq!(channel, "telegram");
582 assert_eq!(thread_id, "12345");
583 assert!(topic_id.is_none());
584 }
585
586 #[test]
587 fn parse_key_with_topic() {
588 let (channel, thread_id, topic_id) =
589 parse_conversation_key("telegram:12345|678").expect("valid key");
590 assert_eq!(channel, "telegram");
591 assert_eq!(thread_id, "12345");
592 assert_eq!(topic_id, Some("678"));
593 }
594
595 #[test]
596 fn parse_key_malformed_returns_none() {
597 assert!(parse_conversation_key("no-colon-here").is_none());
598 }
599
600 #[test]
601 fn conversation_key_round_trips() {
602 for (ch, tid, topic) in [
604 ("telegram", "111", Some("222")),
605 ("discord", "abc", None),
606 ("slack", "x", Some("y")),
607 ] {
608 let key = conversation_key(ch, tid, topic);
609 let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
610 assert_eq!(ch2, ch);
611 assert_eq!(tid2, tid);
612 assert_eq!(topic2, topic);
613 }
614 }
615
616 #[tokio::test]
621 async fn different_topics_do_not_collide() {
622 let transport = Transport::new();
623
624 transport
626 .bind(
627 "owner/repo",
628 "10",
629 "orch-proj-10",
630 "telegram",
631 "999",
632 Some("100"),
633 )
634 .await;
635 transport
637 .bind(
638 "owner/repo",
639 "20",
640 "orch-proj-20",
641 "telegram",
642 "999",
643 Some("200"),
644 )
645 .await;
646
647 let msg_topic_100 = IncomingMessage {
648 channel: "telegram".to_string(),
649 id: "m1".to_string(),
650 thread_id: "999".to_string(),
651 author: "user".to_string(),
652 body: "reply for task 10".to_string(),
653 timestamp: chrono::Utc::now(),
654 metadata: serde_json::json!({}),
655 topic_id: Some("100".to_string()),
656 };
657
658 let msg_topic_200 = IncomingMessage {
659 channel: "telegram".to_string(),
660 id: "m2".to_string(),
661 thread_id: "999".to_string(),
662 author: "user".to_string(),
663 body: "reply for task 20".to_string(),
664 timestamp: chrono::Utc::now(),
665 metadata: serde_json::json!({}),
666 topic_id: Some("200".to_string()),
667 };
668
669 match transport.route(&msg_topic_100).await {
670 MessageRoute::TaskSession { session_key } => {
671 assert_eq!(session_key, "owner/repo:10");
672 }
673 other => panic!("expected TaskSession for task 10, got {other:?}"),
674 }
675
676 match transport.route(&msg_topic_200).await {
677 MessageRoute::TaskSession { session_key } => {
678 assert_eq!(session_key, "owner/repo:20");
679 }
680 other => panic!("expected TaskSession for task 20, got {other:?}"),
681 }
682 }
683
684 #[tokio::test]
688 async fn unbind_removes_binding_and_reverse_lookup() {
689 let transport = Transport::new();
690 transport
691 .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
692 .await;
693
694 assert!(transport.get_binding("owner/repo", "99").await.is_some());
696
697 transport.unbind("owner/repo", "99").await;
699
700 assert!(transport.get_binding("owner/repo", "99").await.is_none());
702
703 let msg = IncomingMessage {
705 channel: "telegram".to_string(),
706 id: "m1".to_string(),
707 thread_id: "555".to_string(),
708 author: "user".to_string(),
709 body: "hello".to_string(),
710 timestamp: chrono::Utc::now(),
711 metadata: serde_json::json!({}),
712 topic_id: None,
713 };
714 match transport.route(&msg).await {
715 MessageRoute::NewTask => {} other => panic!("expected NewTask after unbind, got {other:?}"),
717 }
718 }
719
720 #[tokio::test]
722 async fn unbind_nonexistent_does_not_panic() {
723 let transport = Transport::new();
724 transport.unbind("owner/repo", "nonexistent").await;
726 }
727
728 #[tokio::test]
731 async fn wrong_topic_does_not_route_to_bound_task() {
732 let transport = Transport::new();
733
734 transport
736 .bind(
737 "owner/repo",
738 "5",
739 "orch-proj-5",
740 "telegram",
741 "42",
742 Some("10"),
743 )
744 .await;
745
746 let msg = IncomingMessage {
748 channel: "telegram".to_string(),
749 id: "m1".to_string(),
750 thread_id: "42".to_string(),
751 author: "user".to_string(),
752 body: "hello from a different topic".to_string(),
753 timestamp: chrono::Utc::now(),
754 metadata: serde_json::json!({}),
755 topic_id: Some("99".to_string()),
756 };
757
758 match transport.route(&msg).await {
759 MessageRoute::NewTask => {} MessageRoute::TaskSession { session_key } => {
761 panic!("should not have routed to session {session_key}")
762 }
763 other => panic!("unexpected route result: {other:?}"),
764 }
765 }
766}