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