1use std::fs::{self, File};
16use std::io::{self, BufRead, BufReader, Write as _};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20use zeph_llm::provider::Message;
21
22use super::error::SubAgentError;
23use super::state::SubAgentState;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TranscriptEntry {
32 pub seq: u32,
34 pub timestamp: String,
36 pub message: Message,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TranscriptMeta {
46 pub agent_id: String,
48 pub agent_name: String,
50 pub def_name: String,
52 pub status: SubAgentState,
54 pub started_at: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub finished_at: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub resumed_from: Option<String>,
62 pub turns_used: u32,
64}
65
66pub struct TranscriptWriter {
81 file: File,
82}
83
84impl TranscriptWriter {
85 pub fn new(path: &Path) -> io::Result<Self> {
93 if let Some(parent) = path.parent() {
94 fs::create_dir_all(parent)?;
95 }
96 let file = zeph_common::fs_secure::append_private(path)?;
97 Ok(Self { file })
98 }
99
100 pub fn append(&mut self, seq: u32, message: &Message) -> io::Result<()> {
106 let entry = TranscriptEntry {
107 seq,
108 timestamp: utc_now(),
109 message: message.clone(),
110 };
111 let line = serde_json::to_string(&entry)
112 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
113 self.file.write_all(line.as_bytes())?;
114 self.file.write_all(b"\n")?;
115 self.file.flush()
116 }
117
118 pub fn write_meta(dir: &Path, agent_id: &str, meta: &TranscriptMeta) -> io::Result<()> {
124 let path = dir.join(format!("{agent_id}.meta.json"));
125 let content = serde_json::to_string_pretty(meta)
126 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
127 zeph_common::fs_secure::write_private(&path, content.as_bytes())
128 }
129}
130
131pub struct TranscriptReader;
138
139impl TranscriptReader {
140 pub fn load(path: &Path) -> Result<Vec<Message>, SubAgentError> {
153 let file = match File::open(path) {
154 Ok(f) => f,
155 Err(e) if e.kind() == io::ErrorKind::NotFound => {
156 let meta_path =
160 if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
161 parent.join(format!("{}.meta.json", stem.to_string_lossy()))
162 } else {
163 path.with_extension("meta.json")
164 };
165 if meta_path.exists() {
166 return Err(SubAgentError::Transcript(format!(
167 "transcript file '{}' is missing but meta sidecar exists — \
168 transcript data may have been deleted",
169 path.display()
170 )));
171 }
172 return Ok(vec![]);
173 }
174 Err(e) => {
175 return Err(SubAgentError::Transcript(format!(
176 "failed to open transcript '{}': {e}",
177 path.display()
178 )));
179 }
180 };
181
182 let reader = BufReader::new(file);
183 let mut messages = Vec::new();
184 for (line_no, line_result) in reader.lines().enumerate() {
185 let line = match line_result {
186 Ok(l) => l,
187 Err(e) => {
188 tracing::warn!(
189 path = %path.display(),
190 line = line_no + 1,
191 error = %e,
192 "failed to read transcript line — skipping"
193 );
194 continue;
195 }
196 };
197 let trimmed = line.trim();
198 if trimmed.is_empty() {
199 continue;
200 }
201 match serde_json::from_str::<TranscriptEntry>(trimmed) {
202 Ok(entry) => messages.push(entry.message),
203 Err(e) => {
204 tracing::warn!(
205 path = %path.display(),
206 line = line_no + 1,
207 error = %e,
208 "malformed transcript entry — skipping"
209 );
210 }
211 }
212 }
213 Ok(messages)
214 }
215
216 pub fn load_meta(dir: &Path, agent_id: &str) -> Result<TranscriptMeta, SubAgentError> {
223 let path = dir.join(format!("{agent_id}.meta.json"));
224 let content = fs::read_to_string(&path).map_err(|e| {
225 if e.kind() == io::ErrorKind::NotFound {
226 SubAgentError::NotFound(agent_id.to_owned())
227 } else {
228 SubAgentError::Transcript(format!("failed to read meta '{}': {e}", path.display()))
229 }
230 })?;
231 serde_json::from_str(&content).map_err(|e| {
232 SubAgentError::Transcript(format!("failed to parse meta '{}': {e}", path.display()))
233 })
234 }
235
236 pub fn find_by_prefix(dir: &Path, prefix: &str) -> Result<String, SubAgentError> {
245 let entries = fs::read_dir(dir).map_err(|e| {
246 SubAgentError::Transcript(format!(
247 "failed to read transcript dir '{}': {e}",
248 dir.display()
249 ))
250 })?;
251
252 let mut matches: Vec<String> = Vec::new();
253 for entry in entries {
254 let entry = entry
255 .map_err(|e| SubAgentError::Transcript(format!("failed to read dir entry: {e}")))?;
256 let name = entry.file_name();
257 let name_str = name.to_string_lossy();
258 if let Some(agent_id) = name_str.strip_suffix(".meta.json")
259 && agent_id.starts_with(prefix)
260 {
261 matches.push(agent_id.to_owned());
262 }
263 }
264
265 match matches.len() {
266 0 => Err(SubAgentError::NotFound(prefix.to_owned())),
267 1 => Ok(matches.remove(0)),
268 n => Err(SubAgentError::AmbiguousId(prefix.to_owned(), n)),
269 }
270 }
271}
272
273pub fn sweep_old_transcripts(dir: &Path, max_files: usize) -> io::Result<usize> {
282 if max_files == 0 {
283 return Ok(0);
284 }
285
286 if !dir.exists() {
288 fs::create_dir_all(dir)?;
289 return Ok(0);
290 }
291
292 let mut jsonl_files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
293 for entry in fs::read_dir(dir)? {
294 let entry = entry?;
295 let path = entry.path();
296 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
297 let mtime = entry
298 .metadata()
299 .and_then(|m| m.modified())
300 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
301 jsonl_files.push((path, mtime));
302 }
303 }
304
305 if jsonl_files.len() <= max_files {
306 return Ok(0);
307 }
308
309 jsonl_files.sort_by_key(|(_, mtime)| *mtime);
311
312 let to_delete = jsonl_files.len() - max_files;
313 let mut deleted = 0;
314 for (path, _) in jsonl_files.into_iter().take(to_delete) {
315 let meta = path.with_extension("meta.json");
317 if meta.exists() {
318 let _ = fs::remove_file(&meta);
319 }
320 fs::remove_file(&path)?;
321 deleted += 1;
322 }
323 Ok(deleted)
324}
325
326#[must_use]
342pub fn utc_now_pub() -> String {
343 utc_now()
344}
345
346fn utc_now() -> String {
347 let secs = std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .unwrap_or_default()
352 .as_secs();
353 let (y, mo, d, h, mi, s) = epoch_to_parts(secs);
354 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
355}
356
357fn epoch_to_parts(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
363 let sec = epoch % 60;
364 let epoch = epoch / 60;
365 let min = epoch % 60;
366 let epoch = epoch / 60;
367 let hour = epoch % 24;
368 let days = epoch / 24;
369
370 let z = days + 719_468;
372 let era = z / 146_097;
373 let doe = z - era * 146_097;
374 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
375 let year = yoe + era * 400;
376 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
377 let mp = (5 * doy + 2) / 153;
378 let day = doy - (153 * mp + 2) / 5 + 1;
379 let month = if mp < 10 { mp + 3 } else { mp - 9 };
380 let year = if month <= 2 { year + 1 } else { year };
381
382 #[allow(clippy::cast_possible_truncation)]
384 (
385 year as u32,
386 month as u32,
387 day as u32,
388 hour as u32,
389 min as u32,
390 sec as u32,
391 )
392}
393
394#[cfg(test)]
395mod tests {
396 use zeph_llm::provider::{Message, MessageMetadata, Role};
397
398 use super::*;
399
400 fn test_message(role: Role, content: &str) -> Message {
401 Message {
402 role,
403 content: content.to_owned(),
404 parts: vec![],
405 metadata: MessageMetadata::default(),
406 }
407 }
408
409 fn test_meta(agent_id: &str) -> TranscriptMeta {
410 TranscriptMeta {
411 agent_id: agent_id.to_owned(),
412 agent_name: "bot".to_owned(),
413 def_name: "bot".to_owned(),
414 status: SubAgentState::Completed,
415 started_at: "2026-01-01T00:00:00Z".to_owned(),
416 finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
417 resumed_from: None,
418 turns_used: 2,
419 }
420 }
421
422 #[test]
423 fn writer_reader_roundtrip() {
424 let dir = tempfile::tempdir().unwrap();
425 let path = dir.path().join("test.jsonl");
426
427 let msg1 = test_message(Role::User, "hello");
428 let msg2 = test_message(Role::Assistant, "world");
429
430 let mut writer = TranscriptWriter::new(&path).unwrap();
431 writer.append(0, &msg1).unwrap();
432 writer.append(1, &msg2).unwrap();
433 drop(writer);
434
435 let messages = TranscriptReader::load(&path).unwrap();
436 assert_eq!(messages.len(), 2);
437 assert_eq!(messages[0].content, "hello");
438 assert_eq!(messages[1].content, "world");
439 }
440
441 #[test]
442 fn load_missing_file_no_meta_returns_empty() {
443 let dir = tempfile::tempdir().unwrap();
444 let path = dir.path().join("ghost.jsonl");
445 let messages = TranscriptReader::load(&path).unwrap();
446 assert!(messages.is_empty());
447 }
448
449 #[test]
450 fn load_missing_file_with_meta_returns_error() {
451 let dir = tempfile::tempdir().unwrap();
452 let meta_path = dir.path().join("ghost.meta.json");
453 std::fs::write(&meta_path, "{}").unwrap();
454 let jsonl_path = dir.path().join("ghost.jsonl");
455 let err = TranscriptReader::load(&jsonl_path).unwrap_err();
456 assert!(matches!(err, SubAgentError::Transcript(_)));
457 }
458
459 #[test]
460 fn load_skips_malformed_lines() {
461 let dir = tempfile::tempdir().unwrap();
462 let path = dir.path().join("mixed.jsonl");
463
464 let good = test_message(Role::User, "good");
465 let entry = TranscriptEntry {
466 seq: 0,
467 timestamp: "2026-01-01T00:00:00Z".to_owned(),
468 message: good.clone(),
469 };
470 let good_line = serde_json::to_string(&entry).unwrap();
471 let content = format!("{good_line}\nnot valid json\n{good_line}\n");
472 std::fs::write(&path, &content).unwrap();
473
474 let messages = TranscriptReader::load(&path).unwrap();
475 assert_eq!(messages.len(), 2);
476 }
477
478 #[test]
479 fn meta_roundtrip() {
480 let dir = tempfile::tempdir().unwrap();
481 let meta = test_meta("abc-123");
482 TranscriptWriter::write_meta(dir.path(), "abc-123", &meta).unwrap();
483 let loaded = TranscriptReader::load_meta(dir.path(), "abc-123").unwrap();
484 assert_eq!(loaded.agent_id, "abc-123");
485 assert_eq!(loaded.turns_used, 2);
486 }
487
488 #[test]
489 fn meta_not_found_returns_not_found_error() {
490 let dir = tempfile::tempdir().unwrap();
491 let err = TranscriptReader::load_meta(dir.path(), "ghost").unwrap_err();
492 assert!(matches!(err, SubAgentError::NotFound(_)));
493 }
494
495 #[test]
496 fn find_by_prefix_exact() {
497 let dir = tempfile::tempdir().unwrap();
498 let meta = test_meta("abcdef01-0000-0000-0000-000000000000");
499 TranscriptWriter::write_meta(dir.path(), "abcdef01-0000-0000-0000-000000000000", &meta)
500 .unwrap();
501 let id =
502 TranscriptReader::find_by_prefix(dir.path(), "abcdef01-0000-0000-0000-000000000000")
503 .unwrap();
504 assert_eq!(id, "abcdef01-0000-0000-0000-000000000000");
505 }
506
507 #[test]
508 fn find_by_prefix_short_prefix() {
509 let dir = tempfile::tempdir().unwrap();
510 let meta = test_meta("deadbeef-0000-0000-0000-000000000000");
511 TranscriptWriter::write_meta(dir.path(), "deadbeef-0000-0000-0000-000000000000", &meta)
512 .unwrap();
513 let id = TranscriptReader::find_by_prefix(dir.path(), "deadbeef").unwrap();
514 assert_eq!(id, "deadbeef-0000-0000-0000-000000000000");
515 }
516
517 #[test]
518 fn find_by_prefix_not_found() {
519 let dir = tempfile::tempdir().unwrap();
520 let err = TranscriptReader::find_by_prefix(dir.path(), "xxxxxxxx").unwrap_err();
521 assert!(matches!(err, SubAgentError::NotFound(_)));
522 }
523
524 #[test]
525 fn find_by_prefix_ambiguous() {
526 let dir = tempfile::tempdir().unwrap();
527 TranscriptWriter::write_meta(dir.path(), "aabb0001-x", &test_meta("aabb0001-x")).unwrap();
528 TranscriptWriter::write_meta(dir.path(), "aabb0002-y", &test_meta("aabb0002-y")).unwrap();
529 let err = TranscriptReader::find_by_prefix(dir.path(), "aabb").unwrap_err();
530 assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
531 }
532
533 #[test]
534 fn sweep_old_transcripts_removes_oldest() {
535 let dir = tempfile::tempdir().unwrap();
536
537 for i in 0..5u32 {
538 let path = dir.path().join(format!("file{i:02}.jsonl"));
539 std::fs::write(&path, b"").unwrap();
540 }
545
546 let deleted = sweep_old_transcripts(dir.path(), 3).unwrap();
547 assert_eq!(deleted, 2);
548
549 let remaining: Vec<_> = std::fs::read_dir(dir.path())
550 .unwrap()
551 .filter_map(std::result::Result::ok)
552 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
553 .collect();
554 assert_eq!(remaining.len(), 3);
555 }
556
557 #[test]
558 fn sweep_with_zero_max_does_nothing() {
559 let dir = tempfile::tempdir().unwrap();
560 std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
561 let deleted = sweep_old_transcripts(dir.path(), 0).unwrap();
562 assert_eq!(deleted, 0);
563 }
564
565 #[test]
566 fn sweep_below_max_does_nothing() {
567 let dir = tempfile::tempdir().unwrap();
568 std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
569 let deleted = sweep_old_transcripts(dir.path(), 50).unwrap();
570 assert_eq!(deleted, 0);
571 }
572
573 #[test]
574 fn utc_now_format() {
575 let ts = utc_now();
576 assert_eq!(ts.len(), 20);
578 assert!(ts.ends_with('Z'));
579 assert!(ts.contains('T'));
580 }
581
582 #[test]
583 fn load_empty_file_returns_empty() {
584 let dir = tempfile::tempdir().unwrap();
585 let path = dir.path().join("empty.jsonl");
586 std::fs::write(&path, b"").unwrap();
587 let messages = TranscriptReader::load(&path).unwrap();
588 assert!(messages.is_empty());
589 }
590
591 #[test]
592 fn load_meta_invalid_json_returns_transcript_error() {
593 let dir = tempfile::tempdir().unwrap();
594 std::fs::write(dir.path().join("bad.meta.json"), b"not json at all {{{{").unwrap();
595 let err = TranscriptReader::load_meta(dir.path(), "bad").unwrap_err();
596 assert!(matches!(err, SubAgentError::Transcript(_)));
597 }
598
599 #[test]
600 fn sweep_removes_companion_meta() {
601 let dir = tempfile::tempdir().unwrap();
602 for i in 0..4u32 {
604 let stem = format!("file{i:02}");
605 std::fs::write(dir.path().join(format!("{stem}.jsonl")), b"").unwrap();
606 std::fs::write(dir.path().join(format!("{stem}.meta.json")), b"{}").unwrap();
607 }
608 let deleted = sweep_old_transcripts(dir.path(), 2).unwrap();
609 assert_eq!(deleted, 2);
610 let meta_count = std::fs::read_dir(dir.path())
612 .unwrap()
613 .filter_map(std::result::Result::ok)
614 .filter(|e| e.path().to_string_lossy().ends_with(".meta.json"))
615 .count();
616 assert_eq!(
617 meta_count, 2,
618 "orphaned meta sidecars should have been removed"
619 );
620 }
621
622 #[test]
623 fn data_loss_guard_uses_stem_based_meta_path() {
624 let dir = tempfile::tempdir().unwrap();
627 let agent_id = "deadbeef-0000-0000-0000-000000000000";
628 std::fs::write(dir.path().join(format!("{agent_id}.meta.json")), b"{}").unwrap();
630 let jsonl_path = dir.path().join(format!("{agent_id}.jsonl"));
631 let err = TranscriptReader::load(&jsonl_path).unwrap_err();
632 assert!(matches!(err, SubAgentError::Transcript(ref m) if m.contains("missing")));
633 }
634}