1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::HashMap,
10 path::{Path, PathBuf},
11 sync::{Arc, Mutex, OnceLock},
12};
13
14use crate::core::memory_policy::EpisodicPolicy;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EpisodicStore {
18 pub project_hash: String,
19 pub episodes: Vec<Episode>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Episode {
24 pub id: String,
25 pub session_id: String,
26 pub timestamp: DateTime<Utc>,
27 pub task_description: String,
28 pub actions: Vec<Action>,
29 pub outcome: Outcome,
30 pub affected_files: Vec<String>,
31 pub summary: String,
32 pub duration_secs: u64,
33 pub tokens_used: u64,
34 #[serde(default)]
35 pub agent_id: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Action {
40 pub tool: String,
41 pub description: String,
42 pub timestamp: DateTime<Utc>,
43 pub duration_ms: u64,
44 pub success: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub enum Outcome {
49 Success { tests_passed: bool },
50 Failure { error: String },
51 Partial { details: String },
52 Unknown,
53}
54
55impl Outcome {
56 pub fn label(&self) -> &'static str {
57 match self {
58 Outcome::Success { .. } => "success",
59 Outcome::Failure { .. } => "failure",
60 Outcome::Partial { .. } => "partial",
61 Outcome::Unknown => "unknown",
62 }
63 }
64}
65
66fn episodic_lock(project_hash: &str) -> Arc<Mutex<()>> {
67 static LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
68 let mut locks = LOCKS
69 .get_or_init(|| Mutex::new(HashMap::new()))
70 .lock()
71 .unwrap_or_else(std::sync::PoisonError::into_inner);
72 locks.entry(project_hash.to_string()).or_default().clone()
73}
74
75fn acquire_file_lock(path: &Path) -> Option<std::fs::File> {
76 use fs2::FileExt;
77 let parent = path.parent()?;
78 let name = path.file_name()?.to_string_lossy();
79 let lock_path = parent.join(format!(".{name}.lock"));
80 let file = std::fs::OpenOptions::new()
81 .create(true)
82 .truncate(false)
83 .write(true)
84 .open(&lock_path)
85 .ok()?;
86 #[cfg(unix)]
87 {
88 use std::os::unix::fs::PermissionsExt;
89 let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
90 }
91 file.lock_exclusive().ok()?;
92 Some(file)
93}
94
95impl EpisodicStore {
96 pub fn new(project_hash: &str) -> Self {
97 Self {
98 project_hash: project_hash.to_string(),
99 episodes: Vec::new(),
100 }
101 }
102
103 pub fn record_episode(&mut self, mut episode: Episode, policy: &EpisodicPolicy) {
104 episode.actions.truncate(policy.max_actions_per_episode);
105
106 if episode.summary.is_empty() {
107 episode.summary = auto_summarize(&episode, policy.summary_max_chars);
108 }
109
110 self.episodes.push(episode);
111
112 if self.episodes.len() > policy.max_episodes {
113 self.episodes
114 .drain(0..self.episodes.len() - policy.max_episodes);
115 }
116 }
117
118 pub fn search(&self, query: &str) -> Vec<&Episode> {
119 let q = query.to_lowercase();
120 let terms: Vec<&str> = q.split_whitespace().collect();
121
122 let mut scored: Vec<(&Episode, f32)> = self
123 .episodes
124 .iter()
125 .filter_map(|ep| {
126 let searchable = format!(
127 "{} {} {}",
128 ep.task_description.to_lowercase(),
129 ep.summary.to_lowercase(),
130 ep.affected_files.join(" ").to_lowercase()
131 );
132 let hits = terms.iter().filter(|t| searchable.contains(**t)).count();
133 if hits > 0 {
134 let relevance = hits as f32 / terms.len() as f32;
135 Some((ep, relevance))
136 } else {
137 None
138 }
139 })
140 .collect();
141
142 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
143 scored.into_iter().map(|(ep, _)| ep).collect()
144 }
145
146 pub fn recent(&self, n: usize) -> Vec<&Episode> {
147 self.episodes.iter().rev().take(n).collect()
148 }
149
150 pub fn by_outcome(&self, outcome_label: &str) -> Vec<&Episode> {
151 self.episodes
152 .iter()
153 .filter(|ep| ep.outcome.label() == outcome_label)
154 .collect()
155 }
156
157 pub fn by_file(&self, file_path: &str) -> Vec<&Episode> {
158 self.episodes
159 .iter()
160 .filter(|ep| ep.affected_files.iter().any(|f| f.contains(file_path)))
161 .collect()
162 }
163
164 pub fn stats(&self) -> EpisodicStats {
165 let total = self.episodes.len();
166 let successes = self
167 .episodes
168 .iter()
169 .filter(|ep| matches!(ep.outcome, Outcome::Success { .. }))
170 .count();
171 let failures = self
172 .episodes
173 .iter()
174 .filter(|ep| matches!(ep.outcome, Outcome::Failure { .. }))
175 .count();
176 let total_tokens: u64 = self.episodes.iter().map(|ep| ep.tokens_used).sum();
177
178 EpisodicStats {
179 total_episodes: total,
180 successes,
181 failures,
182 success_rate: if total > 0 {
183 successes as f32 / total as f32
184 } else {
185 0.0
186 },
187 total_tokens,
188 }
189 }
190
191 fn store_path(project_hash: &str) -> Option<PathBuf> {
192 let dir = crate::core::data_dir::lean_ctx_data_dir()
193 .ok()?
194 .join("memory")
195 .join("episodes");
196 Some(dir.join(format!("{project_hash}.json")))
197 }
198
199 pub fn load(project_hash: &str) -> Option<Self> {
200 let path = Self::store_path(project_hash)?;
201 let data = std::fs::read_to_string(path).ok()?;
202 let mut store: Self = serde_json::from_str(&data).ok()?;
203 if store.migrate_legacy_episodes() {
204 let _ = store.save();
205 }
206 Some(store)
207 }
208
209 fn migrate_legacy_episodes(&mut self) -> bool {
215 use std::collections::HashSet;
216
217 let mut seen: HashSet<(String, String)> = HashSet::new();
218 let mut needs_migration = false;
219 for ep in &self.episodes {
220 if !seen.insert((ep.session_id.clone(), ep.id.clone())) {
221 needs_migration = true;
222 break;
223 }
224 }
225 if !needs_migration {
226 return false;
227 }
228
229 let mut sessions: HashSet<String> = HashSet::new();
230 for ep in &self.episodes {
231 sessions.insert(ep.session_id.clone());
232 }
233
234 for session_id in sessions {
235 let mut idx: Vec<usize> = (0..self.episodes.len())
236 .filter(|&i| self.episodes[i].session_id == session_id)
237 .collect();
238 idx.sort_by_key(|&i| self.episodes[i].timestamp);
239
240 let monotonic = idx
243 .windows(2)
244 .all(|w| self.episodes[w[0]].tokens_used <= self.episodes[w[1]].tokens_used);
245
246 let mut prev_tokens: u64 = 0;
247 let mut prev_ts: Option<DateTime<Utc>> = None;
248 let mut used_ids: HashSet<String> = HashSet::new();
249 for &i in &idx {
250 let ts = self.episodes[i].timestamp;
251 let ep = &mut self.episodes[i];
252 let mut id = format!("ep-{}", ts.format("%Y%m%d-%H%M%S"));
253 let mut n = 1;
254 while !used_ids.insert(id.clone()) {
255 n += 1;
256 id = format!("ep-{}-{n}", ts.format("%Y%m%d-%H%M%S"));
257 }
258 ep.id = id;
259 if monotonic {
260 let cumulative = ep.tokens_used;
261 ep.tokens_used = cumulative.saturating_sub(prev_tokens);
262 prev_tokens = cumulative;
263 }
264 if ep.duration_secs == 0
265 && let Some(p) = prev_ts
266 {
267 ep.duration_secs = (ts - p).num_seconds().max(0) as u64;
268 }
269 prev_ts = Some(ts);
270 }
271 }
272 true
273 }
274
275 pub fn load_or_create(project_hash: &str) -> Self {
276 Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
277 }
278
279 pub fn mutate_locked<T>(
280 project_hash: &str,
281 mutate: impl FnOnce(&mut Self) -> T,
282 ) -> Result<(Self, T), String> {
283 let lock = episodic_lock(project_hash);
284 let _guard = lock
285 .lock()
286 .unwrap_or_else(std::sync::PoisonError::into_inner);
287
288 let path = Self::store_path(project_hash)
289 .ok_or_else(|| "Cannot determine data directory".to_string())?;
290 if let Some(dir) = path.parent() {
291 std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
292 }
293 let _file_lock = acquire_file_lock(&path);
294
295 let mut store = Self::load_or_create(project_hash);
296 let result = mutate(&mut store);
297 store.save()?;
298 Ok((store, result))
299 }
300
301 pub fn save(&self) -> Result<(), String> {
302 let path = Self::store_path(&self.project_hash)
303 .ok_or_else(|| "Cannot determine data directory".to_string())?;
304 if let Some(dir) = path.parent() {
305 std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
306 }
307 let json = serde_json::to_string_pretty(self).map_err(|e| format!("{e}"))?;
308 crate::core::atomic_fs::write_bytes_with_fallback(&path, json.as_bytes(), None)
309 }
310}
311
312#[derive(Debug)]
313pub struct EpisodicStats {
314 pub total_episodes: usize,
315 pub successes: usize,
316 pub failures: usize,
317 pub success_rate: f32,
318 pub total_tokens: u64,
319}
320
321pub fn create_episode_from_session(
322 session: &super::session::SessionState,
323 tool_calls: &[(String, u64)],
324) -> Episode {
325 let actions: Vec<Action> = tool_calls
326 .iter()
327 .map(|(tool, duration_ms)| Action {
328 tool: tool.clone(),
329 description: String::new(),
330 timestamp: Utc::now(),
331 duration_ms: *duration_ms,
332 success: true,
333 })
334 .collect();
335
336 let affected_files: Vec<String> = session
337 .files_touched
338 .iter()
339 .map(|f| f.path.clone())
340 .collect();
341
342 let task_description = session
343 .task
344 .as_ref()
345 .map(|t| t.description.clone())
346 .unwrap_or_default();
347
348 let outcome = if session.findings.iter().any(|f| {
349 f.summary.to_lowercase().contains("error") || f.summary.to_lowercase().contains("failed")
350 }) {
351 Outcome::Failure {
352 error: session
353 .findings
354 .iter()
355 .find(|f| {
356 f.summary.to_lowercase().contains("error")
357 || f.summary.to_lowercase().contains("failed")
358 })
359 .map(|f| f.summary.clone())
360 .unwrap_or_default(),
361 }
362 } else if !session.findings.is_empty() || !session.decisions.is_empty() {
363 Outcome::Success { tests_passed: true }
364 } else {
365 Outcome::Unknown
366 };
367
368 Episode {
369 id: format!("ep-{}", Utc::now().format("%Y%m%d-%H%M%S")),
373 session_id: session.id.clone(),
374 timestamp: Utc::now(),
375 task_description,
376 actions,
377 outcome,
378 affected_files,
379 summary: String::new(),
380 duration_secs: 0,
381 tokens_used: session.stats.total_tokens_saved,
384 agent_id: None,
385 }
386}
387
388pub fn record_session_episode(
389 project_hash: &str,
390 session: &super::session::SessionState,
391 tool_calls: &[(String, u64)],
392 agent_id: Option<&str>,
393 policy: &EpisodicPolicy,
394 deduplicate: bool,
395) -> Result<Option<String>, String> {
396 let normalized_agent_id = agent_id
397 .map(str::trim)
398 .filter(|id| !id.is_empty())
399 .map(str::to_string);
400
401 let (_, episode_id) = EpisodicStore::mutate_locked(project_hash, |store| {
402 let mut episode = create_episode_from_session(session, tool_calls);
403 episode.agent_id.clone_from(&normalized_agent_id);
404
405 if deduplicate
406 && store.episodes.iter().any(|existing| {
407 existing.session_id == episode.session_id
408 && existing.agent_id == episode.agent_id
409 && existing.task_description == episode.task_description
410 })
411 {
412 return None;
413 }
414
415 finalize_episode_metrics(&mut episode, store, session.started_at);
416 let id = episode.id.clone();
417 store.record_episode(episode, policy);
418 Some(id)
419 })?;
420
421 Ok(episode_id)
422}
423
424pub fn finalize_episode_metrics(
433 episode: &mut Episode,
434 store: &EpisodicStore,
435 session_started_at: DateTime<Utc>,
436) {
437 let prior_tokens: u64 = store
438 .episodes
439 .iter()
440 .filter(|e| e.session_id == episode.session_id)
441 .map(|e| e.tokens_used)
442 .sum();
443 episode.tokens_used = episode.tokens_used.saturating_sub(prior_tokens);
444
445 let since = store
446 .episodes
447 .iter()
448 .filter(|e| e.session_id == episode.session_id)
449 .map(|e| e.timestamp)
450 .max()
451 .unwrap_or(session_started_at);
452 episode.duration_secs = (episode.timestamp - since).num_seconds().max(0) as u64;
453}
454
455fn auto_summarize(episode: &Episode, max_chars: usize) -> String {
456 let tool_counts = count_tools(&episode.actions);
457 let top_tools: Vec<String> = tool_counts
458 .into_iter()
459 .take(3)
460 .map(|(tool, count)| format!("{tool}x{count}"))
461 .collect();
462
463 let files_hint = if episode.affected_files.len() <= 3 {
464 episode.affected_files.join(", ")
465 } else {
466 format!(
467 "{}, ... +{} more",
468 episode.affected_files[..3].join(", "),
469 episode.affected_files.len() - 3
470 )
471 };
472
473 let task = if episode.task_description.chars().count() > max_chars {
474 episode.task_description.chars().take(max_chars).collect()
475 } else {
476 episode.task_description.clone()
477 };
478 let mut summary = format!(
479 "{task} [{}] tools:[{}]",
480 episode.outcome.label(),
481 top_tools.join(",")
482 );
483
484 if !files_hint.is_empty() {
485 summary.push_str(&format!(" files:[{files_hint}]"));
486 }
487
488 summary
489}
490
491fn count_tools(actions: &[Action]) -> Vec<(String, usize)> {
492 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
493 for action in actions {
494 *counts.entry(&action.tool).or_insert(0) += 1;
495 }
496 let mut sorted: Vec<(String, usize)> = counts
497 .into_iter()
498 .map(|(k, v)| (k.to_string(), v))
499 .collect();
500 sorted.sort_by_key(|item| std::cmp::Reverse(item.1));
501 sorted
502}
503
504pub fn format_episode_compact(episode: &Episode) -> String {
505 format!(
506 "[{}] {} — {} ({} actions, {} files)",
507 episode.outcome.label(),
508 episode.task_description,
509 episode.summary,
510 episode.actions.len(),
511 episode.affected_files.len()
512 )
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn make_episode(task: &str, outcome: Outcome) -> Episode {
520 Episode {
521 id: "ep-test".to_string(),
522 session_id: "sess-1".to_string(),
523 timestamp: Utc::now(),
524 task_description: task.to_string(),
525 actions: vec![
526 Action {
527 tool: "ctx_read".to_string(),
528 description: String::new(),
529 timestamp: Utc::now(),
530 duration_ms: 50,
531 success: true,
532 },
533 Action {
534 tool: "ctx_shell".to_string(),
535 description: String::new(),
536 timestamp: Utc::now(),
537 duration_ms: 200,
538 success: true,
539 },
540 ],
541 outcome,
542 affected_files: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
543 summary: String::new(),
544 duration_secs: 60,
545 tokens_used: 5000,
546 agent_id: None,
547 }
548 }
549
550 #[test]
551 fn mutate_locked_preserves_successive_agent_episodes() {
552 let policy = EpisodicPolicy::default();
553 let mut store = EpisodicStore::new("locked-writes-unit");
554
555 let mut ep_a = make_episode("Task from agent A", Outcome::Unknown);
556 ep_a.agent_id = Some("agent-a".to_string());
557 store.record_episode(ep_a, &policy);
558
559 let mut ep_b = make_episode("Task from agent B", Outcome::Unknown);
560 ep_b.agent_id = Some("agent-b".to_string());
561 store.record_episode(ep_b, &policy);
562
563 assert_eq!(store.episodes.len(), 2);
564 assert!(
565 store
566 .episodes
567 .iter()
568 .any(|episode| episode.agent_id.as_deref() == Some("agent-a"))
569 );
570 assert!(
571 store
572 .episodes
573 .iter()
574 .any(|episode| episode.agent_id.as_deref() == Some("agent-b"))
575 );
576 }
577
578 #[test]
579 fn record_session_episode_deduplicates_per_agent() {
580 let tmp = tempfile::tempdir().unwrap();
581 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
582 let policy = EpisodicPolicy::default();
583 let project_hash = "episodic-agent-dedup";
584 let mut session = super::super::session::SessionState::new();
585 session.set_task("same task", None);
586 let tool_calls = vec![("ctx_read".to_string(), 10)];
587
588 assert!(
589 record_session_episode(
590 project_hash,
591 &session,
592 &tool_calls,
593 Some("agent-a"),
594 &policy,
595 true,
596 )
597 .unwrap()
598 .is_some()
599 );
600 assert!(
601 record_session_episode(
602 project_hash,
603 &session,
604 &tool_calls,
605 Some("agent-a"),
606 &policy,
607 true,
608 )
609 .unwrap()
610 .is_none()
611 );
612 assert!(
613 record_session_episode(
614 project_hash,
615 &session,
616 &tool_calls,
617 Some("agent-b"),
618 &policy,
619 true,
620 )
621 .unwrap()
622 .is_some()
623 );
624
625 let store = EpisodicStore::load_or_create(project_hash);
626 assert_eq!(store.episodes.len(), 2);
627 }
628
629 #[test]
630 fn record_and_search() {
631 let policy = EpisodicPolicy::default();
632 let mut store = EpisodicStore::new("test");
633 store.record_episode(
634 make_episode(
635 "Refactor auth module",
636 Outcome::Success { tests_passed: true },
637 ),
638 &policy,
639 );
640 store.record_episode(
641 make_episode(
642 "Fix database connection",
643 Outcome::Failure {
644 error: "timeout".to_string(),
645 },
646 ),
647 &policy,
648 );
649
650 let results = store.search("auth refactor");
651 assert_eq!(results.len(), 1);
652 assert!(results[0].task_description.contains("auth"));
653 }
654
655 #[test]
656 fn filter_by_outcome() {
657 let policy = EpisodicPolicy::default();
658 let mut store = EpisodicStore::new("test");
659 store.record_episode(
660 make_episode("Task 1", Outcome::Success { tests_passed: true }),
661 &policy,
662 );
663 store.record_episode(
664 make_episode(
665 "Task 2",
666 Outcome::Failure {
667 error: "err".to_string(),
668 },
669 ),
670 &policy,
671 );
672 store.record_episode(
673 make_episode(
674 "Task 3",
675 Outcome::Success {
676 tests_passed: false,
677 },
678 ),
679 &policy,
680 );
681
682 assert_eq!(store.by_outcome("success").len(), 2);
683 assert_eq!(store.by_outcome("failure").len(), 1);
684 }
685
686 #[test]
687 fn filter_by_file() {
688 let policy = EpisodicPolicy::default();
689 let mut store = EpisodicStore::new("test");
690 store.record_episode(make_episode("Task", Outcome::Unknown), &policy);
691
692 let results = store.by_file("main.rs");
693 assert_eq!(results.len(), 1);
694
695 let results = store.by_file("nonexistent.rs");
696 assert!(results.is_empty());
697 }
698
699 #[test]
700 fn recent_episodes() {
701 let policy = EpisodicPolicy::default();
702 let mut store = EpisodicStore::new("test");
703 for i in 0..5 {
704 store.record_episode(
705 make_episode(&format!("Task {i}"), Outcome::Unknown),
706 &policy,
707 );
708 }
709
710 let recent = store.recent(3);
711 assert_eq!(recent.len(), 3);
712 assert!(recent[0].task_description.contains('4'));
713 }
714
715 #[test]
716 fn stats_calculation() {
717 let policy = EpisodicPolicy::default();
718 let mut store = EpisodicStore::new("test");
719 store.record_episode(
720 make_episode("T1", Outcome::Success { tests_passed: true }),
721 &policy,
722 );
723 store.record_episode(
724 make_episode(
725 "T2",
726 Outcome::Failure {
727 error: "e".to_string(),
728 },
729 ),
730 &policy,
731 );
732 store.record_episode(
733 make_episode(
734 "T3",
735 Outcome::Success {
736 tests_passed: false,
737 },
738 ),
739 &policy,
740 );
741
742 let stats = store.stats();
743 assert_eq!(stats.total_episodes, 3);
744 assert_eq!(stats.successes, 2);
745 assert_eq!(stats.failures, 1);
746 assert!((stats.success_rate - 0.6667).abs() < 0.01);
747 }
748
749 #[test]
750 fn auto_summary_generation() {
751 let mut ep = make_episode("Fix the login bug", Outcome::Success { tests_passed: true });
752 ep.summary = String::new();
753 let summary = auto_summarize(&ep, EpisodicPolicy::default().summary_max_chars);
754 assert!(summary.contains("Fix the login bug"));
755 assert!(summary.contains("[success]"));
756 assert!(summary.contains("ctx_read"));
757 }
758
759 #[test]
760 fn max_episodes_enforced() {
761 let policy = EpisodicPolicy::default();
762 let mut store = EpisodicStore::new("test");
763 for i in 0..510 {
764 store.record_episode(
765 make_episode(&format!("Task {i}"), Outcome::Unknown),
766 &policy,
767 );
768 }
769 assert!(store.episodes.len() <= policy.max_episodes);
770 }
771
772 #[test]
773 fn format_compact() {
774 let ep = make_episode("Deploy v2", Outcome::Success { tests_passed: true });
775 let output = format_episode_compact(&ep);
776 assert!(output.contains("[success]"));
777 assert!(output.contains("Deploy v2"));
778 }
779
780 #[test]
781 fn finalize_metrics_converts_cumulative_to_delta() {
782 let mut store = EpisodicStore::new("test");
783 let started = Utc::now() - chrono::Duration::seconds(600);
784
785 let mut first = make_episode("T1", Outcome::Unknown);
786 first.session_id = "sess-x".to_string();
787 first.tokens_used = 1000; first.timestamp = started + chrono::Duration::seconds(100);
789 finalize_episode_metrics(&mut first, &store, started);
790 assert_eq!(first.tokens_used, 1000);
791 assert_eq!(first.duration_secs, 100);
792 store.episodes.push(first);
793
794 let mut second = make_episode("T2", Outcome::Unknown);
795 second.session_id = "sess-x".to_string();
796 second.tokens_used = 1800; second.timestamp = started + chrono::Duration::seconds(400);
798 finalize_episode_metrics(&mut second, &store, started);
799 assert_eq!(second.tokens_used, 800); assert_eq!(second.duration_secs, 300);
801 }
802
803 #[test]
804 fn migrate_legacy_dedupes_ids_and_converts_tokens() {
805 let mut store = EpisodicStore::new("test");
806 let base = Utc::now() - chrono::Duration::seconds(1000);
807 for (i, cumulative) in [1000u64, 3000, 6000].iter().enumerate() {
808 let mut ep = make_episode(&format!("T{i}"), Outcome::Unknown);
809 ep.id = "ep-20260516".to_string(); ep.session_id = "sess-legacy".to_string();
811 ep.tokens_used = *cumulative;
812 ep.duration_secs = 0;
813 ep.timestamp = base + chrono::Duration::seconds(i as i64 * 120);
814 store.episodes.push(ep);
815 }
816
817 assert!(store.migrate_legacy_episodes());
818 let ids: std::collections::HashSet<String> =
819 store.episodes.iter().map(|e| e.id.clone()).collect();
820 assert_eq!(ids.len(), 3, "ids must be unique after migration");
821 let tokens: Vec<u64> = store.episodes.iter().map(|e| e.tokens_used).collect();
822 assert_eq!(tokens, vec![1000, 2000, 3000]);
823 assert!(!store.migrate_legacy_episodes());
825 }
826}