Skip to main content

projectatlas_db/
hydration.rs

1//! Target-local `SQLite` backup hydration for independently writable worktree atlases.
2
3use crate::schema::sqlite_sidecar_path;
4use crate::{
5    AtlasStore, DbError, DbResult, IndexPublicationState, ProjectRootTransition, set_metadata,
6    validate_database_location, verify_project_database,
7};
8use projectatlas_core::graph::ProjectInstanceId;
9#[cfg(test)]
10use projectatlas_core::normalize_native_path_display;
11use projectatlas_core::{CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkStage};
12use rusqlite::Connection;
13use rusqlite::backup::{Backup, StepResult};
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::thread;
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18use tempfile::{Builder, TempPath};
19
20/// Number of pages copied per cooperative online-backup step.
21const HYDRATION_BACKUP_PAGES_PER_STEP: i32 = 256;
22/// Pause between transiently blocked online-backup steps.
23const HYDRATION_BACKUP_BUSY_PAUSE: Duration = Duration::from_millis(1);
24/// Consecutive busy/locked steps admitted before deterministic fallback.
25const HYDRATION_BACKUP_BUSY_ATTEMPTS: usize = 5_000;
26/// Private target-local candidate filename prefix.
27const HYDRATION_CANDIDATE_PREFIX: &str = ".projectatlas-hydration-";
28/// Durable diagnostic provenance keys retained only by the target atlas.
29const HYDRATION_SOURCE_PROJECT_KEY: &str = "worktree_hydration_source_project_instance_id";
30/// Complete source generation whose graph became the detached baseline.
31const HYDRATION_SOURCE_GENERATION_KEY: &str = "worktree_hydration_source_generation";
32/// Wall-clock epoch at which the private target candidate was prepared.
33const HYDRATION_PREPARED_AT_KEY: &str = "worktree_hydration_prepared_at_epoch";
34
35/// Disposable target-local database prepared from a consistent control-atlas backup.
36#[derive(Debug)]
37pub struct WorktreeHydrationCandidate {
38    /// Auto-cleaned unpublished database path.
39    path: Option<TempPath>,
40    /// Exact no-clobber publication path.
41    destination_database: PathBuf,
42    /// Canonical root the copied database now owns.
43    target_root: CanonicalProjectRoot,
44    /// Source atlas identity retained only for diagnostics.
45    source_project_instance_id: ProjectInstanceId,
46    /// New target atlas identity.
47    target_project_instance_id: ProjectInstanceId,
48    /// Rebound baseline generation that normal reconciliation must supersede.
49    baseline_generation: IndexGeneration,
50    /// Caller-confirmed exact source verification when no publication was needed.
51    source_state_verified: bool,
52}
53
54impl WorktreeHydrationCandidate {
55    /// Borrow the unpublished candidate database path.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error only after activation has consumed the private path.
60    pub fn path(&self) -> DbResult<&Path> {
61        self.path
62            .as_deref()
63            .ok_or(DbError::WorktreeHydrationInvalid {
64                reason: "hydration candidate path was already consumed",
65            })
66    }
67
68    /// Borrow the no-clobber activation destination.
69    #[must_use]
70    pub fn destination_database(&self) -> &Path {
71        &self.destination_database
72    }
73
74    /// Return the source control-atlas identity captured by the backup.
75    #[must_use]
76    pub const fn source_project_instance_id(&self) -> ProjectInstanceId {
77        self.source_project_instance_id
78    }
79
80    /// Return the new independently writable target-atlas identity.
81    #[must_use]
82    pub const fn target_project_instance_id(&self) -> ProjectInstanceId {
83        self.target_project_instance_id
84    }
85
86    /// Return the generation containing the rebound source baseline.
87    #[must_use]
88    pub const fn baseline_generation(&self) -> IndexGeneration {
89        self.baseline_generation
90    }
91
92    /// Accept an exact no-delta source verification from the normal runtime scanner.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error when the candidate no longer exposes its complete rebound baseline or
97    /// cancellation has been requested. Callers must invoke this only after comparing the exact
98    /// target source and derivation contract with the candidate.
99    pub fn accept_verified_source_state(&mut self, control: &IndexWorkControl) -> DbResult<()> {
100        control.check(IndexWorkStage::Publication)?;
101        let store = AtlasStore::open_for_project(self.path()?, self.target_root.as_path())?;
102        let publication = store.index_publication()?;
103        if !publication.as_ref().is_some_and(|publication| {
104            publication.state == IndexPublicationState::Complete
105                && publication.generation == self.baseline_generation
106        }) {
107            return Err(DbError::WorktreeHydrationNotReconciled {
108                baseline: self.baseline_generation,
109                found: publication.map_or(IndexGeneration::ZERO, |value| value.generation),
110            });
111        }
112        self.source_state_verified = true;
113        Ok(())
114    }
115
116    /// Verify post-backup reconciliation, checkpoint WAL, and prepare for publication.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error while the candidate is incomplete, busy, canceled, or corrupt. Failure
121    /// leaves the destination untouched and removes the unpublished candidate.
122    pub fn prepare_activation(
123        mut self,
124        control: &IndexWorkControl,
125    ) -> DbResult<PreparedWorktreeHydrationCandidate> {
126        control.check(IndexWorkStage::Publication)?;
127        let candidate_path = self.path()?.to_path_buf();
128        let store = AtlasStore::open_for_project(&candidate_path, self.target_root.as_path())?;
129        let publication = store.index_publication()?.filter(|publication| {
130            publication.state == IndexPublicationState::Complete
131                && (publication.generation > self.baseline_generation
132                    || self.source_state_verified
133                        && publication.generation == self.baseline_generation)
134        });
135        let found_generation = publication
136            .as_ref()
137            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
138        if publication.is_none() {
139            return Err(DbError::WorktreeHydrationNotReconciled {
140                baseline: self.baseline_generation,
141                found: found_generation,
142            });
143        }
144        let (busy, log_frames, checkpointed_frames) =
145            store
146                .connection
147                .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
148                    Ok((
149                        row.get::<_, i64>(0)?,
150                        row.get::<_, i64>(1)?,
151                        row.get::<_, i64>(2)?,
152                    ))
153                })?;
154        if busy != 0 || log_frames != checkpointed_frames {
155            return Err(DbError::WorktreeHydrationInvalid {
156                reason: "candidate WAL checkpoint remained busy or incomplete",
157            });
158        }
159        drop(store);
160
161        verify_project_database(&candidate_path, self.target_root.as_path())?;
162        fs::OpenOptions::new()
163            .write(true)
164            .open(&candidate_path)
165            .and_then(|file| file.sync_all())
166            .map_err(|source| DbError::WorktreeHydrationIo {
167                path: candidate_path.clone(),
168                source,
169            })?;
170        remove_candidate_sidecars(&candidate_path)?;
171        control.check(IndexWorkStage::Publication)?;
172
173        let path = self.path.take().ok_or(DbError::WorktreeHydrationInvalid {
174            reason: "hydration candidate path was already consumed",
175        })?;
176        Ok(PreparedWorktreeHydrationCandidate {
177            path: Some(path),
178            destination_database: self.destination_database.clone(),
179            source_project_instance_id: self.source_project_instance_id,
180            target_project_instance_id: self.target_project_instance_id,
181            baseline_generation: self.baseline_generation,
182            reconciled_generation: found_generation,
183        })
184    }
185
186    /// Verify and publish this candidate without replacing an existing destination.
187    ///
188    /// This compatibility convenience performs both phases. Lifecycle-sensitive callers should
189    /// call [`Self::prepare_activation`] before acquiring external writer exclusion, then publish
190    /// the returned [`PreparedWorktreeHydrationCandidate`] inside that short critical section.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error while preparation fails or another process creates the destination first.
195    pub fn activate(self, control: &IndexWorkControl) -> DbResult<WorktreeHydrationActivation> {
196        self.prepare_activation(control)?.activate(control)
197    }
198}
199
200/// Fully verified target-local hydration candidate awaiting only no-clobber publication.
201#[derive(Debug)]
202pub struct PreparedWorktreeHydrationCandidate {
203    /// Auto-cleaned unpublished database path.
204    path: Option<TempPath>,
205    /// Exact no-clobber publication path.
206    destination_database: PathBuf,
207    /// Source atlas identity retained only for diagnostics.
208    source_project_instance_id: ProjectInstanceId,
209    /// New target atlas identity.
210    target_project_instance_id: ProjectInstanceId,
211    /// Rebound baseline generation.
212    baseline_generation: IndexGeneration,
213    /// Complete reconciled generation verified before writer exclusion.
214    reconciled_generation: IndexGeneration,
215}
216
217impl PreparedWorktreeHydrationCandidate {
218    /// Publish the already verified candidate without replacing an existing destination.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error on cancellation, a publication collision, or an I/O failure. Failure
223    /// preserves an existing destination. A directory-sync failure leaves the newly published
224    /// database intact but prevents callers from binding it.
225    pub fn activate(mut self, control: &IndexWorkControl) -> DbResult<WorktreeHydrationActivation> {
226        control.check(IndexWorkStage::Publication)?;
227
228        let path = self.path.take().ok_or(DbError::WorktreeHydrationInvalid {
229            reason: "hydration candidate path was already consumed",
230        })?;
231        path.persist_noclobber(&self.destination_database)
232            .map_err(|error| {
233                if error.error.kind() == std::io::ErrorKind::AlreadyExists {
234                    DbError::WorktreeHydrationDestinationExists {
235                        path: self.destination_database.clone(),
236                    }
237                } else {
238                    DbError::WorktreeHydrationIo {
239                        path: self.destination_database.clone(),
240                        source: error.error,
241                    }
242                }
243            })?;
244        #[cfg(unix)]
245        sync_activation_directory(&self.destination_database)?;
246
247        Ok(WorktreeHydrationActivation {
248            database: self.destination_database.clone(),
249            source_project_instance_id: self.source_project_instance_id,
250            target_project_instance_id: self.target_project_instance_id,
251            baseline_generation: self.baseline_generation,
252            reconciled_generation: self.reconciled_generation,
253        })
254    }
255}
256
257/// Make a newly published directory entry durable before external binding commits.
258#[cfg(unix)]
259fn sync_activation_directory(destination_database: &Path) -> DbResult<()> {
260    let parent = destination_database
261        .parent()
262        .ok_or(DbError::WorktreeHydrationInvalid {
263            reason: "hydration destination database has no parent",
264        })?;
265    fs::File::open(parent)
266        .and_then(|directory| directory.sync_all())
267        .map_err(|source| DbError::WorktreeHydrationIo {
268            path: parent.to_path_buf(),
269            source,
270        })
271}
272
273impl Drop for WorktreeHydrationCandidate {
274    fn drop(&mut self) {
275        if let Some(path) = self.path.as_deref() {
276            remove_candidate_sidecars_best_effort(path);
277        }
278    }
279}
280
281impl Drop for PreparedWorktreeHydrationCandidate {
282    fn drop(&mut self) {
283        if let Some(path) = self.path.as_deref() {
284            remove_candidate_sidecars_best_effort(path);
285        }
286    }
287}
288
289/// Completed no-clobber activation of one exact reconciled worktree atlas.
290#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct WorktreeHydrationActivation {
292    /// Activated target database path.
293    pub database: PathBuf,
294    /// Control-atlas identity captured by the hydration baseline.
295    pub source_project_instance_id: ProjectInstanceId,
296    /// Independently writable target-atlas identity.
297    pub target_project_instance_id: ProjectInstanceId,
298    /// Generation created when the copied graph was rebound to the target.
299    pub baseline_generation: IndexGeneration,
300    /// Complete target generation published by normal source reconciliation.
301    pub reconciled_generation: IndexGeneration,
302}
303
304impl AtlasStore {
305    /// Prepare one unpublished target-local worktree database through `SQLite` online backup.
306    ///
307    /// The caller must first prove through structural Git evidence that source and target belong
308    /// to the same repository. The returned candidate must be reconciled with the normal scan
309    /// pipeline before [`WorktreeHydrationCandidate::activate`] will publish it.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error for incompatible source state, unsafe target placement, an existing
314    /// destination, cancellation/deadline, backup contention, invalid copied state, or I/O.
315    pub fn prepare_worktree_hydration(
316        &self,
317        target_root: &Path,
318        destination_database: &Path,
319        control: &IndexWorkControl,
320    ) -> DbResult<WorktreeHydrationCandidate> {
321        control.check(IndexWorkStage::Publication)?;
322        let source_root = self
323            .project_root_identity()?
324            .ok_or(DbError::ProjectRootIdentityMissing)?;
325        let source_project_instance_id = self
326            .project_instance_id()?
327            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
328        let source_database =
329            self.database_path
330                .as_deref()
331                .ok_or(DbError::WorktreeHydrationInvalid {
332                    reason: "hydration source is not a file-backed database",
333                })?;
334        if !source_database.exists() {
335            return Err(DbError::WorktreeHydrationInvalid {
336                reason: "hydration source database is missing",
337            });
338        }
339
340        let target_root_identity = canonical_target_root(target_root)?;
341        if target_root_identity == source_root {
342            return Err(DbError::WorktreeHydrationInvalid {
343                reason: "hydration target matches the source project root",
344            });
345        }
346        match crate::project_identity::prove_existing_root_equivalence(
347            target_root_identity.as_path(),
348            source_root.as_path(),
349        ) {
350            Ok(_) => {
351                return Err(DbError::WorktreeHydrationInvalid {
352                    reason: "hydration target matches the source project root",
353                });
354            }
355            Err(DbError::ProjectRootMismatch { .. }) => {}
356            Err(error) => return Err(error),
357        }
358        let destination_database =
359            validated_target_database(target_root_identity.as_path(), destination_database)?;
360        if destination_database.exists() {
361            return Err(DbError::WorktreeHydrationDestinationExists {
362                path: destination_database,
363            });
364        }
365        validate_database_location(&destination_database)?;
366        let destination_parent =
367            destination_database
368                .parent()
369                .ok_or(DbError::WorktreeHydrationInvalid {
370                    reason: "hydration destination has no target-local parent",
371                })?;
372        let reservation = Builder::new()
373            .prefix(HYDRATION_CANDIDATE_PREFIX)
374            .suffix(".sqlite")
375            .tempfile_in(destination_parent)
376            .map_err(|source| DbError::WorktreeHydrationIo {
377                path: destination_parent.to_path_buf(),
378                source,
379            })?;
380        let candidate_path = reservation.into_temp_path();
381
382        let mut capture = Connection::open(&candidate_path)?;
383        copy_online_backup(&self.connection, &mut capture, control)?;
384        drop(capture);
385
386        let copied = AtlasStore::open_for_project(&candidate_path, source_root.as_path())?;
387        let snapshot = copied.export_derived_graph_snapshot_from_stable_copy()?;
388        drop(copied);
389        control.check(IndexWorkStage::Publication)?;
390
391        let transition = AtlasStore::transition_project_root(
392            &candidate_path,
393            target_root_identity.as_path(),
394            ProjectRootTransition::Detach,
395        )?;
396        let mut target =
397            AtlasStore::open_for_project(&candidate_path, target_root_identity.as_path())?;
398        clear_nontransferable_state(
399            &target,
400            source_project_instance_id,
401            snapshot.metadata().source_generation,
402        )?;
403        let baseline = target.import_worktree_hydration_snapshot(&snapshot)?;
404        if baseline.previous_generation != IndexGeneration::ZERO {
405            return Err(DbError::WorktreeHydrationInvalid {
406                reason: "hydration baseline did not start from generation zero",
407            });
408        }
409        drop(target);
410        verify_project_database(&candidate_path, target_root_identity.as_path())?;
411
412        Ok(WorktreeHydrationCandidate {
413            path: Some(candidate_path),
414            destination_database,
415            target_root: target_root_identity,
416            source_project_instance_id,
417            target_project_instance_id: transition.project_instance_id,
418            baseline_generation: baseline.published_generation,
419            source_state_verified: false,
420        })
421    }
422}
423
424/// Copy a consistent live source in bounded cooperative pages.
425fn copy_online_backup(
426    source: &Connection,
427    destination: &mut Connection,
428    control: &IndexWorkControl,
429) -> DbResult<()> {
430    let backup = Backup::new(source, destination)?;
431    let mut busy_attempts = 0usize;
432    loop {
433        control.check(IndexWorkStage::Publication)?;
434        match backup.step(HYDRATION_BACKUP_PAGES_PER_STEP)? {
435            StepResult::Done => return Ok(()),
436            StepResult::More => busy_attempts = 0,
437            StepResult::Busy | StepResult::Locked => {
438                busy_attempts = busy_attempts.saturating_add(1);
439                if busy_attempts > HYDRATION_BACKUP_BUSY_ATTEMPTS {
440                    return Err(DbError::WorktreeHydrationBackupBusy {
441                        attempts: busy_attempts,
442                    });
443                }
444                thread::sleep(HYDRATION_BACKUP_BUSY_PAUSE);
445            }
446            _ => {
447                return Err(DbError::WorktreeHydrationInvalid {
448                    reason: "SQLite returned an unsupported online-backup state",
449                });
450            }
451        }
452    }
453}
454
455/// Canonicalize and require one existing target directory.
456fn canonical_target_root(target_root: &Path) -> DbResult<CanonicalProjectRoot> {
457    if !target_root.is_absolute() {
458        return Err(DbError::WorktreeHydrationInvalid {
459            reason: "hydration target root is not absolute",
460        });
461    }
462    CanonicalProjectRoot::from_path(target_root).map_err(DbError::from)
463}
464
465/// Normalize an absent database destination below the exact canonical target root.
466fn validated_target_database(target_root: &Path, destination_database: &Path) -> DbResult<PathBuf> {
467    if !destination_database.is_absolute() {
468        return Err(DbError::WorktreeHydrationInvalid {
469            reason: "hydration destination database is not absolute",
470        });
471    }
472    let parent = destination_database
473        .parent()
474        .ok_or(DbError::WorktreeHydrationInvalid {
475            reason: "hydration destination database has no parent",
476        })?;
477    let parent = fs::canonicalize(parent).map_err(|source| DbError::WorktreeHydrationIo {
478        path: parent.to_path_buf(),
479        source,
480    })?;
481    let target_identity = CanonicalProjectRoot::from_path(target_root)?;
482    let parent_identity = CanonicalProjectRoot::from_path(&parent)?;
483    if parent_identity == target_identity
484        || !parent_identity
485            .as_path()
486            .starts_with(target_identity.as_path())
487    {
488        return Err(DbError::WorktreeHydrationInvalid {
489            reason: "hydration destination is not inside a target-local subdirectory",
490        });
491    }
492    let file_name = destination_database
493        .file_name()
494        .ok_or(DbError::WorktreeHydrationInvalid {
495            reason: "hydration destination database has no file name",
496        })?;
497    Ok(parent.join(file_name))
498}
499
500/// Remove source telemetry, control registry, and transient health state in one target write.
501fn clear_nontransferable_state(
502    target: &AtlasStore,
503    source_project: ProjectInstanceId,
504    source_generation: IndexGeneration,
505) -> DbResult<()> {
506    let prepared_at = SystemTime::now()
507        .duration_since(UNIX_EPOCH)
508        .map_err(|_source| DbError::WorktreeHydrationInvalid {
509            reason: "system clock precedes the Unix epoch",
510        })?
511        .as_secs();
512    target.with_validated_write(|connection| {
513        connection.execute_batch(
514            "DELETE FROM usage_instance_worktree_origins;
515             DELETE FROM worktree_usage_aggregates;
516             DELETE FROM worktree_registrations;
517             DELETE FROM usage_aggregate_revisions;
518             DELETE FROM health_resolutions;",
519        )?;
520        crate::telemetry::reset_usage_storage_for_hydration(connection)?;
521        set_metadata(
522            connection,
523            HYDRATION_SOURCE_PROJECT_KEY,
524            &source_project.to_string(),
525        )?;
526        set_metadata(
527            connection,
528            HYDRATION_SOURCE_GENERATION_KEY,
529            &source_generation.get().to_string(),
530        )?;
531        set_metadata(
532            connection,
533            HYDRATION_PREPARED_AT_KEY,
534            &prepared_at.to_string(),
535        )?;
536        Ok(())
537    })
538}
539
540/// Remove checkpointed private sidecars before no-clobber publication.
541fn remove_candidate_sidecars(path: &Path) -> DbResult<()> {
542    for sidecar in [
543        sqlite_sidecar_path(path, "-wal"),
544        sqlite_sidecar_path(path, "-shm"),
545        sqlite_sidecar_path(path, "-journal"),
546    ] {
547        match fs::remove_file(&sidecar) {
548            Ok(()) => {}
549            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
550            Err(source) => {
551                return Err(DbError::WorktreeHydrationIo {
552                    path: sidecar,
553                    source,
554                });
555            }
556        }
557    }
558    Ok(())
559}
560
561/// Best-effort cleanup for an unpublished candidate's transient `SQLite` sidecars.
562fn remove_candidate_sidecars_best_effort(path: &Path) {
563    for sidecar in [
564        sqlite_sidecar_path(path, "-wal"),
565        sqlite_sidecar_path(path, "-shm"),
566        sqlite_sidecar_path(path, "-journal"),
567    ] {
568        let _ignored = fs::remove_file(sidecar);
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::{WorktreeAlias, WorktreeRegistrationState};
576    use projectatlas_core::IndexCancellation;
577    use rusqlite::params;
578    use std::error::Error;
579    use std::io;
580
581    /// Return a typed test failure without panic-only assertions.
582    fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn Error>> {
583        if condition {
584            Ok(())
585        } else {
586            Err(io::Error::other(message).into())
587        }
588    }
589
590    #[cfg(windows)]
591    fn hydration_sidecar_snapshot(database: &Path) -> [Option<Vec<u8>>; 3] {
592        ["-wal", "-shm", "-journal"]
593            .map(|suffix| fs::read(sqlite_sidecar_path(database, suffix)).ok())
594    }
595
596    #[cfg(windows)]
597    fn hydration_directory_inventory(root: &Path) -> Result<Vec<String>, Box<dyn Error>> {
598        let mut inventory = fs::read_dir(root)?
599            .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
600            .collect::<Result<Vec<_>, io::Error>>()?;
601        inventory.sort();
602        Ok(inventory)
603    }
604
605    #[cfg(windows)]
606    fn hydration_state_snapshot(
607        store: &AtlasStore,
608    ) -> Result<
609        (
610            Option<ProjectInstanceId>,
611            Option<CanonicalProjectRoot>,
612            Option<String>,
613            (Option<String>, Option<String>, i64, i64, i64, i64, i64),
614            Option<crate::IndexPublication>,
615        ),
616        Box<dyn Error>,
617    > {
618        let authored_and_private_state = store.connection.query_row(
619            "SELECT
620                (SELECT purpose FROM purposes JOIN nodes ON nodes.id = purposes.node_id
621                   WHERE nodes.path = '.'),
622                (SELECT summary FROM summaries JOIN nodes ON nodes.id = summaries.node_id
623                   WHERE nodes.path = '.'),
624                (SELECT COUNT(*) FROM nodes),
625                (SELECT COUNT(*) FROM purposes),
626                (SELECT COUNT(*) FROM summaries),
627                (SELECT COUNT(*) FROM health_resolutions),
628                (SELECT COUNT(*) FROM usage_global_aggregates)",
629            [],
630            |row| {
631                Ok((
632                    row.get::<_, Option<String>>(0)?,
633                    row.get::<_, Option<String>>(1)?,
634                    row.get::<_, i64>(2)?,
635                    row.get::<_, i64>(3)?,
636                    row.get::<_, i64>(4)?,
637                    row.get::<_, i64>(5)?,
638                    row.get::<_, i64>(6)?,
639                ))
640            },
641        )?;
642        Ok((
643            store.project_instance_id()?,
644            store.project_root_identity()?,
645            store.project_root()?,
646            authored_and_private_state,
647            store.index_publication()?,
648        ))
649    }
650
651    /// Seed one complete control atlas with authored state and private control data.
652    fn seed_source(store: &mut AtlasStore, target_root: &Path) -> Result<(), Box<dyn Error>> {
653        let project = store
654            .project_instance_id()?
655            .ok_or_else(|| io::Error::other("source project identity missing"))?;
656        store.connection.execute_batch(
657            "INSERT INTO nodes(path, kind) VALUES('.', 'folder');
658             INSERT INTO purposes(node_id, purpose, source, status, updated_by)
659                SELECT id, 'Own the repository.', 'agent', 'approved', 'agent'
660                  FROM nodes WHERE path = '.';
661             INSERT INTO summaries(node_id, summary_level, subject, summary)
662                SELECT id, 'node', '', 'Repository summary.' FROM nodes WHERE path = '.';
663             INSERT INTO health_resolutions(
664                finding_id, category, path, rationale, resolved_by
665             ) VALUES('hydration-health', 'test', '.', 'source-only state', 'agent');
666             INSERT INTO usage_bucket_dimensions(
667                token_savings_bucket, provider, model, tokenizer_backend,
668                accuracy, baseline_kind, confidence, accounting_layer, estimate_method,
669                denominator_kind, dedupe_scope, overflow
670             ) VALUES(
671                'navigation_avoidance', 'heuristic', 'unknown', 'chars_div_4',
672                'heuristic_estimate', 'selected_candidates', 'inferred',
673                'modeled_avoidance', 'heuristic_chars_or_bytes_div_ceil_4',
674                'selected_candidates', 'session', 0
675             );",
676        )?;
677        let dimension_id = store.connection.last_insert_rowid();
678        store.connection.execute(
679            "INSERT INTO usage_global_aggregates(
680                 project_instance_id, dimension_id, calls, estimated_without,
681                 estimated_with, modeled_without, modeled_with,
682                 deduped_modeled_without, deduped_modeled_with
683             ) VALUES(?1, ?2, 1, 100, 10, 100, 10, 100, 10)",
684            params![project.as_bytes().as_slice(), dimension_id],
685        )?;
686        store.connection.execute(
687            "INSERT INTO usage_aggregate_revisions(project_instance_id, revision)
688             VALUES(?1, 1)",
689            [project.as_bytes().as_slice()],
690        )?;
691        let alias = WorktreeAlias::parse("seeded")?;
692        let source_root = store
693            .project_root_identity()?
694            .ok_or_else(|| io::Error::other("source root identity missing"))?
695            .as_path()
696            .to_path_buf();
697        store.register_worktree(
698            &alias,
699            &source_root.join(".git"),
700            &source_root.join(".git/worktrees/seeded"),
701            &"22".repeat(32),
702            target_root,
703            None,
704            1,
705        )?;
706        let mut publication = store.begin_index_publication("hydration-test")?;
707        publication.replace_repository_graph(project, &[], &[], &[], &[])?;
708        publication.complete()?;
709        Ok(())
710    }
711
712    /// Online backup hydration preserves authored baseline state and clears private authority.
713    #[test]
714    fn hydration_rebinds_reconciles_and_activates_without_private_state_or_clobber()
715    -> Result<(), Box<dyn Error>> {
716        let fixture = tempfile::tempdir()?;
717        let source_root = fixture.path().join("source");
718        let target_root = fixture.path().join("target");
719        let source_dir = source_root.join(".projectatlas");
720        let target_dir = target_root.join(".projectatlas");
721        fs::create_dir_all(&source_dir)?;
722        fs::create_dir_all(&target_dir)?;
723        let source_database = source_dir.join("projectatlas.db");
724        let destination_database = target_dir.join("projectatlas.db");
725        let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
726        seed_source(&mut source, &target_root)?;
727        let source_identity = source
728            .project_instance_id()?
729            .ok_or_else(|| io::Error::other("source identity missing after seed"))?;
730        let control = IndexWorkControl::new(IndexCancellation::new(), None);
731
732        let unreconciled =
733            source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
734        let unreconciled_path = unreconciled.path()?.to_path_buf();
735        let error = match unreconciled.activate(&control) {
736            Ok(_activation) => {
737                return Err(io::Error::other("unreconciled candidate activated").into());
738            }
739            Err(error) => error,
740        };
741        require(
742            matches!(error, DbError::WorktreeHydrationNotReconciled { .. }),
743            "unreconciled activation returned the wrong typed failure",
744        )?;
745        require(
746            !unreconciled_path.exists() && !destination_database.exists(),
747            "unreconciled activation left a candidate or destination",
748        )?;
749
750        let verified_root = fixture.path().join("verified-target");
751        let verified_dir = verified_root.join(".projectatlas");
752        fs::create_dir_all(&verified_dir)?;
753        let verified_database = verified_dir.join("projectatlas.db");
754        let mut verified =
755            source.prepare_worktree_hydration(&verified_root, &verified_database, &control)?;
756        let verified_baseline = verified.baseline_generation();
757        verified.accept_verified_source_state(&control)?;
758        let verified_activation = verified.activate(&control)?;
759        require(
760            verified_activation.reconciled_generation == verified_baseline
761                && verified_database.exists(),
762            "exact no-delta source verification did not activate the copied baseline",
763        )?;
764
765        let candidate =
766            source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
767        let candidate_path = candidate.path()?.to_path_buf();
768        let baseline_generation = candidate.baseline_generation();
769        let target_identity = candidate.target_project_instance_id();
770        require(
771            target_identity != source_identity,
772            "hydration did not rotate the target identity",
773        )?;
774        {
775            let mut target = AtlasStore::open_for_project(&candidate_path, &target_root)?;
776            let copied = target.connection.query_row(
777                "SELECT
778                    (SELECT purpose FROM purposes JOIN nodes ON nodes.id = purposes.node_id
779                       WHERE nodes.path = '.'),
780                    (SELECT summary FROM summaries JOIN nodes ON nodes.id = summaries.node_id
781                       WHERE nodes.path = '.'),
782                    (SELECT COUNT(*) FROM usage_global_aggregates),
783                    (SELECT COUNT(*) FROM worktree_registrations),
784                    (SELECT COUNT(*) FROM health_resolutions)",
785                [],
786                |row| {
787                    Ok((
788                        row.get::<_, String>(0)?,
789                        row.get::<_, String>(1)?,
790                        row.get::<_, i64>(2)?,
791                        row.get::<_, i64>(3)?,
792                        row.get::<_, i64>(4)?,
793                    ))
794                },
795            )?;
796            require(
797                copied
798                    == (
799                        "Own the repository.".to_string(),
800                        "Repository summary.".to_string(),
801                        0,
802                        0,
803                        0,
804                    ),
805                "hydration did not preserve authored state or clear private state",
806            )?;
807            let provenance = target.connection.query_row(
808                "SELECT source.value, generation.value
809                   FROM metadata AS source
810                   JOIN metadata AS generation
811                     ON generation.key = ?2
812                  WHERE source.key = ?1",
813                params![
814                    HYDRATION_SOURCE_PROJECT_KEY,
815                    HYDRATION_SOURCE_GENERATION_KEY
816                ],
817                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
818            )?;
819            require(
820                provenance.0 == source_identity.to_string() && provenance.1 == "1",
821                "hydration provenance does not identify the source baseline",
822            )?;
823            let mut publication = target.begin_index_projection_refresh("hydration-test")?;
824            publication.replace_repository_graph(target_identity, &[], &[], &[], &[])?;
825            publication.complete()?;
826        }
827        let prepared = candidate.prepare_activation(&control)?;
828        require(
829            candidate_path.exists() && !destination_database.exists(),
830            "activation preparation published the candidate early",
831        )?;
832        let activation = prepared.activate(&control)?;
833        require(
834            normalize_native_path_display(&activation.database)
835                == normalize_native_path_display(&destination_database)
836                && activation.baseline_generation == baseline_generation
837                && activation.reconciled_generation > baseline_generation
838                && activation.target_project_instance_id == target_identity,
839            "activation report lost exact hydration identities",
840        )?;
841        require(
842            destination_database.exists() && !candidate_path.exists(),
843            "activation did not publish exactly one database path",
844        )?;
845
846        let raced_root = fixture.path().join("raced-target");
847        let raced_dir = raced_root.join(".projectatlas");
848        let raced_database = raced_dir.join("projectatlas.db");
849        fs::create_dir_all(&raced_dir)?;
850        let mut raced =
851            source.prepare_worktree_hydration(&raced_root, &raced_database, &control)?;
852        let raced_candidate = raced.path()?.to_path_buf();
853        raced.accept_verified_source_state(&control)?;
854        fs::write(&raced_database, b"competing initializer")?;
855        let raced = raced.prepare_activation(&control)?;
856        require(
857            matches!(
858                raced.activate(&control),
859                Err(DbError::WorktreeHydrationDestinationExists { .. })
860            ),
861            "activation collision did not retain the destination-exists fallback",
862        )?;
863        require(
864            fs::read(&raced_database)? == b"competing initializer" && !raced_candidate.exists(),
865            "activation collision changed the winning destination or retained its candidate",
866        )?;
867
868        let activated = AtlasStore::open_for_project(&destination_database, &target_root)?;
869        require(
870            activated.project_instance_id()? == Some(target_identity),
871            "activated database identity changed",
872        )?;
873        let source_private = source.connection.query_row(
874            "SELECT
875                (SELECT COUNT(*) FROM usage_global_aggregates),
876                (SELECT COUNT(*) FROM worktree_registrations WHERE state = ?1)",
877            [WorktreeRegistrationState::Active.as_str()],
878            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
879        )?;
880        require(
881            source_private == (1, 1),
882            "hydration mutated source telemetry or registration authority",
883        )?;
884
885        let occupied =
886            source.prepare_worktree_hydration(&target_root, &destination_database, &control);
887        require(
888            matches!(
889                occupied,
890                Err(DbError::WorktreeHydrationDestinationExists { .. })
891            ),
892            "existing destination was not preserved through a typed no-clobber failure",
893        )?;
894
895        let cancelled = IndexWorkControl::new(IndexCancellation::new(), None);
896        cancelled.cancel();
897        let canceled_result = source.prepare_worktree_hydration(
898            &target_root,
899            &target_dir.join("cancelled.db"),
900            &cancelled,
901        );
902        require(
903            matches!(canceled_result, Err(DbError::IndexWork(_))),
904            "canceled hydration did not return the shared typed work failure",
905        )?;
906        Ok(())
907    }
908
909    #[cfg(windows)]
910    #[test]
911    fn hydration_rejects_case_only_renamed_source_target_before_reservation()
912    -> Result<(), Box<dyn Error>> {
913        let fixture = tempfile::tempdir()?;
914        let original_root = fixture.path().join("HydrationCaseOnly");
915        let staging_root = fixture.path().join("HydrationCaseOnlyStaging");
916        let renamed_root = fixture.path().join("hydrationcaseonly");
917        let source_dir = original_root.join(".projectatlas");
918        fs::create_dir_all(&source_dir)?;
919        let source_database = source_dir.join("projectatlas.db");
920        let mut seeded = AtlasStore::open_for_project(&source_database, &original_root)?;
921        seed_source(&mut seeded, &original_root)?;
922        let expected_state = hydration_state_snapshot(&seeded)?;
923        drop(seeded);
924
925        fs::rename(&original_root, &staging_root)?;
926        fs::rename(&staging_root, &renamed_root)?;
927        let Ok(recanonicalized_original) = CanonicalProjectRoot::from_path(&original_root) else {
928            return Ok(());
929        };
930        let renamed_identity = CanonicalProjectRoot::from_path(&renamed_root)?;
931        require(
932            recanonicalized_original == renamed_identity,
933            "case-only root rename did not preserve a re-canonicalizable directory",
934        )?;
935
936        let source = AtlasStore::open_read_only_for_project(&source_database, &original_root)?;
937        let persisted_identity = source
938            .project_root_identity()?
939            .ok_or_else(|| io::Error::other("hydration source identity is missing"))?;
940        require(
941            persisted_identity != renamed_identity,
942            "case-only fixture lost its stale persisted spelling",
943        )?;
944        let source_state = hydration_state_snapshot(&source)?;
945        let database_before = fs::read(&source_database)?;
946        let sidecars_before = hydration_sidecar_snapshot(&source_database);
947        let inventory_before = hydration_directory_inventory(&source_dir)?;
948        let destination_database = renamed_root.join(".projectatlas").join("alternate.db");
949        require(
950            !destination_database.exists(),
951            "case-only hydration destination unexpectedly exists",
952        )?;
953        let control = IndexWorkControl::new(IndexCancellation::new(), None);
954        let Err(error) =
955            source.prepare_worktree_hydration(&renamed_root, &destination_database, &control)
956        else {
957            return Err(
958                io::Error::other("equivalent case-only hydration target was admitted").into(),
959            );
960        };
961        require(
962            matches!(
963                error,
964                DbError::WorktreeHydrationInvalid {
965                    reason: "hydration target matches the source project root"
966                }
967            ),
968            "equivalent case-only hydration target returned the wrong error",
969        )?;
970        require(
971            fs::read(&source_database)? == database_before,
972            "case-only self-target refusal changed the source database",
973        )?;
974        require(
975            hydration_sidecar_snapshot(&source_database) == sidecars_before,
976            "case-only self-target refusal changed SQLite sidecars",
977        )?;
978        require(
979            hydration_directory_inventory(&source_dir)? == inventory_before,
980            "case-only self-target refusal reserved or removed a candidate",
981        )?;
982        let state_after = hydration_state_snapshot(&source)?;
983        require(
984            state_after == source_state && state_after == expected_state,
985            "case-only self-target refusal changed source identity or authored state",
986        )?;
987        require(
988            !destination_database.exists(),
989            "case-only self-target refusal created the destination database",
990        )?;
991        Ok(())
992    }
993
994    #[cfg(windows)]
995    #[test]
996    fn hydration_allows_distinct_case_sensitive_roots_without_source_mutation()
997    -> Result<(), Box<dyn Error>> {
998        use std::process::Command;
999
1000        let fixture = tempfile::tempdir()?;
1001        let case_sensitive_parent = fixture.path().join("hydration-case-sensitive-parent");
1002        fs::create_dir(&case_sensitive_parent)?;
1003        let enabled = Command::new("fsutil")
1004            .args(["file", "SetCaseSensitiveInfo"])
1005            .arg(&case_sensitive_parent)
1006            .arg("enable")
1007            .status()
1008            .is_ok_and(|status| status.success());
1009        if !enabled {
1010            return Ok(());
1011        }
1012        let source_root = case_sensitive_parent.join("Repo");
1013        let target_root = case_sensitive_parent.join("repo");
1014        if fs::create_dir(&source_root).is_err() || fs::create_dir(&target_root).is_err() {
1015            return Ok(());
1016        }
1017        let source_identity = CanonicalProjectRoot::from_path(&source_root)?;
1018        let target_identity = CanonicalProjectRoot::from_path(&target_root)?;
1019        if source_identity == target_identity {
1020            return Ok(());
1021        }
1022        let source_dir = source_root.join(".projectatlas");
1023        let target_dir = target_root.join(".projectatlas");
1024        fs::create_dir_all(&source_dir)?;
1025        fs::create_dir_all(&target_dir)?;
1026        let source_database = source_dir.join("projectatlas.db");
1027        let destination_database = target_dir.join("alternate.db");
1028        let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
1029        seed_source(&mut source, &target_root)?;
1030        let source_state = hydration_state_snapshot(&source)?;
1031        let source_database_before = fs::read(&source_database)?;
1032        let source_identity_before = source.project_root_identity()?;
1033        let source_instance_before = source.project_instance_id()?;
1034        let control = IndexWorkControl::new(IndexCancellation::new(), None);
1035        let candidate =
1036            source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
1037        require(
1038            candidate.target_root == target_identity,
1039            "case-sensitive distinct hydration selected the wrong target root",
1040        )?;
1041        require(
1042            candidate.target_project_instance_id
1043                != source_instance_before
1044                    .ok_or_else(|| io::Error::other("source project identity is missing"))?,
1045            "case-sensitive distinct hydration did not prepare a new target identity",
1046        )?;
1047        require(
1048            !destination_database.exists(),
1049            "case-sensitive distinct hydration published before activation",
1050        )?;
1051        drop(candidate);
1052        require(
1053            fs::read(&source_database)? == source_database_before
1054                && source.project_root_identity()? == source_identity_before
1055                && source.project_instance_id()? == source_instance_before,
1056            "case-sensitive distinct hydration changed the source binding",
1057        )?;
1058        require(
1059            hydration_state_snapshot(&source)? == source_state,
1060            "case-sensitive distinct hydration changed source authored state",
1061        )?;
1062        Ok(())
1063    }
1064
1065    #[cfg(unix)]
1066    #[test]
1067    fn hydration_preserves_non_utf8_target_identity_and_display_collisions()
1068    -> Result<(), Box<dyn Error>> {
1069        use std::os::unix::ffi::OsStringExt;
1070
1071        let fixture = tempfile::tempdir()?;
1072        let source_root = fixture.path().join("src");
1073        let target_root = fixture
1074            .path()
1075            .join(std::ffi::OsString::from_vec(vec![b't', b'g', b't', 0x81]));
1076        let collision_root = fixture.path().join("tgt-�");
1077        let source_dir = source_root.join(".projectatlas");
1078        let target_dir = target_root.join(".projectatlas");
1079        let collision_dir = collision_root.join(".projectatlas");
1080        fs::create_dir_all(&source_dir)?;
1081        fs::create_dir_all(&target_dir)?;
1082        fs::create_dir_all(&collision_dir)?;
1083        let source_database = source_dir.join("projectatlas.db");
1084        let target_database = target_dir.join("projectatlas.db");
1085        let collision_database = collision_dir.join("projectatlas.db");
1086        let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
1087        seed_source(&mut source, &collision_root)?;
1088        let control = IndexWorkControl::new(IndexCancellation::new(), None);
1089
1090        let mut candidate =
1091            source.prepare_worktree_hydration(&target_root, &target_database, &control)?;
1092        let target_identity = candidate.target_project_instance_id();
1093        candidate.accept_verified_source_state(&control)?;
1094        let prepared = candidate.prepare_activation(&control)?;
1095        let activation = prepared.activate(&control)?;
1096        require(
1097            activation.target_project_instance_id == target_identity,
1098            "non-UTF-8 hydration changed target identity",
1099        )?;
1100        verify_project_database(&target_database, &target_root)?;
1101        let target = AtlasStore::open_read_only_for_project(&target_database, &target_root)?;
1102        require(
1103            target.project_root_identity()? == Some(CanonicalProjectRoot::from_path(&target_root)?),
1104            "hydrated non-UTF-8 target identity was not persisted",
1105        )?;
1106        drop(target);
1107
1108        let mut collision =
1109            source.prepare_worktree_hydration(&collision_root, &collision_database, &control)?;
1110        let collision_identity = collision.target_project_instance_id();
1111        require(
1112            collision_identity != target_identity,
1113            "hydration collapsed replacement-character target identity",
1114        )?;
1115        collision.accept_verified_source_state(&control)?;
1116        let collision = collision.prepare_activation(&control)?;
1117        collision.activate(&control)?;
1118        verify_project_database(&collision_database, &collision_root)?;
1119        Ok(())
1120    }
1121}