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