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