Skip to main content

flint_lean/
gauges.rs

1//! `.flint-sync/gauges.json` — the Phase-3 observability minimum
2//! (§2.6; review ledger OF-6).
3//!
4//! The question this file exists to answer is "why is the manifest not
5//! advancing", and it has to be answerable from inside the pod, with
6//! `cat`, before Phase 6's `/metrics` exists. A healthy no-change tick
7//! and a wedged loop look identical from the outside: both publish
8//! nothing.
9//!
10//! **Every field here is computed from LOCAL state — no bucket request,
11//! ever.** That is enforced by the signature rather than by convention:
12//! `write_gauges` is not `async` and takes no store, so it *cannot*
13//! issue one. This is the "instrument reports on itself" class the
14//! runas campaign paid for five times; here it would also make the
15//! zero-added-cost oracle (leg B8) intermittently red and blame the
16//! syncer for its own instrument.
17//!
18//! Phase 6 renders `/metrics` from this same struct — one renderer over
19//! one struct cannot drift; two computations would.
20
21use serde::{Deserialize, Serialize};
22
23use super::{now_unix, LeanResult, Syncer};
24
25const GAUGES: &str = "gauges.json";
26
27/// Which coherent point last installed a citation, and what it named.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct LastBoundary {
30    pub source: String,
31    pub seq: u64,
32    pub unix: u64,
33}
34
35/// Why visibility is currently withheld. `None` = nothing is withheld.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Withheld {
38    /// A foreign 412 parked at least one path (it is NOT ours to
39    /// publish, and the conflict record says so).
40    Parked412,
41}
42
43impl Withheld {
44    pub fn as_str(&self) -> &'static str {
45        match self {
46            Withheld::Parked412 => "parked-412",
47        }
48    }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct Gauges {
53    /// Since the last boundary was installed. Elapsed time, NOT
54    /// exposure: an idle healthy workspace has nothing at risk and a
55    /// growing `rpo_secs`. Pair it with `withheld_reason`, which carries
56    /// whether there is anything to lose. Alerting on this number alone
57    /// pages someone for a workspace that is simply quiet.
58    pub rpo_secs: u64,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub withheld_reason: Option<String>,
61    pub sentinel_budget_remaining: u64,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub last_boundary: Option<LastBoundary>,
64    /// Unix time of the first renewal the store refused with 401/403
65    /// (`StoreError::Auth`) since the last successful one — a
66    /// credential or broker fault, never contention and never a lease
67    /// conflict. `None` once a renewal succeeds.
68    ///
69    /// This is the ONLY liveness fact a credential-paused holder can
70    /// still record. The renewal that would carry it into the lease
71    /// echo is precisely the request that is failing, so the store
72    /// cannot be told; local evidence is all there is. Without it an
73    /// operator sees a lease going stale and a pod that is plainly
74    /// Running, and nothing that connects the two — the diagnosis
75    /// `StoreError::Auth` was split out of `Other` to make possible in
76    /// the first place (`flint-store/src/lib.rs`), stopping one layer
77    /// short of the binary that needed it.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub auth_paused_since_unix: Option<u64>,
80    /// Refreshed on EVERY tick, news or not: an idle-but-healthy
81    /// workspace must be distinguishable from a dead one (the same
82    /// heartbeat rule `remote.seq` carries).
83    pub updated_unix: u64,
84    /// Carried across ticks so `rpo_secs` measures durability rather
85    /// than "time since this process started".
86    #[serde(default)]
87    pub last_durable_unix: u64,
88}
89
90impl Syncer {
91    fn gauges_path(&self) -> std::path::PathBuf {
92        self.cfg.state_dir().join(GAUGES)
93    }
94
95    /// Last written gauges, or defaults. Never fails on a corrupt file:
96    /// a diagnosis surface that panics is worse than a stale one.
97    pub fn load_gauges(&self) -> LeanResult<Gauges> {
98        let p = self.gauges_path();
99        if !p.exists() {
100            return Ok(Gauges::default());
101        }
102        Ok(std::fs::read(&p)
103            .ok()
104            .and_then(|b| serde_json::from_slice(&b).ok())
105            .unwrap_or_default())
106    }
107
108    /// Record that a boundary installed.
109    pub fn note_boundary(&self, source: &str, seq: u64) -> LeanResult<()> {
110        let mut g = self.load_gauges()?;
111        g.last_boundary = Some(LastBoundary { source: source.into(), seq, unix: now_unix() });
112        g.last_durable_unix = now_unix();
113        self.save_gauges(&g)
114    }
115
116    /// Record that the store refused our credentials on a renewal.
117    /// First refusal wins: the gauge answers "since when", so a second
118    /// consecutive failure must not keep resetting the clock to now.
119    pub fn note_auth_pause(&self) -> LeanResult<()> {
120        let mut g = self.load_gauges()?;
121        if g.auth_paused_since_unix.is_none() {
122            g.auth_paused_since_unix = Some(now_unix());
123            self.save_gauges(&g)?;
124        }
125        Ok(())
126    }
127
128    /// Clear the pause. Writes only on an actual transition: this runs
129    /// on every successful renewal and floor tick, and the healthy path
130    /// must not rewrite the file every tick for no change.
131    pub fn clear_auth_pause(&self) -> LeanResult<()> {
132        let mut g = self.load_gauges()?;
133        if g.auth_paused_since_unix.is_some() {
134            g.auth_paused_since_unix = None;
135            self.save_gauges(&g)?;
136        }
137        Ok(())
138    }
139
140    fn save_gauges(&self, g: &Gauges) -> LeanResult<()> {
141        let bytes = serde_json::to_vec_pretty(g)
142            .map_err(|e| super::LeanError::State(format!("gauges: {e}")))?;
143        super::control::write_atomic(&self.gauges_path(), &bytes)
144    }
145
146    /// Recompute and write the gauges. Deliberately synchronous and
147    /// store-free: a scrape, a tick and an exec all cost zero bucket
148    /// requests, and the type system is what says so.
149    pub fn write_gauges(&self, withheld: Option<Withheld>) -> LeanResult<Gauges> {
150        let now = now_unix();
151        let prev = self.load_gauges()?;
152        let budget = self.load_budget().unwrap_or_default();
153        let g = Gauges {
154            rpo_secs: if prev.last_durable_unix == 0 {
155                0
156            } else {
157                now.saturating_sub(prev.last_durable_unix)
158            },
159            withheld_reason: withheld.map(|w| w.as_str().to_string()),
160            sentinel_budget_remaining: budget.remaining(now, self.cfg.sentinel_hourly_budget),
161            last_boundary: prev.last_boundary.clone(),
162            updated_unix: now,
163            last_durable_unix: prev.last_durable_unix,
164            // Carried, not recomputed: `write_gauges` is store-free by
165            // construction, so it cannot observe the credential state
166            // that set this. Dropping it here would erase the pause on
167            // the very next tick — the gauge would exist and always
168            // read `None`.
169            auth_paused_since_unix: prev.auth_paused_since_unix,
170        };
171        self.save_gauges(&g)?;
172        Ok(g)
173    }
174}
175
176/// What `flint-sync status` renders. Read STRICTLY from files: the verb
177/// exists to diagnose a workspace whose syncer is dead or deposed, so
178/// it must neither claim the lease (which would depose the very syncer
179/// under diagnosis) nor take the state-directory occupancy lock (which
180/// a live syncer already holds).
181#[derive(Debug, Clone, Serialize)]
182pub struct StatusReport {
183    pub root: String,
184    pub prefix: String,
185    pub gauges: Option<Gauges>,
186    pub capabilities: Option<super::control::Capabilities>,
187    pub remote_seq: Option<super::control::RemoteSeq>,
188    pub baseline_seq: u64,
189    pub incarnation_epoch: Option<u64>,
190    pub incarnation_holder: Option<String>,
191    /// A pending sentinel still standing (verb name) — the "my agent is
192    /// blocked on an ack" question.
193    pub pending_sentinels: Vec<String>,
194    pub recent_conflicts: usize,
195    pub checkout_complete: bool,
196}
197
198fn read_json<T: serde::de::DeserializeOwned>(p: &std::path::Path) -> Option<T> {
199    serde_json::from_slice(&std::fs::read(p).ok()?).ok()
200}
201
202pub fn status_report(cfg: &super::LeanConfig) -> LeanResult<StatusReport> {
203    let sd = cfg.state_dir();
204    let cd = cfg.control_dir();
205    let baseline: super::state::Baseline = read_json(&sd.join("baseline.json")).unwrap_or_default();
206    let inc: Option<super::state::Incarnation> = read_json(&sd.join("incarnation.json"));
207    let mut pending_sentinels = vec![];
208    // Named by the SAME function that writes them. The previous form
209    // built the name a second time and got it wrong, so this field —
210    // the "is my agent blocked on an ack?" answer — was permanently
211    // empty on a workspace that had a sentinel standing.
212    for verb in [super::sentinel::Verb::Publish, super::sentinel::Verb::Sync] {
213        if sd.join(verb.pending_name()).exists() {
214            pending_sentinels.push(verb.sentinel_name().to_string());
215        }
216    }
217    let conflicts = std::fs::read_to_string(sd.join("conflicts.jsonl"))
218        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
219        .unwrap_or(0);
220    Ok(StatusReport {
221        root: cfg.root.display().to_string(),
222        prefix: cfg.prefix.clone(),
223        gauges: read_json(&sd.join(GAUGES)),
224        capabilities: read_json(&cd.join(super::control::CAPABILITIES)),
225        remote_seq: read_json(&cd.join(super::control::REMOTE_SEQ)),
226        baseline_seq: baseline.seq,
227        incarnation_epoch: inc.as_ref().map(|i| i.epoch),
228        incarnation_holder: inc.map(|i| i.holder_id),
229        pending_sentinels,
230        recent_conflicts: conflicts,
231        checkout_complete: sd.join("checkout-complete").exists(),
232    })
233}