Skip to main content

planarian_daemon/
render.rs

1//! Plain-language rendering of transitions, records and results.
2
3use planarian_exec::Submitted;
4use planarian_ipc::Response;
5use planarian_schema::*;
6use planarian_txn::ledger::Record;
7use planarian_txn::machine::tag;
8
9pub fn value(v: &Value) -> String {
10    match v {
11        Value::Bool(b) => b.to_string(),
12        Value::Int(i) => i.to_string(),
13        Value::Str(s) => s.clone(),
14        Value::List(l) => format!("[{}]", l.iter().map(value).collect::<Vec<_>>().join(", ")),
15    }
16}
17
18pub fn until(u: &Until) -> String {
19    match u {
20        Until::Explicit => "until you lift it".into(),
21        Until::NextBoot => "until the next boot".into(),
22        Until::At(t) => format!("until {}", t.0),
23    }
24}
25
26pub fn transition(t: &Transition) -> String {
27    match t {
28        Transition::RestartUnit(u) => format!("restart {u}"),
29        Transition::StopUnit(u) => format!("stop {u}"),
30        Transition::BindDriver { device, driver } => format!("bind {} to {device}", driver.name),
31        Transition::ApplyChange { change, args } => {
32            let a: Vec<String> = args.iter().map(|(k, v)| format!("{k}={}", value(v))).collect();
33            format!("apply {} {}", change.0, a.join(" ")).trim_end().to_string()
34        }
35        Transition::SwitchGeneration(g) => format!("switch to generation {}", g.0),
36        Transition::Revert { to } => format!("revert to generation {}", to.0),
37        Transition::Rollback => "roll back to the known-good generation".into(),
38        Transition::LoadModel(m) => format!("load model {}", m.0),
39        Transition::RestoreSnapshot(s) => format!("restore snapshot {}", s.0),
40        Transition::CollectGarbage { emergency: true } => "emergency garbage collection".into(),
41        Transition::CollectGarbage { .. } => "garbage collection".into(),
42        Transition::SetRuntimeOption { path, value: v } => format!("set {path} to {}", value(v)),
43        Transition::SetOverride(s) => match s.desired {
44            OverrideDesired::LeaveAlone => format!("leave {} alone {}", s.unit, until(&s.until)),
45            OverrideDesired::Stopped => format!("stop {} and keep it stopped {}", s.unit, until(&s.until)),
46            OverrideDesired::Absent => format!("disable {} and keep it off {}", s.unit, until(&s.until)),
47            OverrideDesired::Detected => format!("detach {} and keep it detached {}", s.unit, until(&s.until)),
48        },
49        Transition::LiftOverride(u) => format!("stop leaving {u} alone"),
50        Transition::StopAndSuppress { unit, until: u, .. } => format!("stop {unit} and leave it stopped {}", until(u)),
51        Transition::Resolve { txn, resolution } => format!("resolve transaction {} as {resolution:?}", txn.0),
52    }
53}
54
55pub fn record(r: &Record) -> String {
56    match r {
57        Record::Prepared(t) => {
58            format!("txn {}: prepared: {} (by {:?})", t.id.0, transition(&t.transition), t.facts.caller)
59        }
60        Record::Advance { txn, to, note, .. } if note.is_empty() => format!("txn {}: {}", txn.0, tag(to)),
61        Record::Advance { txn, to, note, .. } => format!("txn {}: {} — {note}", txn.0, tag(to)),
62        Record::Takeover { txn, lease, .. } => {
63            format!("txn {}: taken over by {:?} (epoch {})", txn.0, lease.owner, lease.epoch)
64        }
65        Record::Renew { txn, .. } => format!("txn {}: lease renewed", txn.0),
66        Record::OpBegin { op, call } => format!("txn {}: {call}", op.txn.0),
67        Record::OpAck { op, outcome } => format!("txn {}: → {outcome:?}", op.txn.0),
68        Record::OverrideSet(s) => format!("{} left alone {} (by {:?})", s.unit, until(&s.until), s.by),
69        Record::OverrideLifted(u) => format!("{u} no longer left alone"),
70        Record::Incident(i) => format!(
71            "incident {} on {}: {:?}, {} attempt(s), budget {}",
72            i.id.0,
73            i.target,
74            i.state,
75            i.attempts.len(),
76            i.budget_left
77        ),
78        Record::KnownGood(g) => format!("generation {} confirmed known-good", g.0),
79        Record::LiveTested(Some(g)) => format!("generation {} passed its live tests", g.0),
80        Record::LiveTested(None) => "live-tested generation cleared".into(),
81        Record::AcceptedBaseline(g) => format!("accepted baseline: {g:?}"),
82        Record::SecurityFloor(g) => format!("security floor: {g:?}"),
83        Record::ExternalAccepted { seq, .. } => format!("external desired state {seq} accepted"),
84        Record::TokenUsed { approver, txn, .. } => match (*approver, *txn) {
85            (0, _) => "approval token used".into(),
86            (a, t) if a < 1 << 32 => format!("transaction {t} approved by uid {a}"),
87            (a, t) => format!("transaction {t} approved by principal {a}"),
88        },
89        Record::Resolved { txn, resolution, by } => format!("txn {} resolved as {resolution:?} by {by:?}", txn.0),
90        Record::Reconciliation(r) => format!("reconciliation by {:?}: {}", r.by, r.actions.join("; ")),
91        Record::AgentStarted { name, by, .. } => format!("agent {name} started by uid {by}, acting for it"),
92        Record::HoldApplied { unit, by } => format!("traffic held for {unit} ({by:?})"),
93        Record::HoldLifted { unit, reason } => format!("traffic hold on {unit} lifted: {reason}"),
94    }
95}
96
97pub fn evidence(s: &SystemState, u: &UnitId) -> String {
98    let Some((o, ev)) = s.units.get(u) else { return String::new() };
99    let mut parts = vec![format!("{o:?}")];
100    for e in ev {
101        let r = match &e.result {
102            ProbeResult::Pass => "pass".to_string(),
103            ProbeResult::Fail(m) => format!("FAIL ({m})"),
104            ProbeResult::Timeout => "timeout".to_string(),
105            ProbeResult::Abandoned => "abandoned at the batch deadline".to_string(),
106            ProbeResult::Error(m) => format!("error ({m})"),
107        };
108        let stale = if e.fresh_at(s.observed_at) { "" } else { ", stale" };
109        parts.push(format!("{}: {r}{stale}", e.probe.0));
110    }
111    parts.join("; ")
112}
113
114pub fn submitted_short(s: &Submitted) -> String {
115    match s {
116        Submitted::Rejected { reason, detail, .. } => format!("{reason:?}: {detail}"),
117        Submitted::Parked { approve, .. } => format!("needs approval {approve}"),
118        Submitted::Finished { state, .. } => tag(state).to_string(),
119        Submitted::Error(e) => e.clone(),
120    }
121}
122
123pub fn facts_summary(f: &DerivedFacts) -> String {
124    let mut why = vec![];
125    if f.sensitivity.contains(Sensitivity::Security) {
126        why.push("it touches security-relevant settings".to_string());
127    }
128    if f.sensitivity.contains(Sensitivity::External) {
129        why.push("it affects external connectivity".to_string());
130    }
131    if f.sensitivity.contains(Sensitivity::UserData) {
132        why.push("it touches user data".to_string());
133    }
134    // Every kind of disruption the change carries (C10).
135    if f.disruption.session_loss {
136        why.push("it would end your graphical session".into());
137    }
138    if f.disruption.data_interruption {
139        why.push("it would interrupt a data service".into());
140    }
141    if f.disruption.connectivity_loss {
142        why.push("it would drop network connectivity".into());
143    }
144    if f.recovery == Recovery::NonReversible {
145        why.push("it cannot be undone".into());
146    }
147    if f.touches_manifest {
148        why.push("it changes Planarian's own manifest".into());
149    }
150    if f.touches_canonical_probe {
151        why.push("it changes a canonical health check".into());
152    }
153    if why.is_empty() {
154        why.push("the autonomy setting requires approval".into());
155    }
156    let units: Vec<String> = f.affected_units.iter().map(|u| u.to_string()).collect();
157    format!("{}; affects {}", why.join(", "), if units.is_empty() { "no units".into() } else { units.join(", ") })
158}
159
160pub fn response(s: &Submitted) -> Response {
161    match s {
162        Submitted::Finished { txn, state, .. } => {
163            let message = match state {
164                TxnState::Committed => "done".to_string(),
165                TxnState::Reverted => "the change failed its health checks and was reverted; nothing changed".to_string(),
166                TxnState::RecoveryFailed => "the change failed AND reverting it failed; autonomous repair is stopped. See `planarian status`, then `planarian resolve`".to_string(),
167                TxnState::Rejected(r) => format!("not done: {r:?}"),
168                TxnState::Cancelled(_) => "withdrawn".to_string(),
169                s => tag(s).to_string(),
170            };
171            Response::Done { txn: *txn, state: state.clone(), message }
172        }
173        Submitted::Parked { txn, approve, facts } => Response::NeedsApproval {
174            txn: *txn,
175            approve: *approve,
176            what: format!("transaction {}", txn.0),
177            why: facts_summary(facts),
178        },
179        Submitted::Rejected { reason, detail, .. } => {
180            Response::Rejected { reason: format!("{reason:?}"), message: detail.clone() }
181        }
182        Submitted::Error(e) => Response::Error { message: e.clone() },
183    }
184}
185
186/// A size for people: `1.5 GB`, `300 MB`.
187pub fn bytes(n: u64) -> String {
188    const GB: f64 = 1e9;
189    const MB: f64 = 1e6;
190    let x = n as f64;
191    if x >= GB { format!("{:.1} GB", x / GB) } else { format!("{:.0} MB", x / MB) }
192}
193
194/// A timestamp as UTC, to the second.
195pub fn when(t: planarian_schema::Timestamp) -> String {
196    let secs = t.0 / 1000;
197    let (days, rem) = (secs / 86_400, secs % 86_400);
198    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
199    let z = days as i64 + 719_468;
200    let era = z.div_euclid(146_097);
201    let doe = z - era * 146_097;
202    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
203    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
204    let mp = (5 * doy + 2) / 153;
205    let d = doy - (153 * mp + 2) / 5 + 1;
206    let m = if mp < 10 { mp + 3 } else { mp - 9 };
207    let y = yoe + era * 400 + i64::from(m <= 2);
208    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC", rem / 3600, rem % 3600 / 60, rem % 60)
209}