Skip to main content

repo/
thread_storage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Thread storage and lifecycle management.
3
4use std::{
5    collections::HashSet,
6    path::{Path, PathBuf},
7};
8
9use chrono::{DateTime, Utc};
10use objects::{
11    lock::RepoLock,
12    object::StateId,
13    store::{HeddleError, ObjectStore, Result},
14};
15
16use crate::{
17    thread_model::{
18        EphemeralMarker, ThreadConfidenceSummary, ThreadFreshness, ThreadImpactCategory,
19        ThreadIntegrationPolicy, ThreadMode, ThreadRecord, ThreadRuntimeOverlay, ThreadState,
20        ThreadVerificationSummary, ThreadView,
21    },
22    thread_record_store::FilesystemThreadRecordStore,
23};
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct Thread {
27    pub id: String,
28    pub thread: String,
29    #[serde(default)]
30    pub target_thread: Option<String>,
31    #[serde(default)]
32    pub parent_thread: Option<String>,
33    pub mode: ThreadMode,
34    pub state: ThreadState,
35    pub base_state: String,
36    pub base_root: String,
37    #[serde(default)]
38    pub current_state: Option<String>,
39    #[serde(default)]
40    pub merged_state: Option<String>,
41    #[serde(default)]
42    pub task: Option<String>,
43    #[serde(default)]
44    pub execution_path: PathBuf,
45    #[serde(default)]
46    pub materialized_path: Option<PathBuf>,
47    #[serde(default)]
48    pub changed_paths: Vec<String>,
49    #[serde(default)]
50    pub impact_categories: Vec<ThreadImpactCategory>,
51    #[serde(default)]
52    pub heavy_impact_paths: Vec<String>,
53    #[serde(default)]
54    pub promotion_suggested: bool,
55    #[serde(default = "default_freshness")]
56    pub freshness: ThreadFreshness,
57    #[serde(default)]
58    pub verification_summary: ThreadVerificationSummary,
59    #[serde(default)]
60    pub confidence_summary: ThreadConfidenceSummary,
61    #[serde(default)]
62    pub integration_policy_result: ThreadIntegrationPolicy,
63    pub created_at: DateTime<Utc>,
64    pub updated_at: DateTime<Utc>,
65    /// Optional ephemeral-thread marker (W1). Preserved across record/Thread
66    /// round-trips so the auto-collapse sweep can find it.
67    #[serde(default)]
68    pub ephemeral: Option<EphemeralMarker>,
69    /// Mirror of [`ThreadRecord::auto`]. See that field for semantics.
70    /// Defaults to `false` for back-compat with pre-2.2 records.
71    #[serde(default)]
72    pub auto: bool,
73    /// Mirror of [`ThreadRecord::shared_target_dir`]. See that field for
74    /// semantics. `None` for threads that use cargo's default per-checkout
75    /// `target/`. Defaults to `None` for back-compat with records written
76    /// before the field existed.
77    #[serde(default)]
78    pub shared_target_dir: Option<PathBuf>,
79}
80
81#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
82struct ThreadWorkspaceState {
83    #[serde(default)]
84    execution_path: PathBuf,
85    #[serde(default)]
86    materialized_path: Option<PathBuf>,
87}
88
89impl Thread {
90    pub fn to_record(&self) -> ThreadRecord {
91        ThreadRecord {
92            id: self.id.clone(),
93            thread: self.thread.clone(),
94            target_thread: self.target_thread.clone(),
95            parent_thread: self.parent_thread.clone(),
96            mode: self.mode.clone(),
97            state: self.state.clone(),
98            base_state: self.base_state.clone(),
99            base_root: self.base_root.clone(),
100            current_state: self.current_state.clone(),
101            merged_state: self.merged_state.clone(),
102            task: self.task.clone(),
103            changed_paths: self.changed_paths.clone(),
104            impact_categories: self.impact_categories.clone(),
105            heavy_impact_paths: self.heavy_impact_paths.clone(),
106            promotion_suggested: self.promotion_suggested,
107            freshness: self.freshness.clone(),
108            verification_summary: self.verification_summary.clone(),
109            confidence_summary: self.confidence_summary.clone(),
110            integration_policy_result: self.integration_policy_result.clone(),
111            created_at: self.created_at,
112            updated_at: self.updated_at,
113            ephemeral: self.ephemeral.clone(),
114            auto: self.auto,
115            shared_target_dir: self.shared_target_dir.clone(),
116        }
117    }
118
119    pub fn from_record(record: ThreadRecord) -> Self {
120        Self {
121            id: record.id.clone(),
122            thread: record.thread,
123            target_thread: record.target_thread,
124            parent_thread: record.parent_thread,
125            mode: record.mode,
126            state: record.state,
127            base_state: record.base_state,
128            base_root: record.base_root,
129            current_state: record.current_state,
130            merged_state: record.merged_state,
131            task: record.task,
132            execution_path: PathBuf::new(),
133            materialized_path: None,
134            changed_paths: record.changed_paths,
135            impact_categories: record.impact_categories,
136            heavy_impact_paths: record.heavy_impact_paths,
137            promotion_suggested: record.promotion_suggested,
138            freshness: record.freshness,
139            verification_summary: record.verification_summary,
140            confidence_summary: record.confidence_summary,
141            integration_policy_result: record.integration_policy_result,
142            created_at: record.created_at,
143            updated_at: record.updated_at,
144            ephemeral: record.ephemeral,
145            auto: record.auto,
146            shared_target_dir: record.shared_target_dir,
147        }
148    }
149
150    pub fn to_view(&self, runtime: ThreadRuntimeOverlay, is_current: bool) -> ThreadView {
151        ThreadView::from_record(self.to_record(), runtime, is_current)
152    }
153
154    fn workspace_state(&self) -> ThreadWorkspaceState {
155        ThreadWorkspaceState {
156            execution_path: self.execution_path.clone(),
157            materialized_path: self.materialized_path.clone(),
158        }
159    }
160}
161
162impl ThreadWorkspaceState {
163    fn apply_to_thread(&self, thread: &mut Thread) {
164        thread.execution_path = self.execution_path.clone();
165        thread.materialized_path = self.materialized_path.clone();
166    }
167}
168
169pub type SyncedThreadMetadata = ThreadRecord;
170
171impl SyncedThreadMetadata {
172    pub fn from_record(
173        repo: &crate::Repository,
174        record: &ThreadRecord,
175        current_state_override: Option<StateId>,
176    ) -> Result<Self> {
177        let mut record = record.clone();
178        let resolve_full = |spec: &str| -> Result<String> {
179            Ok(repo
180                .resolve_state(spec)?
181                .map(|id| id.to_string_full())
182                .unwrap_or_else(|| spec.to_string()))
183        };
184        record.base_state = resolve_full(&record.base_state)?;
185        record.current_state = match current_state_override {
186            Some(id) => Some(id.to_string_full()),
187            None => record
188                .current_state
189                .as_deref()
190                .map(resolve_full)
191                .transpose()?,
192        };
193        record.merged_state = record
194            .merged_state
195            .as_deref()
196            .map(resolve_full)
197            .transpose()?;
198        record.base_root = repo
199            .resolve_state(&record.base_state)?
200            .and_then(|id| repo.store().get_state(&id).ok().flatten())
201            .map(|state| state.tree.to_hex())
202            .unwrap_or_else(|| record.base_root.clone());
203        Ok(record)
204    }
205
206    pub fn from_thread(
207        repo: &crate::Repository,
208        thread: &Thread,
209        current_state_override: Option<StateId>,
210    ) -> Result<Self> {
211        Self::from_record(repo, &thread.to_record(), current_state_override)
212    }
213
214    pub fn current_state_id(&self, repo: &crate::Repository) -> Result<Option<StateId>> {
215        match self.current_state.as_deref() {
216            Some(state) => Ok(repo.resolve_state(state)?),
217            None => Ok(None),
218        }
219    }
220}
221
222fn default_freshness() -> ThreadFreshness {
223    ThreadFreshness::Unknown
224}
225
226#[derive(Debug, Clone)]
227pub struct ThreadManager {
228    record_store: FilesystemThreadRecordStore,
229    workspace_store: FilesystemThreadRecordStore,
230    lock_path: PathBuf,
231}
232
233impl ThreadManager {
234    pub fn new(heddle_dir: &Path) -> Self {
235        Self {
236            record_store: FilesystemThreadRecordStore::new(heddle_dir.join("thread_records")),
237            workspace_store: FilesystemThreadRecordStore::new(heddle_dir.join("thread_workspaces")),
238            lock_path: heddle_dir.join("thread_records").join(".lock"),
239        }
240    }
241
242    fn lock_path(&self) -> PathBuf {
243        self.lock_path.clone()
244    }
245
246    fn write_lock(&self) -> Result<objects::lock::WriteLockGuard> {
247        RepoLock::at(self.lock_path())
248            .write()
249            .map_err(|err| HeddleError::Config(format!("failed to acquire thread lock: {err}")))
250    }
251
252    fn save_record_file(&self, record: &ThreadRecord) -> Result<()> {
253        self.record_store.save_value(&record.id, record)
254    }
255
256    fn save_workspace_file(&self, thread_id: &str, workspace: &ThreadWorkspaceState) -> Result<()> {
257        self.workspace_store.save_value(thread_id, workspace)
258    }
259
260    fn load_record_file(&self, thread_id: &str) -> Result<Option<ThreadRecord>> {
261        self.record_store.load_value(thread_id)
262    }
263
264    fn load_workspace_file(&self, thread_id: &str) -> Result<Option<ThreadWorkspaceState>> {
265        self.workspace_store.load_value(thread_id)
266    }
267
268    fn list_record_files(&self) -> Result<Vec<ThreadRecord>> {
269        self.record_store.list_values()
270    }
271
272    fn hydrate_thread_from_record(&self, record: ThreadRecord) -> Result<Thread> {
273        let mut thread = Thread::from_record(record.clone());
274        if let Some(workspace) = self.load_workspace_file(&record.id)? {
275            workspace.apply_to_thread(&mut thread);
276        }
277        Ok(thread)
278    }
279
280    pub fn save(&self, thread: &Thread) -> Result<()> {
281        let _lock = self.write_lock()?;
282        self.save_record_file(&thread.to_record())?;
283        objects::fault_inject::maybe_fail_at("thread_manager_save_before_workspace")?;
284        self.save_workspace_file(&thread.id, &thread.workspace_state())
285    }
286
287    pub fn load(&self, thread_id: &str) -> Result<Option<Thread>> {
288        self.load_record_file(thread_id)?
289            .map(|record| self.hydrate_thread_from_record(record))
290            .transpose()
291    }
292
293    pub fn list(&self) -> Result<Vec<Thread>> {
294        let mut threads: Vec<Thread> = self
295            .list_record_files()?
296            .into_iter()
297            .map(|record| self.hydrate_thread_from_record(record))
298            .collect::<Result<Vec<_>>>()?;
299        threads.sort_by(|a, b| a.id.cmp(&b.id));
300        Ok(threads)
301    }
302
303    pub fn find_by_thread(&self, thread: &str) -> Result<Option<Thread>> {
304        Ok(self
305            .list()?
306            .into_iter()
307            .filter(|thread_entry| thread_entry.thread == thread)
308            .max_by_key(|thread_entry| thread_entry.updated_at))
309    }
310
311    pub fn find_by_execution_root(&self, root: &Path) -> Result<Option<Thread>> {
312        let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
313        Ok(self.list()?.into_iter().find(|thread| {
314            thread
315                .execution_path
316                .canonicalize()
317                .unwrap_or_else(|_| thread.execution_path.clone())
318                == canonical
319                || thread
320                    .materialized_path
321                    .as_ref()
322                    .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone()) == canonical)
323                    .unwrap_or(false)
324        }))
325    }
326
327    pub fn delete(&self, thread_id: &str) -> Result<()> {
328        let _lock = self.write_lock()?;
329        self.record_store.delete_value(thread_id)?;
330        self.workspace_store.delete_value(thread_id)
331    }
332
333    /// Converge the persisted record set for thread `name` to EXACTLY `desired`
334    /// (by record id), atomically under a single write lock: delete every record
335    /// filed under `name` whose id is not in `desired`, then save every record in
336    /// `desired`. Post-condition: `{ records with .thread == name }` has exactly
337    /// the id-set of `desired`, so [`find_by_thread`](Self::find_by_thread)
338    /// returns one of `desired` REGARDLESS of what (possibly unknown-id,
339    /// newer-timestamped) record a rolled-back or duplicating forward left behind
340    /// — the leaked record's id never has to be known, because every non-`desired`
341    /// record under the name is dropped.
342    ///
343    /// This is the SOLE lock-atomic mutation point for a thread's record set:
344    /// every undo/redo thread-record convergence (save, redo-restore, undo-remove)
345    /// routes through it, so there is one chokepoint, not a per-path collection of
346    /// save/delete sequences each racing a concurrent same-name writer.
347    pub fn converge_records(&self, name: &str, desired: &[Thread]) -> Result<()> {
348        // Acquire the write lock ONCE and perform the whole enumerate→delete→save
349        // under that single guard, via the PRIVATE file-level helpers. The public
350        // `list`/`delete`/`save` each re-acquire `write_lock()`, which is NOT
351        // re-entrant (flock on a second FD in the same process blocks), so calling
352        // them here would deadlock — and a concurrent same-name writer between an
353        // unlocked `list()` snapshot and the deletes could leak a record that
354        // survives the converge.
355        let _lock = self.write_lock()?;
356        let keep: HashSet<&str> = desired.iter().map(|t| t.id.as_str()).collect();
357        for record in self.list_record_files()? {
358            if record.thread == name && !keep.contains(record.id.as_str()) {
359                self.record_store.delete_value(&record.id)?;
360                self.workspace_store.delete_value(&record.id)?;
361            }
362        }
363        for thread in desired {
364            self.save_record_file(&thread.to_record())?;
365            self.save_workspace_file(&thread.id, &thread.workspace_state())?;
366        }
367        Ok(())
368    }
369
370    /// Snapshot every record currently filed under `name` (lock-atomic), hydrated
371    /// with its workspace half, for registering a converge-back inverse. Pairs
372    /// with [`converge_records`](Self::converge_records): capture the full prior
373    /// same-name set with this, and `converge_records(name, &prior)` restores it
374    /// exactly on rollback.
375    pub fn snapshot_records(&self, name: &str) -> Result<Vec<Thread>> {
376        // Single write lock across the enumerate→hydrate; the file-level helpers
377        // (`list_record_files`, `load_workspace_file` via `hydrate_…`) never
378        // re-acquire it, so the read is consistent and deadlock-free.
379        let _lock = self.write_lock()?;
380        self.list_record_files()?
381            .into_iter()
382            .filter(|record| record.thread == name)
383            .map(|record| self.hydrate_thread_from_record(record))
384            .collect()
385    }
386
387    pub fn load_record(&self, record_id: &str) -> Result<Option<ThreadRecord>> {
388        self.load_record_file(record_id)
389    }
390
391    pub fn save_record(&self, record: &ThreadRecord) -> Result<()> {
392        let _lock = self.write_lock()?;
393        self.save_record_file(record)
394    }
395
396    pub fn list_records(&self) -> Result<Vec<ThreadRecord>> {
397        let mut records: Vec<ThreadRecord> = self.list_record_files()?;
398        records.sort_by(|a, b| a.id.cmp(&b.id));
399        Ok(records)
400    }
401
402    pub fn find_record_by_thread(&self, thread: &str) -> Result<Option<ThreadRecord>> {
403        let mut records = self
404            .list_records()?
405            .into_iter()
406            .filter(|record| record.thread == thread)
407            .collect::<Vec<_>>();
408        records.sort_by(|a, b| {
409            a.updated_at
410                .cmp(&b.updated_at)
411                .then_with(|| a.id.cmp(&b.id))
412        });
413        Ok(records.pop())
414    }
415
416    pub fn find_synced_record_by_thread(
417        &self,
418        repo: &crate::Repository,
419        thread: &str,
420        current_state_override: Option<StateId>,
421    ) -> Result<Option<SyncedThreadMetadata>> {
422        self.find_record_by_thread(thread)?
423            .map(|record| SyncedThreadMetadata::from_record(repo, &record, current_state_override))
424            .transpose()
425    }
426
427    pub fn delete_record(&self, record_id: &str) -> Result<()> {
428        let _lock = self.write_lock()?;
429        self.record_store.delete_value(record_id)
430    }
431
432    /// Encode the `Thread` record matching `thread_name` to opaque
433    /// rmp-serde bytes for inclusion in `OpRecord::ThreadCreate`'s
434    /// `manager_snapshot` field. Returns `Ok(None)` when no record
435    /// exists for that thread (the caller doesn't have a record to
436    /// snapshot — e.g. `cmd_start --path` writes the record only after
437    /// materialization, and the rename batch's new-name arm never has
438    /// one). heddle#23 r2.
439    ///
440    /// The encoding is opaque to the `oplog` crate: it stores the bytes
441    /// without interpreting them. The shape is `Thread`'s serde form,
442    /// which has `#[serde(default)]` on every optional field, so
443    /// records written by future versions of heddle remain decodable
444    /// by older readers and vice versa.
445    pub fn snapshot_thread_record(&self, thread_name: &str) -> Result<Option<Vec<u8>>> {
446        let Some(thread) = self.find_by_thread(thread_name)? else {
447            return Ok(None);
448        };
449        self.encode_thread_record_snapshot(&thread).map(Some)
450    }
451
452    /// Encode a concrete `Thread` record using the same opaque snapshot format
453    /// as [`snapshot_thread_record`](Self::snapshot_thread_record).
454    pub fn encode_thread_record_snapshot(&self, thread: &Thread) -> Result<Vec<u8>> {
455        rmp_serde::to_vec_named(thread).map_err(|e| {
456            HeddleError::Serialization(format!(
457                "encode thread record snapshot for '{}': {}",
458                thread.thread, e
459            ))
460        })
461    }
462
463    /// Decode a `Thread` record from rmp-serde bytes produced by
464    /// `snapshot_thread_record`. Used by `heddle undo --redo` of a
465    /// `ThreadCreate` to reconstruct the record body that undo destroyed
466    /// (heddle#23 r2 Codex P1, mirroring the FastForward pattern from
467    /// heddle#99 r2 — record what redo needs).
468    ///
469    /// Decode ONLY — it does NOT persist. The redo applier converges the
470    /// decoded record onto the thread's record set via
471    /// [`converge_records`](Self::converge_records), so the restore writes the
472    /// record as the SOLE record under the name (a raw `save` would leave a
473    /// pre-existing duplicate behind). An invalid snapshot surfaces as
474    /// `HeddleError::Serialization` — the redo arm logs and falls back to
475    /// ref-only restore rather than failing the whole batch.
476    pub fn decode_thread_record_snapshot(&self, bytes: &[u8]) -> Result<Thread> {
477        rmp_serde::from_slice(bytes).map_err(|e| {
478            HeddleError::Serialization(format!("decode thread record snapshot: {}", e))
479        })
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use chrono::Utc;
486    use tempfile::TempDir;
487
488    use super::*;
489
490    fn sample_thread() -> Thread {
491        Thread {
492            id: "thread-1".to_string(),
493            thread: "feature/thread-1".to_string(),
494            target_thread: Some("main".to_string()),
495            parent_thread: None,
496            mode: ThreadMode::Solid,
497            state: ThreadState::Active,
498            base_state: "abc123".to_string(),
499            base_root: "def456".to_string(),
500            current_state: Some("abc123".to_string()),
501            merged_state: None,
502            task: Some("implement thing".to_string()),
503            execution_path: PathBuf::from("/tmp/work"),
504            materialized_path: Some(PathBuf::from("/tmp/materialized")),
505            changed_paths: vec!["src/lib.rs".to_string()],
506            impact_categories: vec![],
507            heavy_impact_paths: vec![],
508            promotion_suggested: false,
509            freshness: ThreadFreshness::Current,
510            verification_summary: ThreadVerificationSummary::default(),
511            confidence_summary: ThreadConfidenceSummary::default(),
512            integration_policy_result: ThreadIntegrationPolicy::default(),
513            created_at: Utc::now(),
514            updated_at: Utc::now(),
515            ephemeral: None,
516            auto: false,
517            shared_target_dir: None,
518        }
519    }
520
521    #[test]
522    fn thread_manager_round_trips_canonical_record_and_workspace() {
523        let temp = TempDir::new().unwrap();
524        let manager = ThreadManager::new(temp.path());
525        let thread = sample_thread();
526
527        manager.save(&thread).unwrap();
528        let record = manager.load_record(&thread.id).unwrap().unwrap();
529        let hydrated = manager.load(&thread.id).unwrap().unwrap();
530
531        assert_eq!(record.id, thread.id);
532        assert_eq!(record.thread, thread.thread);
533        assert_eq!(record.base_state, thread.base_state);
534        assert_eq!(record.task, thread.task);
535        assert_eq!(hydrated.execution_path, thread.execution_path);
536        assert_eq!(hydrated.materialized_path, thread.materialized_path);
537    }
538
539    /// Item 2.2 of the heddle 6→8 plan adds an `auto` flag to
540    /// distinguish harness-created threads from user-created ones.
541    /// The flag must round-trip through both storage halves and
542    /// default to `false` when missing on disk (back-compat for
543    /// records written before the field existed).
544    #[test]
545    fn thread_manager_round_trips_auto_flag() {
546        let temp = TempDir::new().unwrap();
547        let manager = ThreadManager::new(temp.path());
548        let mut thread = sample_thread();
549        thread.id = "thread-auto".to_string();
550        thread.thread = "feature/thread-auto".to_string();
551        thread.auto = true;
552
553        manager.save(&thread).unwrap();
554        let record = manager.load_record(&thread.id).unwrap().unwrap();
555        let hydrated = manager.load(&thread.id).unwrap().unwrap();
556
557        assert!(
558            record.auto,
559            "auto flag must persist on the canonical record"
560        );
561        assert!(
562            hydrated.auto,
563            "auto flag must round-trip back into the hydrated Thread"
564        );
565    }
566
567    /// `converge_records` is the SOLE lock-atomic record-set mutation point every
568    /// thread-record undo/redo path routes through (heddle#355 r8, close-the-class).
569    /// A non-atomic / duplicating forward can write a record under a DIFFERENT id
570    /// with a NEWER `updated_at`, which `find_by_thread`'s `max_by_key(updated_at)`
571    /// would then return. Converging to the single target must delete that leaked
572    /// record so the post-condition holds: the records filed under `name` are
573    /// exactly the id-set of `desired`.
574    #[test]
575    fn converge_records_drops_leaked_newer_id_record() {
576        let temp = TempDir::new().unwrap();
577        let manager = ThreadManager::new(temp.path());
578        let name = "feature/converge";
579
580        let mut prev = sample_thread();
581        prev.id = "rec-prev".to_string();
582        prev.thread = name.to_string();
583        prev.updated_at = Utc::now();
584        manager.save(&prev).unwrap();
585
586        // A forward wrote a SECOND record under the same name — different id,
587        // NEWER timestamp — so `find_by_thread` returns the leaked record.
588        let mut leaked = sample_thread();
589        leaked.id = "rec-leaked".to_string();
590        leaked.thread = name.to_string();
591        leaked.updated_at = prev.updated_at + chrono::Duration::seconds(10);
592        manager.save(&leaked).unwrap();
593        assert_eq!(
594            manager.find_by_thread(name).unwrap().unwrap().id,
595            "rec-leaked",
596            "precondition: newer leaked record shadows prev"
597        );
598
599        manager
600            .converge_records(name, std::slice::from_ref(&prev))
601            .unwrap();
602
603        assert_eq!(
604            manager.find_by_thread(name).unwrap().unwrap().id,
605            "rec-prev",
606            "converge returns find_by_thread to the target, not the leak"
607        );
608        let under_name: Vec<_> = manager
609            .list()
610            .unwrap()
611            .into_iter()
612            .filter(|t| t.thread == name)
613            .collect();
614        assert_eq!(
615            under_name.len(),
616            1,
617            "exactly the target remains filed under the name"
618        );
619    }
620
621    /// Lock-atomic converge over MULTIPLE same-name records (heddle#355 r7/r8).
622    /// With the target PLUS two other same-name records (one newer-timestamped, so
623    /// it would shadow the target via `find_by_thread`'s `max_by_key(updated_at)`),
624    /// the converge enumerates and deletes them all under a SINGLE write lock via
625    /// the private file-level helpers — never re-acquiring the (non-re-entrant)
626    /// lock through the public `list`/`delete`/`save`. Both storage halves (record
627    /// + workspace file) of each dropped record must be gone.
628    #[test]
629    fn converge_records_converges_multiple_same_name_records() {
630        let temp = TempDir::new().unwrap();
631        let manager = ThreadManager::new(temp.path());
632        let name = "feature/multi";
633
634        let mut target = sample_thread();
635        target.id = "rec-target".to_string();
636        target.thread = name.to_string();
637        target.updated_at = Utc::now();
638        manager.save(&target).unwrap();
639
640        let mut older = sample_thread();
641        older.id = "rec-older".to_string();
642        older.thread = name.to_string();
643        older.updated_at = target.updated_at - chrono::Duration::seconds(30);
644        manager.save(&older).unwrap();
645
646        let mut newer = sample_thread();
647        newer.id = "rec-newer".to_string();
648        newer.thread = name.to_string();
649        newer.updated_at = target.updated_at + chrono::Duration::seconds(30);
650        manager.save(&newer).unwrap();
651        assert_eq!(
652            manager.find_by_thread(name).unwrap().unwrap().id,
653            "rec-newer",
654            "precondition: the newer same-name record shadows the target"
655        );
656
657        manager
658            .converge_records(name, std::slice::from_ref(&target))
659            .unwrap();
660
661        let under_name: Vec<_> = manager
662            .list()
663            .unwrap()
664            .into_iter()
665            .filter(|t| t.thread == name)
666            .collect();
667        assert_eq!(
668            under_name.len(),
669            1,
670            "exactly the target remains under the name"
671        );
672        assert_eq!(under_name[0].id, "rec-target");
673        assert_eq!(
674            manager.find_by_thread(name).unwrap().unwrap().id,
675            "rec-target",
676            "converge returns find_by_thread to the target"
677        );
678        // Both storage halves of every dropped record are gone.
679        for dropped in ["rec-older", "rec-newer"] {
680            assert!(
681                manager.load_record_file(dropped).unwrap().is_none(),
682                "{dropped} record file deleted"
683            );
684            assert!(
685                manager.load_workspace_file(dropped).unwrap().is_none(),
686                "{dropped} workspace file deleted"
687            );
688        }
689        // The target keeps both halves.
690        assert!(manager.load_record_file("rec-target").unwrap().is_some());
691        assert!(manager.load_workspace_file("rec-target").unwrap().is_some());
692    }
693
694    /// Converging to a 2-record `desired` set keeps EXACTLY those two ids and drops
695    /// every other same-name record — the full-set generalization (heddle#355 r8):
696    /// converge is not limited to one-or-empty. Start with ≥3 records under the
697    /// name and converge to a chosen pair.
698    #[test]
699    fn converge_records_converges_to_a_two_record_set() {
700        let temp = TempDir::new().unwrap();
701        let manager = ThreadManager::new(temp.path());
702        let name = "feature/pair";
703
704        let mut keep_a = sample_thread();
705        keep_a.id = "keep-a".to_string();
706        keep_a.thread = name.to_string();
707        keep_a.updated_at = Utc::now();
708        manager.save(&keep_a).unwrap();
709
710        let mut keep_b = sample_thread();
711        keep_b.id = "keep-b".to_string();
712        keep_b.thread = name.to_string();
713        keep_b.updated_at = keep_a.updated_at - chrono::Duration::seconds(10);
714        manager.save(&keep_b).unwrap();
715
716        let mut drop_c = sample_thread();
717        drop_c.id = "drop-c".to_string();
718        drop_c.thread = name.to_string();
719        drop_c.updated_at = keep_a.updated_at + chrono::Duration::seconds(10);
720        manager.save(&drop_c).unwrap();
721        assert_eq!(
722            manager
723                .list()
724                .unwrap()
725                .iter()
726                .filter(|t| t.thread == name)
727                .count(),
728            3,
729            "precondition: three records under the name"
730        );
731
732        manager
733            .converge_records(name, &[keep_a.clone(), keep_b.clone()])
734            .unwrap();
735
736        let ids: HashSet<String> = manager
737            .list()
738            .unwrap()
739            .into_iter()
740            .filter(|t| t.thread == name)
741            .map(|t| t.id)
742            .collect();
743        assert_eq!(
744            ids,
745            HashSet::from(["keep-a".to_string(), "keep-b".to_string()]),
746            "exactly the desired pair remains under the name"
747        );
748        assert!(
749            manager.load_record_file("drop-c").unwrap().is_none(),
750            "the non-desired record is dropped"
751        );
752        assert!(
753            manager.load_workspace_file("drop-c").unwrap().is_none(),
754            "the non-desired record's workspace half is dropped"
755        );
756    }
757
758    /// An empty `desired` empties the name: every record filed under it is dropped,
759    /// and records for OTHER names are untouched.
760    #[test]
761    fn converge_records_empty_empties_only_the_named_thread() {
762        let temp = TempDir::new().unwrap();
763        let manager = ThreadManager::new(temp.path());
764
765        let mut a1 = sample_thread();
766        a1.id = "a1".to_string();
767        a1.thread = "feature/a".to_string();
768        manager.save(&a1).unwrap();
769        let mut a2 = sample_thread();
770        a2.id = "a2".to_string();
771        a2.thread = "feature/a".to_string();
772        a2.updated_at = a1.updated_at + chrono::Duration::seconds(5);
773        manager.save(&a2).unwrap();
774        let mut other = sample_thread();
775        other.id = "b1".to_string();
776        other.thread = "feature/b".to_string();
777        manager.save(&other).unwrap();
778
779        manager.converge_records("feature/a", &[]).unwrap();
780
781        assert!(
782            manager.find_by_thread("feature/a").unwrap().is_none(),
783            "all records for the named thread are deleted"
784        );
785        assert_eq!(
786            manager.find_by_thread("feature/b").unwrap().unwrap().id,
787            "b1",
788            "records for other threads are untouched"
789        );
790    }
791
792    /// When the desired single record is already the sole record under the name,
793    /// the converge is a no-op that leaves it intact.
794    #[test]
795    fn converge_records_target_already_sole_is_noop() {
796        let temp = TempDir::new().unwrap();
797        let manager = ThreadManager::new(temp.path());
798        let name = "feature/sole";
799
800        let mut rec = sample_thread();
801        rec.id = "rec-sole".to_string();
802        rec.thread = name.to_string();
803        manager.save(&rec).unwrap();
804
805        manager
806            .converge_records(name, std::slice::from_ref(&rec))
807            .unwrap();
808
809        assert_eq!(
810            manager.find_by_thread(name).unwrap().unwrap().id,
811            "rec-sole"
812        );
813        assert_eq!(
814            manager
815                .list()
816                .unwrap()
817                .into_iter()
818                .filter(|t| t.thread == name)
819                .count(),
820            1
821        );
822    }
823
824    /// `snapshot_records` returns the full hydrated same-name set (record + the
825    /// workspace half), and only that set — the read paired with
826    /// `converge_records` for capturing a converge-back inverse.
827    #[test]
828    fn snapshot_records_returns_full_hydrated_same_name_set() {
829        let temp = TempDir::new().unwrap();
830        let manager = ThreadManager::new(temp.path());
831        let name = "feature/snap";
832
833        let mut a = sample_thread();
834        a.id = "snap-a".to_string();
835        a.thread = name.to_string();
836        a.materialized_path = Some(PathBuf::from("/work/snap-a"));
837        manager.save(&a).unwrap();
838        let mut b = sample_thread();
839        b.id = "snap-b".to_string();
840        b.thread = name.to_string();
841        manager.save(&b).unwrap();
842        // A record under a DIFFERENT name must not appear in the snapshot.
843        let mut other = sample_thread();
844        other.id = "snap-other".to_string();
845        other.thread = "feature/other".to_string();
846        manager.save(&other).unwrap();
847
848        let snap = manager.snapshot_records(name).unwrap();
849        let ids: HashSet<String> = snap.iter().map(|t| t.id.clone()).collect();
850        assert_eq!(
851            ids,
852            HashSet::from(["snap-a".to_string(), "snap-b".to_string()]),
853            "exactly the same-name set is snapshotted, other names excluded"
854        );
855        let snap_a = snap.iter().find(|t| t.id == "snap-a").unwrap();
856        assert_eq!(
857            snap_a.materialized_path,
858            Some(PathBuf::from("/work/snap-a")),
859            "the workspace half is hydrated into the snapshot"
860        );
861    }
862
863    #[test]
864    fn thread_record_reader_rejects_minimal_legacy_shape_after_migration_gate() {
865        let raw = r#"
866id = "legacy-minimal"
867thread = "legacy/minimal"
868mode = "solid"
869state = "active"
870base_state = "abc123"
871base_root = "def456"
872created_at = "2024-01-01T00:00:00Z"
873updated_at = "2024-01-01T00:00:01Z"
874"#;
875
876        let err = toml::from_str::<ThreadRecord>(raw)
877            .expect_err("legacy records must go through 0002 before live reads");
878        assert!(
879            err.to_string().contains("missing field"),
880            "strict reader should reject missing current fields, got: {err}",
881        );
882    }
883}