Skip to main content

flint_lean/
state.rs

1//! Durable emptyDir bookkeeping (plan §2.1 "restart matrix").
2//!
3//! Everything the barrier needs to survive a CONTAINER restart lives
4//! here as small JSON files under `<root>/.flint-sync/`, each written
5//! temp+rename. The baseline is the exception (lever D): a snapshot
6//! written temp+rename and a journal that each save appends to in place,
7//! held parsed in memory for the life of the process.
8//!
9//! A POD replacement gets a fresh emptyDir and therefore a fresh
10//! identity — that asymmetry is deliberate (the plan's P4: the
11//! incarnation id is emptyDir-scoped, so only the same pod's restarted
12//! container may self-supersede the lease).
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use super::tracked::{TrackedMap, TrackedSet};
21use super::{LeanError, LeanResult};
22
23/// One published path as this syncer last knew it: the handle it
24/// integrated (the citation repair re-cites it, the merge recognises it
25/// as this tree's own) and the ETag that attests its bytes.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct BaselineEntry {
28    pub etag: String,
29    /// The HANDLE this tree integrated at the path — its own upload, the
30    /// entry it consumed, the citation it checked out (design 2026-09-19,
31    /// R1-R2). The merge counts a document citing it as citing THIS tree's
32    /// version, not a foreign one. `None` only on a baseline written
33    /// before handles.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub key: Option<String>,
36    pub generation: u64,
37    pub size: u64,
38    pub mtime_unix: i64,
39    /// Sub-second mtime, when this entry was written by a syncer that
40    /// stamps it; `None` compares as "unknown" (`scan::stat_changed`).
41    #[serde(default)]
42    pub mtime_nanos: Option<u32>,
43    /// The mode (`scan::PERM_BITS` of it) the document cites for the
44    /// version this tree integrated — its own upload's, or the one it
45    /// materialised, which is the tree's whenever the write could apply
46    /// it. A `chmod` moves neither size nor mtime, so the scan compares
47    /// the tree's mode with this (`scan::stat_changed`); and it publishes
48    /// the same bytes under the same etag, so a consume owes an entry
49    /// whose cited mode is not this one (`cites_mode`). A mode the tree
50    /// cannot hold (a bit the kernel refuses an unprivileged owner)
51    /// scans as a change and publishes what the tree does hold, once.
52    /// `None` compares as "unknown", as above.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub mode: Option<u32>,
55    /// CRC-64/NVME (wire form) of the object bytes this syncer
56    /// integrated at this path — computed from the bytes it wrote or
57    /// uploaded, never from a HEAD: content convergence compares it, and
58    /// it is what lets the manifest carry a CRC for an object whose
59    /// backend returns none (Ozone). Every writer sets it; `None` only on
60    /// a baseline from before it existed.
61    pub crc64_b64: Option<String>,
62    /// The stat above was taken within `scan::RACY_WINDOW_NANOS` of the
63    /// file's mtime, so it cannot vouch for the bytes on its own: the next
64    /// scan hashes the file before calling it clean (`scan::confirm_racy`).
65    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
66    pub racy: bool,
67}
68
69impl BaselineEntry {
70    /// Whether `cited`, a document entry's mode, is the one this entry
71    /// integrated. With the etag, what a consume asks before it skips an
72    /// entry: a `chmod` publishes the same bytes, so the same etag.
73    pub fn cites_mode(&self, cited: u32) -> bool {
74        self.mode.is_none_or(|m| (m ^ cited) & super::scan::PERM_BITS == 0)
75    }
76}
77
78/// The persisted baseline snapshot: what this syncer believes the
79/// bucket holds AND has integrated locally — and, since P1-lite
80/// (2026-09-25), the three-way MERGE BASE too. A separate merge base
81/// existed because a tree could adopt a UI write the document did not
82/// cite yet; under P2 every UI edit commits first, so no tree holds a
83/// version the document never cited, and the split had nothing left to
84/// keep apart (the sandbox pair `LeanCoreP1`/`LeanCoreP2R`).
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct Baseline {
87    /// Manifest seq at our last install/checkout.
88    pub seq: u64,
89    /// Manifest document ETag we expect at the next CAS.
90    pub manifest_etag: Option<String>,
91    pub entries: TrackedMap<BaselineEntry>,
92    /// THE CHEAP PATH'S RECORD (scan trigger). The pointer etag of the
93    /// document the last full consume derived against, with nothing left
94    /// but the agent's own work; `None` = derive at the next consume (a
95    /// failed fetch, a sync, a baseline from before this field). Only the
96    /// consume writes it — and the commit, when its CAS replaced exactly
97    /// this document, so the new one is it plus this tree's own changes.
98    #[serde(default)]
99    pub derived_etag: Option<String>,
100    /// The paths that derive left untaken because the agent is working on
101    /// them (dirty against the baseline where the document differs). The
102    /// cheap path holds only while every one is still dirty: an agent that
103    /// backs out makes the document's version owed.
104    #[serde(default)]
105    pub skipped: TrackedSet,
106    /// Paths present at the PREVIOUS scan (the two-consecutive-scans
107    /// deletion rule: absence must survive two scans).
108    pub prev_scan: TrackedSet,
109    /// This snapshot's id, minted at each whole write: the journal
110    /// (`baseline.log`) applies to the snapshot its first line names
111    /// (lever D). An id rather than a checksum, so a snapshot edited in
112    /// place keeps its journal, and one written whole anew does not.
113    /// LAST, so the fields before it keep the order the e2e plant relies
114    /// on (`run-verbs.sh`).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub(crate) journal: Option<String>,
117}
118
119/// A rescope in flight (scoped-read design §4.3). `target` is the scope
120/// the workspace is moving TO; `None` means the whole tree.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct ScopeIntent {
123    pub target: Option<Vec<String>>,
124    /// The paths this rescope is removing from the held set, recorded
125    /// BEFORE the first mutation.
126    ///
127    /// Load-bearing, and the mutation check that found it: replay
128    /// cannot RE-DERIVE this set. Once a path is out of
129    /// `baseline.entries` it is indistinguishable from a file the agent
130    /// just created — same scan row, same absence from the baseline —
131    /// and the two have opposite correct answers: unlink the leftover,
132    /// KEEP the agent's new file. Deriving the drop set from the
133    /// baseline made a crash between the uncite and the unlink leave
134    /// six uncited files on disk forever; deriving it from the tree
135    /// would delete the agent's work instead.
136    #[serde(default)]
137    pub drop: Vec<String>,
138    /// What the uncite dropped, per path, recorded BEFORE the uncite (and
139    /// kept across a replay, which can no longer read it off the
140    /// baseline). The unlink takes a file only while its bytes are still
141    /// these: the agent may write a dropped path between the uncite and
142    /// the unlink — a crash leaves that window open until the next
143    /// barrier — and once uncited, its write and the leftover read alike
144    /// (the model's `LeanP1UnlinkBlind`, 2026-10-02).
145    #[serde(default)]
146    pub held: BTreeMap<String, BaselineEntry>,
147}
148
149/// The pod-incarnation identity + lease bookkeeping ({last_token,
150/// quiet_polls} persist so container restarts RESUME the takeover
151/// observation instead of resetting it — plan §2.1).
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Incarnation {
154    pub holder_id: String,
155    pub epoch: u64,
156    pub last_token: Option<String>,
157    pub quiet_polls: u32,
158}
159
160/// What the conditional-write probes saw this store do
161/// (`conformance.rs`). Cached because the probes cost ~10 requests and
162/// a store does not change its mind between two barriers — but keyed by
163/// `identity`, because a workspace re-pointed at a different endpoint or
164/// bucket is a different store and must be asked again.
165///
166/// `probe_version` is what makes the cache safe to extend: a build that
167/// asks a NEW question must not read an old verdict as the answer to
168/// it, so a bump invalidates every cached verdict rather than inheriting
169/// a silent pass.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct StoreConformance {
172    pub identity: String,
173    pub probe_version: u32,
174    /// Whether the probe got an ANSWER at all. False means it could not
175    /// write its own object — a credential, a policy, the network — and
176    /// the two flags below say nothing. Never cached false: an outage
177    /// must not become this workspace's standing verdict.
178    pub answered: bool,
179    /// `If-None-Match:*` and `If-Match` on PUT — the manifest CAS, the
180    /// upload gate, the lease cell. Nothing works without it.
181    pub conditional_put: bool,
182    /// `If-Match` on DELETE — the file collector, and only it.
183    pub conditional_delete: bool,
184    /// What the failing probe said, for the operator message.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub detail: Option<String>,
187    pub at_unix: u64,
188}
189
190/// The intent journal: the flush id of the barrier in flight, and the H10
191/// carrier. (Its `keys`, `recent_uuids` and `declared_deletes` served the
192/// slot's AdoptOwn and the cell's declared removals; nothing read them
193/// once both were gone — M6 of the 2026-09-24 simplification analysis.)
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
195pub struct IntentJournal {
196    pub flush_uuid: String,
197    /// What the installs that CARRIED the standing publish declaration
198    /// published, journalled in the write that follows each CAS (review
199    /// 2026-09-18, H10). The ack reads the barrier that honors the
200    /// declaration, and that is not always the one that carried it: a
201    /// restart after step 7, an ack write that failed, or an honor that
202    /// failed before the floor's cadence barrier published the
203    /// declaration, each left the ack to a later barrier — and when a
204    /// peer had deleted a declared path in between, that barrier was a
205    /// pull of the peer's document, answered `ok` while the tree still
206    /// held the declared bytes. Carried forward across barriers; matched
207    /// on the pending record's `id`.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub carrier: Option<Carrier>,
210}
211
212/// See `IntentJournal::carrier`.
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
214pub struct Carrier {
215    /// `PendingSentinel::id` of the declaration these installs carried.
216    pub pending_id: String,
217    /// The dirty paths they published — uploads and deletes.
218    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
219    pub paths: BTreeSet<String>,
220    /// Which of `paths` were DELETES: a document that does not cite one
221    /// of those is the declaration honoured, not dropped.
222    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
223    pub deletes: BTreeSet<String>,
224    /// Carried uploads the document the LAST barrier ended at does not
225    /// cite with the bytes this writer published: a peer deleted them after
226    /// this writer published them, or replaced them (L-137: deleted, then
227    /// renamed another file onto the path). The ack's document is that
228    /// one, so it says so (H10). Recomputed at every barrier's end from the
229    /// document in hand; no request.
230    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
231    pub uncited: BTreeSet<String>,
232    /// The bytes each carried upload published: the `crc64_b64` of the
233    /// entry its CAS wrote. What `uncited` compares the document against.
234    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235    pub published: BTreeMap<String, String>,
236    /// Declared paths they did not carry (parked, deferred, or a delete
237    /// the merge outranked) — the ack's `report.dropped` survives them.
238    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
239    pub dropped: BTreeSet<String>,
240}
241
242/// One surfaced conflict: both versions stay recoverable (local bytes in
243/// the tree, foreign bytes preserved at `preserved_key` or still current
244/// under the data key). Never a silent winner.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ConflictRecord {
247    pub path: String,
248    pub foreign_etag: String,
249    /// Where the foreign bytes were preserved (server-side copy), when
250    /// the local version is about to overwrite them.
251    pub preserved_key: Option<String>,
252    pub kind: String, // "consume-dirty" | "upload-412-parked" | "sync-dirty" | "gc-skip"
253    pub at_unix: u64,
254}
255
256pub struct SyncerState {
257    dir: PathBuf,
258    /// The state-directory occupancy lock (flock, held for the process
259    /// lifetime). Self-recognition of the lease via the persisted
260    /// incarnation id is only sound because the PREVIOUS process is
261    /// gone — and this lock is what makes that true. Without it, a
262    /// second flint-sync on the same workspace self-recognizes,
263    /// deposes a LIVE sibling, and both write the tree concurrently
264    /// (observed on the 0b rig: a diagnostic re-run raced a live 1M
265    /// checkout into tmp-rename ENOENT collisions). The hub has the
266    /// identical gate (`state_backend::is_single_occupant`).
267    _lock: std::fs::File,
268    /// The baseline, parsed once per process (lever D).
269    baseline: std::sync::Mutex<BaselineStore>,
270}
271
272const MARKER: &str = "checkout-complete";
273/// Written by the SIGTERM drain only after it published: the node
274/// plugin's evidence that a worker that is now gone actually drained
275/// (audit 2026-09-03, finding 3). Absent ⇒ the tree is preserved.
276pub const DRAINED: &str = "drained.json";
277const LOCK: &str = "lock";
278const BASELINE: &str = "baseline.json";
279/// The admitted set of a SCOPED checkout. Deliberately its own
280/// document rather than a field on `Baseline`: the baseline is
281/// rewritten by every barrier, every sync and every consume, and a
282/// scope that rides along is one `Baseline::default()` away from
283/// silently widening a workspace to the whole tree. This file is
284/// written once, by checkout, and thereafter only read. Absent means
285/// UNSCOPED — the only encoding of "no restriction", since an empty
286/// admitted set is refused before it can be written.
287const SCOPE: &str = "scope.json";
288
289/// A rescope IN FLIGHT: the target scope, written before the first
290/// mutation and removed only after the last (scoped-read design §4.3).
291///
292/// Its own document and not a field on `scope.json`, because the two
293/// answer different questions — `scope.json` is what this workspace
294/// holds, this is what it is on its way to holding — and a crash must
295/// leave both answers readable.
296///
297/// `null` is a LEGAL target (the whole tree), so absence of the file
298/// means "no rescope in flight", never "unscoped". That is why the
299/// document wraps the target in a struct instead of being a bare
300/// `Option<Vec<String>>`: a bare `null` on disk and a missing file
301/// would deserialize to the same thing.
302const SCOPE_INTENT: &str = "scope-intent.json";
303const INCARNATION: &str = "incarnation.json";
304const INTENT: &str = "intent.json";
305/// When this writer last swept for untracked uploads (`untracked.rs`).
306const UNTRACKED_SWEEP: &str = "untracked-sweep";
307const ORPHAN_SWEEP: &str = "orphan-sweep";
308/// When this writer's commit section last ran the collection pass
309/// (`LeanConfig::collect_every_secs`, latency plan lever C).
310const COLLECT_AT: &str = "collect-at";
311/// The retire logs this writer has read (`untracked.rs`): immutable, so each
312/// is fetched once.
313const RETIRE_CACHE: &str = "retire-cache.json";
314/// The cached conditional-write verdict for this store (`conformance.rs`).
315const CONFORMANCE: &str = "conformance.json";
316const CONFLICTS: &str = "conflicts.jsonl";
317/// The rotated generation. `load_conflicts` reads it FIRST, so the
318/// sequence a reader sees is unbroken across a rotation — which matters
319/// because `honor_sync` takes the count before a sync and `skip`s it
320/// after, and a rotation that SHORTENED the list would make it skip past
321/// the very records the sync just produced.
322const CONFLICTS_PREV: &str = "conflicts.1.jsonl";
323/// How many records were dropped when a SECOND rotation overwrote the
324/// first. Truncation that nobody can count is truncation that reads as
325/// "there were no more".
326const CONFLICTS_DROPPED: &str = "conflicts.dropped";
327/// Rotate past this. The file is append-only and `load_conflicts` parses
328/// it WHOLE — twice per sync honor, again per status read and per
329/// scrape (U23) — so an unbounded file is an unbounded parse on a hot
330/// path, and a standing condition (a UI autosaving over a path the agent
331/// holds dirty) appends on every barrier. Two generations bound the
332/// resident set at ~2 MiB, which is thousands of records.
333const CONFLICTS_MAX_BYTES: u64 = 1 << 20;
334
335fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
336    // The state dir lives in the same app-writable emptyDir.
337    super::safefs::check_parent(path)?;
338    let tmp = path.with_extension("tmp");
339    super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
340}
341
342/// The journal beside `baseline.json`: what each save changed since the
343/// snapshot it names (lever D).
344const BASELINE_LOG: &str = "baseline.log";
345
346/// A journal longer than this share of its snapshot is folded into a new
347/// snapshot at the next save: a reload never replays more than a quarter
348/// of the document, and a full write is paid once per that many bytes of
349/// change rather than at every save.
350const COMPACT_SHARE: u64 = 4;
351/// ...and never for a journal shorter than this.
352const COMPACT_MIN_BYTES: u64 = 256 * 1024;
353
354/// The journal's first line: the snapshot it applies to.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356struct JournalHead {
357    snapshot: String,
358}
359
360/// One save: its scalars, and the members it changed.
361#[derive(Debug, Default, Serialize, Deserialize)]
362struct JournalRecord {
363    seq: u64,
364    #[serde(default)]
365    manifest_etag: Option<String>,
366    #[serde(default)]
367    derived_etag: Option<String>,
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    put: Vec<(String, BaselineEntry)>,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    del: Vec<String>,
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    skipped_add: Vec<String>,
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    skipped_del: Vec<String>,
376    #[serde(default, skip_serializing_if = "Vec::is_empty")]
377    scan_add: Vec<String>,
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    scan_del: Vec<String>,
380}
381
382impl JournalRecord {
383    /// What `b` changed since its lineage, draining what it recorded.
384    fn drain(b: &mut Baseline) -> JournalRecord {
385        let mut r = JournalRecord {
386            seq: b.seq,
387            manifest_etag: b.manifest_etag.clone(),
388            derived_etag: b.derived_etag.clone(),
389            ..Default::default()
390        };
391        for k in b.entries.take_touched() {
392            match b.entries.get(&k) {
393                Some(e) => r.put.push((k, e.clone())),
394                None => r.del.push(k),
395            }
396        }
397        for k in b.skipped.take_touched() {
398            match b.skipped.contains(&k) {
399                true => r.skipped_add.push(k),
400                false => r.skipped_del.push(k),
401            }
402        }
403        for k in b.prev_scan.take_touched() {
404            match b.prev_scan.contains(&k) {
405                true => r.scan_add.push(k),
406                false => r.scan_del.push(k),
407            }
408        }
409        r
410    }
411
412    fn changes_members(&self) -> bool {
413        !(self.put.is_empty()
414            && self.del.is_empty()
415            && self.skipped_add.is_empty()
416            && self.skipped_del.is_empty()
417            && self.scan_add.is_empty()
418            && self.scan_del.is_empty())
419    }
420
421    fn apply(self, b: &mut Baseline) {
422        b.seq = self.seq;
423        b.manifest_etag = self.manifest_etag;
424        b.derived_etag = self.derived_etag;
425        for (k, e) in self.put {
426            b.entries.apply_put(k, e);
427        }
428        for k in &self.del {
429            b.entries.apply_del(k);
430        }
431        for k in self.skipped_add {
432            b.skipped.apply_add(k);
433        }
434        for k in &self.skipped_del {
435            b.skipped.apply_del(k);
436        }
437        for k in self.scan_add {
438            b.prev_scan.apply_add(k);
439        }
440        for k in &self.scan_del {
441            b.prev_scan.apply_del(k);
442        }
443    }
444}
445
446/// `<crc64 hex> <json>\n`. serde_json never writes a raw newline, so a
447/// line is one record, and a line without its newline is a torn one.
448fn journal_line(json: &[u8]) -> Vec<u8> {
449    let mut l = format!("{:016x} ", flint_store::crc64_nvme(json)).into_bytes();
450    l.extend_from_slice(json);
451    l.push(b'\n');
452    l
453}
454
455/// The JSON of one verified line, or `None` (torn, or not this format).
456fn journal_payload(line: &[u8]) -> Option<&[u8]> {
457    if line.len() < 18 || line[16] != b' ' { return None }
458    let crc = u64::from_str_radix(std::str::from_utf8(&line[..16]).ok()?, 16).ok()?;
459    let json = &line[17..];
460    (flint_store::crc64_nvme(json) == crc).then_some(json)
461}
462
463/// The scalars a save writes every time: a save that changed none of
464/// them and no member writes nothing.
465#[derive(Debug, Clone, Default, PartialEq, Eq)]
466struct Scalars {
467    seq: u64,
468    manifest_etag: Option<String>,
469    derived_etag: Option<String>,
470}
471
472impl Scalars {
473    fn of(b: &Baseline) -> Scalars {
474        Scalars { seq: b.seq, manifest_etag: b.manifest_etag.clone(), derived_etag: b.derived_etag.clone() }
475    }
476}
477
478/// The baseline as the disk holds it, parsed once per process. The state
479/// directory's flock makes this process its only writer, so nothing but
480/// this process's own saves can move the disk out from under it.
481#[derive(Default)]
482struct BaselineStore {
483    /// The disk's state, unless a caller holds it (`take_baseline`).
484    cache: Option<Baseline>,
485    /// The lineage the disk holds; 0 = not loaded yet.
486    lineage: u64,
487    next_lineage: u64,
488    /// The snapshot's length on disk; `None` = no snapshot.
489    snapshot: Option<u64>,
490    /// Verified bytes of `baseline.log`, head included; 0 = no journal
491    /// that belongs to the snapshot.
492    log_len: u64,
493    scalars: Scalars,
494    /// Whole-snapshot parses this process made (`tests_baseline`).
495    parses: u64,
496    #[cfg(test)]
497    compact_min_bytes: Option<u64>,
498}
499
500impl BaselineStore {
501    fn next(&mut self) -> u64 {
502        self.next_lineage += 1;
503        self.next_lineage
504    }
505}
506
507fn stamp(b: &mut Baseline, lineage: u64) {
508    b.entries.stamp(lineage);
509    b.skipped.stamp(lineage);
510    b.prev_scan.stamp(lineage);
511}
512
513impl SyncerState {
514    fn baseline_store(&self) -> std::sync::MutexGuard<'_, BaselineStore> {
515        // A panic while holding it leaves the cache as it was: the disk
516        // is the truth either way.
517        self.baseline.lock().unwrap_or_else(|p| p.into_inner())
518    }
519
520    /// Parse the disk's baseline into the store if no copy is there.
521    fn fill(&self, st: &mut BaselineStore) -> LeanResult<()> {
522        if st.cache.is_some() {
523            return Ok(());
524        }
525        let disk = read_baseline_files(&self.dir, true)?;
526        st.parses += disk.snapshot_len.is_some() as u64;
527        let mut b = disk.baseline;
528        let lineage = st.next();
529        stamp(&mut b, lineage);
530        st.lineage = lineage;
531        st.snapshot = disk.snapshot_len;
532        st.log_len = disk.log_len;
533        st.scalars = Scalars::of(&b);
534        st.cache = Some(b);
535        Ok(())
536    }
537
538    /// Write `b` to disk: what it changed, as one journal line, when it
539    /// is the disk's own lineage and the journal has room; else the whole
540    /// document, as before D, with a fresh journal. On success `b` is the
541    /// disk's state and of its new lineage.
542    fn persist(&self, st: &mut BaselineStore, b: &mut Baseline) -> LeanResult<()> {
543        let ours = st.lineage != 0
544            && b.entries.lineage() == st.lineage
545            && b.skipped.lineage() == st.lineage
546            && b.prev_scan.lineage() == st.lineage;
547        let room = match st.snapshot {
548            Some(len) if st.log_len > 0 => {
549                #[cfg(test)]
550                let min = st.compact_min_bytes.unwrap_or(COMPACT_MIN_BYTES);
551                #[cfg(not(test))]
552                let min = COMPACT_MIN_BYTES;
553                st.log_len < (len / COMPACT_SHARE).max(min)
554            }
555            _ => false,
556        };
557        // A save that changed much of the tree (a checkout, a rescope) is a
558        // new snapshot, not a journal line as long as one.
559        let changed = b.entries.touched_len() + b.skipped.touched_len() + b.prev_scan.touched_len();
560        let small = changed <= (b.entries.len() / 8).max(4096);
561        if ours && room && small {
562            let rec = JournalRecord::drain(b);
563            if !rec.changes_members() && Scalars::of(b) == st.scalars {
564                return Ok(());
565            }
566            let json = serde_json::to_vec(&rec).map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
567            let line = journal_line(&json);
568            let path = self.dir.join(BASELINE_LOG);
569            super::safefs::check_parent(&path)?;
570            // Written at the verified length, not appended: a write that
571            // failed half-way left bytes past it, and this one replaces
572            // them rather than following them.
573            use std::io::{Seek, SeekFrom, Write};
574            let mut f = fs::OpenOptions::new().write(true).open(&path)?;
575            f.seek(SeekFrom::Start(st.log_len))?;
576            f.write_all(&line)?;
577            let len = st.log_len + line.len() as u64;
578            f.set_len(len)?;
579            f.sync_data()?;
580            st.log_len = len;
581        } else {
582            // Written whole: what it recorded is in the document.
583            b.entries.take_touched();
584            b.skipped.take_touched();
585            b.prev_scan.take_touched();
586            let id = uuid::Uuid::new_v4().to_string();
587            b.journal = Some(id.clone());
588            // COMPACT JSON: the snapshot is the O(files) document.
589            let bytes = serde_json::to_vec(&*b).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
590            write_atomic(&self.dir.join(BASELINE), &bytes)?;
591            // The snapshot first, then its journal: a death between the two
592            // leaves the old journal naming the old snapshot's id, which the
593            // next load ignores.
594            let json = serde_json::to_vec(&JournalHead { snapshot: id })
595                .map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
596            let line = journal_line(&json);
597            write_atomic(&self.dir.join(BASELINE_LOG), &line)?;
598            // Held as the bytes just written parse, not as the map that was
599            // written: a map a checkout built by inserting in arrival order is
600            // scattered across the heap, and every later walk of it, and of
601            // the documents allocated around it, pays for that (measured at
602            // 200k files: `merge` 225-235 -> 167-168 ms and `retire_set`
603            // 181-184 -> 100-104 ms per publish). A parse allocates in key
604            // order. A whole write is rare (a checkout, a compaction, a large
605            // change): one parse each. The written map is freed first, so
606            // the two are never held at once.
607            *b = Baseline::default();
608            *b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
609            st.snapshot = Some(bytes.len() as u64);
610            st.log_len = line.len() as u64;
611        }
612        // Every copy made before this save is now stale: a later save of
613        // one writes it whole, as every save did before D.
614        let lineage = st.next();
615        stamp(b, lineage);
616        st.lineage = lineage;
617        st.scalars = Scalars::of(b);
618        Ok(())
619    }
620
621    /// A copy of the baseline. O(entries): for reads off the hot path and
622    /// the battery. The hot paths use `take_baseline` / `with_baseline`.
623    pub fn load_baseline(&self) -> LeanResult<Baseline> {
624        let mut st = self.baseline_store();
625        self.fill(&mut st)?;
626        Ok(st.cache.clone().expect("filled above"))
627    }
628
629    /// Read the baseline in place.
630    pub fn with_baseline<R>(&self, f: impl FnOnce(&Baseline) -> R) -> LeanResult<R> {
631        let mut st = self.baseline_store();
632        self.fill(&mut st)?;
633        Ok(f(st.cache.as_ref().expect("filled above")))
634    }
635
636    /// The baseline itself, to change and hand back with
637    /// `commit_baseline`. Until then the store holds none: a caller that
638    /// never hands it back costs the next load one parse, and changes
639    /// nothing on disk.
640    pub fn take_baseline(&self) -> LeanResult<Baseline> {
641        let mut st = self.baseline_store();
642        self.fill(&mut st)?;
643        Ok(st.cache.take().expect("filled above"))
644    }
645
646    /// Persist a baseline taken with `take_baseline` and keep it. A
647    /// baseline that changed nothing writes nothing.
648    pub fn commit_baseline(&self, mut b: Baseline) -> LeanResult<()> {
649        let mut st = self.baseline_store();
650        if let Err(e) = self.persist(&mut st, &mut b) {
651            // What the disk holds is no longer known here (a snapshot
652            // renamed before its journal failed, say): forget it, and the
653            // next load reads the disk.
654            st.cache = None;
655            st.lineage = 0;
656            st.snapshot = None;
657            st.log_len = 0;
658            return Err(e);
659        }
660        st.cache = Some(b);
661        Ok(())
662    }
663
664    /// Persist a copy, keeping one (O(entries)).
665    pub fn save_baseline(&self, b: &Baseline) -> LeanResult<()> {
666        self.commit_baseline(b.clone())
667    }
668
669    /// Whole-snapshot parses this process has made: one at the first
670    /// load, and then one after a save failed or a taken baseline was not
671    /// handed back.
672    pub fn baseline_parses(&self) -> u64 {
673        self.baseline_store().parses
674    }
675
676    /// Drop the held baseline, as a new process would start without one.
677    #[cfg(test)]
678    pub(crate) fn forget_baseline(&self) {
679        let mut st = self.baseline_store();
680        st.cache = None;
681        st.lineage = 0;
682    }
683
684    #[cfg(test)]
685    pub(crate) fn set_baseline_compact_min(&self, bytes: u64) {
686        self.baseline_store().compact_min_bytes = Some(bytes);
687    }
688}
689
690/// The baseline as the disk holds it: the snapshot, and the journal lines
691/// that belong to it.
692struct DiskBaseline {
693    baseline: Baseline,
694    /// The snapshot's length; `None` = there is none.
695    snapshot_len: Option<u64>,
696    /// Verified bytes of the journal; 0 = none that belongs.
697    log_len: u64,
698}
699
700/// Read `dir`'s baseline. `owner` (the process holding the state
701/// directory's flock) also cuts a torn last line off, so its next save
702/// follows the last good one. Any other reader leaves the file alone: the
703/// line it found torn may be the owner's write in progress.
704fn read_baseline_files(dir: &Path, owner: bool) -> LeanResult<DiskBaseline> {
705    // NotFound alone means absent: `exists()` answered false for EACCES
706    // too, and an unreadable baseline read as an empty one is the
707    // "unreadable means deleted" class (`load_scope`).
708    match fs::read(dir.join(BASELINE)) {
709        Ok(bytes) => {
710            let b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
711            replay_onto(dir, b, bytes.len() as u64, owner)
712        }
713        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
714            Ok(DiskBaseline { baseline: Baseline::default(), snapshot_len: None, log_len: 0 })
715        }
716        Err(e) => Err(LeanError::State(format!("read {BASELINE}: {e}"))),
717    }
718}
719
720fn replay_onto(dir: &Path, mut b: Baseline, snapshot_len: u64, owner: bool) -> LeanResult<DiskBaseline> {
721    let none = |b| Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: 0 });
722    let Some(id) = b.journal.clone() else { return none(b) };
723    let path = dir.join(BASELINE_LOG);
724    let bytes = match fs::read(&path) {
725        Ok(bytes) => bytes,
726        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return none(b),
727        Err(e) => return Err(LeanError::State(format!("read {BASELINE_LOG}: {e}"))),
728    };
729    let mut lines = bytes.split_inclusive(|c| *c == b'\n');
730    let Some(first) = lines.next().filter(|l| l.ends_with(b"\n")) else { return none(b) };
731    let belongs = journal_payload(&first[..first.len() - 1])
732        .and_then(|j| serde_json::from_slice::<JournalHead>(j).ok())
733        .is_some_and(|h| h.snapshot == id);
734    if !belongs {
735        // Written for another snapshot: a whole write that landed its new
736        // snapshot and died before its fresh journal. Everything in this
737        // one is in that snapshot. The next save replaces it.
738        return none(b);
739    }
740    let mut good = first.len() as u64;
741    for line in lines {
742        let rec = line
743            .strip_suffix(b"\n")
744            .and_then(journal_payload)
745            .and_then(|j| serde_json::from_slice::<JournalRecord>(j).ok());
746        match rec {
747            Some(r) => {
748                r.apply(&mut b);
749                good += line.len() as u64;
750            }
751            None => break,
752        }
753    }
754    if owner && good < bytes.len() as u64 {
755        let f = fs::OpenOptions::new().write(true).open(&path)?;
756        f.set_len(good)?;
757        f.sync_all()?;
758    }
759    Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: good })
760}
761
762/// The baseline of a state directory this process does not hold: what
763/// its owner's next load would read, never cutting anything. For
764/// `status`, which runs beside a live syncer.
765pub fn read_baseline_at(dir: &Path) -> LeanResult<Baseline> {
766    Ok(read_baseline_files(dir, false)?.baseline)
767}
768
769impl SyncerState {
770    pub fn open(dir: PathBuf) -> LeanResult<SyncerState> {
771        fs::create_dir_all(&dir)?;
772        let lock = fs::OpenOptions::new()
773            .create(true)
774            .truncate(false)
775            .write(true)
776            .open(dir.join(LOCK))?;
777        #[cfg(unix)]
778        {
779            use std::os::fd::AsRawFd;
780            // SAFETY: flock on an owned, open fd.
781            let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
782            if rc != 0 {
783                return Err(LeanError::State(format!(
784                    "another flint-sync already holds this workspace ({}): \
785                     refusing to run two syncers over one tree",
786                    dir.display()
787                )));
788            }
789        }
790        Ok(SyncerState { dir, _lock: lock, baseline: Default::default() })
791    }
792
793    pub fn marker_present(&self) -> bool {
794        self.dir.join(MARKER).exists()
795    }
796
797    /// Flush the filesystem holding the tree (the state dir lives in
798    /// it) so everything materialised so far is on stable storage.
799    pub fn sync_tree(&self) -> LeanResult<()> {
800        super::safefs::sync_tree(&self.dir)
801    }
802
803    /// The drain's attestation. `seq` is the manifest the drain left
804    /// installed, when it knows it.
805    pub fn write_drained(&self, seq: Option<u64>, acks: usize) -> LeanResult<()> {
806        let doc = serde_json::json!({
807            "unix": super::now_unix(),
808            "seq": seq,
809            "acks": acks,
810        });
811        write_atomic(&self.dir.join(DRAINED), doc.to_string().as_bytes())
812    }
813
814    /// A fresh incarnation owes its own attestation; a stale one from
815    /// an earlier life of this tree must not vouch for it.
816    pub fn clear_drained(&self) -> LeanResult<()> {
817        match fs::remove_file(self.dir.join(DRAINED)) {
818            Ok(()) => Ok(()),
819            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
820            Err(e) => Err(LeanError::State(format!("clear {DRAINED}: {e}"))),
821        }
822    }
823
824    pub fn drained_path(&self) -> PathBuf {
825        self.dir.join(DRAINED)
826    }
827
828    /// Written LAST at checkout: the agent-start gate.
829    pub fn write_marker(&self) -> LeanResult<()> {
830        write_atomic(&self.dir.join(MARKER), b"ok\n")
831    }
832
833    /// What this workspace was admitted to hold, or `None` for an
834    /// unscoped (whole-manifest) checkout.
835    ///
836    /// `fs::read` directly, with NO `exists()` pre-check: `exists()`
837    /// answers false for EACCES and EIO alike, and an unreadable scope
838    /// answered as "unscoped" is the same class of bug as an unreadable
839    /// path answered as "deleted" (`barrier.rs`, 14b3637c). Only
840    /// NotFound means absent.
841    pub fn load_scope(&self) -> LeanResult<Option<Vec<String>>> {
842        let p = self.dir.join(SCOPE);
843        let bytes = match fs::read(&p) {
844            Ok(b) => b,
845            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
846            Err(e) => {
847                return Err(LeanError::State(format!(
848                    "cannot read the checkout scope at {}: {e} — refusing to \
849                     guess what this workspace is holding",
850                    p.display()
851                )))
852            }
853        };
854        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("scope: {e}")))
855    }
856
857    /// `Some` writes the admitted set; `None` REMOVES the document.
858    /// Removing matters: a scoped checkout that crashed before its
859    /// marker leaves a scope behind, and the whole-tree checkout that
860    /// replaces it must not inherit a claim it did not make.
861    pub fn save_scope(&self, scope: Option<&[String]>) -> LeanResult<()> {
862        let p = self.dir.join(SCOPE);
863        match scope {
864            Some(entries) => {
865                let bytes = serde_json::to_vec(entries)
866                    .map_err(|e| LeanError::State(format!("scope: {e}")))?;
867                write_atomic(&p, &bytes)
868            }
869            None => match fs::remove_file(&p) {
870                Ok(()) => Ok(()),
871                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
872                Err(e) => Err(LeanError::State(format!("clear {SCOPE}: {e}"))),
873            },
874        }
875    }
876
877    /// Read a rescope in flight. `None` = none in flight.
878    pub fn load_scope_intent(&self) -> LeanResult<Option<ScopeIntent>> {
879        let p = self.dir.join(SCOPE_INTENT);
880        let bytes = match fs::read(&p) {
881            Ok(b) => b,
882            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
883            Err(e) => {
884                return Err(LeanError::State(format!(
885                    "cannot read the rescope intent at {}: {e} — refusing to run a barrier \
886                     over a half-applied scope",
887                    p.display()
888                )))
889            }
890        };
891        serde_json::from_slice(&bytes)
892            .map(Some)
893            .map_err(|e| LeanError::State(format!("scope intent: {e}")))
894    }
895
896    pub fn save_scope_intent(&self, intent: &ScopeIntent) -> LeanResult<()> {
897        let bytes = serde_json::to_vec(intent)
898            .map_err(|e| LeanError::State(format!("scope intent: {e}")))?;
899        write_atomic(&self.dir.join(SCOPE_INTENT), &bytes)
900    }
901
902    /// Cleared LAST, after the new scope is durable. Clearing it first
903    /// would turn a crash into a workspace whose scope says one thing
904    /// and whose held set says another, with nothing left to replay.
905    pub fn clear_scope_intent(&self) -> LeanResult<()> {
906        match fs::remove_file(self.dir.join(SCOPE_INTENT)) {
907            Ok(()) => Ok(()),
908            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
909            Err(e) => Err(LeanError::State(format!("clear {SCOPE_INTENT}: {e}"))),
910        }
911    }
912
913    pub fn load_incarnation(&self) -> LeanResult<Option<Incarnation>> {
914        let p = self.dir.join(INCARNATION);
915        if !p.exists() {
916            return Ok(None);
917        }
918        let bytes = fs::read(&p)?;
919        serde_json::from_slice(&bytes)
920            .map(Some)
921            .map_err(|e| LeanError::State(format!("incarnation: {e}")))
922    }
923
924    pub fn save_incarnation(&self, i: &Incarnation) -> LeanResult<()> {
925        let bytes = serde_json::to_vec_pretty(i)
926            .map_err(|e| LeanError::State(format!("incarnation: {e}")))?;
927        write_atomic(&self.dir.join(INCARNATION), &bytes)
928    }
929
930    /// Unix seconds of this writer's last untracked-upload sweep; 0 when it
931    /// has never swept.
932    pub fn load_untracked_sweep_at(&self) -> LeanResult<u64> {
933        match fs::read_to_string(self.dir.join(UNTRACKED_SWEEP)) {
934            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
935            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
936            Err(e) => Err(e.into()),
937        }
938    }
939
940    pub fn save_untracked_sweep_at(&self, at: u64) -> LeanResult<()> {
941        write_atomic(&self.dir.join(UNTRACKED_SWEEP), at.to_string().as_bytes())
942    }
943
944    /// The orphan sweep's clock (`untracked.rs`, `sweep_orphans_if_due`):
945    /// 0 = never swept, and the first commit starts it.
946    pub fn load_orphan_sweep_at(&self) -> LeanResult<u64> {
947        match fs::read_to_string(self.dir.join(ORPHAN_SWEEP)) {
948            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
949            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
950            Err(e) => Err(e.into()),
951        }
952    }
953
954    pub fn save_orphan_sweep_at(&self, at: u64) -> LeanResult<()> {
955        write_atomic(&self.dir.join(ORPHAN_SWEEP), at.to_string().as_bytes())
956    }
957
958    /// The last collection pass, unix seconds; 0 = never (or unreadable,
959    /// which runs the pass: the safe direction is collecting).
960    pub fn load_collect_at(&self) -> u64 {
961        fs::read_to_string(self.dir.join(COLLECT_AT)).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0)
962    }
963
964    pub fn save_collect_at(&self, at: u64) -> LeanResult<()> {
965        write_atomic(&self.dir.join(COLLECT_AT), at.to_string().as_bytes())
966    }
967
968    /// The retire logs read so far, by key. A cache: unreadable reads as
969    /// empty (the logs are fetched again), never as an error.
970    pub fn load_retire_cache(&self) -> BTreeMap<String, super::manifest::RetireLog> {
971        fs::read(self.dir.join(RETIRE_CACHE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
972    }
973
974    pub fn save_retire_cache(&self, c: &BTreeMap<String, super::manifest::RetireLog>) -> LeanResult<()> {
975        let bytes = serde_json::to_vec(c).map_err(|e| LeanError::State(format!("retire cache: {e}")))?;
976        write_atomic(&self.dir.join(RETIRE_CACHE), &bytes)
977    }
978
979    /// A verdict that cannot be parsed reads as ABSENT, never as a pass:
980    /// the probe runs again. The expensive outcome of a corrupt cache is
981    /// ten requests; the cheap one would be running the collector on a
982    /// store nobody asked.
983    pub fn load_conformance(&self) -> LeanResult<Option<StoreConformance>> {
984        match fs::read(self.dir.join(CONFORMANCE)) {
985            Ok(b) => Ok(serde_json::from_slice(&b).ok()),
986            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
987            Err(e) => Err(e.into()),
988        }
989    }
990
991    pub fn save_conformance(&self, c: &StoreConformance) -> LeanResult<()> {
992        let bytes = serde_json::to_vec_pretty(c)
993            .map_err(|e| LeanError::State(format!("conformance: {e}")))?;
994        write_atomic(&self.dir.join(CONFORMANCE), &bytes)
995    }
996
997    pub fn load_intent(&self) -> LeanResult<IntentJournal> {
998        let p = self.dir.join(INTENT);
999        if !p.exists() {
1000            return Ok(IntentJournal::default());
1001        }
1002        let bytes = fs::read(&p)?;
1003        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("intent: {e}")))
1004    }
1005
1006    pub fn save_intent(&self, j: &IntentJournal) -> LeanResult<()> {
1007        let bytes =
1008            serde_json::to_vec_pretty(j).map_err(|e| LeanError::State(format!("intent: {e}")))?;
1009        write_atomic(&self.dir.join(INTENT), &bytes)
1010    }
1011
1012    /// The barrier is over: no flush in flight. The carrier stays.
1013    pub fn clear_intent_keys(&self) -> LeanResult<()> {
1014        let mut j = self.load_intent()?;
1015        j.flush_uuid = String::new();
1016        self.save_intent(&j)
1017    }
1018
1019    pub fn append_conflict(&self, c: &ConflictRecord) -> LeanResult<()> {
1020        use std::io::Write;
1021        let line =
1022            serde_json::to_string(c).map_err(|e| LeanError::State(format!("conflict: {e}")))?;
1023        let live = self.dir.join(CONFLICTS);
1024        let mut f = fs::OpenOptions::new().create(true).append(true).open(&live)?;
1025        writeln!(f, "{line}")?;
1026        // Size off the handle we just wrote through — no extra stat of
1027        // the path, and no line count, which would make every append
1028        // O(records).
1029        let len = f.metadata()?.len();
1030        drop(f);
1031        if len > CONFLICTS_MAX_BYTES {
1032            self.rotate_conflicts()?;
1033        }
1034        Ok(())
1035    }
1036
1037    /// Rotate live -> `.1`, counting whatever the previous `.1` held so
1038    /// the loss is a NUMBER rather than a silence. Best effort: a
1039    /// rotation that fails leaves a large file, which is a performance
1040    /// problem; losing the record we just appended would be a
1041    /// correctness one.
1042    fn rotate_conflicts(&self) -> LeanResult<()> {
1043        let live = self.dir.join(CONFLICTS);
1044        let prev = self.dir.join(CONFLICTS_PREV);
1045        // Only read the outgoing generation at ROTATION time — rare by
1046        // construction, since it takes CONFLICTS_MAX_BYTES to get here.
1047        if let Ok(text) = fs::read_to_string(&prev) {
1048            let dropped = text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
1049            let total = self.conflicts_dropped()? + dropped;
1050            write_atomic(&self.dir.join(CONFLICTS_DROPPED), total.to_string().as_bytes())?;
1051        }
1052        fs::rename(&live, &prev)?;
1053        Ok(())
1054    }
1055
1056    /// Records lost to rotation. A caller rendering conflicts owes the
1057    /// user this number: a truncated list that does not say it was
1058    /// truncated reads as a complete one.
1059    pub fn conflicts_dropped(&self) -> LeanResult<u64> {
1060        match fs::read_to_string(self.dir.join(CONFLICTS_DROPPED)) {
1061            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
1062            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
1063            Err(e) => Err(LeanError::State(format!("{CONFLICTS_DROPPED}: {e}"))),
1064        }
1065    }
1066
1067    /// Every record ever appended: the ones rotation dropped plus the
1068    /// ones held. Unlike the held count it never goes down, so a reader
1069    /// that takes it before an operation knows exactly how many records
1070    /// the operation produced, across any rotation (`conflicts_since`).
1071    pub fn conflicts_appended(&self) -> LeanResult<u64> {
1072        Ok(self.conflicts_dropped()? + self.load_conflicts()?.len() as u64)
1073    }
1074
1075    /// The rotated generation first, then the live one: oldest to
1076    /// newest, unbroken across a single rotation.
1077    pub fn load_conflicts(&self) -> LeanResult<Vec<ConflictRecord>> {
1078        let mut out = vec![];
1079        for name in [CONFLICTS_PREV, CONFLICTS] {
1080            let p = self.dir.join(name);
1081            // `fs::read_to_string` directly: `exists()` answers false
1082            // for EACCES and EIO alike, and a conflict log that reads as
1083            // EMPTY because it is unreadable is the same shape of bug as
1084            // an unreadable path reading as deleted.
1085            let text = match fs::read_to_string(&p) {
1086                Ok(t) => t,
1087                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
1088                Err(e) => {
1089                    return Err(LeanError::State(format!(
1090                        "cannot read {name}: {e} — refusing to report an unreadable \
1091                         conflict log as an empty one"
1092                    )))
1093                }
1094            };
1095            for line in text.lines() {
1096                if line.trim().is_empty() {
1097                    continue;
1098                }
1099                out.push(
1100                    serde_json::from_str(line)
1101                        .map_err(|e| LeanError::State(format!("conflict line: {e}")))?,
1102                );
1103            }
1104        }
1105        Ok(out)
1106    }
1107}