Skip to main content

flint_lean/
sync.rs

1//! The sync verb (v1, HITL — decided §9 Q4), plus the scoped form the
2//! `.flint/sync` sentinel invokes (boundary-verbs plan §2.2, D4).
3//! Harness- or sentinel-invoked, never background.
4//!
5//! Sync BEGINS with a full scan: "locally dirty" means dirty per THAT
6//! scan against the baseline — never per the last barrier's snapshot
7//! (otherwise sync honors a remote delete over the agent's un-scanned
8//! latest work: the review's steady-state destruction finding). Policy:
9//! locally-dirty wins; remote deletions apply only to locally-clean
10//! paths; adds/changes fetch through the store; every skipped apply is
11//! a surfaced conflict, never silent.
12//!
13//! **The scope rule (D4) is a correctness rule, not an optimization.**
14//! A scoped sync moves the baseline — which is also the merge base since
15//! P1-lite — only for the paths it applied or verified in scope, and
16//! leaves `baseline.seq` / `baseline.manifest_etag` UNTOUCHED. What it
17//! leaves out stays OWED: the next barrier's consume derives it and takes
18//! it, untouched from today. And a path the WORKSPACE'S scope declined
19//! (a scoped checkout) is never fetched here either: the baseline does
20//! not hold it, and only the held set and the scope's cover are owed.
21
22use std::collections::{BTreeMap, BTreeSet};
23
24use serde::Serialize;
25
26use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, StoreError};
27
28use super::barrier::{mtime_nanos_of, mtime_of};
29use super::state::{BaselineEntry, ConflictRecord};
30use super::{manifest, now_unix, scan, LeanError, LeanResult, Syncer};
31
32#[derive(Debug, Default, Serialize)]
33pub struct SyncReport {
34    pub applied: Vec<String>,
35    pub deleted: Vec<String>,
36    pub conflicts: Vec<String>,
37    pub seq: u64,
38    /// Remote changes seen but left owed because they fell outside the
39    /// requested scope (D4): the next barrier's consume takes those the
40    /// tree holds. Zero for a whole-tree
41    /// sync.
42    #[serde(default)]
43    pub out_of_scope_foreign: usize,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub scope: Option<Vec<String>>,
46}
47
48/// A normalized scope: path prefixes and exact paths, matched on
49/// COMPONENT boundaries (`"in"` never matches `internal/`).
50#[derive(Debug, Clone)]
51pub struct Scope {
52    entries: Vec<String>,
53}
54
55impl Scope {
56    pub fn new(raw: &[String]) -> Scope {
57        let mut entries = vec![];
58        for e in raw.iter().take(super::sentinel::MAX_SCOPE_ENTRIES) {
59            if e.len() > super::sentinel::MAX_SCOPE_ENTRY_LEN {
60                continue;
61            }
62            let norm = e.trim_matches('/').replace('\\', "/");
63            if norm.is_empty() || norm.split('/').any(|c| c == ".." || c == ".") {
64                continue;
65            }
66            entries.push(norm);
67        }
68        Scope { entries }
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.entries.is_empty()
73    }
74
75    pub fn entries(&self) -> &[String] {
76        &self.entries
77    }
78
79    /// Component-boundary match: an entry covers itself exactly and
80    /// everything beneath it, never a sibling sharing a name prefix.
81    pub fn covers(&self, path: &str) -> bool {
82        self.entries.iter().any(|e| {
83            path == e.as_str()
84                || path.strip_prefix(e.as_str()).map(|r| r.starts_with('/')).unwrap_or(false)
85        })
86    }
87}
88
89impl Syncer {
90    /// Whole-tree sync: advances `seq` and the cheap path's record once it
91    /// has taken everything owed; `manifest_etag` stays where the last
92    /// barrier left it (`reader.rs` relies on that).
93    pub async fn sync(&mut self) -> LeanResult<SyncReport> {
94        self.sync_scoped(None).await
95    }
96
97    pub async fn sync_scoped(&mut self, scope: Option<Vec<String>>) -> LeanResult<SyncReport> {
98        // An all-rejected scope is REFUSED, never widened. `Scope::new`
99        // silently drops entries that are too long, past
100        // MAX_SCOPE_ENTRIES, or contain `.`/`..` — so a caller naming
101        // three malformed paths used to get `None`, and `in_scope`'s
102        // `.unwrap_or(true)` turned that into the WHOLE TREE. A
103        // whole-tree sync re-derives against the remote manifest and
104        // DELETES local files for remotely-deleted paths, so the failure
105        // mode of a typo was maximum privilege. An error must not
106        // return a legal value.
107        let scope = match scope {
108            None => None,
109            Some(raw) => {
110                let s = Scope::new(&raw);
111                if s.is_empty() {
112                    return Err(LeanError::State(format!(
113                        "sync scope named {} entr{} and NONE survived validation — \
114                         refusing, because an empty scope widens to the WHOLE TREE",
115                        raw.len(),
116                        if raw.len() == 1 { "y" } else { "ies" }
117                    )));
118                }
119                Some(s)
120            }
121        };
122        let in_scope = |path: &str| scope.as_ref().map(|s| s.covers(path)).unwrap_or(true);
123        let mut report = SyncReport::default();
124        report.scope = scope.as_ref().map(|s| s.entries().to_vec());
125        let mut baseline = self.state.take_baseline()?;
126        // What the baseline cited before, for the trace (only when it is on):
127        // the paths this sync took are the ones whose citation it moved.
128        let before: Option<BTreeMap<String, String>> = self
129            .cfg
130            .event_trace
131            .is_some()
132            .then(|| baseline.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect());
133
134        // 1. The scan comes FIRST; dirt is judged against it alone.
135        let scanned = scan::scan_with(&self.cfg.root, self.cfg.scan_threads)?;
136        let mut classified = scan::classify(&scanned, &baseline);
137        scan::confirm_racy(&self.cfg.root, &scanned, &baseline, &mut classified);
138        let locally_dirty = |path: &str| {
139            classified.uploads.contains(path)
140                || classified.deletes.contains(path)
141                || classified.first_absence.contains(path)
142        };
143        // A path the syncer may not read (`scan::Scan::held`) is neither
144        // taken nor removed: what is there is unknown, and may be the
145        // agent's. Named, so the agent learns why.
146        let unreadable = |path: &str| ConflictRecord {
147            path: path.to_string(),
148            foreign_etag: String::new(),
149            preserved_key: None,
150            kind: "sync-unreadable: the syncer may not read this path (EACCES or EPERM); the \
151                   bucket's change to it is not applied until it is readable"
152                .into(),
153            at_unix: now_unix(),
154        };
155
156        // 2. Remote truth: the committed document (since P2 the gateway
157        //    commits each UI verb, so there is no inbox overlay).
158        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
159        // The agent's paths this sync leaves untaken (dirty), for the
160        // consume's cheap path: owed again if the agent backs out.
161        let mut dirty_skips: BTreeSet<String> = BTreeSet::new();
162        let (theirs, metag) = match loaded {
163            Some(l) => (l.manifest, Some(l.etag)),
164            // No document at all. For a tree that holds nothing that is a
165            // new workspace, and there is nothing to do. For one that
166            // holds paths it is a wiped or re-pointed prefix, and reading
167            // it as an EMPTY document would delete every clean file here —
168            // perhaps the last copy. The consume takes nothing in this
169            // case; the sync refuses (review 2026-09-29, S1).
170            None if baseline.entries.is_empty() => (Default::default(), None),
171            None => {
172                return Err(LeanError::State(format!(
173                    "sync: the workspace has no document (no pointer, no manifest) but this tree's \
174                     baseline holds {} paths — refusing to read a missing document as an empty \
175                     one, which would delete every clean file here",
176                    baseline.entries.len()
177                )))
178            }
179        };
180        // Something owed could not be taken now (a fetch that lost to a
181        // newer document, a failed write): the next consume derives again
182        // even if the pointer does not move — exactly the consume's `left`
183        // (review 2026-09-29, S2/S3).
184        let mut left = false;
185        // The paths `left` names, for the trace.
186        let mut failed: BTreeSet<String> = BTreeSet::new();
187        // Path -> (etag, HANDLE): the entry's own key, which is what the
188        // fetch below reads (design 2026-09-19: a handle is fetched by
189        // name; the bare path is nobody's to read).
190        let remote: BTreeMap<String, (String, String)> =
191            theirs.entries.iter().map(|(p, e)| (p.clone(), (e.etag.clone(), e.key.clone()))).collect();
192        // The writer's CRC for each remote etag: the manifest's.
193        let remote_crc: BTreeMap<String, Option<String>> = theirs
194            .entries
195            .iter()
196            .map(|(p, e)| (p.clone(), Some(e.crc64_b64.clone())))
197            .collect();
198
199        // The workspace's own scope (a scoped checkout): a path it neither
200        // holds nor covers is not this tree's to fetch.
201        let held_scope = self.state.load_scope()?.map(|v| Scope::new(&v));
202        let held = |b: &super::state::Baseline, p: &str| {
203            b.entries.contains_key(p) || held_scope.as_ref().is_none_or(|s| s.covers(p))
204        };
205
206        // 3. Remote deletions: in the baseline, gone from the manifest —
207        //    apply only on locally-clean paths.
208        //
209        //    BEFORE the adds, and that order is a correctness rule, not
210        //    a tidiness one. A remote generation that turned the file
211        //    `build` into the directory `build/log.txt` sends both a
212        //    deletion and an add; with the adds first, the add is
213        //    refused for containment (`sync-refused-containment`) while
214        //    the file is still there, and the deletion then takes the
215        //    file away — one sync leaves the workspace without either,
216        //    and sync is harness-invoked, never background, so "the next
217        //    sync fixes it" may be never. The two passes act on disjoint
218        //    path sets (this one takes what is NOT in `remote`, that one
219        //    what IS), so nothing else moves with the order.
220        let base_paths: Vec<String> = baseline.entries.keys().cloned().collect();
221        for path in base_paths {
222            if remote.contains_key(&path) {
223                continue;
224            }
225            if !in_scope(&path) {
226                report.out_of_scope_foreign += 1;
227                continue;
228            }
229            // Before `exists`, which answers false for a path it may not
230            // stat: that is unseen, not gone.
231            if scanned.holds(&path) {
232                self.state.append_conflict(&unreadable(&path))?;
233                report.conflicts.push(path.clone());
234                dirty_skips.insert(path.clone());
235                continue;
236            }
237            let local = self.cfg.root.join(&path);
238            if !local.exists() {
239                baseline.entries.remove(&path);
240                continue;
241            }
242            super::barrier::consume_window("before-delete", &path);
243            // H7: judged by the step-1 scan AND by a fresh stat now — a
244            // write since the scan is the agent's, and it stays.
245            if locally_dirty(&path) || super::barrier::local_dirty(&local, baseline.entries.get(&path)) {
246                self.state.append_conflict(&ConflictRecord {
247                    path: path.clone(),
248                    foreign_etag: String::new(),
249                    preserved_key: None,
250                    kind: "sync-remote-delete-vs-dirty".into(),
251                    at_unix: now_unix(),
252                })?;
253                report.conflicts.push(path.clone());
254                dirty_skips.insert(path.clone());
255                continue;
256            }
257            std::fs::remove_file(&local)?;
258            baseline.entries.remove(&path);
259            report.deleted.push(path.clone());
260        }
261
262        // 4. Apply adds/changes (remote differs from the baseline).
263        for (path, (etag, key)) in &remote {
264            let cited_mode = theirs.entries.get(path).map(|e| e.mode);
265            if baseline
266                .entries
267                .get(path)
268                .map(|b| &b.etag == etag && cited_mode.is_none_or(|m| b.cites_mode(m)))
269                .unwrap_or(false)
270            {
271                continue;
272            }
273            if !held(&baseline, path) {
274                continue;
275            }
276            if !in_scope(path) {
277                // D4: NOT integrated — it stays owed, and the next
278                // barrier's consume takes it.
279                report.out_of_scope_foreign += 1;
280                continue;
281            }
282            if scanned.holds(path) {
283                self.state.append_conflict(&unreadable(path))?;
284                report.conflicts.push(path.clone());
285                dirty_skips.insert(path.clone());
286                continue;
287            }
288            if locally_dirty(path) {
289                // The phantom-conflict rule (§2.2): `sync` saves the
290                // baseline only at the end, so a crash mid-apply
291                // followed by a re-honor makes already-applied paths
292                // scan dirty against the stale baseline. Declaring a
293                // conflict there would report a conflict for a path
294                // whose local bytes ARE the remote bytes, and the path
295                // would then re-publish as a spurious generation bump.
296                // Compare content identity first.
297                // The remote's crc, read off the object we would apply
298                // (the backend's attestation) before the manifest's.
299                // One HEAD, only on the dirty-vs-remote path.
300                let remote_meta = match self.store.head(key).await {
301                    Ok(m) if m.etag == *etag => Some(m),
302                    _ => None,
303                };
304                // The remote's CRC: the backend's attestation when it
305                // offers one, else the writer's for this etag — client-
306                // computed, so it exists on a backend that attests none
307                // (Ozone). Without either the bytes cannot be judged
308                // identical, and the path is a conflict as before.
309                let want: Option<String> = remote_meta.as_ref().and_then(|m| {
310                    m.crc64_b64.clone().or_else(|| remote_crc.get(path).cloned().flatten())
311                });
312                let local_path = self.cfg.root.join(path);
313                let local_crc =
314                    std::fs::read(&local_path).ok().map(|b| crc64_to_b64(crc64_nvme(&b)));
315                let identical = want.is_some() && want == local_crc;
316                if identical {
317                    let st = std::fs::metadata(&local_path)?;
318                    let stamps =
319                        remote_meta.as_ref().and_then(|m| GenerationStamps::from_meta(&m.meta));
320                    baseline.entries.insert(
321                        path.clone(),
322                        BaselineEntry {
323                            etag: etag.clone(),
324                            key: Some(key.clone()),
325                            generation: stamps
326                                .map(|s| s.generation)
327                                .or_else(|| theirs.entries.get(path).map(|e| e.generation))
328                                .unwrap_or(0),
329                            size: st.len(),
330                            mtime_unix: mtime_of(&st),
331                            mtime_nanos: Some(mtime_nanos_of(&st)),
332                            // The document's, as every entry records: a
333                            // local mode change still publishes.
334                            mode: cited_mode.map(|m| m & scan::PERM_BITS),
335                            racy: super::barrier::racy_stat(&st),
336                            change: Some(super::scan::ChangeStamp::of(&st)),
337                            crc64_b64: local_crc,
338                        },
339                    );
340                        report.applied.push(path.clone());
341                    continue;
342                }
343                self.state.append_conflict(&ConflictRecord {
344                    path: path.clone(),
345                    foreign_etag: etag.clone(),
346                    preserved_key: None, // remote version stays in the bucket
347                    kind: "sync-dirty".into(),
348                    at_unix: now_unix(),
349                })?;
350                report.conflicts.push(path.clone());
351                dirty_skips.insert(path.clone());
352                continue;
353            }
354            let fetched = self.store.get_whole(key, Some(etag)).await;
355            let (meta, body) = match fetched {
356                Ok(ok) => ok,
357                Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
358                    // Superseded mid-sync, or not readable yet: owed still.
359                    left = true;
360                    failed.insert(path.clone());
361                    continue;
362                }
363                Err(e) => return Err(e.into()),
364            };
365            // The document's mode, as the consume and checkout apply.
366            let mode = cited_mode;
367            // VERIFIED before it is written, exactly as checkout's fresh
368            // fetch is: against the writer's CRC for this etag (the
369            // manifest's), else the backend's attestation when it offers
370            // one. What the baseline records is OURS, over the bytes
371            // written.
372            let got = crc64_to_b64(crc64_nvme(&body));
373            let want =
374                remote_crc.get(path).cloned().flatten().or_else(|| meta.crc64_b64.clone());
375            if let Some(want) = want {
376                if want != got {
377                    return Err(LeanError::State(format!(
378                        "sync: {path} (etag {etag}) is cited with CRC-64 {want}, but the bytes \
379                         fetched under that etag hash to {got} — the object is corrupt or the \
380                         store returned the wrong bytes; refusing to apply it (nothing written)"
381                    )));
382                }
383            }
384            // A path this tree can never safely materialise is refused and
385            // NOT left owed (as the consume does); a write that fails for
386            // any other reason below is transient and stays owed.
387            if let Err(e) = super::barrier::check_contained(&self.cfg.root, path) {
388                self.state.append_conflict(&ConflictRecord {
389                    path: path.clone(),
390                    foreign_etag: etag.clone(),
391                    preserved_key: None,
392                    kind: format!("sync-refused-containment: {e}"),
393                    at_unix: now_unix(),
394                })?;
395                report.conflicts.push(path.clone());
396                continue;
397            }
398            super::barrier::consume_window("before-write", path);
399            // Review 2026-09-18, H7: dirt was judged by the scan at step 1,
400            // and a whole-tree sync fetches for minutes after it; a path
401            // the agent writes since then is modified, and its version
402            // wins. So the licence is checked once more against a fresh
403            // stat, with the temp written, immediately before the rename.
404            let local = self.cfg.root.join(path);
405            let base = baseline.entries.get(path).cloned();
406            let still_clean = || !super::barrier::local_dirty(&local, base.as_ref());
407            let written = super::barrier::contained_path(&self.cfg.root, path).and_then(|target| {
408                if self.cfg.tree_reserve {
409                    super::safefs::leave_the_reserve(&self.cfg.root, path, body.len() as u64, self.cfg.tree_room_for_tests)?;
410                }
411                super::barrier::write_file_atomic_if(&target, &body, mode, &still_clean)
412            });
413            let st = match written {
414                Ok(Some(st)) => st,
415                Ok(None) => {
416                    self.state.append_conflict(&ConflictRecord {
417                        path: path.clone(),
418                        foreign_etag: etag.clone(),
419                        preserved_key: None, // remote version stays in the bucket
420                        kind: "sync-dirty".into(),
421                        at_unix: now_unix(),
422                    })?;
423                    report.conflicts.push(path.clone());
424                    dirty_skips.insert(path.clone());
425                    continue;
426                }
427                Err(e) => {
428                    // TRANSIENT (a full disk, a directory it cannot write
429                    // to): surfaced, never a wedge, and still owed.
430                    self.state.append_conflict(&ConflictRecord {
431                        path: path.clone(),
432                        foreign_etag: etag.clone(),
433                        preserved_key: None,
434                        kind: format!("sync-write-failed (will retry): {e}"),
435                        at_unix: now_unix(),
436                    })?;
437                    report.conflicts.push(path.clone());
438                    left = true;
439                    failed.insert(path.clone());
440                    continue;
441                }
442            };
443            // The baseline records the inode this sync wrote (its fstat),
444            // never a fresh stat of the path an agent may have written
445            // since (H7).
446            super::barrier::consume_window("after-rename", path);
447            let stamps = GenerationStamps::from_meta(&meta.meta);
448            baseline.entries.insert(
449                path.clone(),
450                BaselineEntry {
451                    etag: meta.etag.clone(),
452                    key: Some(key.clone()),
453                    generation: stamps.map(|s| s.generation).unwrap_or(0),
454                    size: st.len(),
455                    mtime_unix: mtime_of(&st),
456                    mtime_nanos: Some(mtime_nanos_of(&st)),
457                    mode: cited_mode.map(|m| m & scan::PERM_BITS),
458                    racy: super::barrier::racy_stat(&st),
459                    change: Some(super::scan::ChangeStamp::of(&st)),
460                    crc64_b64: Some(got),
461                },
462            );
463            report.applied.push(path.clone());
464        }
465
466        // 5. What this sync leaves owed. Whole-tree, the sync derived against
467        //    this document: what it left is the agent's (dirty) and is owed
468        //    the moment the agent backs out — the consume's cheap path record,
469        //    as a consume would write it. Scoped, it derived only part: the
470        //    next consume derives again (D4 keeps manifest_etag where it was).
471        match &scope {
472            None if !left => {
473                baseline.seq = theirs.seq;
474                baseline.derived_etag = Some(metag.clone().unwrap_or_default());
475                baseline.skipped.sync_to(&dirty_skips);
476                report.seq = theirs.seq;
477            }
478            // Something is still owed: nothing is recorded as derived, and
479            // the tree is not integrated with this document yet.
480            None => {
481                baseline.derived_etag = None;
482                baseline.skipped.clear();
483                report.seq = baseline.seq;
484            }
485            Some(_) => {
486                baseline.derived_etag = None;
487                baseline.skipped.clear();
488                report.seq = baseline.seq;
489            }
490        }
491        let rescan = scan::scan_with(&self.cfg.root, self.cfg.scan_threads)?;
492        baseline.prev_scan.sync_to(rescan.present(&baseline.entries));
493        // Materialised files before the baseline that vouches for them.
494        self.state.sync_tree()?;
495        // What the trace reports, read before the baseline is handed back.
496        let taken: Vec<String> = before
497            .as_ref()
498            .map(|before| {
499                before
500                    .keys()
501                    .chain(baseline.entries.keys())
502                    .collect::<BTreeSet<_>>()
503                    .into_iter()
504                    .filter(|p| before.get(*p) != baseline.entries.get(*p).map(|e| &e.etag))
505                    .cloned()
506                    .collect()
507            })
508            .unwrap_or_default();
509        let skipped: Vec<String> = baseline.skipped.iter().cloned().collect();
510        self.state.commit_baseline(baseline)?;
511        self.trace("sync", serde_json::json!({"scoped": scope.is_some(), "applied": report.applied.len(),
512            "deleted": report.deleted.len(), "conflicts": report.conflicts.len(), "seq": report.seq,
513            "taken": taken, "skipped": skipped, "left": left, "failed": failed,
514            "reader": self.cfg.access.is_read()}));
515        Ok(report)
516    }
517}