1use std::fs::{self, OpenOptions};
4use std::io::{BufWriter, Write};
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use super::DEFAULT_EVENT_LOG_MAX_BYTES;
12use super::merge::MergeMode;
13
14pub struct MergeConfidenceInfo<'a> {
16 pub engineer: &'a str,
17 pub task: &'a str,
18 pub confidence: f64,
19 pub files_changed: usize,
20 pub lines_changed: usize,
21 pub has_migrations: bool,
22 pub has_config_changes: bool,
23 pub rename_count: usize,
24}
25
26pub struct AutoMergeDecisionInfo<'a> {
27 pub engineer: &'a str,
28 pub task: &'a str,
29 pub action_type: &'a str,
30 pub confidence: f64,
31 pub reason: &'a str,
32 pub details: &'a str,
33}
34
35pub struct VerificationPhaseChangeInfo<'a> {
36 pub engineer: &'a str,
37 pub task: &'a str,
38 pub from_phase: &'a str,
39 pub to_phase: &'a str,
40 pub iteration: u32,
41}
42
43pub struct QualityMetricsInfo<'a> {
44 pub backend: &'a str,
45 pub role: &'a str,
46 pub task: &'a str,
47 pub narration_ratio: f64,
48 pub commit_frequency: f64,
49 pub first_pass_test_rate: f64,
50 pub retry_rate: f64,
51 pub time_to_completion_secs: u64,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55
56pub struct TeamEvent {
57 pub event: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub action_type: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub version: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub git_ref: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub role: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub task: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub recipient: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub from: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub to: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub restart: Option<bool>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub restart_count: Option<u32>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub load: Option<f64>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub working_members: Option<u32>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub total_members: Option<u32>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub session_running: Option<bool>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub reason: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub step: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub error: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub details: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub uptime_secs: Option<u64>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub session_size_bytes: Option<u64>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub output_bytes: Option<u64>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub filename: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub content_hash: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub backend: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub success: Option<bool>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub merge_mode: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub narration_ratio: Option<f64>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub commit_frequency: Option<f64>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub first_pass_test_rate: Option<f64>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub retry_rate: Option<f64>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub time_to_completion_secs: Option<u64>,
120 pub ts: u64,
121}
122
123impl TeamEvent {
124 fn now() -> u64 {
125 SystemTime::now()
126 .duration_since(UNIX_EPOCH)
127 .unwrap_or_default()
128 .as_secs()
129 }
130
131 fn base(event: &str) -> Self {
132 Self {
133 event: event.into(),
134 action_type: None,
135 version: None,
136 git_ref: None,
137 role: None,
138 task: None,
139 recipient: None,
140 from: None,
141 to: None,
142 restart: None,
143 restart_count: None,
144 load: None,
145 working_members: None,
146 total_members: None,
147 session_running: None,
148 reason: None,
149 step: None,
150 error: None,
151 details: None,
152 uptime_secs: None,
153 session_size_bytes: None,
154 output_bytes: None,
155 filename: None,
156 content_hash: None,
157 backend: None,
158 success: None,
159 merge_mode: None,
160 narration_ratio: None,
161 commit_frequency: None,
162 first_pass_test_rate: None,
163 retry_rate: None,
164 time_to_completion_secs: None,
165 ts: Self::now(),
166 }
167 }
168
169 pub fn daemon_started() -> Self {
170 Self {
171 action_type: Some("session_lifecycle".into()),
172 session_running: Some(true),
173 ..Self::base("daemon_started")
174 }
175 }
176
177 pub fn daemon_reloading() -> Self {
178 Self {
179 action_type: Some("session_lifecycle".into()),
180 session_running: Some(true),
181 ..Self::base("daemon_reloading")
182 }
183 }
184
185 pub fn daemon_reloaded() -> Self {
186 Self {
187 action_type: Some("session_lifecycle".into()),
188 session_running: Some(true),
189 ..Self::base("daemon_reloaded")
190 }
191 }
192
193 pub fn daemon_stopped() -> Self {
194 Self {
195 action_type: Some("session_lifecycle".into()),
196 session_running: Some(false),
197 ..Self::base("daemon_stopped")
198 }
199 }
200
201 pub fn daemon_stopped_with_reason(reason: &str, uptime_secs: u64) -> Self {
202 Self {
203 action_type: Some("session_lifecycle".into()),
204 session_running: Some(false),
205 reason: Some(reason.into()),
206 uptime_secs: Some(uptime_secs),
207 ..Self::base("daemon_stopped")
208 }
209 }
210
211 pub fn daemon_heartbeat(uptime_secs: u64) -> Self {
212 Self {
213 uptime_secs: Some(uptime_secs),
214 ..Self::base("daemon_heartbeat")
215 }
216 }
217
218 pub fn context_exhausted(
219 role: &str,
220 task: Option<u32>,
221 session_size_bytes: Option<u64>,
222 ) -> Self {
223 Self {
224 role: Some(role.into()),
225 task: task.map(|task_id| task_id.to_string()),
226 session_size_bytes,
227 ..Self::base("context_exhausted")
228 }
229 }
230
231 pub fn loop_step_error(step: &str, error: &str) -> Self {
232 Self {
233 step: Some(step.into()),
234 error: Some(error.into()),
235 ..Self::base("loop_step_error")
236 }
237 }
238
239 pub fn daemon_panic(reason: &str) -> Self {
240 Self {
241 reason: Some(reason.into()),
242 ..Self::base("daemon_panic")
243 }
244 }
245
246 pub fn task_assigned(role: &str, task: &str) -> Self {
247 Self {
248 role: Some(role.into()),
249 task: Some(task.into()),
250 ..Self::base("task_assigned")
251 }
252 }
253
254 pub fn dispatch_fallback_used(role: &str, task: &str, recipient: &str, reason: &str) -> Self {
255 Self {
256 role: Some(role.into()),
257 task: Some(task.into()),
258 recipient: Some(recipient.into()),
259 reason: Some(reason.into()),
260 ..Self::base("dispatch_fallback_used")
261 }
262 }
263
264 pub fn dispatch_overlap_prevented(task_id: u32, blocked_by: &[u32], details: &str) -> Self {
265 Self {
266 task: Some(task_id.to_string()),
267 reason: Some(
268 blocked_by
269 .iter()
270 .map(|task_id| task_id.to_string())
271 .collect::<Vec<_>>()
272 .join(","),
273 ),
274 details: Some(details.into()),
275 ..Self::base("dispatch_overlap_prevented")
276 }
277 }
278
279 pub fn dispatch_overlap_skipped(
280 candidate_task_id: u32,
281 conflicting_task_id: &str,
282 conflicting_files: &[String],
283 ) -> Self {
284 Self {
285 task: Some(candidate_task_id.to_string()),
286 reason: Some(conflicting_task_id.to_string()),
287 details: Some(conflicting_files.join(",")),
288 ..Self::base("dispatch_overlap_skipped")
289 }
290 }
291
292 pub fn cwd_corrected(role: &str, path: &str) -> Self {
293 Self {
294 role: Some(role.into()),
295 reason: Some(path.into()),
296 ..Self::base("cwd_corrected")
297 }
298 }
299
300 pub fn review_nudge_sent(role: &str, task: &str) -> Self {
301 Self {
302 role: Some(role.into()),
303 task: Some(task.into()),
304 ..Self::base("review_nudge_sent")
305 }
306 }
307
308 pub fn review_escalated(task: &str, reason: &str) -> Self {
309 Self {
310 task: Some(task.into()),
311 reason: Some(reason.into()),
312 ..Self::base("review_escalated")
313 }
314 }
315
316 pub fn state_reconciliation(role: Option<&str>, task: Option<&str>, correction: &str) -> Self {
317 Self {
318 role: role.map(str::to_string),
319 task: task.map(str::to_string),
320 reason: Some(correction.into()),
321 ..Self::base("state_reconciliation")
322 }
323 }
324
325 pub fn task_escalated(role: &str, task: &str, reason: Option<&str>) -> Self {
326 Self {
327 role: Some(role.into()),
328 task: Some(task.into()),
329 reason: reason.map(|r| r.into()),
330 ..Self::base("task_escalated")
331 }
332 }
333
334 pub fn task_stale(role: &str, task: &str, reason: &str) -> Self {
335 Self {
336 role: Some(role.into()),
337 task: Some(task.into()),
338 reason: Some(reason.into()),
339 ..Self::base("task_stale")
340 }
341 }
342
343 pub fn task_aged(task: &str, reason: &str) -> Self {
344 Self {
345 task: Some(task.into()),
346 reason: Some(reason.into()),
347 ..Self::base("task_aged")
348 }
349 }
350
351 pub fn review_stale(task: &str, reason: &str) -> Self {
352 Self {
353 task: Some(task.into()),
354 reason: Some(reason.into()),
355 ..Self::base("review_stale")
356 }
357 }
358
359 pub fn task_unblocked(role: &str, task: &str) -> Self {
360 Self {
361 role: Some(role.into()),
362 task: Some(task.into()),
363 ..Self::base("task_unblocked")
364 }
365 }
366
367 pub fn task_claim_created(role: &str, task: &str, ttl_secs: u64, expires_at: &str) -> Self {
368 Self {
369 role: Some(role.into()),
370 task: Some(task.into()),
371 reason: Some(format!("ttl_secs={ttl_secs} expires_at={expires_at}")),
372 ..Self::base("task_claim_created")
373 }
374 }
375
376 pub fn task_claim_progress(role: &str, task: &str, progress_type: &str) -> Self {
377 Self {
378 role: Some(role.into()),
379 task: Some(task.into()),
380 reason: Some(progress_type.into()),
381 ..Self::base("task_claim_progress")
382 }
383 }
384
385 pub fn task_claim_warning(role: &str, task: &str, expires_in_secs: u64) -> Self {
386 Self {
387 role: Some(role.into()),
388 task: Some(task.into()),
389 reason: Some(format!("expires_in_secs={expires_in_secs}")),
390 ..Self::base("task_claim_warning")
391 }
392 }
393
394 pub fn task_claim_expired(
395 role: &str,
396 task: &str,
397 reclaimed: bool,
398 time_held_secs: Option<u64>,
399 ) -> Self {
400 let mut reason = format!("reclaimed={reclaimed}");
401 if let Some(time_held_secs) = time_held_secs {
402 reason.push_str(&format!(" time_held_secs={time_held_secs}"));
403 }
404 Self {
405 role: Some(role.into()),
406 task: Some(task.into()),
407 reason: Some(reason),
408 ..Self::base("task_claim_expired")
409 }
410 }
411
412 pub fn task_claim_extended(role: &str, task: &str, new_expires_at: &str) -> Self {
413 Self {
414 role: Some(role.into()),
415 task: Some(task.into()),
416 reason: Some(format!("new_expires_at={new_expires_at}")),
417 ..Self::base("task_claim_extended")
418 }
419 }
420
421 pub fn scope_fence_violation(role: &str, task_id: u32, details: &str) -> Self {
422 Self {
423 role: Some(role.into()),
424 task: Some(task_id.to_string()),
425 reason: Some(details.into()),
426 ..Self::base("scope_fence_violation")
427 }
428 }
429
430 pub fn board_task_archived(task: &str, role: Option<&str>) -> Self {
431 Self {
432 role: role.map(str::to_string),
433 task: Some(task.into()),
434 ..Self::base("board_task_archived")
435 }
436 }
437
438 pub fn auto_doctor_action(
439 action_type: &str,
440 task_id: Option<u32>,
441 engineer: Option<&str>,
442 details: &str,
443 ) -> Self {
444 Self {
445 action_type: Some(action_type.into()),
446 task: task_id.map(|task_id| task_id.to_string()),
447 role: engineer.map(str::to_string),
448 details: Some(details.into()),
449 ..Self::base("auto_doctor_action")
450 }
451 }
452
453 pub fn performance_regression(task: &str, reason: &str) -> Self {
454 Self {
455 task: Some(task.into()),
456 reason: Some(reason.into()),
457 ..Self::base("performance_regression")
458 }
459 }
460
461 pub fn task_completed(role: &str, task: Option<&str>) -> Self {
462 Self {
463 role: Some(role.into()),
464 task: task.map(|t| t.into()),
465 ..Self::base("task_completed")
466 }
467 }
468
469 pub fn quality_metrics_recorded(info: &QualityMetricsInfo<'_>) -> Self {
470 Self {
471 role: Some(info.role.into()),
472 task: Some(info.task.into()),
473 backend: Some(info.backend.into()),
474 narration_ratio: Some(info.narration_ratio),
475 commit_frequency: Some(info.commit_frequency),
476 first_pass_test_rate: Some(info.first_pass_test_rate),
477 retry_rate: Some(info.retry_rate),
478 time_to_completion_secs: Some(info.time_to_completion_secs),
479 ..Self::base("quality_metrics_recorded")
480 }
481 }
482
483 pub fn standup_generated(recipient: &str) -> Self {
484 Self {
485 recipient: Some(recipient.into()),
486 ..Self::base("standup_generated")
487 }
488 }
489
490 pub fn supervisory_digest_emitted(
491 role: &str,
492 notice_count: u32,
493 suppressed_duplicates: u32,
494 ) -> Self {
495 Self {
496 role: Some(role.into()),
497 details: Some(format!(
498 "notice_count={notice_count} suppressed_duplicates={suppressed_duplicates}"
499 )),
500 ..Self::base("supervisory_digest_emitted")
501 }
502 }
503
504 pub fn retro_generated() -> Self {
505 Self::base("retro_generated")
506 }
507
508 pub fn pattern_detected(pattern_type: &str, frequency: u32) -> Self {
509 Self {
510 reason: Some(format!("{pattern_type}:{frequency}")),
511 ..Self::base("pattern_detected")
512 }
513 }
514
515 pub fn disk_hygiene_cleanup(summary: &str) -> Self {
516 Self {
517 details: Some(summary.into()),
518 ..Self::base("disk_hygiene_cleanup")
519 }
520 }
521
522 pub fn member_crashed(role: &str, restart: bool) -> Self {
523 Self {
524 role: Some(role.into()),
525 restart: Some(restart),
526 ..Self::base("member_crashed")
527 }
528 }
529
530 pub fn pane_death(role: &str) -> Self {
531 Self {
532 role: Some(role.into()),
533 ..Self::base("pane_death")
534 }
535 }
536
537 pub fn pane_respawned(role: &str) -> Self {
538 Self {
539 role: Some(role.into()),
540 ..Self::base("pane_respawned")
541 }
542 }
543
544 pub fn shim_disconnect(role: &str, reason: &str, details: &str, expected: bool) -> Self {
545 Self {
546 role: Some(role.into()),
547 reason: Some(reason.into()),
548 details: Some(details.into()),
549 success: Some(expected),
550 ..Self::base("shim_disconnect")
551 }
552 }
553
554 pub fn narration_detected(role: &str, task: Option<u32>) -> Self {
555 Self {
556 role: Some(role.into()),
557 task: task.map(|id| id.to_string()),
558 ..Self::base("narration_detected")
559 }
560 }
561
562 pub fn narration_nudged(role: &str, task: Option<u32>) -> Self {
563 Self {
564 role: Some(role.into()),
565 task: task.map(|id| id.to_string()),
566 ..Self::base("narration_nudged")
567 }
568 }
569
570 pub fn narration_restart(role: &str, task: Option<u32>) -> Self {
571 Self {
572 role: Some(role.into()),
573 task: task.map(|id| id.to_string()),
574 ..Self::base("narration_restart")
575 }
576 }
577
578 pub fn narration_rejection(role: &str, task_id: u32, rejection_count: u32) -> Self {
579 Self {
580 role: Some(role.into()),
581 task: Some(task_id.to_string()),
582 reason: Some(format!("rejection_count={rejection_count}")),
583 ..Self::base("narration_rejection")
584 }
585 }
586
587 pub fn tact_cycle_triggered(role: &str, idle_engineers: u32, board_summary: &str) -> Self {
588 Self {
589 role: Some(role.into()),
590 working_members: Some(idle_engineers),
591 reason: Some(board_summary.into()),
592 ..Self::base("tact_cycle_triggered")
593 }
594 }
595
596 pub fn tact_tasks_created(
597 role: &str,
598 tasks_created: u32,
599 latency_secs: u64,
600 success: bool,
601 error: Option<&str>,
602 ) -> Self {
603 Self {
604 role: Some(role.into()),
605 restart_count: Some(tasks_created),
606 uptime_secs: Some(latency_secs),
607 reason: Some(if success { "success" } else { "failure" }.into()),
608 error: error.map(str::to_string),
609 ..Self::base("tact_tasks_created")
610 }
611 }
612
613 pub fn context_pressure_warning(
614 role: &str,
615 task: Option<u32>,
616 pressure_score: u64,
617 threshold: u64,
618 output_bytes: u64,
619 ) -> Self {
620 Self {
621 role: Some(role.into()),
622 task: task.map(|id| id.to_string()),
623 load: Some(pressure_score as f64),
624 output_bytes: Some(output_bytes),
625 reason: Some(format!("threshold={threshold}")),
626 ..Self::base("context_pressure_warning")
627 }
628 }
629
630 pub fn stall_detected(role: &str, task: Option<u32>, stall_duration_secs: u64) -> Self {
631 Self::stall_detected_with_reason(role, task, stall_duration_secs, None)
632 }
633
634 pub fn stall_detected_with_reason(
635 role: &str,
636 task: Option<u32>,
637 stall_duration_secs: u64,
638 reason: Option<&str>,
639 ) -> Self {
640 Self {
641 role: Some(role.into()),
642 task: task.map(|id| id.to_string()),
643 uptime_secs: Some(stall_duration_secs),
644 reason: reason.map(str::to_string),
645 ..Self::base("stall_detected")
646 }
647 }
648
649 pub fn health_changed(role: &str, reason: &str) -> Self {
653 Self {
654 role: Some(role.into()),
655 reason: Some(reason.into()),
656 ..Self::base("health_changed")
657 }
658 }
659
660 pub fn message_routed(from: &str, to: &str) -> Self {
661 Self {
662 from: Some(from.into()),
663 to: Some(to.into()),
664 ..Self::base("message_routed")
665 }
666 }
667
668 pub fn discord_event_sent(channel: &str, source_event: &str) -> Self {
669 Self {
670 action_type: Some("notification".into()),
671 recipient: Some(channel.into()),
672 reason: Some(source_event.into()),
673 ..Self::base("discord_event_sent")
674 }
675 }
676
677 pub fn notification_delivery_sample(
678 from: &str,
679 to: &str,
680 latency_secs: u64,
681 isolation_mode: &str,
682 ) -> Self {
683 Self {
684 action_type: Some(isolation_mode.into()),
685 from: Some(from.into()),
686 to: Some(to.into()),
687 uptime_secs: Some(latency_secs),
688 ..Self::base("notification_delivery_sample")
689 }
690 }
691
692 pub fn inbox_message_expired(target: &str, from: &str, message_age_secs: u64) -> Self {
693 Self {
694 recipient: Some(target.into()),
695 from: Some(from.into()),
696 uptime_secs: Some(message_age_secs),
697 ..Self::base("inbox_message_expired")
698 }
699 }
700
701 pub fn inbox_message_deduplicated(target: &str, from: &str, signature: u64) -> Self {
702 Self {
703 recipient: Some(target.into()),
704 from: Some(from.into()),
705 content_hash: Some(format!("{signature:016x}")),
706 ..Self::base("inbox_message_deduplicated")
707 }
708 }
709
710 pub fn inbox_batch_delivered(
711 target: &str,
712 message_count: usize,
713 escalation_count: usize,
714 ) -> Self {
715 Self {
716 recipient: Some(target.into()),
717 restart_count: Some(message_count as u32),
718 reason: Some(format!("escalation_count={escalation_count}")),
719 ..Self::base("inbox_batch_delivered")
720 }
721 }
722
723 pub fn backend_quota_exhausted(role: &str, reason: &str) -> Self {
724 Self {
725 role: Some(role.into()),
726 reason: Some(reason.into()),
727 ..Self::base("backend_quota_exhausted")
728 }
729 }
730
731 pub fn agent_spawned(role: &str) -> Self {
732 Self {
733 role: Some(role.into()),
734 ..Self::base("agent_spawned")
735 }
736 }
737
738 pub fn agent_restarted(role: &str, task: &str, reason: &str, restart_count: u32) -> Self {
739 Self {
740 role: Some(role.into()),
741 task: Some(task.into()),
742 reason: Some(reason.into()),
743 restart_count: Some(restart_count),
744 ..Self::base("agent_restarted")
745 }
746 }
747
748 pub fn agent_handoff(role: &str, task: &str, reason: &str, success: bool) -> Self {
749 Self {
750 role: Some(role.into()),
751 task: Some(task.into()),
752 reason: Some(reason.into()),
753 success: Some(success),
754 ..Self::base("agent_handoff")
755 }
756 }
757
758 pub fn handoff_injected(role: &str, task: &str, reason: &str) -> Self {
759 Self {
760 role: Some(role.into()),
761 task: Some(task.into()),
762 reason: Some(reason.into()),
763 success: Some(true),
764 ..Self::base("handoff_injected")
765 }
766 }
767
768 pub fn task_resumed(role: &str, task: &str, reason: &str, restart_count: u32) -> Self {
769 Self {
770 role: Some(role.into()),
771 task: Some(task.into()),
772 reason: Some(reason.into()),
773 restart_count: Some(restart_count),
774 ..Self::base("task_resumed")
775 }
776 }
777
778 pub fn delivery_failed(role: &str, from: &str, reason: &str) -> Self {
779 Self::delivery_failed_with_details(role, from, reason, None)
780 }
781
782 pub fn delivery_failed_with_details(
783 role: &str,
784 from: &str,
785 reason: &str,
786 details: Option<&str>,
787 ) -> Self {
788 Self {
789 role: Some(role.into()),
790 from: Some(from.into()),
791 reason: Some(reason.into()),
792 details: details.map(str::to_string),
793 ..Self::base("delivery_failed")
794 }
795 }
796
797 pub fn task_auto_merged(
798 engineer: &str,
799 task: &str,
800 confidence: f64,
801 files_changed: usize,
802 lines_changed: usize,
803 ) -> Self {
804 Self::task_auto_merged_with_mode(
805 engineer,
806 task,
807 confidence,
808 files_changed,
809 lines_changed,
810 None,
811 )
812 }
813
814 pub fn task_auto_merged_with_mode(
815 engineer: &str,
816 task: &str,
817 confidence: f64,
818 files_changed: usize,
819 lines_changed: usize,
820 merge_mode: Option<MergeMode>,
821 ) -> Self {
822 Self {
823 role: Some(engineer.into()),
824 task: Some(task.into()),
825 load: Some(confidence),
826 reason: Some(format!("files={} lines={}", files_changed, lines_changed)),
827 success: Some(true),
828 merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
829 ..Self::base("task_auto_merged")
830 }
831 }
832
833 pub fn auto_merge_decision_recorded(info: &AutoMergeDecisionInfo<'_>) -> Self {
834 Self {
835 role: Some(info.engineer.into()),
836 task: Some(info.task.into()),
837 action_type: Some(info.action_type.into()),
838 load: Some(info.confidence),
839 reason: Some(info.reason.into()),
840 details: Some(info.details.into()),
841 ..Self::base("auto_merge_decision_recorded")
842 }
843 }
844
845 pub fn auto_merge_post_verify_result(
846 engineer: &str,
847 task: &str,
848 success: Option<bool>,
849 reason: &str,
850 details: Option<&str>,
851 ) -> Self {
852 Self {
853 role: Some(engineer.into()),
854 task: Some(task.into()),
855 success,
856 reason: Some(reason.into()),
857 details: details.map(str::to_string),
858 ..Self::base("auto_merge_post_verify_result")
859 }
860 }
861
862 pub fn task_manual_merged(task: &str) -> Self {
863 Self::task_manual_merged_with_mode(task, None)
864 }
865
866 pub fn task_manual_merged_with_mode(task: &str, merge_mode: Option<MergeMode>) -> Self {
867 Self {
868 task: Some(task.into()),
869 success: Some(true),
870 merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
871 ..Self::base("task_manual_merged")
872 }
873 }
874
875 pub fn task_merge_failed(
876 engineer: &str,
877 task: &str,
878 merge_mode: Option<MergeMode>,
879 details: &str,
880 ) -> Self {
881 Self {
882 role: Some(engineer.into()),
883 task: Some(task.into()),
884 success: Some(false),
885 merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
886 reason: Some("merge_failed".into()),
887 details: Some(details.into()),
888 ..Self::base("task_merge_failed")
889 }
890 }
891
892 pub fn merge_confidence_scored(info: &MergeConfidenceInfo<'_>) -> Self {
894 let detail = format!(
895 "files={} lines={} migrations={} config={} renames={}",
896 info.files_changed,
897 info.lines_changed,
898 info.has_migrations,
899 info.has_config_changes,
900 info.rename_count
901 );
902 Self {
903 role: Some(info.engineer.into()),
904 task: Some(info.task.into()),
905 load: Some(info.confidence),
906 reason: Some(detail),
907 ..Self::base("merge_confidence_scored")
908 }
909 }
910
911 pub fn verification_phase_changed(info: &VerificationPhaseChangeInfo<'_>) -> Self {
912 Self {
913 role: Some(info.engineer.into()),
914 task: Some(info.task.into()),
915 step: Some(info.to_phase.into()),
916 reason: Some(format!(
917 "from={} to={} iteration={}",
918 info.from_phase, info.to_phase, info.iteration
919 )),
920 restart_count: Some(info.iteration),
921 ..Self::base("verification_phase_changed")
922 }
923 }
924
925 pub fn verification_evidence_collected(
926 engineer: &str,
927 task: &str,
928 evidence_kind: &str,
929 detail: &str,
930 ) -> Self {
931 Self {
932 role: Some(engineer.into()),
933 task: Some(task.into()),
934 step: Some(evidence_kind.into()),
935 reason: Some(detail.into()),
936 ..Self::base("verification_evidence_collected")
937 }
938 }
939
940 pub fn verification_max_iterations_reached(
941 engineer: &str,
942 task: &str,
943 iteration: u32,
944 escalated_to: &str,
945 ) -> Self {
946 Self {
947 role: Some(engineer.into()),
948 task: Some(task.into()),
949 recipient: Some(escalated_to.into()),
950 reason: Some(format!("iteration={iteration}")),
951 restart_count: Some(iteration),
952 ..Self::base("verification_max_iterations_reached")
953 }
954 }
955
956 pub fn review_escalated_by_role(role: &str, task: &str) -> Self {
957 Self {
958 role: Some(role.into()),
959 task: Some(task.into()),
960 ..Self::base("review_escalated")
961 }
962 }
963
964 pub fn pipeline_starvation_detected(idle_engineers: usize, todo_tasks: usize) -> Self {
965 Self {
966 reason: Some(format!(
967 "idle_engineers={idle_engineers} todo_tasks={todo_tasks}"
968 )),
969 ..Self::base("pipeline_starvation_detected")
970 }
971 }
972
973 pub fn task_reworked(role: &str, task: &str) -> Self {
974 Self {
975 role: Some(role.into()),
976 task: Some(task.into()),
977 ..Self::base("task_reworked")
978 }
979 }
980
981 pub fn task_recycled(task_id: u32, cron_expr: &str) -> Self {
982 Self {
983 task: Some(format!("#{task_id}")),
984 reason: Some(cron_expr.into()),
985 ..Self::base("task_recycled")
986 }
987 }
988
989 pub fn barrier_artifact_created(role: &str, filename: &str, content_hash: &str) -> Self {
990 Self {
991 role: Some(role.into()),
992 filename: Some(filename.into()),
993 content_hash: Some(content_hash.into()),
994 ..Self::base("barrier_artifact_created")
995 }
996 }
997
998 pub fn barrier_artifact_read(role: &str, filename: &str, content_hash: &str) -> Self {
999 Self {
1000 role: Some(role.into()),
1001 filename: Some(filename.into()),
1002 content_hash: Some(content_hash.into()),
1003 ..Self::base("barrier_artifact_read")
1004 }
1005 }
1006
1007 pub fn barrier_violation_attempt(role: &str, filename: &str, reason: &str) -> Self {
1008 Self {
1009 role: Some(role.into()),
1010 filename: Some(filename.into()),
1011 reason: Some(reason.into()),
1012 ..Self::base("barrier_violation_attempt")
1013 }
1014 }
1015
1016 pub fn load_snapshot(working_members: u32, total_members: u32, session_running: bool) -> Self {
1017 let load = if total_members == 0 {
1018 0.0
1019 } else {
1020 working_members as f64 / total_members as f64
1021 };
1022 Self {
1023 load: Some(load),
1024 working_members: Some(working_members),
1025 total_members: Some(total_members),
1026 session_running: Some(session_running),
1027 ..Self::base("load_snapshot")
1028 }
1029 }
1030
1031 pub fn parity_updated(summary: &crate::team::parity::ParitySummary) -> Self {
1032 Self {
1033 load: Some(summary.overall_parity_pct as f64 / 100.0),
1034 reason: Some(format!(
1035 "total={} spec={} tests={} implementation={} verified_pass={} verified_fail={}",
1036 summary.total_behaviors,
1037 summary.spec_complete,
1038 summary.tests_complete,
1039 summary.implementation_complete,
1040 summary.verified_pass,
1041 summary.verified_fail
1042 )),
1043 ..Self::base("parity_updated")
1044 }
1045 }
1046
1047 pub fn release_succeeded(
1048 version: &str,
1049 git_ref: &str,
1050 tag: &str,
1051 notes_path: Option<&str>,
1052 ) -> Self {
1053 Self {
1054 action_type: Some("release".into()),
1055 version: Some(version.into()),
1056 git_ref: Some(git_ref.into()),
1057 task: Some(tag.into()),
1058 reason: Some("tag_created".into()),
1059 success: Some(true),
1060 details: notes_path.map(str::to_string),
1061 ..Self::base("release_succeeded")
1062 }
1063 }
1064
1065 pub fn release_failed(
1066 version: Option<&str>,
1067 git_ref: Option<&str>,
1068 tag: Option<&str>,
1069 reason: &str,
1070 details: Option<&str>,
1071 ) -> Self {
1072 Self {
1073 action_type: Some("release".into()),
1074 version: version.map(str::to_string),
1075 git_ref: git_ref.map(str::to_string),
1076 task: tag.map(str::to_string),
1077 reason: Some(reason.into()),
1078 details: details.map(str::to_string),
1079 success: Some(false),
1080 ..Self::base("release_failed")
1081 }
1082 }
1083
1084 pub fn worktree_reconciled(role: &str, branch: &str) -> Self {
1085 Self {
1086 role: Some(role.into()),
1087 reason: Some(format!("branch '{branch}' merged into main")),
1088 ..Self::base("worktree_reconciled")
1089 }
1090 }
1091
1092 pub fn worktree_refreshed(role: &str, reason: &str) -> Self {
1093 Self {
1094 role: Some(role.into()),
1095 reason: Some(reason.into()),
1096 ..Self::base("worktree_refreshed")
1097 }
1098 }
1099
1100 pub fn topology_changed(added: u32, removed: u32, reason: &str) -> Self {
1104 Self {
1105 working_members: Some(added),
1106 total_members: Some(removed),
1107 reason: Some(reason.into()),
1108 ..Self::base("topology_changed")
1109 }
1110 }
1111
1112 pub fn agent_removed(role: &str, reason: &str) -> Self {
1114 Self {
1115 role: Some(role.into()),
1116 reason: Some(reason.into()),
1117 ..Self::base("agent_removed")
1118 }
1119 }
1120}
1121
1122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1128#[serde(rename_all = "snake_case")]
1129pub enum SessionLifecycle {
1130 Spawning,
1131 Ready,
1132 Working,
1133 Blocked,
1134 Idle,
1135 Finished,
1136 Failed,
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1141#[serde(rename_all = "snake_case")]
1142pub enum TaskLifecycle {
1143 Claimed,
1144 InProgress,
1145 TestsRunning,
1146 TestsPassed,
1147 TestsFailed,
1148 Review,
1149 Merged,
1150 Rejected,
1151}
1152
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1155#[serde(rename_all = "snake_case")]
1156pub enum MergeLifecycle {
1157 Started,
1158 Conflict,
1159 Success,
1160}
1161
1162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1164#[serde(tag = "category", content = "phase")]
1165pub enum LifecycleEventType {
1166 #[serde(rename = "session")]
1167 Session(SessionLifecycle),
1168 #[serde(rename = "task")]
1169 Task(TaskLifecycle),
1170 #[serde(rename = "merge")]
1171 Merge(MergeLifecycle),
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1176pub struct LifecycleEvent {
1177 pub schema_version: u32,
1178 pub ts: u64,
1179 pub event_type: LifecycleEventType,
1180 pub agent_name: String,
1181 #[serde(skip_serializing_if = "Option::is_none")]
1182 pub task_id: Option<u32>,
1183 #[serde(skip_serializing_if = "Option::is_none")]
1184 pub details: Option<String>,
1185}
1186
1187impl LifecycleEvent {
1188 pub const SCHEMA_VERSION: u32 = 1;
1190
1191 pub fn new(
1193 event_type: LifecycleEventType,
1194 agent_name: &str,
1195 task_id: Option<u32>,
1196 details: Option<String>,
1197 ) -> Self {
1198 Self {
1199 schema_version: Self::SCHEMA_VERSION,
1200 ts: SystemTime::now()
1201 .duration_since(UNIX_EPOCH)
1202 .unwrap_or_default()
1203 .as_secs(),
1204 event_type,
1205 agent_name: agent_name.to_string(),
1206 task_id,
1207 details,
1208 }
1209 }
1210}
1211
1212pub fn emit_lifecycle_event(path: &Path, event: &LifecycleEvent) -> Result<()> {
1214 if let Some(parent) = path.parent() {
1215 fs::create_dir_all(parent)?;
1216 }
1217 let mut file = OpenOptions::new()
1218 .create(true)
1219 .append(true)
1220 .open(path)
1221 .with_context(|| format!("failed to open lifecycle event log: {}", path.display()))?;
1222 let json = serde_json::to_string(event)?;
1223 writeln!(file, "{json}")?;
1224 file.flush()?;
1225 Ok(())
1226}
1227
1228pub struct EventSink {
1229 writer: Box<dyn Write + Send>,
1230 path: PathBuf,
1231 max_bytes: Option<u64>,
1232}
1233
1234impl EventSink {
1235 pub fn new(path: &Path) -> Result<Self> {
1236 Self::new_with_max_bytes(path, DEFAULT_EVENT_LOG_MAX_BYTES)
1237 }
1238
1239 pub fn new_with_max_bytes(path: &Path, max_bytes: u64) -> Result<Self> {
1240 if let Some(parent) = path.parent() {
1241 std::fs::create_dir_all(parent)?;
1242 }
1243 rotate_event_log_if_needed(path, max_bytes, 0)?;
1244 let file = OpenOptions::new()
1245 .create(true)
1246 .append(true)
1247 .open(path)
1248 .with_context(|| format!("failed to open event sink: {}", path.display()))?;
1249 Ok(Self {
1250 writer: Box::new(BufWriter::new(file)),
1251 path: path.to_path_buf(),
1252 max_bytes: Some(max_bytes),
1253 })
1254 }
1255
1256 #[cfg(test)]
1257 pub(crate) fn from_writer(path: &Path, writer: impl Write + Send + 'static) -> Self {
1258 Self {
1259 writer: Box::new(writer),
1260 path: path.to_path_buf(),
1261 max_bytes: None,
1262 }
1263 }
1264
1265 pub fn emit(&mut self, event: TeamEvent) -> Result<()> {
1266 let json = serde_json::to_string(&event)?;
1267 self.rotate_if_needed((json.len() + 1) as u64)?;
1268 writeln!(self.writer, "{json}")?;
1269 self.writer.flush()?;
1270 Ok(())
1271 }
1272
1273 pub fn path(&self) -> &Path {
1274 &self.path
1275 }
1276
1277 fn rotate_if_needed(&mut self, next_entry_bytes: u64) -> Result<()> {
1278 let Some(max_bytes) = self.max_bytes else {
1279 return Ok(());
1280 };
1281 self.writer.flush()?;
1282 if rotate_event_log_if_needed(&self.path, max_bytes, next_entry_bytes)? {
1283 self.writer = Box::new(BufWriter::new(
1284 OpenOptions::new()
1285 .create(true)
1286 .append(true)
1287 .open(&self.path)
1288 .with_context(|| {
1289 format!("failed to reopen event sink: {}", self.path.display())
1290 })?,
1291 ));
1292 }
1293 Ok(())
1294 }
1295}
1296
1297fn rotated_event_log_path(path: &Path) -> PathBuf {
1298 PathBuf::from(format!("{}.1", path.display()))
1299}
1300
1301fn rotate_event_log_if_needed(path: &Path, max_bytes: u64, next_entry_bytes: u64) -> Result<bool> {
1302 let len = match fs::metadata(path) {
1303 Ok(metadata) => metadata.len(),
1304 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1305 Err(error) => {
1306 return Err(error).with_context(|| format!("failed to stat {}", path.display()));
1307 }
1308 };
1309
1310 if len == 0 {
1311 return Ok(false);
1312 }
1313
1314 if len.saturating_add(next_entry_bytes) <= max_bytes {
1315 return Ok(false);
1316 }
1317
1318 let rotated = rotated_event_log_path(path);
1319 if rotated.exists() {
1320 fs::remove_file(&rotated)
1321 .with_context(|| format!("failed to remove {}", rotated.display()))?;
1322 }
1323 fs::rename(path, &rotated).with_context(|| {
1324 format!(
1325 "failed to rotate event log {} to {}",
1326 path.display(),
1327 rotated.display()
1328 )
1329 })?;
1330 Ok(true)
1331}
1332
1333pub fn read_events(path: &Path) -> Result<Vec<TeamEvent>> {
1334 if !path.exists() {
1335 return Ok(Vec::new());
1336 }
1337 let content = fs::read_to_string(path).context("failed to read event log")?;
1338 let mut events = Vec::new();
1339 for line in content.lines() {
1340 let line = line.trim();
1341 if line.is_empty() {
1342 continue;
1343 }
1344 if let Ok(event) = serde_json::from_str::<TeamEvent>(line) {
1345 events.push(event);
1346 }
1347 }
1348 Ok(events)
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use std::sync::{Arc, Mutex};
1354 use std::thread;
1355
1356 use super::*;
1357
1358 #[test]
1359 fn event_serializes_to_json() {
1360 let event = TeamEvent::task_assigned("eng-1-1", "fix auth bug");
1361 let json = serde_json::to_string(&event).unwrap();
1362 assert!(json.contains("\"event\":\"task_assigned\""));
1363 assert!(json.contains("\"role\":\"eng-1-1\""));
1364 assert!(json.contains("\"task\":\"fix auth bug\""));
1365 assert!(json.contains("\"ts\":"));
1366 }
1367
1368 #[test]
1369 fn optional_fields_omitted() {
1370 let event = TeamEvent::daemon_started();
1371 let json = serde_json::to_string(&event).unwrap();
1372 assert!(!json.contains("\"role\""));
1373 assert!(!json.contains("\"task\""));
1374 }
1375
1376 #[test]
1377 fn event_sink_writes_jsonl() {
1378 let tmp = tempfile::tempdir().unwrap();
1379 let path = tmp.path().join("events.jsonl");
1380 let mut sink = EventSink::new(&path).unwrap();
1381 sink.emit(TeamEvent::daemon_started()).unwrap();
1382 sink.emit(TeamEvent::task_assigned("eng-1", "fix bug"))
1383 .unwrap();
1384 sink.emit(TeamEvent::daemon_stopped()).unwrap();
1385
1386 let content = std::fs::read_to_string(&path).unwrap();
1387 let lines: Vec<&str> = content.lines().collect();
1388 assert_eq!(lines.len(), 3);
1389 assert!(lines[0].contains("daemon_started"));
1390 assert!(lines[1].contains("task_assigned"));
1391 assert!(lines[2].contains("daemon_stopped"));
1392 }
1393
1394 #[test]
1395 fn all_event_variants_serialize_with_correct_event_field() {
1396 let variants: Vec<(&str, TeamEvent)> = vec![
1397 ("daemon_started", TeamEvent::daemon_started()),
1398 ("daemon_reloading", TeamEvent::daemon_reloading()),
1399 ("daemon_reloaded", TeamEvent::daemon_reloaded()),
1400 ("daemon_stopped", TeamEvent::daemon_stopped()),
1401 (
1402 "daemon_stopped",
1403 TeamEvent::daemon_stopped_with_reason("signal", 3600),
1404 ),
1405 ("daemon_heartbeat", TeamEvent::daemon_heartbeat(120)),
1406 (
1407 "context_exhausted",
1408 TeamEvent::context_exhausted("eng-1", Some(42), Some(1_024)),
1409 ),
1410 (
1411 "loop_step_error",
1412 TeamEvent::loop_step_error("poll_watchers", "tmux error"),
1413 ),
1414 (
1415 "daemon_panic",
1416 TeamEvent::daemon_panic("index out of bounds"),
1417 ),
1418 ("task_assigned", TeamEvent::task_assigned("eng-1", "task")),
1419 (
1420 "cwd_corrected",
1421 TeamEvent::cwd_corrected("eng-1", "/tmp/worktree"),
1422 ),
1423 (
1424 "task_escalated",
1425 TeamEvent::task_escalated("eng-1", "task", None),
1426 ),
1427 ("task_unblocked", TeamEvent::task_unblocked("eng-1", "task")),
1428 (
1429 "performance_regression",
1430 TeamEvent::performance_regression("42", "runtime_ms=1300 avg_ms=1000 pct=30"),
1431 ),
1432 (
1433 "task_completed",
1434 TeamEvent::task_completed("eng-1", Some("42")),
1435 ),
1436 ("standup_generated", TeamEvent::standup_generated("manager")),
1437 ("retro_generated", TeamEvent::retro_generated()),
1438 (
1439 "pattern_detected",
1440 TeamEvent::pattern_detected("merge_conflict_recurrence", 5),
1441 ),
1442 ("member_crashed", TeamEvent::member_crashed("eng-1", true)),
1443 ("pane_death", TeamEvent::pane_death("eng-1")),
1444 ("pane_respawned", TeamEvent::pane_respawned("eng-1")),
1445 (
1446 "context_pressure_warning",
1447 TeamEvent::context_pressure_warning("eng-1", Some(42), 88, 100, 400_000),
1448 ),
1449 (
1450 "tact_cycle_triggered",
1451 TeamEvent::tact_cycle_triggered("architect", 3, "todo=0, backlog=1"),
1452 ),
1453 (
1454 "tact_tasks_created",
1455 TeamEvent::tact_tasks_created("architect", 2, 14, true, None),
1456 ),
1457 (
1458 "board_task_archived",
1459 TeamEvent::board_task_archived("42", Some("eng-1")),
1460 ),
1461 ("message_routed", TeamEvent::message_routed("a", "b")),
1462 ("agent_spawned", TeamEvent::agent_spawned("eng-1")),
1463 (
1464 "agent_restarted",
1465 TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 1),
1466 ),
1467 (
1468 "delivery_failed",
1469 TeamEvent::delivery_failed("eng-1", "manager", "message marker missing"),
1470 ),
1471 (
1472 "task_auto_merged",
1473 TeamEvent::task_auto_merged_with_mode(
1474 "eng-1",
1475 "42",
1476 0.95,
1477 2,
1478 30,
1479 Some(MergeMode::IsolatedIntegration),
1480 ),
1481 ),
1482 (
1483 "task_manual_merged",
1484 TeamEvent::task_manual_merged_with_mode("42", Some(MergeMode::DirectRoot)),
1485 ),
1486 (
1487 "task_merge_failed",
1488 TeamEvent::task_merge_failed(
1489 "eng-1",
1490 "43",
1491 Some(MergeMode::IsolatedIntegration),
1492 "isolated merge path failed: boom",
1493 ),
1494 ),
1495 (
1496 "merge_confidence_scored",
1497 TeamEvent::merge_confidence_scored(&MergeConfidenceInfo {
1498 engineer: "eng-1",
1499 task: "42",
1500 confidence: 0.85,
1501 files_changed: 3,
1502 lines_changed: 50,
1503 has_migrations: false,
1504 has_config_changes: false,
1505 rename_count: 0,
1506 }),
1507 ),
1508 (
1509 "review_nudge_sent",
1510 TeamEvent::review_nudge_sent("manager", "42"),
1511 ),
1512 (
1513 "review_escalated",
1514 TeamEvent::review_escalated("42", "stale review"),
1515 ),
1516 (
1517 "pipeline_starvation_detected",
1518 TeamEvent::pipeline_starvation_detected(3, 0),
1519 ),
1520 (
1521 "state_reconciliation",
1522 TeamEvent::state_reconciliation(Some("eng-1"), Some("42"), "adopt"),
1523 ),
1524 ("task_reworked", TeamEvent::task_reworked("eng-1", "42")),
1525 ("load_snapshot", TeamEvent::load_snapshot(2, 5, true)),
1526 (
1527 "parity_updated",
1528 TeamEvent::parity_updated(&crate::team::parity::ParitySummary {
1529 total_behaviors: 10,
1530 spec_complete: 8,
1531 tests_complete: 6,
1532 implementation_complete: 5,
1533 verified_pass: 4,
1534 verified_fail: 1,
1535 overall_parity_pct: 40,
1536 }),
1537 ),
1538 (
1539 "release_succeeded",
1540 TeamEvent::release_succeeded(
1541 "0.10.0",
1542 "abc123",
1543 "v0.10.0",
1544 Some("/tmp/v0.10.0.md"),
1545 ),
1546 ),
1547 (
1548 "release_failed",
1549 TeamEvent::release_failed(
1550 Some("0.10.0"),
1551 Some("abc123"),
1552 Some("v0.10.0"),
1553 "verification failed",
1554 None,
1555 ),
1556 ),
1557 (
1558 "worktree_reconciled",
1559 TeamEvent::worktree_reconciled("eng-1", "eng-1/42"),
1560 ),
1561 ];
1562 for (expected_event, event) in &variants {
1563 let json = serde_json::to_string(event).unwrap();
1564 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1565 assert_eq!(parsed["event"].as_str().unwrap(), *expected_event);
1566 assert!(parsed["ts"].as_u64().is_some());
1567 }
1568 }
1569
1570 #[test]
1571 fn load_snapshot_serializes_optional_metrics() {
1572 let event = TeamEvent::load_snapshot(3, 7, false);
1573 let json = serde_json::to_string(&event).unwrap();
1574 assert!(json.contains("\"event\":\"load_snapshot\""));
1575 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1576 assert_eq!(parsed["load"].as_f64().unwrap(), 3.0 / 7.0);
1577 assert_eq!(parsed["working_members"].as_u64().unwrap(), 3);
1578 assert_eq!(parsed["total_members"].as_u64().unwrap(), 7);
1579 assert!(!parsed["session_running"].as_bool().unwrap());
1580 }
1581
1582 #[test]
1583 fn parity_updated_serializes_summary_metrics() {
1584 let event = TeamEvent::parity_updated(&crate::team::parity::ParitySummary {
1585 total_behaviors: 10,
1586 spec_complete: 8,
1587 tests_complete: 6,
1588 implementation_complete: 5,
1589 verified_pass: 4,
1590 verified_fail: 1,
1591 overall_parity_pct: 40,
1592 });
1593 let json = serde_json::to_string(&event).unwrap();
1594 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1595 assert_eq!(parsed["event"].as_str().unwrap(), "parity_updated");
1596 assert_eq!(parsed["load"].as_f64().unwrap(), 0.4);
1597 let reason = parsed["reason"].as_str().unwrap();
1598 assert!(reason.contains("total=10"));
1599 assert!(reason.contains("spec=8"));
1600 assert!(reason.contains("verified_pass=4"));
1601 }
1602
1603 #[test]
1604 fn release_succeeded_serializes_version_and_git_ref() {
1605 let event =
1606 TeamEvent::release_succeeded("0.10.0", "abc123", "v0.10.0", Some("/tmp/v0.10.0.md"));
1607 let json = serde_json::to_string(&event).unwrap();
1608 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1609 assert_eq!(parsed["event"].as_str().unwrap(), "release_succeeded");
1610 assert_eq!(parsed["version"].as_str().unwrap(), "0.10.0");
1611 assert_eq!(parsed["git_ref"].as_str().unwrap(), "abc123");
1612 assert_eq!(parsed["task"].as_str().unwrap(), "v0.10.0");
1613 assert!(parsed["success"].as_bool().unwrap());
1614 }
1615
1616 #[test]
1617 fn release_failed_serializes_reason_and_attempted_tag() {
1618 let event = TeamEvent::release_failed(
1619 Some("0.10.0"),
1620 Some("abc123"),
1621 Some("v0.10.0"),
1622 "verification_failed",
1623 Some("suite::it_breaks"),
1624 );
1625 let json = serde_json::to_string(&event).unwrap();
1626 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1627 assert_eq!(parsed["event"].as_str().unwrap(), "release_failed");
1628 assert_eq!(parsed["version"].as_str().unwrap(), "0.10.0");
1629 assert_eq!(parsed["git_ref"].as_str().unwrap(), "abc123");
1630 assert_eq!(parsed["task"].as_str().unwrap(), "v0.10.0");
1631 assert_eq!(parsed["reason"].as_str().unwrap(), "verification_failed");
1632 assert_eq!(parsed["details"].as_str().unwrap(), "suite::it_breaks");
1633 assert!(!parsed["success"].as_bool().unwrap());
1634 }
1635
1636 #[test]
1637 fn read_events_parses_all_known_lines() {
1638 let tmp = tempfile::tempdir().unwrap();
1639 let path = tmp.path().join("events.jsonl");
1640 let mut sink = EventSink::new(&path).unwrap();
1641 sink.emit(TeamEvent::daemon_started()).unwrap();
1642 sink.emit(TeamEvent::load_snapshot(1, 4, true)).unwrap();
1643 sink.emit(TeamEvent::load_snapshot(2, 4, true)).unwrap();
1644
1645 let events = read_events(&path).unwrap();
1646 assert_eq!(events.len(), 3);
1647 assert_eq!(events[1].event, "load_snapshot");
1648 assert_eq!(events[1].working_members, Some(1));
1649 assert_eq!(events[2].total_members, Some(4));
1650 }
1651
1652 #[test]
1653 fn event_sink_appends_to_existing_file() {
1654 let tmp = tempfile::tempdir().unwrap();
1655 let path = tmp.path().join("events.jsonl");
1656
1657 {
1659 let mut sink = EventSink::new(&path).unwrap();
1660 sink.emit(TeamEvent::daemon_started()).unwrap();
1661 }
1662
1663 {
1665 let mut sink = EventSink::new(&path).unwrap();
1666 sink.emit(TeamEvent::daemon_stopped()).unwrap();
1667 }
1668
1669 let content = std::fs::read_to_string(&path).unwrap();
1670 let lines: Vec<&str> = content.lines().collect();
1671 assert_eq!(lines.len(), 2);
1672 assert!(lines[0].contains("daemon_started"));
1673 assert!(lines[1].contains("daemon_stopped"));
1674 }
1675
1676 #[test]
1677 fn event_with_special_characters_in_task() {
1678 let event = TeamEvent::task_assigned("eng-1", "fix: \"quotes\" & <angles> / \\ newline\n");
1679 let json = serde_json::to_string(&event).unwrap();
1680 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1681 let task_val = parsed["task"].as_str().unwrap();
1682 assert!(task_val.contains("\"quotes\""));
1683 assert!(task_val.contains("<angles>"));
1684 }
1685
1686 #[test]
1687 fn task_escalated_serializes_role_and_task() {
1688 let event = TeamEvent::task_escalated("eng-1-1", "42", Some("tests_failed"));
1689 let json = serde_json::to_string(&event).unwrap();
1690 assert!(json.contains("\"event\":\"task_escalated\""));
1691 assert!(json.contains("\"role\":\"eng-1-1\""));
1692 assert!(json.contains("\"task\":\"42\""));
1693 assert!(json.contains("\"reason\":\"tests_failed\""));
1694 }
1695
1696 #[test]
1697 fn cwd_corrected_serializes_role_and_reason() {
1698 let event = TeamEvent::cwd_corrected("eng-1-1", "/tmp/worktree");
1699 let json = serde_json::to_string(&event).unwrap();
1700 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1701 assert_eq!(parsed["event"].as_str().unwrap(), "cwd_corrected");
1702 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1703 assert_eq!(parsed["reason"].as_str().unwrap(), "/tmp/worktree");
1704 }
1705
1706 #[test]
1707 fn pane_death_serializes_role() {
1708 let event = TeamEvent::pane_death("eng-1-1");
1709 let json = serde_json::to_string(&event).unwrap();
1710 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1711 assert_eq!(parsed["event"].as_str().unwrap(), "pane_death");
1712 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1713 }
1714
1715 #[test]
1716 fn pane_respawned_serializes_role() {
1717 let event = TeamEvent::pane_respawned("eng-1-1");
1718 let json = serde_json::to_string(&event).unwrap();
1719 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1720 assert_eq!(parsed["event"].as_str().unwrap(), "pane_respawned");
1721 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1722 }
1723
1724 #[test]
1725 fn agent_restarted_includes_reason_task_and_count() {
1726 let event = TeamEvent::agent_restarted("eng-1-2", "67", "context_exhausted", 2);
1727 let json = serde_json::to_string(&event).unwrap();
1728 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1729 assert_eq!(parsed["event"].as_str().unwrap(), "agent_restarted");
1730 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1731 assert_eq!(parsed["task"].as_str().unwrap(), "67");
1732 assert_eq!(parsed["reason"].as_str().unwrap(), "context_exhausted");
1733 assert_eq!(parsed["restart_count"].as_u64().unwrap(), 2);
1734 }
1735
1736 #[test]
1737 fn context_pressure_warning_includes_threshold_and_output_bytes() {
1738 let event = TeamEvent::context_pressure_warning("eng-1-2", Some(67), 91, 100, 420_000);
1739 let json = serde_json::to_string(&event).unwrap();
1740 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1741
1742 assert_eq!(
1743 parsed["event"].as_str().unwrap(),
1744 "context_pressure_warning"
1745 );
1746 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1747 assert_eq!(parsed["task"].as_str().unwrap(), "67");
1748 assert_eq!(parsed["load"].as_f64().unwrap(), 91.0);
1749 assert_eq!(parsed["output_bytes"].as_u64().unwrap(), 420_000);
1750 assert_eq!(parsed["reason"].as_str().unwrap(), "threshold=100");
1751 }
1752
1753 #[test]
1754 fn tact_tasks_created_includes_latency_status_and_created_count() {
1755 let event = TeamEvent::tact_tasks_created("architect", 4, 19, false, Some("parse failed"));
1756 let json = serde_json::to_string(&event).unwrap();
1757 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1758
1759 assert_eq!(parsed["event"].as_str().unwrap(), "tact_tasks_created");
1760 assert_eq!(parsed["role"].as_str().unwrap(), "architect");
1761 assert_eq!(parsed["restart_count"].as_u64().unwrap(), 4);
1762 assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 19);
1763 assert_eq!(parsed["reason"].as_str().unwrap(), "failure");
1764 assert_eq!(parsed["error"].as_str().unwrap(), "parse failed");
1765 }
1766
1767 #[test]
1768 fn board_task_archived_includes_task_and_role() {
1769 let event = TeamEvent::board_task_archived("88", Some("eng-1-2"));
1770 let json = serde_json::to_string(&event).unwrap();
1771 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1772
1773 assert_eq!(parsed["event"].as_str().unwrap(), "board_task_archived");
1774 assert_eq!(parsed["task"].as_str().unwrap(), "88");
1775 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1776 }
1777
1778 #[test]
1779 fn delivery_failed_includes_role_sender_and_reason() {
1780 let event = TeamEvent::delivery_failed("eng-1-2", "manager", "message marker missing");
1781 let json = serde_json::to_string(&event).unwrap();
1782 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1783 assert_eq!(parsed["event"].as_str().unwrap(), "delivery_failed");
1784 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1785 assert_eq!(parsed["from"].as_str().unwrap(), "manager");
1786 assert_eq!(parsed["reason"].as_str().unwrap(), "message marker missing");
1787 }
1788
1789 #[test]
1790 fn delivery_failed_with_details_serializes_actionable_context() {
1791 let event = TeamEvent::delivery_failed_with_details(
1792 "eng-1-2",
1793 "manager",
1794 "recipient has no live shim handle",
1795 Some("classification=missing_shim queue_action=cleared"),
1796 );
1797 let json = serde_json::to_string(&event).unwrap();
1798 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1799 assert_eq!(
1800 parsed["details"].as_str().unwrap(),
1801 "classification=missing_shim queue_action=cleared"
1802 );
1803 }
1804
1805 #[test]
1806 fn task_unblocked_serializes_role_and_task() {
1807 let event = TeamEvent::task_unblocked("eng-1-1", "42");
1808 let json = serde_json::to_string(&event).unwrap();
1809 assert!(json.contains("\"event\":\"task_unblocked\""));
1810 assert!(json.contains("\"role\":\"eng-1-1\""));
1811 assert!(json.contains("\"task\":\"42\""));
1812 }
1813
1814 #[test]
1815 fn merge_confidence_scored_includes_all_fields() {
1816 let event = TeamEvent::merge_confidence_scored(&MergeConfidenceInfo {
1817 engineer: "eng-1-1",
1818 task: "42",
1819 confidence: 0.85,
1820 files_changed: 3,
1821 lines_changed: 50,
1822 has_migrations: true,
1823 has_config_changes: false,
1824 rename_count: 1,
1825 });
1826 let json = serde_json::to_string(&event).unwrap();
1827 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1828 assert_eq!(parsed["event"].as_str().unwrap(), "merge_confidence_scored");
1829 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1830 assert_eq!(parsed["task"].as_str().unwrap(), "42");
1831 assert!((parsed["load"].as_f64().unwrap() - 0.85).abs() < 0.001);
1832 let reason = parsed["reason"].as_str().unwrap();
1833 assert!(reason.contains("files=3"));
1834 assert!(reason.contains("lines=50"));
1835 assert!(reason.contains("migrations=true"));
1836 assert!(reason.contains("config=false"));
1837 assert!(reason.contains("renames=1"));
1838 }
1839
1840 #[test]
1841 fn auto_merge_decision_recorded_serializes_reason_and_details() {
1842 let event = TeamEvent::auto_merge_decision_recorded(&AutoMergeDecisionInfo {
1843 engineer: "eng-1-1",
1844 task: "42",
1845 action_type: "manual_review",
1846 confidence: 0.55,
1847 reason: "routed to manual review: confidence 0.55; 4 files, 90 lines, 3 modules; reasons: touches sensitive paths",
1848 details: "{\"decision\":\"manual_review\",\"reasons\":[\"touches sensitive paths\"]}",
1849 });
1850 let json = serde_json::to_string(&event).unwrap();
1851 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1852 assert_eq!(
1853 parsed["event"].as_str().unwrap(),
1854 "auto_merge_decision_recorded"
1855 );
1856 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1857 assert_eq!(parsed["task"].as_str().unwrap(), "42");
1858 assert_eq!(parsed["action_type"].as_str().unwrap(), "manual_review");
1859 assert!((parsed["load"].as_f64().unwrap() - 0.55).abs() < 0.001);
1860 assert!(parsed["reason"].as_str().unwrap().contains("manual review"));
1861 assert!(
1862 parsed["details"]
1863 .as_str()
1864 .unwrap()
1865 .contains("\"touches sensitive paths\"")
1866 );
1867 }
1868
1869 #[test]
1870 fn auto_merge_post_verify_result_serializes_success() {
1871 let event = TeamEvent::auto_merge_post_verify_result(
1872 "eng-1",
1873 "42",
1874 Some(false),
1875 "failed",
1876 Some("post-merge verification on main failed"),
1877 );
1878 let json = serde_json::to_string(&event).unwrap();
1879 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1880 assert_eq!(
1881 parsed["event"].as_str().unwrap(),
1882 "auto_merge_post_verify_result"
1883 );
1884 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
1885 assert_eq!(parsed["task"].as_str().unwrap(), "42");
1886 assert!(!parsed["success"].as_bool().unwrap());
1887 assert_eq!(parsed["reason"].as_str().unwrap(), "failed");
1888 }
1889
1890 #[test]
1891 fn merge_events_serialize_merge_mode_and_outcome() {
1892 let auto = TeamEvent::task_auto_merged_with_mode(
1893 "eng-1",
1894 "42",
1895 0.95,
1896 2,
1897 30,
1898 Some(MergeMode::DirectRoot),
1899 );
1900 let auto_json: serde_json::Value =
1901 serde_json::from_str(&serde_json::to_string(&auto).unwrap()).unwrap();
1902 assert_eq!(auto_json["merge_mode"].as_str().unwrap(), "direct_root");
1903 assert!(auto_json["success"].as_bool().unwrap());
1904
1905 let manual =
1906 TeamEvent::task_manual_merged_with_mode("43", Some(MergeMode::IsolatedIntegration));
1907 let manual_json: serde_json::Value =
1908 serde_json::from_str(&serde_json::to_string(&manual).unwrap()).unwrap();
1909 assert_eq!(
1910 manual_json["merge_mode"].as_str().unwrap(),
1911 "isolated_integration"
1912 );
1913 assert!(manual_json["success"].as_bool().unwrap());
1914
1915 let failed = TeamEvent::task_merge_failed(
1916 "eng-2",
1917 "44",
1918 Some(MergeMode::IsolatedIntegration),
1919 "isolated merge path failed: broken",
1920 );
1921 let failed_json: serde_json::Value =
1922 serde_json::from_str(&serde_json::to_string(&failed).unwrap()).unwrap();
1923 assert_eq!(
1924 failed_json["merge_mode"].as_str().unwrap(),
1925 "isolated_integration"
1926 );
1927 assert!(!failed_json["success"].as_bool().unwrap());
1928 assert_eq!(failed_json["reason"].as_str().unwrap(), "merge_failed");
1929 }
1930
1931 #[test]
1932 fn performance_regression_serializes_task_and_reason() {
1933 let event = TeamEvent::performance_regression("42", "runtime_ms=1300 avg_ms=1000 pct=30");
1934 let json = serde_json::to_string(&event).unwrap();
1935 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1936 assert_eq!(parsed["event"].as_str().unwrap(), "performance_regression");
1937 assert_eq!(parsed["task"].as_str().unwrap(), "42");
1938 assert_eq!(
1939 parsed["reason"].as_str().unwrap(),
1940 "runtime_ms=1300 avg_ms=1000 pct=30"
1941 );
1942 }
1943
1944 #[test]
1945 fn daemon_stopped_with_reason_includes_fields() {
1946 let event = TeamEvent::daemon_stopped_with_reason("signal", 7200);
1947 let json = serde_json::to_string(&event).unwrap();
1948 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1949 assert_eq!(parsed["reason"].as_str().unwrap(), "signal");
1950 assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 7200);
1951 }
1952
1953 #[test]
1954 fn heartbeat_includes_uptime() {
1955 let event = TeamEvent::daemon_heartbeat(600);
1956 let json = serde_json::to_string(&event).unwrap();
1957 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1958 assert_eq!(parsed["event"].as_str().unwrap(), "daemon_heartbeat");
1959 assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 600);
1960 assert!(parsed.get("reason").is_none());
1962 assert!(parsed.get("step").is_none());
1963 }
1964
1965 #[test]
1966 fn context_exhausted_includes_role_task_and_session_size() {
1967 let event = TeamEvent::context_exhausted("eng-1", Some(77), Some(4096));
1968 let json = serde_json::to_string(&event).unwrap();
1969 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1970 assert_eq!(parsed["event"].as_str().unwrap(), "context_exhausted");
1971 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
1972 assert_eq!(parsed["task"].as_str().unwrap(), "77");
1973 assert_eq!(parsed["session_size_bytes"].as_u64().unwrap(), 4096);
1974 }
1975
1976 #[test]
1977 fn loop_step_error_includes_step_and_error() {
1978 let event = TeamEvent::loop_step_error("poll_watchers", "connection refused");
1979 let json = serde_json::to_string(&event).unwrap();
1980 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1981 assert_eq!(parsed["step"].as_str().unwrap(), "poll_watchers");
1982 assert_eq!(parsed["error"].as_str().unwrap(), "connection refused");
1983 }
1984
1985 #[test]
1986 fn daemon_panic_includes_reason() {
1987 let event = TeamEvent::daemon_panic("index out of bounds");
1988 let json = serde_json::to_string(&event).unwrap();
1989 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1990 assert_eq!(parsed["event"].as_str().unwrap(), "daemon_panic");
1991 assert_eq!(parsed["reason"].as_str().unwrap(), "index out of bounds");
1992 }
1993
1994 #[test]
1995 fn new_fields_omitted_from_basic_events() {
1996 let event = TeamEvent::daemon_started();
1997 let json = serde_json::to_string(&event).unwrap();
1998 assert!(!json.contains("\"reason\""));
1999 assert!(!json.contains("\"step\""));
2000 assert!(!json.contains("\"error\""));
2001 assert!(!json.contains("\"uptime_secs\""));
2002 assert!(!json.contains("\"restart_count\""));
2003 }
2004
2005 #[test]
2006 fn pattern_detected_includes_reason_payload() {
2007 let event = TeamEvent::pattern_detected("escalation_cluster", 6);
2008 let json = serde_json::to_string(&event).unwrap();
2009 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2010 assert_eq!(parsed["event"].as_str().unwrap(), "pattern_detected");
2011 assert_eq!(parsed["reason"].as_str().unwrap(), "escalation_cluster:6");
2012 }
2013
2014 #[test]
2015 fn event_sink_creates_parent_directories() {
2016 let tmp = tempfile::tempdir().unwrap();
2017 let path = tmp.path().join("deep").join("nested").join("events.jsonl");
2018 let mut sink = EventSink::new(&path).unwrap();
2019 sink.emit(TeamEvent::daemon_started()).unwrap();
2020 assert!(path.exists());
2021 assert_eq!(sink.path(), path);
2022 }
2023
2024 #[test]
2025 fn event_sink_rotates_oversized_log_on_open() {
2026 let tmp = tempfile::tempdir().unwrap();
2027 let path = tmp.path().join("events.jsonl");
2028 fs::write(&path, "0123456789").unwrap();
2029
2030 let mut sink = EventSink::new_with_max_bytes(&path, 5).unwrap();
2031 sink.emit(TeamEvent::daemon_started()).unwrap();
2032
2033 let rotated = rotated_event_log_path(&path);
2034 assert_eq!(fs::read_to_string(&rotated).unwrap(), "0123456789");
2035 let current = fs::read_to_string(&path).unwrap();
2036 assert!(current.contains("daemon_started"));
2037 }
2038
2039 #[test]
2040 fn event_sink_rotates_before_write_that_would_exceed_threshold() {
2041 let tmp = tempfile::tempdir().unwrap();
2042 let path = tmp.path().join("events.jsonl");
2043 let first_line = "{\"event\":\"first\"}\n";
2044 fs::write(&path, first_line).unwrap();
2045
2046 let mut sink = EventSink::new_with_max_bytes(&path, first_line.len() as u64 + 10).unwrap();
2047 sink.emit(TeamEvent::task_assigned(
2048 "eng-1",
2049 "this assignment is long enough to rotate",
2050 ))
2051 .unwrap();
2052
2053 let rotated = rotated_event_log_path(&path);
2054 assert_eq!(fs::read_to_string(&rotated).unwrap(), first_line);
2055 let current = fs::read_to_string(&path).unwrap();
2056 assert!(current.contains("task_assigned"));
2057 assert!(!current.contains("\"event\":\"first\""));
2058 }
2059
2060 #[test]
2061 fn event_round_trip_preserves_fields_for_agent_restarted() {
2062 let original = TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 3);
2063
2064 let json = serde_json::to_string(&original).unwrap();
2065 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2066
2067 assert_eq!(parsed.event, "agent_restarted");
2068 assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2069 assert_eq!(parsed.task.as_deref(), Some("42"));
2070 assert_eq!(parsed.reason.as_deref(), Some("context_exhausted"));
2071 assert_eq!(parsed.restart_count, Some(3));
2072 assert_eq!(parsed.ts, original.ts);
2073 }
2074
2075 #[test]
2076 fn event_round_trip_preserves_fields_for_task_resumed() {
2077 let original = TeamEvent::task_resumed("eng-1", "42", "context_pressure", 1);
2078
2079 let json = serde_json::to_string(&original).unwrap();
2080 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2081
2082 assert_eq!(parsed.event, "task_resumed");
2083 assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2084 assert_eq!(parsed.task.as_deref(), Some("42"));
2085 assert_eq!(parsed.reason.as_deref(), Some("context_pressure"));
2086 assert_eq!(parsed.restart_count, Some(1));
2087 assert_eq!(parsed.ts, original.ts);
2088 }
2089
2090 #[test]
2091 fn event_round_trip_preserves_fields_for_load_snapshot() {
2092 let original = TeamEvent::load_snapshot(4, 8, true);
2093
2094 let json = serde_json::to_string(&original).unwrap();
2095 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2096
2097 assert_eq!(parsed.event, "load_snapshot");
2098 assert_eq!(parsed.working_members, Some(4));
2099 assert_eq!(parsed.total_members, Some(8));
2100 assert_eq!(parsed.session_running, Some(true));
2101 assert_eq!(parsed.load, Some(0.5));
2102 assert_eq!(parsed.ts, original.ts);
2103 }
2104
2105 #[test]
2106 fn event_round_trip_preserves_fields_for_delivery_failed() {
2107 let original = TeamEvent::delivery_failed("eng-2", "manager", "marker missing");
2108
2109 let json = serde_json::to_string(&original).unwrap();
2110 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2111
2112 assert_eq!(parsed.event, "delivery_failed");
2113 assert_eq!(parsed.role.as_deref(), Some("eng-2"));
2114 assert_eq!(parsed.from.as_deref(), Some("manager"));
2115 assert_eq!(parsed.reason.as_deref(), Some("marker missing"));
2116 assert!(parsed.details.is_none());
2117 assert_eq!(parsed.ts, original.ts);
2118 }
2119
2120 #[test]
2121 fn read_events_skips_blank_and_malformed_lines() {
2122 let tmp = tempfile::tempdir().unwrap();
2123 let path = tmp.path().join("events.jsonl");
2124 fs::write(
2125 &path,
2126 [
2127 "",
2128 "{\"event\":\"daemon_started\",\"ts\":1}",
2129 "not-json",
2130 " ",
2131 "{\"event\":\"daemon_stopped\",\"ts\":2}",
2132 ]
2133 .join("\n"),
2134 )
2135 .unwrap();
2136
2137 let events = read_events(&path).unwrap();
2138
2139 assert_eq!(events.len(), 2);
2140 assert_eq!(events[0].event, "daemon_started");
2141 assert_eq!(events[1].event, "daemon_stopped");
2142 }
2143
2144 #[test]
2145 fn rotate_event_log_if_needed_returns_false_for_missing_file() {
2146 let tmp = tempfile::tempdir().unwrap();
2147 let path = tmp.path().join("events.jsonl");
2148
2149 let rotated = rotate_event_log_if_needed(&path, 128, 0).unwrap();
2150
2151 assert!(!rotated);
2152 assert!(!rotated_event_log_path(&path).exists());
2153 }
2154
2155 #[test]
2156 fn rotate_event_log_if_needed_returns_false_for_empty_file() {
2157 let tmp = tempfile::tempdir().unwrap();
2158 let path = tmp.path().join("events.jsonl");
2159 fs::write(&path, "").unwrap();
2160
2161 let rotated = rotate_event_log_if_needed(&path, 1, 1).unwrap();
2162
2163 assert!(!rotated);
2164 assert!(path.exists());
2165 assert!(!rotated_event_log_path(&path).exists());
2166 }
2167
2168 #[test]
2169 fn rotate_event_log_if_needed_replaces_existing_rotated_file() {
2170 let tmp = tempfile::tempdir().unwrap();
2171 let path = tmp.path().join("events.jsonl");
2172 let rotated_path = rotated_event_log_path(&path);
2173 fs::write(&path, "current-events").unwrap();
2174 fs::write(&rotated_path, "old-rotated-events").unwrap();
2175
2176 let rotated = rotate_event_log_if_needed(&path, 5, 0).unwrap();
2177
2178 assert!(rotated);
2179 assert_eq!(fs::read_to_string(&rotated_path).unwrap(), "current-events");
2180 }
2181
2182 #[test]
2183 fn concurrent_event_sinks_append_without_losing_lines() {
2184 let tmp = tempfile::tempdir().unwrap();
2185 let path = Arc::new(tmp.path().join("events.jsonl"));
2186 let ready = Arc::new(std::sync::Barrier::new(5));
2187 let errors = Arc::new(Mutex::new(Vec::<String>::new()));
2188 let mut handles = Vec::new();
2189
2190 for idx in 0..4 {
2191 let path = Arc::clone(&path);
2192 let ready = Arc::clone(&ready);
2193 let errors = Arc::clone(&errors);
2194 handles.push(thread::spawn(move || {
2195 ready.wait();
2196 let result = (|| -> Result<()> {
2197 let mut sink = EventSink::new(&path)?;
2198 sink.emit(TeamEvent::task_assigned(
2199 &format!("eng-{idx}"),
2200 &format!("task-{idx}"),
2201 ))?;
2202 Ok(())
2203 })();
2204 if let Err(error) = result {
2205 errors.lock().unwrap().push(error.to_string());
2206 }
2207 }));
2208 }
2209
2210 ready.wait();
2211 for handle in handles {
2212 handle.join().unwrap();
2213 }
2214
2215 assert!(errors.lock().unwrap().is_empty());
2216 let events = read_events(&path).unwrap();
2217 assert_eq!(events.len(), 4);
2218 for idx in 0..4 {
2219 assert!(
2220 events
2221 .iter()
2222 .any(|event| event.role.as_deref() == Some(&format!("eng-{idx}")))
2223 );
2224 }
2225 }
2226
2227 #[test]
2228 fn read_events_handles_large_log_file() {
2229 let tmp = tempfile::tempdir().unwrap();
2230 let path = tmp.path().join("events.jsonl");
2231 let mut sink = EventSink::new(&path).unwrap();
2232
2233 for idx in 0..512 {
2234 sink.emit(TeamEvent::task_assigned(
2235 &format!("eng-{idx}"),
2236 &"x".repeat(128),
2237 ))
2238 .unwrap();
2239 }
2240
2241 let events = read_events(&path).unwrap();
2242
2243 assert_eq!(events.len(), 512);
2244 assert_eq!(events.first().unwrap().event, "task_assigned");
2245 assert_eq!(events.last().unwrap().event, "task_assigned");
2246 }
2247
2248 fn production_unwrap_expect_count(source: &str) -> usize {
2249 let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
2250 &source[..pos]
2251 } else {
2252 source
2253 };
2254 prod.lines()
2255 .filter(|line| {
2256 let trimmed = line.trim();
2257 !trimmed.starts_with("#[cfg(test)]")
2258 && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
2259 })
2260 .count()
2261 }
2262
2263 #[test]
2264 fn task_completed_includes_task_id() {
2265 let event = TeamEvent::task_completed("eng-1", Some("42"));
2266 let json = serde_json::to_string(&event).unwrap();
2267 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2268 assert_eq!(parsed["event"].as_str().unwrap(), "task_completed");
2269 assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
2270 assert_eq!(parsed["task"].as_str().unwrap(), "42");
2271 }
2272
2273 #[test]
2274 fn task_completed_without_task_id_omits_task_field() {
2275 let event = TeamEvent::task_completed("eng-1", None);
2276 let json = serde_json::to_string(&event).unwrap();
2277 assert!(json.contains("\"event\":\"task_completed\""));
2278 assert!(json.contains("\"role\":\"eng-1\""));
2279 assert!(!json.contains("\"task\""));
2280 }
2281
2282 #[test]
2283 fn task_escalated_without_reason_omits_reason_field() {
2284 let event = TeamEvent::task_escalated("eng-1", "42", None);
2285 let json = serde_json::to_string(&event).unwrap();
2286 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2287 assert_eq!(parsed["event"].as_str().unwrap(), "task_escalated");
2288 assert_eq!(parsed["task"].as_str().unwrap(), "42");
2289 assert!(parsed.get("reason").is_none());
2290 }
2291
2292 #[test]
2293 fn task_escalated_with_reason_includes_reason_field() {
2294 let event = TeamEvent::task_escalated("eng-1", "42", Some("merge_conflict"));
2295 let json = serde_json::to_string(&event).unwrap();
2296 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2297 assert_eq!(parsed["event"].as_str().unwrap(), "task_escalated");
2298 assert_eq!(parsed["task"].as_str().unwrap(), "42");
2299 assert_eq!(parsed["reason"].as_str().unwrap(), "merge_conflict");
2300 }
2301
2302 #[test]
2303 fn task_completed_round_trip_preserves_task_id() {
2304 let original = TeamEvent::task_completed("eng-1", Some("99"));
2305 let json = serde_json::to_string(&original).unwrap();
2306 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2307 assert_eq!(parsed.event, "task_completed");
2308 assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2309 assert_eq!(parsed.task.as_deref(), Some("99"));
2310 }
2311
2312 #[test]
2313 fn task_escalated_round_trip_preserves_reason() {
2314 let original = TeamEvent::task_escalated("eng-1", "42", Some("context_exhausted"));
2315 let json = serde_json::to_string(&original).unwrap();
2316 let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2317 assert_eq!(parsed.event, "task_escalated");
2318 assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2319 assert_eq!(parsed.task.as_deref(), Some("42"));
2320 assert_eq!(parsed.reason.as_deref(), Some("context_exhausted"));
2321 }
2322
2323 #[test]
2324 fn production_events_has_no_unwrap_or_expect_calls() {
2325 let src = include_str!("events.rs");
2326 assert_eq!(
2327 production_unwrap_expect_count(src),
2328 0,
2329 "production events.rs should avoid unwrap/expect"
2330 );
2331 }
2332
2333 #[test]
2334 fn stall_detected_event_fields() {
2335 let event = TeamEvent::stall_detected("eng-1-1", Some(42), 300);
2336 assert_eq!(event.event, "stall_detected");
2337 assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2338 assert_eq!(event.task.as_deref(), Some("42"));
2339 assert_eq!(event.uptime_secs, Some(300));
2340 }
2341
2342 #[test]
2343 fn stall_detected_event_without_task() {
2344 let event = TeamEvent::stall_detected("eng-1-1", None, 600);
2345 assert_eq!(event.event, "stall_detected");
2346 assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2347 assert!(event.task.is_none());
2348 assert_eq!(event.uptime_secs, Some(600));
2349 }
2350
2351 #[test]
2352 fn stall_detected_event_with_reason_records_heuristic() {
2353 let event = TeamEvent::stall_detected_with_reason(
2354 "lead",
2355 None,
2356 300,
2357 Some("supervisory_stalled_manager_shim_activity_only"),
2358 );
2359 assert_eq!(
2360 event.reason.as_deref(),
2361 Some("supervisory_stalled_manager_shim_activity_only")
2362 );
2363 }
2364
2365 #[test]
2366 fn stall_detected_event_serializes_to_jsonl() {
2367 let event = TeamEvent::stall_detected("eng-1-1", Some(42), 300);
2368 let json = serde_json::to_string(&event).unwrap();
2369 assert!(json.contains("\"stall_detected\""));
2370 assert!(json.contains("\"eng-1-1\""));
2371 assert!(json.contains("\"42\""));
2372 }
2373
2374 #[test]
2375 fn health_changed_event_fields() {
2376 let event = TeamEvent::health_changed("eng-1-1", "healthy→unreachable");
2377 assert_eq!(event.event, "health_changed");
2378 assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2379 assert_eq!(event.reason.as_deref(), Some("healthy→unreachable"));
2380 }
2381
2382 #[test]
2383 fn health_changed_event_serializes_to_jsonl() {
2384 let event = TeamEvent::health_changed("eng-1-2", "unreachable→healthy");
2385 let json = serde_json::to_string(&event).unwrap();
2386 assert!(json.contains("\"health_changed\""));
2387 assert!(json.contains("\"eng-1-2\""));
2388 }
2389
2390 #[test]
2393 fn event_sink_on_readonly_dir_returns_error() {
2394 #[cfg(unix)]
2395 {
2396 use std::os::unix::fs::PermissionsExt;
2397 let tmp = tempfile::tempdir().unwrap();
2398 let readonly_dir = tmp.path().join("readonly");
2399 fs::create_dir(&readonly_dir).unwrap();
2400 fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o444)).unwrap();
2401
2402 let path = readonly_dir.join("subdir").join("events.jsonl");
2403 let result = EventSink::new(&path);
2404 assert!(result.is_err());
2405
2406 fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o755)).unwrap();
2408 }
2409 }
2410
2411 #[test]
2412 fn read_events_from_nonexistent_file_returns_empty() {
2413 let tmp = tempfile::tempdir().unwrap();
2414 let path = tmp.path().join("does_not_exist.jsonl");
2415 let events = read_events(&path).unwrap();
2416 assert!(events.is_empty());
2417 }
2418
2419 #[test]
2420 fn read_events_all_malformed_lines_returns_empty() {
2421 let tmp = tempfile::tempdir().unwrap();
2422 let path = tmp.path().join("events.jsonl");
2423 fs::write(&path, "not json\nalso not json\n{invalid}\n").unwrap();
2424 let events = read_events(&path).unwrap();
2425 assert!(events.is_empty());
2426 }
2427
2428 #[test]
2429 fn event_sink_emit_with_failing_writer() {
2430 struct FailWriter;
2431 impl Write for FailWriter {
2432 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2433 Err(std::io::Error::new(
2434 std::io::ErrorKind::BrokenPipe,
2435 "simulated write failure",
2436 ))
2437 }
2438 fn flush(&mut self) -> std::io::Result<()> {
2439 Err(std::io::Error::new(
2440 std::io::ErrorKind::BrokenPipe,
2441 "simulated flush failure",
2442 ))
2443 }
2444 }
2445
2446 let tmp = tempfile::tempdir().unwrap();
2447 let path = tmp.path().join("events.jsonl");
2448 let mut sink = EventSink::from_writer(&path, FailWriter);
2449 let result = sink.emit(TeamEvent::daemon_started());
2450 assert!(result.is_err());
2451 }
2452
2453 #[test]
2454 fn rotate_event_log_replaces_stale_rotated_file() {
2455 let tmp = tempfile::tempdir().unwrap();
2456 let path = tmp.path().join("events.jsonl");
2457 let rotated = rotated_event_log_path(&path);
2458
2459 fs::write(&path, "current-data-that-is-large").unwrap();
2460 fs::write(&rotated, "old-rotated-data").unwrap();
2461
2462 let did_rotate = rotate_event_log_if_needed(&path, 5, 0).unwrap();
2463 assert!(did_rotate);
2464 assert_eq!(
2466 fs::read_to_string(&rotated).unwrap(),
2467 "current-data-that-is-large"
2468 );
2469 assert!(!path.exists());
2471 }
2472
2473 #[test]
2474 fn event_sink_handles_zero_max_bytes_rotation() {
2475 let tmp = tempfile::tempdir().unwrap();
2476 let path = tmp.path().join("events.jsonl");
2477
2478 fs::write(&path, "").unwrap();
2480 let did_rotate = rotate_event_log_if_needed(&path, 0, 0).unwrap();
2481 assert!(!did_rotate); fs::write(&path, "x").unwrap();
2484 let did_rotate = rotate_event_log_if_needed(&path, 0, 0).unwrap();
2485 assert!(did_rotate); }
2487
2488 #[test]
2489 fn narration_rejection_event_has_correct_fields() {
2490 let event = TeamEvent::narration_rejection("eng-1-1", 42, 2);
2491 assert_eq!(event.event, "narration_rejection");
2492 assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2493 assert_eq!(event.task.as_deref(), Some("42"));
2494 assert_eq!(event.reason.as_deref(), Some("rejection_count=2"));
2495 }
2496
2497 #[test]
2498 fn narration_events_have_correct_fields() {
2499 let detected = TeamEvent::narration_detected("eng-1-4", Some(415));
2500 assert_eq!(detected.event, "narration_detected");
2501 assert_eq!(detected.role.as_deref(), Some("eng-1-4"));
2502 assert_eq!(detected.task.as_deref(), Some("415"));
2503
2504 let nudged = TeamEvent::narration_nudged("eng-1-4", Some(415));
2505 assert_eq!(nudged.event, "narration_nudged");
2506 assert_eq!(nudged.role.as_deref(), Some("eng-1-4"));
2507 assert_eq!(nudged.task.as_deref(), Some("415"));
2508
2509 let restarted = TeamEvent::narration_restart("eng-1-4", Some(415));
2510 assert_eq!(restarted.event, "narration_restart");
2511 assert_eq!(restarted.role.as_deref(), Some("eng-1-4"));
2512 assert_eq!(restarted.task.as_deref(), Some("415"));
2513 }
2514
2515 #[test]
2516 fn read_events_partial_json_with_valid_lines_mixed() {
2517 let tmp = tempfile::tempdir().unwrap();
2518 let path = tmp.path().join("events.jsonl");
2519 let content = format!(
2521 "{}\n{{\"event\":\"trunca\n{}\n",
2522 r#"{"event":"daemon_started","ts":1}"#, r#"{"event":"daemon_stopped","ts":3}"#
2523 );
2524 fs::write(&path, content).unwrap();
2525
2526 let events = read_events(&path).unwrap();
2527 assert_eq!(events.len(), 2);
2528 assert_eq!(events[0].event, "daemon_started");
2529 assert_eq!(events[1].event, "daemon_stopped");
2530 }
2531
2532 #[test]
2537 fn session_lifecycle_serialization_roundtrip() {
2538 let variants = [
2539 SessionLifecycle::Spawning,
2540 SessionLifecycle::Ready,
2541 SessionLifecycle::Working,
2542 SessionLifecycle::Blocked,
2543 SessionLifecycle::Idle,
2544 SessionLifecycle::Finished,
2545 SessionLifecycle::Failed,
2546 ];
2547 for variant in &variants {
2548 let json = serde_json::to_string(variant).unwrap();
2549 let deserialized: SessionLifecycle = serde_json::from_str(&json).unwrap();
2550 assert_eq!(*variant, deserialized);
2551 }
2552 }
2553
2554 #[test]
2555 fn task_lifecycle_serialization_roundtrip() {
2556 let variants = [
2557 TaskLifecycle::Claimed,
2558 TaskLifecycle::InProgress,
2559 TaskLifecycle::TestsRunning,
2560 TaskLifecycle::TestsPassed,
2561 TaskLifecycle::TestsFailed,
2562 TaskLifecycle::Review,
2563 TaskLifecycle::Merged,
2564 TaskLifecycle::Rejected,
2565 ];
2566 for variant in &variants {
2567 let json = serde_json::to_string(variant).unwrap();
2568 let deserialized: TaskLifecycle = serde_json::from_str(&json).unwrap();
2569 assert_eq!(*variant, deserialized);
2570 }
2571 }
2572
2573 #[test]
2574 fn merge_lifecycle_serialization_roundtrip() {
2575 let variants = [
2576 MergeLifecycle::Started,
2577 MergeLifecycle::Conflict,
2578 MergeLifecycle::Success,
2579 ];
2580 for variant in &variants {
2581 let json = serde_json::to_string(variant).unwrap();
2582 let deserialized: MergeLifecycle = serde_json::from_str(&json).unwrap();
2583 assert_eq!(*variant, deserialized);
2584 }
2585 }
2586
2587 #[test]
2588 fn lifecycle_event_serialization_roundtrip() {
2589 let event = LifecycleEvent::new(
2590 LifecycleEventType::Session(SessionLifecycle::Ready),
2591 "eng-1-1",
2592 Some(42),
2593 Some("agent is ready".into()),
2594 );
2595 let json = serde_json::to_string(&event).unwrap();
2596 let deserialized: LifecycleEvent = serde_json::from_str(&json).unwrap();
2597
2598 assert_eq!(deserialized.schema_version, LifecycleEvent::SCHEMA_VERSION);
2599 assert_eq!(deserialized.agent_name, "eng-1-1");
2600 assert_eq!(deserialized.task_id, Some(42));
2601 assert_eq!(deserialized.details.as_deref(), Some("agent is ready"));
2602 assert_eq!(
2603 deserialized.event_type,
2604 LifecycleEventType::Session(SessionLifecycle::Ready)
2605 );
2606 }
2607
2608 #[test]
2609 fn lifecycle_event_optional_fields_omitted() {
2610 let event = LifecycleEvent::new(
2611 LifecycleEventType::Task(TaskLifecycle::Claimed),
2612 "eng-1-2",
2613 None,
2614 None,
2615 );
2616 let json = serde_json::to_string(&event).unwrap();
2617 assert!(!json.contains("task_id"));
2618 assert!(!json.contains("details"));
2619 assert!(json.contains("\"agent_name\":\"eng-1-2\""));
2620 }
2621
2622 #[test]
2623 fn lifecycle_event_type_tagged_serialization() {
2624 let session = LifecycleEventType::Session(SessionLifecycle::Working);
2626 let json = serde_json::to_string(&session).unwrap();
2627 assert!(json.contains("\"category\":\"session\""));
2628 assert!(json.contains("\"phase\":\"working\""));
2629
2630 let task = LifecycleEventType::Task(TaskLifecycle::TestsRunning);
2632 let json = serde_json::to_string(&task).unwrap();
2633 assert!(json.contains("\"category\":\"task\""));
2634 assert!(json.contains("\"phase\":\"tests_running\""));
2635
2636 let merge = LifecycleEventType::Merge(MergeLifecycle::Conflict);
2638 let json = serde_json::to_string(&merge).unwrap();
2639 assert!(json.contains("\"category\":\"merge\""));
2640 assert!(json.contains("\"phase\":\"conflict\""));
2641 }
2642
2643 #[test]
2644 fn emit_lifecycle_event_writes_to_file() {
2645 let tmp = tempfile::tempdir().unwrap();
2646 let path = tmp.path().join("events.jsonl");
2647
2648 let event = LifecycleEvent::new(
2649 LifecycleEventType::Session(SessionLifecycle::Spawning),
2650 "eng-1-1",
2651 None,
2652 Some("starting up".into()),
2653 );
2654 emit_lifecycle_event(&path, &event).unwrap();
2655
2656 let content = fs::read_to_string(&path).unwrap();
2657 let lines: Vec<&str> = content.lines().collect();
2658 assert_eq!(lines.len(), 1);
2659
2660 let parsed: LifecycleEvent = serde_json::from_str(lines[0]).unwrap();
2661 assert_eq!(parsed.agent_name, "eng-1-1");
2662 assert_eq!(
2663 parsed.event_type,
2664 LifecycleEventType::Session(SessionLifecycle::Spawning)
2665 );
2666 }
2667
2668 #[test]
2669 fn emit_lifecycle_event_appends_multiple() {
2670 let tmp = tempfile::tempdir().unwrap();
2671 let path = tmp.path().join("events.jsonl");
2672
2673 let events = [
2674 LifecycleEvent::new(
2675 LifecycleEventType::Task(TaskLifecycle::Claimed),
2676 "eng-1-1",
2677 Some(100),
2678 None,
2679 ),
2680 LifecycleEvent::new(
2681 LifecycleEventType::Task(TaskLifecycle::InProgress),
2682 "eng-1-1",
2683 Some(100),
2684 None,
2685 ),
2686 LifecycleEvent::new(
2687 LifecycleEventType::Task(TaskLifecycle::TestsPassed),
2688 "eng-1-1",
2689 Some(100),
2690 Some("all 42 tests passed".into()),
2691 ),
2692 ];
2693 for event in &events {
2694 emit_lifecycle_event(&path, event).unwrap();
2695 }
2696
2697 let content = fs::read_to_string(&path).unwrap();
2698 let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
2699 assert_eq!(lines.len(), 3);
2700
2701 let parsed: LifecycleEvent = serde_json::from_str(lines[2]).unwrap();
2702 assert_eq!(
2703 parsed.event_type,
2704 LifecycleEventType::Task(TaskLifecycle::TestsPassed)
2705 );
2706 assert_eq!(parsed.details.as_deref(), Some("all 42 tests passed"));
2707 }
2708
2709 #[test]
2710 fn emit_lifecycle_event_creates_parent_dirs() {
2711 let tmp = tempfile::tempdir().unwrap();
2712 let path = tmp.path().join("nested").join("dir").join("events.jsonl");
2713
2714 let event = LifecycleEvent::new(
2715 LifecycleEventType::Merge(MergeLifecycle::Success),
2716 "eng-1-3",
2717 Some(200),
2718 None,
2719 );
2720 emit_lifecycle_event(&path, &event).unwrap();
2721 assert!(path.exists());
2722 }
2723
2724 #[test]
2725 fn lifecycle_event_schema_version_is_one() {
2726 let event = LifecycleEvent::new(
2727 LifecycleEventType::Session(SessionLifecycle::Failed),
2728 "eng-1-1",
2729 None,
2730 Some("out of context".into()),
2731 );
2732 assert_eq!(event.schema_version, 1);
2733 let json = serde_json::to_string(&event).unwrap();
2734 assert!(json.contains("\"schema_version\":1"));
2735 }
2736
2737 #[test]
2738 fn all_session_variants_snake_case_names() {
2739 let expected = [
2740 (SessionLifecycle::Spawning, "spawning"),
2741 (SessionLifecycle::Ready, "ready"),
2742 (SessionLifecycle::Working, "working"),
2743 (SessionLifecycle::Blocked, "blocked"),
2744 (SessionLifecycle::Idle, "idle"),
2745 (SessionLifecycle::Finished, "finished"),
2746 (SessionLifecycle::Failed, "failed"),
2747 ];
2748 for (variant, name) in &expected {
2749 let json = serde_json::to_string(variant).unwrap();
2750 assert_eq!(json, format!("\"{}\"", name));
2751 }
2752 }
2753
2754 #[test]
2755 fn all_task_variants_snake_case_names() {
2756 let expected = [
2757 (TaskLifecycle::Claimed, "claimed"),
2758 (TaskLifecycle::InProgress, "in_progress"),
2759 (TaskLifecycle::TestsRunning, "tests_running"),
2760 (TaskLifecycle::TestsPassed, "tests_passed"),
2761 (TaskLifecycle::TestsFailed, "tests_failed"),
2762 (TaskLifecycle::Review, "review"),
2763 (TaskLifecycle::Merged, "merged"),
2764 (TaskLifecycle::Rejected, "rejected"),
2765 ];
2766 for (variant, name) in &expected {
2767 let json = serde_json::to_string(variant).unwrap();
2768 assert_eq!(json, format!("\"{}\"", name));
2769 }
2770 }
2771
2772 #[test]
2773 fn all_merge_variants_snake_case_names() {
2774 let expected = [
2775 (MergeLifecycle::Started, "started"),
2776 (MergeLifecycle::Conflict, "conflict"),
2777 (MergeLifecycle::Success, "success"),
2778 ];
2779 for (variant, name) in &expected {
2780 let json = serde_json::to_string(variant).unwrap();
2781 assert_eq!(json, format!("\"{}\"", name));
2782 }
2783 }
2784}