Skip to main content

terraphim_orchestrator/
handoff.rs

1use std::collections::HashMap;
2use std::fs::OpenOptions;
3use std::io::{BufRead, BufReader, Write};
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Shallow context transferred between agents during handoff.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct HandoffContext {
13    /// Unique ID for each handoff.
14    pub handoff_id: Uuid,
15    /// Source agent name.
16    pub from_agent: String,
17    /// Target agent name.
18    pub to_agent: String,
19    /// Task description being handed off.
20    pub task: String,
21    /// Summary of work completed so far.
22    pub progress_summary: String,
23    /// Key decisions made.
24    pub decisions: Vec<String>,
25    /// Files modified.
26    pub files_touched: Vec<PathBuf>,
27    /// Timestamp of handoff.
28    pub timestamp: chrono::DateTime<chrono::Utc>,
29    /// Time-to-live in seconds (None = use buffer default).
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub ttl_secs: Option<u64>,
32}
33
34impl HandoffContext {
35    /// Create a new HandoffContext with a generated UUID and current timestamp.
36    pub fn new(
37        from_agent: impl Into<String>,
38        to_agent: impl Into<String>,
39        task: impl Into<String>,
40    ) -> Self {
41        Self {
42            handoff_id: Uuid::new_v4(),
43            from_agent: from_agent.into(),
44            to_agent: to_agent.into(),
45            task: task.into(),
46            progress_summary: String::new(),
47            decisions: Vec::new(),
48            files_touched: Vec::new(),
49            timestamp: chrono::Utc::now(),
50            ttl_secs: None,
51        }
52    }
53
54    /// Serialize to JSON string.
55    pub fn to_json(&self) -> Result<String, serde_json::Error> {
56        serde_json::to_string_pretty(self)
57    }
58
59    /// Deserialize from JSON string.
60    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
61        serde_json::from_str(json)
62    }
63
64    /// Deserialize from JSON string with lenient defaults for missing new fields.
65    /// Provides backward compatibility with old JSON files.
66    pub fn from_json_lenient(json: &str) -> Result<Self, serde_json::Error> {
67        let mut value: serde_json::Value = serde_json::from_str(json)?;
68
69        // Add default values for new fields if missing
70        if let Some(obj) = value.as_object_mut() {
71            if !obj.contains_key("handoff_id") {
72                obj.insert("handoff_id".to_string(), serde_json::json!(Uuid::new_v4()));
73            }
74            if !obj.contains_key("from_agent") {
75                obj.insert("from_agent".to_string(), serde_json::json!("unknown"));
76            }
77            if !obj.contains_key("to_agent") {
78                obj.insert("to_agent".to_string(), serde_json::json!("unknown"));
79            }
80            if !obj.contains_key("timestamp") {
81                obj.insert(
82                    "timestamp".to_string(),
83                    serde_json::json!(chrono::Utc::now()),
84                );
85            }
86            // ttl_secs is Option<u64> with serde(default), so it's handled automatically
87        }
88
89        serde_json::from_value(value)
90    }
91
92    /// Write handoff context to a file.
93    pub fn write_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), std::io::Error> {
94        let json = serde_json::to_string_pretty(self)
95            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
96        std::fs::write(path, json)
97    }
98
99    /// Write handoff context to a file atomically using a temporary file and rename.
100    pub fn write_to_file_atomic(
101        &self,
102        path: impl AsRef<std::path::Path>,
103    ) -> Result<(), std::io::Error> {
104        let path = path.as_ref();
105        let json = serde_json::to_string_pretty(self)
106            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
107
108        // Create temporary file in the same directory as the target
109        let parent = path.parent().unwrap_or(std::path::Path::new("."));
110        let file_name = path
111            .file_name()
112            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid path"))?
113            .to_string_lossy();
114        let tmp_path = parent.join(format!(".tmp.{}", file_name));
115
116        // Write to temporary file
117        std::fs::write(&tmp_path, json)?;
118
119        // Atomically rename to final path (atomic on same filesystem)
120        std::fs::rename(&tmp_path, path)?;
121
122        Ok(())
123    }
124
125    /// Read handoff context from a file.
126    pub fn read_from_file(path: impl AsRef<std::path::Path>) -> Result<Self, std::io::Error> {
127        let content = std::fs::read_to_string(path)?;
128        serde_json::from_str(&content)
129            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
130    }
131}
132
133/// Entry in the handoff buffer with expiry timestamp.
134#[derive(Debug, Clone)]
135struct BufferEntry {
136    context: HandoffContext,
137    expiry: DateTime<Utc>,
138}
139
140/// In-memory buffer for handoff contexts with TTL-based expiry.
141#[derive(Debug)]
142pub struct HandoffBuffer {
143    entries: HashMap<Uuid, BufferEntry>,
144    default_ttl_secs: u64,
145}
146
147impl HandoffBuffer {
148    /// Create a new HandoffBuffer with the specified default TTL in seconds.
149    pub fn new(default_ttl_secs: u64) -> Self {
150        Self {
151            entries: HashMap::new(),
152            default_ttl_secs,
153        }
154    }
155
156    /// Insert a handoff context into the buffer.
157    /// Computes expiry from ctx.ttl_secs or falls back to default_ttl.
158    pub fn insert(&mut self, context: HandoffContext) -> Uuid {
159        let ttl_secs = context.ttl_secs.unwrap_or(self.default_ttl_secs);
160        // Cap at ~100 years to avoid chrono::Duration overflow
161        const MAX_TTL_SECS: i64 = 100 * 365 * 24 * 3600;
162        let ttl_i64 = i64::try_from(ttl_secs)
163            .unwrap_or(MAX_TTL_SECS)
164            .min(MAX_TTL_SECS);
165        let expiry = Utc::now() + chrono::Duration::seconds(ttl_i64);
166        let id = context.handoff_id;
167
168        self.entries.insert(id, BufferEntry { context, expiry });
169        id
170    }
171
172    /// Get a reference to a handoff context by ID.
173    /// Returns None if not found or if expired.
174    pub fn get(&self, id: &Uuid) -> Option<&HandoffContext> {
175        self.entries.get(id).and_then(|entry| {
176            if Utc::now() < entry.expiry {
177                Some(&entry.context)
178            } else {
179                None
180            }
181        })
182    }
183
184    /// Get the most recent handoff for a specific target agent.
185    /// Returns the handoff with the latest timestamp that hasn't expired.
186    pub fn latest_for_agent(&self, to_agent: &str) -> Option<&HandoffContext> {
187        let now = Utc::now();
188        self.entries
189            .values()
190            .filter(|entry| entry.context.to_agent == to_agent && now < entry.expiry)
191            .max_by_key(|entry| entry.context.timestamp)
192            .map(|entry| &entry.context)
193    }
194
195    /// Remove all expired entries and return the count swept.
196    pub fn sweep_expired(&mut self) -> usize {
197        let now = Utc::now();
198        let initial_count = self.entries.len();
199        self.entries.retain(|_, entry| now < entry.expiry);
200        initial_count - self.entries.len()
201    }
202
203    /// Get the number of entries in the buffer.
204    pub fn len(&self) -> usize {
205        self.entries.len()
206    }
207
208    /// Check if the buffer is empty.
209    pub fn is_empty(&self) -> bool {
210        self.entries.is_empty()
211    }
212
213    /// Iterate over all entries (including expired ones).
214    /// The iterator yields (id, context, expiry) tuples.
215    pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &HandoffContext, &DateTime<Utc>)> {
216        self.entries
217            .iter()
218            .map(|(id, entry)| (id, &entry.context, &entry.expiry))
219    }
220
221    /// Get the default TTL in seconds.
222    pub fn default_ttl_secs(&self) -> u64 {
223        self.default_ttl_secs
224    }
225}
226
227/// Append-only JSONL ledger for handoff contexts.
228/// Provides durable, append-only storage for handoff history.
229#[derive(Debug)]
230pub struct HandoffLedger {
231    path: PathBuf,
232}
233
234impl HandoffLedger {
235    /// Create a new HandoffLedger with the specified file path.
236    pub fn new(path: impl Into<PathBuf>) -> Self {
237        Self { path: path.into() }
238    }
239
240    /// Append a handoff context to the ledger.
241    /// Opens the file with O_APPEND + create flags, writes JSON line + newline, and fsyncs.
242    pub fn append(&self, context: &HandoffContext) -> Result<(), std::io::Error> {
243        let json = serde_json::to_string(context)
244            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
245
246        let mut file = OpenOptions::new()
247            .create(true)
248            .append(true)
249            .open(&self.path)?;
250
251        writeln!(file, "{}", json)?;
252        file.sync_all()?;
253
254        Ok(())
255    }
256
257    /// Read all entries from the ledger file.
258    /// Returns Vec<HandoffContext> in order of insertion.
259    pub fn read_all(&self) -> Result<Vec<HandoffContext>, std::io::Error> {
260        let file = OpenOptions::new().read(true).open(&self.path)?;
261
262        let reader = BufReader::new(file);
263        let mut entries = Vec::new();
264
265        for line in reader.lines() {
266            let line = line?;
267            if line.trim().is_empty() {
268                continue;
269            }
270            let context: HandoffContext = serde_json::from_str(&line)
271                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
272            entries.push(context);
273        }
274
275        Ok(entries)
276    }
277
278    /// Count entries in the ledger without loading all into memory.
279    /// Efficiently counts lines in the file.
280    pub fn count(&self) -> Result<usize, std::io::Error> {
281        let metadata = std::fs::metadata(&self.path)?;
282        if metadata.len() == 0 {
283            return Ok(0);
284        }
285
286        let file = OpenOptions::new().read(true).open(&self.path)?;
287
288        let reader = BufReader::new(file);
289        let mut count = 0;
290
291        for line in reader.lines() {
292            let line = line?;
293            if !line.trim().is_empty() {
294                count += 1;
295            }
296        }
297
298        Ok(count)
299    }
300
301    /// Return the file size in bytes for monitoring.
302    pub fn size_bytes(&self) -> Result<u64, std::io::Error> {
303        let metadata = std::fs::metadata(&self.path)?;
304        Ok(metadata.len())
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use chrono::Utc;
312
313    fn make_handoff() -> HandoffContext {
314        HandoffContext {
315            handoff_id: Uuid::new_v4(),
316            from_agent: "agent-a".to_string(),
317            to_agent: "agent-b".to_string(),
318            task: "Fix authentication bug".to_string(),
319            progress_summary: "Identified root cause in token validation".to_string(),
320            decisions: vec![
321                "Use JWT instead of session cookies".to_string(),
322                "Add refresh token rotation".to_string(),
323            ],
324            files_touched: vec![
325                PathBuf::from("src/auth/token.rs"),
326                PathBuf::from("src/auth/middleware.rs"),
327            ],
328            timestamp: Utc::now(),
329            ttl_secs: Some(3600),
330        }
331    }
332
333    #[test]
334    fn test_handoff_new_generates_uuid() {
335        let ctx1 = HandoffContext::new("agent-a", "agent-b", "test task");
336        let ctx2 = HandoffContext::new("agent-a", "agent-b", "test task");
337
338        // UUIDs should be different
339        assert_ne!(ctx1.handoff_id, ctx2.handoff_id);
340
341        // Other fields should be set correctly
342        assert_eq!(ctx1.from_agent, "agent-a");
343        assert_eq!(ctx1.to_agent, "agent-b");
344        assert_eq!(ctx1.task, "test task");
345        assert!(ctx1.progress_summary.is_empty());
346        assert!(ctx1.decisions.is_empty());
347        assert!(ctx1.files_touched.is_empty());
348        assert!(ctx1.ttl_secs.is_none());
349
350        // Timestamp should be recent (within last minute)
351        let now = Utc::now();
352        let diff = now.signed_duration_since(ctx1.timestamp);
353        assert!(diff.num_seconds() < 60);
354    }
355
356    #[test]
357    fn test_handoff_roundtrip_json() {
358        let original = make_handoff();
359        let json = original.to_json().unwrap();
360        let restored = HandoffContext::from_json(&json).unwrap();
361        assert_eq!(original, restored);
362    }
363
364    #[test]
365    fn test_handoff_roundtrip_json_with_new_fields() {
366        let original = HandoffContext {
367            handoff_id: Uuid::new_v4(),
368            from_agent: "test-from".to_string(),
369            to_agent: "test-to".to_string(),
370            task: "Test task".to_string(),
371            progress_summary: "Test progress".to_string(),
372            decisions: vec!["decision1".to_string()],
373            files_touched: vec![PathBuf::from("test.rs")],
374            timestamp: Utc::now(),
375            ttl_secs: Some(7200),
376        };
377
378        let json = original.to_json().unwrap();
379        let restored = HandoffContext::from_json(&json).unwrap();
380
381        assert_eq!(original.handoff_id, restored.handoff_id);
382        assert_eq!(original.from_agent, restored.from_agent);
383        assert_eq!(original.to_agent, restored.to_agent);
384        assert_eq!(original.task, restored.task);
385        assert_eq!(original.ttl_secs, restored.ttl_secs);
386        assert_eq!(original, restored);
387    }
388
389    #[test]
390    fn test_handoff_from_json_lenient_missing_new_fields() {
391        // Old format JSON without new fields
392        let old_json = r#"{
393            "task": "Legacy task",
394            "progress_summary": "Legacy progress",
395            "decisions": ["decision1"],
396            "files_touched": ["file1.rs"],
397            "timestamp": "2024-01-15T10:30:00Z"
398        }"#;
399
400        let ctx = HandoffContext::from_json_lenient(old_json).unwrap();
401
402        // Legacy fields should be preserved
403        assert_eq!(ctx.task, "Legacy task");
404        assert_eq!(ctx.progress_summary, "Legacy progress");
405        assert_eq!(ctx.decisions, vec!["decision1"]);
406        assert_eq!(ctx.files_touched, vec![PathBuf::from("file1.rs")]);
407
408        // New fields should have defaults
409        assert_eq!(ctx.from_agent, "unknown");
410        assert_eq!(ctx.to_agent, "unknown");
411        assert!(ctx.ttl_secs.is_none());
412
413        // UUID should be generated
414        // Timestamp should be preserved from old JSON
415        let expected_ts: chrono::DateTime<Utc> = "2024-01-15T10:30:00Z".parse().unwrap();
416        assert_eq!(ctx.timestamp, expected_ts);
417    }
418
419    #[test]
420    fn test_handoff_from_json_lenient_partial_new_fields() {
421        // JSON with some new fields but missing others
422        let partial_json = r#"{
423            "handoff_id": "550e8400-e29b-41d4-a716-446655440000",
424            "task": "Partial task",
425            "progress_summary": "Partial progress",
426            "decisions": [],
427            "files_touched": [],
428            "timestamp": "2024-06-01T12:00:00Z",
429            "from_agent": "agent-source"
430        }"#;
431
432        let ctx = HandoffContext::from_json_lenient(partial_json).unwrap();
433
434        // Provided fields should be preserved
435        assert_eq!(
436            ctx.handoff_id,
437            Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
438        );
439        assert_eq!(ctx.from_agent, "agent-source");
440        assert_eq!(ctx.task, "Partial task");
441
442        // Missing fields should have defaults
443        assert_eq!(ctx.to_agent, "unknown");
444        assert!(ctx.ttl_secs.is_none());
445    }
446
447    #[test]
448    fn test_handoff_roundtrip_file() {
449        let original = make_handoff();
450        let dir = tempfile::tempdir().unwrap();
451        let path = dir.path().join("handoff.json");
452
453        original.write_to_file(&path).unwrap();
454        let restored = HandoffContext::read_from_file(&path).unwrap();
455        assert_eq!(original, restored);
456    }
457
458    #[test]
459    fn test_handoff_write_atomic_creates_file() {
460        let original = make_handoff();
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("atomic-handoff.json");
463
464        original.write_to_file_atomic(&path).unwrap();
465
466        // File should exist
467        assert!(path.exists());
468
469        // Content should be readable and match
470        let restored = HandoffContext::read_from_file(&path).unwrap();
471        assert_eq!(original.handoff_id, restored.handoff_id);
472        assert_eq!(original.from_agent, restored.from_agent);
473        assert_eq!(original.to_agent, restored.to_agent);
474        assert_eq!(original.task, restored.task);
475    }
476
477    #[test]
478    fn test_handoff_write_atomic_no_partial() {
479        let original = make_handoff();
480        let dir = tempfile::tempdir().unwrap();
481        let path = dir.path().join("no-partial.json");
482
483        original.write_to_file_atomic(&path).unwrap();
484
485        // Temporary file should not exist (should be cleaned up by rename)
486        let tmp_path = dir.path().join(".tmp.no-partial.json");
487        assert!(!tmp_path.exists());
488
489        // Final file should exist
490        assert!(path.exists());
491    }
492
493    #[test]
494    fn test_handoff_empty_decisions() {
495        let ctx = HandoffContext::new("from", "to", "simple task");
496        let json = ctx.to_json().unwrap();
497        let restored = HandoffContext::from_json(&json).unwrap();
498        assert_eq!(ctx.handoff_id, restored.handoff_id);
499        assert_eq!(ctx.from_agent, restored.from_agent);
500        assert_eq!(ctx.to_agent, restored.to_agent);
501        assert_eq!(ctx.task, restored.task);
502        assert!(restored.decisions.is_empty());
503    }
504
505    #[test]
506    fn test_ttl_serialization() {
507        // Test that ttl_secs is skipped when None
508        let ctx_without_ttl = HandoffContext {
509            handoff_id: Uuid::new_v4(),
510            from_agent: "a".to_string(),
511            to_agent: "b".to_string(),
512            task: "test".to_string(),
513            progress_summary: String::new(),
514            decisions: vec![],
515            files_touched: vec![],
516            timestamp: Utc::now(),
517            ttl_secs: None,
518        };
519
520        let json = ctx_without_ttl.to_json().unwrap();
521        assert!(!json.contains("ttl_secs"));
522
523        // Test that ttl_secs is included when Some
524        let ctx_with_ttl = HandoffContext {
525            ttl_secs: Some(3600),
526            ..ctx_without_ttl
527        };
528
529        let json = ctx_with_ttl.to_json().unwrap();
530        assert!(json.contains("ttl_secs"));
531    }
532
533    // =========================================================================
534    // HandoffBuffer Tests
535    // =========================================================================
536
537    #[test]
538    fn test_buffer_new() {
539        let buffer = HandoffBuffer::new(3600);
540        assert_eq!(buffer.len(), 0);
541        assert!(buffer.is_empty());
542        assert_eq!(buffer.default_ttl_secs(), 3600);
543    }
544
545    #[test]
546    fn test_buffer_insert_and_get() {
547        let mut buffer = HandoffBuffer::new(3600);
548        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
549        let id = ctx.handoff_id;
550
551        buffer.insert(ctx.clone());
552
553        assert_eq!(buffer.len(), 1);
554        assert!(!buffer.is_empty());
555
556        let retrieved = buffer.get(&id);
557        assert!(retrieved.is_some());
558        assert_eq!(retrieved.unwrap().handoff_id, id);
559        assert_eq!(retrieved.unwrap().from_agent, "agent-a");
560        assert_eq!(retrieved.unwrap().to_agent, "agent-b");
561    }
562
563    #[test]
564    fn test_buffer_get_returns_none_for_unknown() {
565        let buffer = HandoffBuffer::new(3600);
566        let unknown_id = Uuid::new_v4();
567
568        let retrieved = buffer.get(&unknown_id);
569        assert!(retrieved.is_none());
570    }
571
572    #[test]
573    fn test_buffer_latest_for_agent() {
574        let mut buffer = HandoffBuffer::new(3600);
575
576        // Insert two handoffs for the same target agent
577        let ctx1 = HandoffContext::new("agent-a", "agent-c", "task 1");
578        let ctx2 = HandoffContext::new("agent-b", "agent-c", "task 2");
579
580        buffer.insert(ctx1.clone());
581        buffer.insert(ctx2.clone());
582
583        // Get latest for agent-c
584        let latest = buffer.latest_for_agent("agent-c");
585        assert!(latest.is_some());
586        // Should return the most recent one
587        assert_eq!(latest.unwrap().handoff_id, ctx2.handoff_id);
588    }
589
590    #[test]
591    fn test_buffer_latest_for_agent_returns_none_for_unknown() {
592        let buffer = HandoffBuffer::new(3600);
593
594        let latest = buffer.latest_for_agent("unknown-agent");
595        assert!(latest.is_none());
596    }
597
598    #[test]
599    fn test_buffer_sweep_expired() {
600        let mut buffer = HandoffBuffer::new(0); // TTL = 0 means immediate expiry
601        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
602        let id = ctx.handoff_id;
603
604        buffer.insert(ctx);
605        assert_eq!(buffer.len(), 1);
606
607        // Sweep should remove the immediately expired entry
608        let swept = buffer.sweep_expired();
609        assert_eq!(swept, 1);
610        assert_eq!(buffer.len(), 0);
611        assert!(buffer.is_empty());
612
613        // Get should return None for expired
614        let retrieved = buffer.get(&id);
615        assert!(retrieved.is_none());
616    }
617
618    #[test]
619    fn test_buffer_sweep_preserves_live() {
620        let mut buffer = HandoffBuffer::new(3600); // 1 hour TTL
621        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
622        let id = ctx.handoff_id;
623
624        buffer.insert(ctx);
625        assert_eq!(buffer.len(), 1);
626
627        // Sweep should not remove entries with 1 hour TTL
628        let swept = buffer.sweep_expired();
629        assert_eq!(swept, 0);
630        assert_eq!(buffer.len(), 1);
631
632        // Get should still work
633        let retrieved = buffer.get(&id);
634        assert!(retrieved.is_some());
635    }
636
637    #[test]
638    fn test_buffer_get_returns_none_for_expired() {
639        let mut buffer = HandoffBuffer::new(0); // TTL = 0 means immediate expiry
640        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
641        let id = ctx.handoff_id;
642
643        buffer.insert(ctx);
644        assert_eq!(buffer.len(), 1);
645
646        // Get should return None because entry is expired (TTL=0)
647        let retrieved = buffer.get(&id);
648        assert!(retrieved.is_none());
649
650        // But the entry is still in the buffer until sweep
651        assert_eq!(buffer.len(), 1);
652    }
653
654    #[test]
655    fn test_buffer_iter() {
656        let mut buffer = HandoffBuffer::new(3600);
657        let ctx1 = HandoffContext::new("agent-a", "agent-b", "task 1");
658        let ctx2 = HandoffContext::new("agent-c", "agent-d", "task 2");
659
660        buffer.insert(ctx1.clone());
661        buffer.insert(ctx2.clone());
662
663        let mut count = 0;
664        for (id, ctx, expiry) in buffer.iter() {
665            count += 1;
666            assert!(*id == ctx1.handoff_id || *id == ctx2.handoff_id);
667            assert!(!ctx.task.is_empty());
668            assert!(expiry > &Utc::now());
669        }
670        assert_eq!(count, 2);
671    }
672
673    #[test]
674    fn test_buffer_uses_context_ttl() {
675        let mut buffer = HandoffBuffer::new(3600); // default 1 hour
676        let mut ctx = HandoffContext::new("agent-a", "agent-b", "test task");
677        ctx.ttl_secs = Some(0); // Override with 0 TTL
678        let id = ctx.handoff_id;
679
680        buffer.insert(ctx);
681
682        // Get should return None because context TTL=0
683        let retrieved = buffer.get(&id);
684        assert!(retrieved.is_none());
685    }
686
687    #[test]
688    fn test_buffer_default_ttl_when_context_ttl_none() {
689        let mut buffer = HandoffBuffer::new(3600); // default 1 hour
690        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
691        // ctx.ttl_secs is None, so it should use default
692        let id = ctx.handoff_id;
693
694        buffer.insert(ctx);
695
696        // Get should work because default TTL=3600
697        let retrieved = buffer.get(&id);
698        assert!(retrieved.is_some());
699    }
700
701    #[test]
702    fn test_buffer_multiple_agents() {
703        let mut buffer = HandoffBuffer::new(3600);
704
705        // Insert handoffs to different agents
706        buffer.insert(HandoffContext::new("agent-a", "target-1", "task 1"));
707        buffer.insert(HandoffContext::new("agent-a", "target-2", "task 2"));
708        buffer.insert(HandoffContext::new("agent-b", "target-1", "task 3"));
709
710        assert_eq!(buffer.len(), 3);
711
712        // Get latest for target-1 should return task 3
713        let latest = buffer.latest_for_agent("target-1");
714        assert!(latest.is_some());
715        assert_eq!(latest.unwrap().task, "task 3");
716
717        // Get latest for target-2 should return task 2
718        let latest = buffer.latest_for_agent("target-2");
719        assert!(latest.is_some());
720        assert_eq!(latest.unwrap().task, "task 2");
721    }
722
723    // =========================================================================
724    // HandoffLedger Tests
725    // =========================================================================
726
727    #[test]
728    fn test_ledger_append_and_read_all() {
729        let dir = tempfile::tempdir().unwrap();
730        let ledger_path = dir.path().join("handoff-ledger.jsonl");
731        let ledger = HandoffLedger::new(&ledger_path);
732
733        // Create and append 3 entries
734        let ctx1 = HandoffContext::new("agent-a", "agent-b", "task 1");
735        let ctx2 = HandoffContext::new("agent-b", "agent-c", "task 2");
736        let ctx3 = HandoffContext::new("agent-c", "agent-d", "task 3");
737
738        ledger.append(&ctx1).unwrap();
739        ledger.append(&ctx2).unwrap();
740        ledger.append(&ctx3).unwrap();
741
742        // Read all entries and verify
743        let entries = ledger.read_all().unwrap();
744        assert_eq!(entries.len(), 3);
745
746        // Verify each entry matches what was appended
747        assert_eq!(entries[0].from_agent, "agent-a");
748        assert_eq!(entries[0].to_agent, "agent-b");
749        assert_eq!(entries[0].task, "task 1");
750
751        assert_eq!(entries[1].from_agent, "agent-b");
752        assert_eq!(entries[1].to_agent, "agent-c");
753        assert_eq!(entries[1].task, "task 2");
754
755        assert_eq!(entries[2].from_agent, "agent-c");
756        assert_eq!(entries[2].to_agent, "agent-d");
757        assert_eq!(entries[2].task, "task 3");
758    }
759
760    #[test]
761    fn test_ledger_append_creates_file() {
762        let dir = tempfile::tempdir().unwrap();
763        let ledger_path = dir.path().join("new-ledger.jsonl");
764
765        // File should not exist yet
766        assert!(!ledger_path.exists());
767
768        let ledger = HandoffLedger::new(&ledger_path);
769        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
770
771        // Append to nonexistent file
772        ledger.append(&ctx).unwrap();
773
774        // File should now exist
775        assert!(ledger_path.exists());
776
777        // Should be able to read it back
778        let entries = ledger.read_all().unwrap();
779        assert_eq!(entries.len(), 1);
780        assert_eq!(entries[0].task, "test task");
781    }
782
783    #[test]
784    fn test_ledger_count() {
785        let dir = tempfile::tempdir().unwrap();
786        let ledger_path = dir.path().join("count-ledger.jsonl");
787        let ledger = HandoffLedger::new(&ledger_path);
788
789        // First append creates the file
790        let ctx = HandoffContext::new("agent-a", "agent-b", "first");
791        ledger.append(&ctx).unwrap();
792
793        // Count N entries
794        let n = 5;
795        for i in 1..n {
796            let ctx = HandoffContext::new("agent-a", "agent-b", format!("task {}", i));
797            ledger.append(&ctx).unwrap();
798        }
799
800        let count = ledger.count().unwrap();
801        assert_eq!(count, n);
802    }
803
804    #[test]
805    fn test_ledger_append_is_one_line_per_entry() {
806        let dir = tempfile::tempdir().unwrap();
807        let ledger_path = dir.path().join("line-ledger.jsonl");
808        let ledger = HandoffLedger::new(&ledger_path);
809
810        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
811        ledger.append(&ctx).unwrap();
812        ledger.append(&ctx).unwrap();
813        ledger.append(&ctx).unwrap();
814
815        // Read the raw file and count lines
816        let content = std::fs::read_to_string(&ledger_path).unwrap();
817        let lines: Vec<&str> = content.lines().collect();
818
819        // Should have exactly 3 lines
820        assert_eq!(lines.len(), 3);
821
822        // Each line should end with newline (content.lines() strips them)
823        // Verify each line is valid JSON
824        for (i, line) in lines.iter().enumerate() {
825            assert!(!line.is_empty(), "Line {} should not be empty", i);
826            let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
827            assert!(parsed.is_object());
828        }
829    }
830
831    #[test]
832    fn test_ledger_handles_special_chars() {
833        let dir = tempfile::tempdir().unwrap();
834        let ledger_path = dir.path().join("special-ledger.jsonl");
835        let ledger = HandoffLedger::new(&ledger_path);
836
837        // Create context with special characters
838        let mut ctx = HandoffContext::new("agent-a", "agent-b", "line1\nline2\nline3");
839        ctx.progress_summary = "Contains \"quotes\" and \t tabs".to_string();
840        ctx.decisions = vec![
841            "Unicode: 日本語".to_string(),
842            "Emoji: 🎉🚀".to_string(),
843            "Backslash: C:\\path\\to\\file".to_string(),
844        ];
845
846        ledger.append(&ctx).unwrap();
847
848        // Read back and verify
849        let entries = ledger.read_all().unwrap();
850        assert_eq!(entries.len(), 1);
851
852        let restored = &entries[0];
853        assert_eq!(restored.task, "line1\nline2\nline3");
854        assert_eq!(restored.progress_summary, "Contains \"quotes\" and \t tabs");
855        assert_eq!(restored.decisions.len(), 3);
856        assert_eq!(restored.decisions[0], "Unicode: 日本語");
857        assert_eq!(restored.decisions[1], "Emoji: 🎉🚀");
858        assert_eq!(restored.decisions[2], "Backslash: C:\\path\\to\\file");
859    }
860
861    #[test]
862    fn test_ledger_size_bytes() {
863        let dir = tempfile::tempdir().unwrap();
864        let ledger_path = dir.path().join("size-ledger.jsonl");
865        let ledger = HandoffLedger::new(&ledger_path);
866
867        // Size should be 0 before any entries (file doesn't exist)
868        // Note: size_bytes returns error for non-existent file
869        let ctx = HandoffContext::new("agent-a", "agent-b", "test task");
870        ledger.append(&ctx).unwrap();
871
872        let size = ledger.size_bytes().unwrap();
873        assert!(
874            size > 0,
875            "Ledger file should have non-zero size after append"
876        );
877
878        // Size should increase after second append
879        ledger.append(&ctx).unwrap();
880        let new_size = ledger.size_bytes().unwrap();
881        assert!(
882            new_size > size,
883            "Ledger size should increase after second append"
884        );
885    }
886
887    #[test]
888    fn test_ttl_overflow_saturates() {
889        let mut buffer = HandoffBuffer::new(3600);
890        let mut ctx = HandoffContext::new("agent-a", "agent-b", "overflow test");
891        ctx.ttl_secs = Some(u64::MAX); // would overflow i64 if cast with `as`
892
893        // Should not panic -- saturates to i64::MAX
894        let id = buffer.insert(ctx);
895
896        // Entry should be retrievable (expiry is far in the future)
897        let retrieved = buffer.get(&id);
898        assert!(retrieved.is_some());
899    }
900}