1use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use tracing::{info, warn};
8
9use super::{completion, config, hierarchy, inbox, merge, team_config_path, telegram};
10
11const INBOX_BODY_PREVIEW_CHARS: usize = 140;
12
13fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
16 if matches!(member_name, "human" | "daemon") {
18 return member_name.to_string();
19 }
20 let config_path = team_config_path(project_root);
21 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
22 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
23 if let Some(m) = members.iter().find(|m| m.name == member_name) {
24 return m.role_name.clone();
25 }
26 }
27 }
28 member_name.to_string()
30}
31
32pub(crate) fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
39 if matches!(member_name, "human" | "daemon") {
40 return Ok(member_name.to_string());
41 }
42
43 let config_path = team_config_path(project_root);
44 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
45 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
46 if let Some(member) = members.iter().find(|m| m.name == member_name) {
47 return Ok(member.name.clone());
48 }
49
50 let matches: Vec<String> = members
51 .iter()
52 .filter(|m| m.role_name == member_name)
53 .map(|m| m.name.clone())
54 .collect();
55
56 return match matches.len() {
57 0 => Ok(member_name.to_string()),
58 1 => Ok(matches[0].clone()),
59 _ => bail!(
60 "'{member_name}' matches multiple members: {}. Use the explicit member name.",
61 matches.join(", ")
62 ),
63 };
64 }
65 }
66
67 Ok(member_name.to_string())
68}
69
70pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
76 let from = detect_sender().unwrap_or_else(|| "human".to_string());
77 let recipient = resolve_member_name(project_root, role)?;
78
79 let config_path = team_config_path(project_root);
81 if config_path.exists() {
82 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
83 let from_role = resolve_role_name(project_root, &from);
84 let to_role = resolve_role_name(project_root, &recipient);
85 if !team_config.can_talk(&from_role, &to_role) {
86 bail!(
87 "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
88 Check talks_to in team.yaml."
89 );
90 }
91 }
92 }
93
94 let root = inbox::inboxes_root(project_root);
95 let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
96 let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
97 if let Err(error) = completion::ingest_completion_message(project_root, msg) {
98 warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
99 }
100 info!(to = %recipient, id = %id, "message delivered to inbox");
101 Ok(())
102}
103
104pub(crate) fn detect_sender() -> Option<String> {
107 let pane_id = std::env::var("TMUX_PANE").ok()?;
108 let output = std::process::Command::new("tmux")
109 .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
110 .output()
111 .ok()?;
112 if output.status.success() {
113 let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
114 if !role.is_empty() { Some(role) } else { None }
115 } else {
116 None
117 }
118}
119
120pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
122 let from = detect_sender().unwrap_or_else(|| "human".to_string());
123 let recipient = resolve_member_name(project_root, engineer)?;
124
125 let config_path = team_config_path(project_root);
126 if config_path.exists() {
127 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
128 let from_role = resolve_role_name(project_root, &from);
129 let to_role = resolve_role_name(project_root, &recipient);
130 if !team_config.can_talk(&from_role, &to_role) {
131 bail!(
132 "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
133 Check talks_to in team.yaml."
134 );
135 }
136 }
137 }
138
139 let root = inbox::inboxes_root(project_root);
140 let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
141 let id = inbox::deliver_to_inbox(&root, &msg)?;
142 info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
143 Ok(id)
144}
145
146pub fn list_inbox(project_root: &Path, member: &str, limit: Option<usize>) -> Result<()> {
148 let member = resolve_member_name(project_root, member)?;
149 let root = inbox::inboxes_root(project_root);
150 let messages = inbox::all_messages(&root, &member)?;
151 print!("{}", format_inbox_listing(&member, &messages, limit));
152 Ok(())
153}
154
155fn format_inbox_listing(
156 member: &str,
157 messages: &[(inbox::InboxMessage, bool)],
158 limit: Option<usize>,
159) -> String {
160 if messages.is_empty() {
161 return format!("No messages for {member}.\n");
162 }
163
164 let start = match limit {
165 Some(0) => messages.len(),
166 Some(n) => messages.len().saturating_sub(n),
167 None => 0,
168 };
169 let shown = &messages[start..];
170 let refs = inbox_message_refs(messages);
171 let shown_refs = &refs[start..];
172
173 let mut out = String::new();
174 if shown.len() < messages.len() {
175 out.push_str(&format!(
176 "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
177 shown.len(),
178 messages.len()
179 ));
180 }
181 out.push_str(&format!(
182 "{:<10} {:<12} {:<12} {:<14} BODY\n",
183 "STATUS", "FROM", "TYPE", "REF"
184 ));
185 out.push_str(&format!("{}\n", "-".repeat(96)));
186 for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
187 let status = if *delivered { "delivered" } else { "pending" };
188 let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
189 out.push_str(&format!(
190 "{:<10} {:<12} {:<12} {:<14} {}\n",
191 status,
192 msg.from,
193 format!("{:?}", msg.msg_type).to_lowercase(),
194 msg_ref,
195 body_short,
196 ));
197 }
198 out
199}
200
201fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
202 let mut totals = HashMap::new();
203 for (msg, _) in messages {
204 *totals.entry(msg.timestamp).or_insert(0usize) += 1;
205 }
206
207 let mut seen = HashMap::new();
208 messages
209 .iter()
210 .map(|(msg, _)| {
211 let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
212 *ordinal += 1;
213 if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
214 msg.timestamp.to_string()
215 } else {
216 format!("{}-{}", msg.timestamp, ordinal)
217 }
218 })
219 .collect()
220}
221
222fn resolve_inbox_message_indices(
223 messages: &[(inbox::InboxMessage, bool)],
224 selector: &str,
225) -> Vec<usize> {
226 let refs = inbox_message_refs(messages);
227 messages
228 .iter()
229 .enumerate()
230 .filter_map(|(idx, (msg, _))| {
231 if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
232 Some(idx)
233 } else {
234 None
235 }
236 })
237 .collect()
238}
239
240fn truncate_chars(input: &str, max_chars: usize) -> String {
241 if input.chars().count() <= max_chars {
242 return input.to_string();
243 }
244 let mut truncated: String = input.chars().take(max_chars).collect();
245 truncated.push_str("...");
246 truncated
247}
248
249pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
251 let member = resolve_member_name(project_root, member)?;
252 let root = inbox::inboxes_root(project_root);
253 let messages = inbox::all_messages(&root, &member)?;
254
255 let matching = resolve_inbox_message_indices(&messages, id);
256
257 match matching.len() {
258 0 => bail!("no message matching '{id}' in {member}'s inbox"),
259 1 => {
260 let (msg, delivered) = &messages[matching[0]];
261 let status = if *delivered { "delivered" } else { "pending" };
262 println!("ID: {}", msg.id);
263 println!("From: {}", msg.from);
264 println!("To: {}", msg.to);
265 println!("Type: {:?}", msg.msg_type);
266 println!("Status: {status}");
267 println!("Time: {}", msg.timestamp);
268 println!();
269 println!("{}", msg.body);
270 }
271 n => {
272 bail!(
273 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
274 );
275 }
276 }
277
278 Ok(())
279}
280
281pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
283 let member = resolve_member_name(project_root, member)?;
284 let root = inbox::inboxes_root(project_root);
285 let messages = inbox::all_messages(&root, &member)?;
286 let matching = resolve_inbox_message_indices(&messages, id);
287 let resolved_id = match matching.len() {
288 0 => bail!("no message matching '{id}' in {member}'s inbox"),
289 1 => messages[matching[0]].0.id.clone(),
290 n => bail!(
291 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
292 ),
293 };
294 inbox::mark_delivered(&root, &member, &resolved_id)?;
295 info!(member, id = %resolved_id, "message acknowledged");
296 Ok(())
297}
298
299pub fn purge_inbox(
301 project_root: &Path,
302 member: Option<&str>,
303 all_roles: bool,
304 before: Option<u64>,
305 purge_all: bool,
306) -> Result<inbox::InboxPurgeSummary> {
307 if !purge_all && before.is_none() {
308 bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
309 }
310
311 let root = inbox::inboxes_root(project_root);
312 if all_roles {
313 return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
314 }
315
316 let member = member.context("member is required unless using `--all-roles`")?;
317 let member = resolve_member_name(project_root, member)?;
318 let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
319 Ok(inbox::InboxPurgeSummary { roles: 1, messages })
320}
321
322pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
324 let engineer = resolve_member_name(project_root, engineer)?;
325 match merge::merge_engineer_branch(project_root, &engineer)? {
326 merge::MergeOutcome::Success => Ok(()),
327 merge::MergeOutcome::RebaseConflict(stderr) => {
328 bail!("merge blocked by rebase conflict: {stderr}")
329 }
330 merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
331 }
332}
333
334pub fn setup_telegram(project_root: &Path) -> Result<()> {
336 telegram::setup_telegram(project_root)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::team::{board, inbox, team_config_dir, team_config_path};
343 use serial_test::serial;
344
345 struct EnvVarGuard {
346 key: &'static str,
347 original: Option<String>,
348 }
349
350 impl EnvVarGuard {
351 fn unset(key: &'static str) -> Self {
352 let original = std::env::var(key).ok();
353 unsafe {
354 std::env::remove_var(key);
355 }
356 Self { key, original }
357 }
358 }
359
360 impl Drop for EnvVarGuard {
361 fn drop(&mut self) {
362 match self.original.as_deref() {
363 Some(value) => unsafe {
364 std::env::set_var(self.key, value);
365 },
366 None => unsafe {
367 std::env::remove_var(self.key);
368 },
369 }
370 }
371 }
372
373 fn write_team_config(project_root: &Path, yaml: &str) {
374 std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
375 std::fs::write(team_config_path(project_root), yaml).unwrap();
376 }
377
378 #[test]
379 fn send_message_delivers_to_inbox() {
380 let tmp = tempfile::tempdir().unwrap();
381 send_message(tmp.path(), "architect", "hello").unwrap();
382
383 let root = inbox::inboxes_root(tmp.path());
384 let pending = inbox::pending_messages(&root, "architect").unwrap();
385 assert_eq!(pending.len(), 1);
386 let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
389 assert_eq!(pending[0].from, expected_from);
390 assert_eq!(pending[0].to, "architect");
391 assert_eq!(pending[0].body, "hello");
392 }
393
394 #[test]
395 fn send_message_ingests_completion_packet_into_workflow_metadata() {
396 let tmp = tempfile::tempdir().unwrap();
397 let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
398 std::fs::create_dir_all(&tasks_dir).unwrap();
399 let task_path = tasks_dir.join("027-completion-packets.md");
400 std::fs::write(
401 &task_path,
402 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
403 )
404 .unwrap();
405
406 send_message(
407 tmp.path(),
408 "architect",
409 r#"Done.
410
411## Completion Packet
412
413```json
414{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
415```"#,
416 )
417 .unwrap();
418
419 let metadata = board::read_workflow_metadata(&task_path).unwrap();
420 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
421 assert_eq!(
422 metadata.worktree_path.as_deref(),
423 Some(".batty/worktrees/eng-1-4")
424 );
425 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
426 assert_eq!(metadata.tests_run, Some(true));
427 assert_eq!(metadata.tests_passed, Some(true));
428 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
429 assert!(metadata.review_blockers.is_empty());
430 }
431
432 #[test]
433 fn assign_task_delivers_to_inbox() {
434 let tmp = tempfile::tempdir().unwrap();
435 let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
436 assert!(!id.is_empty());
437
438 let root = inbox::inboxes_root(tmp.path());
439 let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
440 assert_eq!(pending.len(), 1);
441 let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
442 assert_eq!(pending[0].from, expected_from);
443 assert_eq!(pending[0].to, "eng-1-1");
444 assert_eq!(pending[0].body, "fix bug");
445 assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
446 }
447
448 #[test]
449 fn resolve_member_name_maps_unique_role_alias_to_instance() {
450 let tmp = tempfile::tempdir().unwrap();
451 write_team_config(
452 tmp.path(),
453 r#"
454name: test
455roles:
456 - name: human
457 role_type: user
458 talks_to:
459 - sam-designer
460 - name: jordan-pm
461 role_type: manager
462 agent: claude
463 instances: 1
464 - name: sam-designer
465 role_type: engineer
466 agent: codex
467 instances: 1
468 talks_to:
469 - jordan-pm
470"#,
471 );
472
473 assert_eq!(
474 resolve_member_name(tmp.path(), "sam-designer").unwrap(),
475 "sam-designer-1-1"
476 );
477 assert_eq!(
478 resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
479 "sam-designer-1-1"
480 );
481 }
482
483 #[test]
484 fn resolve_member_name_rejects_ambiguous_role_alias() {
485 let tmp = tempfile::tempdir().unwrap();
486 write_team_config(
487 tmp.path(),
488 r#"
489name: test
490roles:
491 - name: jordan-pm
492 role_type: manager
493 agent: claude
494 instances: 2
495 - name: sam-designer
496 role_type: engineer
497 agent: codex
498 instances: 1
499 talks_to:
500 - jordan-pm
501"#,
502 );
503
504 let error = resolve_member_name(tmp.path(), "sam-designer")
505 .unwrap_err()
506 .to_string();
507 assert!(error.contains("matches multiple members"));
508 assert!(error.contains("sam-designer-1-1"));
509 assert!(error.contains("sam-designer-2-1"));
510 }
511
512 #[test]
513 #[serial]
514 fn send_message_delivers_to_unique_instance_inbox() {
515 let tmp = tempfile::tempdir().unwrap();
516 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
517 write_team_config(
518 tmp.path(),
519 r#"
520name: test
521roles:
522 - name: human
523 role_type: user
524 talks_to:
525 - sam-designer
526 - name: jordan-pm
527 role_type: manager
528 agent: claude
529 instances: 1
530 - name: sam-designer
531 role_type: engineer
532 agent: codex
533 instances: 1
534 talks_to:
535 - jordan-pm
536"#,
537 );
538
539 let original_tmux_pane = std::env::var_os("TMUX_PANE");
540 unsafe {
541 std::env::remove_var("TMUX_PANE");
542 }
543 let send_result = send_message(tmp.path(), "sam-designer", "hello");
544 match original_tmux_pane {
545 Some(value) => unsafe {
546 std::env::set_var("TMUX_PANE", value);
547 },
548 None => unsafe {
549 std::env::remove_var("TMUX_PANE");
550 },
551 }
552 send_result.unwrap();
553
554 let root = inbox::inboxes_root(tmp.path());
555 assert!(
556 inbox::pending_messages(&root, "sam-designer")
557 .unwrap()
558 .is_empty()
559 );
560
561 let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
562 assert_eq!(pending.len(), 1);
563 assert_eq!(pending[0].to, "sam-designer-1-1");
564 assert_eq!(pending[0].body, "hello");
565 }
566
567 #[test]
568 fn truncate_chars_handles_unicode_boundaries() {
569 let body = "Task #109 confirmed complete on main. I'm available for next assignment.";
570 let truncated = truncate_chars(body, 40);
571 assert!(truncated.ends_with("..."));
572 assert!(truncated.starts_with("Task #109 confirmed complete on main."));
573 }
574
575 #[test]
576 fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
577 let messages: Vec<_> = (0..25)
578 .map(|idx| {
579 (
580 inbox::InboxMessage {
581 id: format!("msg{idx:05}"),
582 from: "architect".to_string(),
583 to: "black-lead".to_string(),
584 body: format!("message {idx}"),
585 msg_type: inbox::MessageType::Send,
586 timestamp: idx,
587 },
588 true,
589 )
590 })
591 .collect();
592
593 let rendered = format_inbox_listing("black-lead", &messages, Some(20));
594 assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
595 assert!(!rendered.contains("message 0"));
596 assert!(rendered.contains("message 5"));
597 assert!(rendered.contains("message 24"));
598 assert!(!rendered.contains("msg00005"));
599 assert!(!rendered.contains("msg00024"));
600 }
601
602 #[test]
603 fn format_inbox_listing_allows_showing_all_messages() {
604 let messages: Vec<_> = (0..3)
605 .map(|idx| {
606 (
607 inbox::InboxMessage {
608 id: format!("msg{idx:05}"),
609 from: "architect".to_string(),
610 to: "black-lead".to_string(),
611 body: format!("message {idx}"),
612 msg_type: inbox::MessageType::Send,
613 timestamp: idx,
614 },
615 idx % 2 == 0,
616 )
617 })
618 .collect();
619
620 let rendered = format_inbox_listing("black-lead", &messages, None);
621 assert!(!rendered.contains("Showing 20"));
622 assert!(rendered.contains("REF"));
623 assert!(rendered.contains("BODY"));
624 assert!(rendered.contains("message 0"));
625 assert!(rendered.contains("message 1"));
626 assert!(rendered.contains("message 2"));
627 assert!(!rendered.contains("msg00000"));
628 assert!(!rendered.contains("msg00001"));
629 assert!(!rendered.contains("msg00002"));
630 }
631
632 #[test]
633 fn format_inbox_listing_hides_internal_message_ids() {
634 let messages = vec![(
635 inbox::InboxMessage {
636 id: "1773930387654321.M123456P7890Q42.example".to_string(),
637 from: "architect".to_string(),
638 to: "black-lead".to_string(),
639 body: "message body".to_string(),
640 msg_type: inbox::MessageType::Send,
641 timestamp: 1_773_930_725,
642 },
643 true,
644 )];
645
646 let rendered = format_inbox_listing("black-lead", &messages, None);
647 assert!(rendered.contains("1773930725"));
648 assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
649 assert!(!rendered.contains("ID BODY"));
650 }
651
652 #[test]
653 fn inbox_message_refs_use_timestamp_when_unique() {
654 let messages = vec![(
655 inbox::InboxMessage {
656 id: "msg-1".to_string(),
657 from: "architect".to_string(),
658 to: "black-lead".to_string(),
659 body: "message body".to_string(),
660 msg_type: inbox::MessageType::Send,
661 timestamp: 1_773_930_725,
662 },
663 true,
664 )];
665
666 let refs = inbox_message_refs(&messages);
667 assert_eq!(refs, vec!["1773930725".to_string()]);
668 assert_eq!(
669 resolve_inbox_message_indices(&messages, "1773930725"),
670 vec![0]
671 );
672 }
673
674 #[test]
675 fn inbox_message_refs_suffix_same_second_collisions() {
676 let messages = vec![
677 (
678 inbox::InboxMessage {
679 id: "msg-1".to_string(),
680 from: "architect".to_string(),
681 to: "black-lead".to_string(),
682 body: "first".to_string(),
683 msg_type: inbox::MessageType::Send,
684 timestamp: 1_773_930_725,
685 },
686 true,
687 ),
688 (
689 inbox::InboxMessage {
690 id: "msg-2".to_string(),
691 from: "architect".to_string(),
692 to: "black-lead".to_string(),
693 body: "second".to_string(),
694 msg_type: inbox::MessageType::Send,
695 timestamp: 1_773_930_725,
696 },
697 true,
698 ),
699 ];
700
701 let refs = inbox_message_refs(&messages);
702 assert_eq!(
703 refs,
704 vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
705 );
706 assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
707 assert_eq!(
708 resolve_inbox_message_indices(&messages, "1773930725-1"),
709 vec![0]
710 );
711 assert_eq!(
712 resolve_inbox_message_indices(&messages, "1773930725-2"),
713 vec![1]
714 );
715 }
716}