1use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use crate::broker::{BrokerMessage, BrokerState};
15use crate::error::PawError;
16use crate::session::Session;
17
18#[derive(Clone, Debug)]
20pub struct TestResult {
21 pub success: bool,
22 pub output: String,
23}
24
25pub fn write_session_summary<S: std::hash::BuildHasher>(
36 state: &BrokerState,
37 session: &Session,
38 merge_order: &[String],
39 test_results: &std::collections::HashMap<String, TestResult, S>,
40 output_path: &Path,
41) -> Result<(), PawError> {
42 let now = SystemTime::now();
43 let total_duration = now
44 .duration_since(session.created_at)
45 .unwrap_or(Duration::ZERO);
46
47 let inner = state.read();
48
49 let cli_by_slug: HashMap<String, String> = session
51 .worktrees
52 .iter()
53 .map(|wt| {
54 (
55 crate::broker::messages::slugify_branch(&wt.branch),
56 wt.cli.clone(),
57 )
58 })
59 .collect();
60
61 let mut agent_ids: Vec<&String> = inner.agents.keys().collect();
62 agent_ids.sort();
63
64 let date = format_date(session.created_at);
65 let mut out = String::new();
66
67 let _ = writeln!(
68 out,
69 "# Session Summary \u{2014} {} \u{2014} {date}\n",
70 session.project_name
71 );
72
73 out.push_str("## Overview\n");
75 let _ = writeln!(out, "- **Duration:** {}", format_duration(total_duration));
76 let _ = writeln!(out, "- **Agents:** {}", agent_ids.len());
77 let merge_list = if merge_order.is_empty() {
78 "(none)".to_string()
79 } else {
80 merge_order.join(", ")
81 };
82 let _ = writeln!(out, "- **Merge order:** {merge_list}\n");
83
84 out.push_str("## Agents\n\n");
86 for agent_id in &agent_ids {
87 let record = &inner.agents[*agent_id];
88 let cli = cli_by_slug.get(*agent_id).map_or("unknown", String::as_str);
89
90 let _ = writeln!(out, "### {agent_id} ({cli})");
91 let _ = writeln!(out, "- **Status:** {}", record.status);
92
93 let (files, exports) = last_artifact_fields(&inner.message_log, agent_id);
94 let _ = writeln!(
95 out,
96 "- **Files modified:** {}",
97 format_list(files.as_deref())
98 );
99 let _ = writeln!(out, "- **Exports:** {}", format_list(exports.as_deref()));
100
101 let blocked = estimated_blocked_time(&inner.message_log, agent_id);
102 let blocked_str = if blocked.is_zero() {
103 "none".to_string()
104 } else {
105 format_duration(blocked)
106 };
107 let _ = writeln!(out, "- **Estimated blocked time:** {blocked_str}\n");
108 }
109
110 out.push_str("## Totals\n");
112 let _ = writeln!(out, "- Total agents: {}", agent_ids.len());
113 let _ = writeln!(out, "- Total time: {}", format_duration(total_duration));
114
115 if !test_results.is_empty() {
117 out.push_str("\n## Test Results\n");
118 for (branch, result) in test_results {
119 let status = if result.success {
120 "✓ PASS"
121 } else {
122 "✗ FAIL"
123 };
124 let _ = writeln!(out, "- **{branch}**: {status}");
125 if !result.output.is_empty() {
126 let _ = writeln!(out, " ```\n{}\n ```", result.output);
127 }
128 }
129 }
130
131 drop(inner);
132
133 fs::write(output_path, out).map_err(|e| {
134 PawError::SessionError(format!(
135 "failed to write session summary to {}: {e}",
136 output_path.display()
137 ))
138 })
139}
140
141pub fn write_supervisor_summary<S: std::hash::BuildHasher>(
153 state: &BrokerState,
154 session: &Session,
155 merge_order: &[String],
156 test_results: &std::collections::HashMap<String, TestResult, S>,
157 repo_root: &Path,
158) -> Result<PathBuf, PawError> {
159 let dir = repo_root.join(".git-paw").join("sessions");
160 fs::create_dir_all(&dir).map_err(|e| {
161 PawError::SessionError(format!(
162 "failed to create {} for session summary: {e}",
163 dir.display()
164 ))
165 })?;
166 let filename = format!("{}.md", filesystem_safe_utc_timestamp());
167 let path = dir.join(&filename);
168 write_session_summary(state, session, merge_order, test_results, &path)?;
169 Ok(path)
170}
171
172fn filesystem_safe_utc_timestamp() -> String {
177 use chrono::{SecondsFormat, Utc};
178 let iso = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
179 iso.replace(':', "-")
180}
181
182fn last_artifact_fields(
186 log: &[(u64, SystemTime, BrokerMessage)],
187 agent_id: &str,
188) -> (Option<Vec<String>>, Option<Vec<String>>) {
189 for (_seq, _ts, msg) in log.iter().rev() {
190 if let BrokerMessage::Artifact {
191 agent_id: id,
192 payload,
193 } = msg
194 && id == agent_id
195 {
196 return (
197 Some(payload.modified_files.clone()),
198 Some(payload.exports.clone()),
199 );
200 }
201 }
202 (None, None)
203}
204
205fn estimated_blocked_time(log: &[(u64, SystemTime, BrokerMessage)], agent_id: &str) -> Duration {
208 let mut total = Duration::ZERO;
209 let mut blocked_at: Option<SystemTime> = None;
210
211 for (_seq, ts, msg) in log {
212 if msg.agent_id() != agent_id {
213 continue;
214 }
215 match msg {
216 BrokerMessage::Blocked { .. } if blocked_at.is_none() => {
217 blocked_at = Some(*ts);
218 }
219 BrokerMessage::Status { .. } | BrokerMessage::Artifact { .. } => {
220 if let Some(start) = blocked_at.take()
221 && let Ok(gap) = ts.duration_since(start)
222 {
223 total += gap;
224 }
225 }
226 _ => {}
227 }
228 }
229 total
230}
231
232fn format_list(items: Option<&[String]>) -> String {
234 match items {
235 Some(list) if !list.is_empty() => list.join(", "),
236 _ => "(none)".to_string(),
237 }
238}
239
240fn format_duration(d: Duration) -> String {
242 let secs = d.as_secs();
243 let h = secs / 3600;
244 let m = (secs % 3600) / 60;
245 let s = secs % 60;
246 if h > 0 {
247 format!("{h}h {m}m")
248 } else if m > 0 {
249 format!("{m}m {s}s")
250 } else {
251 format!("{s}s")
252 }
253}
254
255fn format_date(time: SystemTime) -> String {
258 let secs = time
259 .duration_since(SystemTime::UNIX_EPOCH)
260 .map_or(0, |d| d.as_secs());
261
262 #[allow(clippy::cast_possible_wrap)]
264 let mut days = (secs / 86400) as i64;
265 days += 719_468;
266 let era = days.div_euclid(146_097);
267 let doe = days - era * 146_097;
268 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
269 let y = yoe + era * 400;
270 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
271 let mp = (5 * doy + 2) / 153;
272 let d = doy - (153 * mp + 2) / 5 + 1;
273 let m = if mp < 10 { mp + 3 } else { mp - 9 };
274 let y = if m <= 2 { y + 1 } else { y };
275 format!("{y:04}-{m:02}-{d:02}")
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::broker::messages::{ArtifactPayload, StatusPayload};
282 use crate::session::{SessionStatus, WorktreeEntry};
283 use std::path::PathBuf;
284 use std::time::UNIX_EPOCH;
285 use tempfile::TempDir;
286
287 fn sample_session() -> Session {
288 Session {
289 session_name: "paw-demo".to_string(),
290 repo_path: PathBuf::from("/tmp/demo"),
291 project_name: "demo".to_string(),
292 #[allow(clippy::duration_suboptimal_units)]
296 created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
297 status: SessionStatus::Active,
298 worktrees: vec![
299 WorktreeEntry {
300 branch: "feat/config".to_string(),
301 worktree_path: PathBuf::from("/tmp/demo-feat-config"),
302 cli: "claude".to_string(),
303 branch_created: true,
304 },
305 WorktreeEntry {
306 branch: "feat/errors".to_string(),
307 worktree_path: PathBuf::from("/tmp/demo-feat-errors"),
308 cli: "gemini".to_string(),
309 branch_created: true,
310 },
311 ],
312 broker_port: None,
313 broker_bind: None,
314 broker_log_path: None,
315 }
316 }
317
318 fn populate_state(state: &BrokerState, agent_id: &str, status: &str) {
319 use crate::broker::AgentRecord;
320 use std::time::Instant;
321
322 let mut inner = state.write();
323 inner.agents.insert(
324 agent_id.to_string(),
325 AgentRecord {
326 agent_id: agent_id.to_string(),
327 status: status.to_string(),
328 last_seen: Instant::now(),
329 last_message: None,
330 },
331 );
332 }
333
334 fn push_log(state: &BrokerState, seq: u64, ts: SystemTime, msg: BrokerMessage) {
335 let mut inner = state.write();
336 inner.message_log.push((seq, ts, msg));
337 }
338
339 #[test]
340 fn writes_file_at_specified_path() {
341 let tmp = TempDir::new().unwrap();
342 let path = tmp.path().join("session-summary.md");
343
344 let state = BrokerState::new(None);
345 populate_state(&state, "feat-config", "verified");
346 populate_state(&state, "feat-errors", "verified");
347
348 let session = sample_session();
349 write_session_summary(
350 &state,
351 &session,
352 &[],
353 &std::collections::HashMap::new(),
354 &path,
355 )
356 .unwrap();
357 assert!(path.exists());
358 }
359
360 #[test]
361 fn output_contains_project_name() {
362 let tmp = TempDir::new().unwrap();
363 let path = tmp.path().join("s.md");
364 let state = BrokerState::new(None);
365 let session = sample_session();
366 write_session_summary(
367 &state,
368 &session,
369 &[],
370 &std::collections::HashMap::new(),
371 &path,
372 )
373 .unwrap();
374 let content = fs::read_to_string(&path).unwrap();
375 assert!(content.contains("demo"));
376 }
377
378 #[test]
379 fn output_contains_agent_count() {
380 let tmp = TempDir::new().unwrap();
381 let path = tmp.path().join("s.md");
382
383 let state = BrokerState::new(None);
384 populate_state(&state, "a", "verified");
385 populate_state(&state, "b", "verified");
386 populate_state(&state, "c", "verified");
387
388 let session = sample_session();
389 write_session_summary(
390 &state,
391 &session,
392 &[],
393 &std::collections::HashMap::new(),
394 &path,
395 )
396 .unwrap();
397 let content = fs::read_to_string(&path).unwrap();
398 assert!(content.contains("**Agents:** 3"));
399 }
400
401 #[test]
402 fn output_lists_merge_order_in_sequence() {
403 let tmp = TempDir::new().unwrap();
404 let path = tmp.path().join("s.md");
405 let state = BrokerState::new(None);
406 let session = sample_session();
407 let merge_order = vec![
408 "feat-errors".to_string(),
409 "feat-config".to_string(),
410 "feat-detect".to_string(),
411 ];
412 write_session_summary(
413 &state,
414 &session,
415 &merge_order,
416 &std::collections::HashMap::new(),
417 &path,
418 )
419 .unwrap();
420 let content = fs::read_to_string(&path).unwrap();
421 let line = content
422 .lines()
423 .find(|l| l.contains("Merge order"))
424 .expect("merge order line");
425 let errors_pos = line.find("feat-errors").unwrap();
426 let config_pos = line.find("feat-config").unwrap();
427 let detect_pos = line.find("feat-detect").unwrap();
428 assert!(errors_pos < config_pos);
429 assert!(config_pos < detect_pos);
430 }
431
432 #[test]
433 fn agent_section_shows_none_when_no_artifact() {
434 let tmp = TempDir::new().unwrap();
435 let path = tmp.path().join("s.md");
436 let state = BrokerState::new(None);
437 populate_state(&state, "feat-config", "working");
438 let session = sample_session();
439 write_session_summary(
440 &state,
441 &session,
442 &[],
443 &std::collections::HashMap::new(),
444 &path,
445 )
446 .unwrap();
447 let content = fs::read_to_string(&path).unwrap();
448 assert!(content.contains("**Files modified:** (none)"));
449 assert!(content.contains("**Exports:** (none)"));
450 }
451
452 #[test]
453 fn agent_section_shows_modified_files_from_last_artifact() {
454 let tmp = TempDir::new().unwrap();
455 let path = tmp.path().join("s.md");
456 let state = BrokerState::new(None);
457 populate_state(&state, "feat-config", "verified");
458
459 push_log(
460 &state,
461 1,
462 SystemTime::now(),
463 BrokerMessage::Artifact {
464 agent_id: "feat-config".to_string(),
465 payload: ArtifactPayload {
466 status: "done".to_string(),
467 exports: vec!["SupervisorConfig".to_string()],
468 modified_files: vec!["src/config.rs".to_string()],
469 },
470 },
471 );
472
473 let session = sample_session();
474 write_session_summary(
475 &state,
476 &session,
477 &[],
478 &std::collections::HashMap::new(),
479 &path,
480 )
481 .unwrap();
482 let content = fs::read_to_string(&path).unwrap();
483 assert!(content.contains("src/config.rs"));
484 assert!(content.contains("SupervisorConfig"));
485 }
486
487 #[test]
488 fn last_artifact_wins_when_multiple_present() {
489 let tmp = TempDir::new().unwrap();
490 let path = tmp.path().join("s.md");
491 let state = BrokerState::new(None);
492 populate_state(&state, "feat-config", "verified");
493
494 push_log(
495 &state,
496 1,
497 SystemTime::now(),
498 BrokerMessage::Artifact {
499 agent_id: "feat-config".to_string(),
500 payload: ArtifactPayload {
501 status: "in-progress".to_string(),
502 exports: vec![],
503 modified_files: vec!["old.rs".to_string()],
504 },
505 },
506 );
507 push_log(
508 &state,
509 2,
510 SystemTime::now(),
511 BrokerMessage::Artifact {
512 agent_id: "feat-config".to_string(),
513 payload: ArtifactPayload {
514 status: "done".to_string(),
515 exports: vec![],
516 modified_files: vec!["new.rs".to_string()],
517 },
518 },
519 );
520
521 let session = sample_session();
522 write_session_summary(
523 &state,
524 &session,
525 &[],
526 &std::collections::HashMap::new(),
527 &path,
528 )
529 .unwrap();
530 let content = fs::read_to_string(&path).unwrap();
531 assert!(content.contains("new.rs"));
532 assert!(!content.contains("old.rs"));
533 }
534
535 #[test]
536 fn existing_file_is_overwritten() {
537 let tmp = TempDir::new().unwrap();
538 let path = tmp.path().join("s.md");
539 fs::write(&path, "old garbage content that should be replaced").unwrap();
540
541 let state = BrokerState::new(None);
542 let session = sample_session();
543 write_session_summary(
544 &state,
545 &session,
546 &[],
547 &std::collections::HashMap::new(),
548 &path,
549 )
550 .unwrap();
551
552 let content = fs::read_to_string(&path).unwrap();
553 assert!(!content.contains("garbage"));
554 assert!(content.contains("Session Summary"));
555 }
556
557 #[test]
558 fn write_to_invalid_path_returns_err() {
559 let state = BrokerState::new(None);
560 let session = sample_session();
561 let bad = Path::new("/nonexistent-dir-xyz/sub/session-summary.md");
562 let result = write_session_summary(
563 &state,
564 &session,
565 &[],
566 &std::collections::HashMap::new(),
567 bad,
568 );
569 assert!(result.is_err());
570 }
571
572 #[test]
573 fn unused_status_payload_compiles() {
574 let _ = StatusPayload {
577 status: "working".to_string(),
578 modified_files: vec![],
579 message: None,
580 };
581 }
582
583 #[test]
584 fn blocked_time_sums_gap_to_next_status() {
585 let state = BrokerState::new(None);
586 populate_state(&state, "feat-config", "working");
587
588 let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
589 let t1 = t0 + Duration::from_secs(30);
590
591 push_log(
592 &state,
593 1,
594 t0,
595 BrokerMessage::Blocked {
596 agent_id: "feat-config".to_string(),
597 payload: crate::broker::messages::BlockedPayload {
598 needs: "types".to_string(),
599 from: "feat-errors".to_string(),
600 },
601 },
602 );
603 push_log(
604 &state,
605 2,
606 t1,
607 BrokerMessage::Status {
608 agent_id: "feat-config".to_string(),
609 payload: StatusPayload {
610 status: "working".to_string(),
611 modified_files: vec![],
612 message: None,
613 },
614 },
615 );
616
617 let inner = state.read();
618 let blocked = estimated_blocked_time(&inner.message_log, "feat-config");
619 assert_eq!(blocked, Duration::from_secs(30));
620 }
621
622 #[test]
623 fn output_contains_totals_section() {
624 let tmp = TempDir::new().unwrap();
625 let path = tmp.path().join("s.md");
626 let state = BrokerState::new(None);
627 let session = sample_session();
628 write_session_summary(
629 &state,
630 &session,
631 &[],
632 &std::collections::HashMap::new(),
633 &path,
634 )
635 .unwrap();
636 let content = fs::read_to_string(&path).unwrap();
637 assert!(content.contains("## Totals"));
638 }
639
640 #[test]
641 fn write_supervisor_summary_creates_timestamped_file_under_sessions_dir() {
642 let tmp = TempDir::new().unwrap();
643 let repo_root = tmp.path();
644
645 let state = BrokerState::new(None);
646 populate_state(&state, "feat-config", "verified");
647 let session = sample_session();
648
649 let written = write_supervisor_summary(
650 &state,
651 &session,
652 &[],
653 &std::collections::HashMap::new(),
654 repo_root,
655 )
656 .unwrap();
657
658 assert!(
660 written.starts_with(repo_root.join(".git-paw").join("sessions")),
661 "summary written outside .git-paw/sessions/: {}",
662 written.display()
663 );
664 assert_eq!(written.extension().and_then(|s| s.to_str()), Some("md"));
665 assert!(written.exists(), "summary file does not exist on disk");
666
667 let filename = written.file_name().unwrap().to_string_lossy().to_string();
669 assert!(
670 filename.ends_with("Z.md"),
671 "expected ISO-8601 UTC suffix, got {filename}"
672 );
673 assert!(
674 !filename.contains(':'),
675 "filename must not contain colons: {filename}"
676 );
677
678 let content = fs::read_to_string(&written).unwrap();
679 assert!(content.contains("# Session Summary"));
680 assert!(content.contains("demo"));
681 }
682
683 #[test]
684 fn write_supervisor_summary_creates_sessions_dir_when_missing() {
685 let tmp = TempDir::new().unwrap();
686 let repo_root = tmp.path();
687 assert!(!repo_root.join(".git-paw").exists());
688
689 let state = BrokerState::new(None);
690 let session = sample_session();
691 let written = write_supervisor_summary(
692 &state,
693 &session,
694 &[],
695 &std::collections::HashMap::new(),
696 repo_root,
697 )
698 .unwrap();
699
700 assert!(repo_root.join(".git-paw").is_dir());
701 assert!(repo_root.join(".git-paw").join("sessions").is_dir());
702 assert!(written.exists());
703 }
704
705 #[test]
706 fn write_supervisor_summary_two_sequential_calls_produce_distinct_files() {
707 let tmp = TempDir::new().unwrap();
712 let repo_root = tmp.path();
713 let state = BrokerState::new(None);
714 let session = sample_session();
715
716 let first = write_supervisor_summary(
717 &state,
718 &session,
719 &[],
720 &std::collections::HashMap::new(),
721 repo_root,
722 )
723 .unwrap();
724 std::thread::sleep(Duration::from_secs(1));
725 let second = write_supervisor_summary(
726 &state,
727 &session,
728 &[],
729 &std::collections::HashMap::new(),
730 repo_root,
731 )
732 .unwrap();
733
734 assert_ne!(
735 first,
736 second,
737 "back-to-back supervisor runs must produce distinct summary files; both wrote to {}",
738 first.display()
739 );
740 assert!(first.exists() && second.exists());
741 }
742
743 #[test]
744 fn format_duration_examples() {
745 assert_eq!(format_duration(Duration::from_secs(45)), "45s");
746 assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
747 assert_eq!(format_duration(Duration::from_secs(3700)), "1h 1m");
748 }
749
750 #[test]
755 fn test_results_in_summary() {
756 let tmp = TempDir::new().unwrap();
757 let path = tmp.path().join("s.md");
758
759 let state = BrokerState::new(None);
760 populate_state(&state, "feat-config", "verified");
761 populate_state(&state, "feat-errors", "verified");
762 let session = sample_session();
763
764 let mut test_results: std::collections::HashMap<String, TestResult> =
765 std::collections::HashMap::new();
766 test_results.insert(
767 "feat-config".to_string(),
768 TestResult {
769 success: true,
770 output: "all 42 tests passed".to_string(),
771 },
772 );
773 test_results.insert(
774 "feat-errors".to_string(),
775 TestResult {
776 success: false,
777 output: "thread 'main' panicked: oh no".to_string(),
778 },
779 );
780
781 write_session_summary(&state, &session, &[], &test_results, &path).unwrap();
782 let content = fs::read_to_string(&path).unwrap();
783
784 assert!(
786 content.contains("## Test Results"),
787 "summary should include Test Results section; got:\n{content}"
788 );
789
790 assert!(
792 content.contains("**feat-config**: \u{2713} PASS"),
793 "passing branch must render with check mark; got:\n{content}"
794 );
795 assert!(
796 content.contains("**feat-errors**: \u{2717} FAIL"),
797 "failing branch must render with cross mark; got:\n{content}"
798 );
799
800 assert!(
802 content.contains("all 42 tests passed"),
803 "passing output must appear in summary; got:\n{content}"
804 );
805 assert!(
806 content.contains("thread 'main' panicked: oh no"),
807 "failing output must appear in summary; got:\n{content}"
808 );
809 }
810
811 #[test]
814 fn test_results_section_omitted_when_empty() {
815 let tmp = TempDir::new().unwrap();
816 let path = tmp.path().join("s.md");
817 let state = BrokerState::new(None);
818 let session = sample_session();
819 write_session_summary(
820 &state,
821 &session,
822 &[],
823 &std::collections::HashMap::new(),
824 &path,
825 )
826 .unwrap();
827 let content = fs::read_to_string(&path).unwrap();
828 assert!(
829 !content.contains("## Test Results"),
830 "Test Results section must be absent when no results provided; got:\n{content}"
831 );
832 }
833}