Skip to main content

planarian_gym/
live.rs

1//! The VM harness's in-VM half. `nix/tests/gym-vm.nix` boots a NixOS VM with
2//! the gym fixtures and a Planarian built with `gym-hooks`, then drives one
3//! catalog entry through these commands:
4//!
5//!   planarian-gym vm plan  --id ID       how each step runs: vm, host, armed or unsupported
6//!   planarian-gym vm begin --id ID       wait for a healthy baseline, record it, start sampling
7//!   planarian-gym vm step  --id ID --index N
8//!   planarian-gym vm request --json REQ  (run as the step's actor, via runuser)
9//!   planarian-gym vm sample               the status sampler (a transient unit)
10//!   planarian-gym vm judge --id ID       wait the window, gather evidence, judge
11//!
12//! Host steps (reboot, suspend, hot-unplug, the upstream machine) are done by
13//! the test driver. Steps timed to a journal state are armed: a planariad kill
14//! becomes a crash point (`/run/planarian-gym/crash`) on the request before
15//! it, as in the fake harness; other timed steps run from a watcher.
16
17use crate::check;
18use crate::evidence::{Effect, Evidence};
19use crate::sim::{dur, unit, value};
20use planarian_backend_linux::{Paths, Real};
21use planarian_daemon::LogRecord;
22use planarian_daemon::config::{CONFIG, FileConfig};
23use planarian_exec::Submitted;
24use planarian_ipc::{ConsoleReport, Request, Response, SOCKET};
25use planarian_schema::*;
26use planarian_txn::journal::Journal;
27use planarian_txn::ledger::{Entry, Ledger, Record};
28use planarian_txn::store::FileStore;
29use serde::{Deserialize, Serialize};
30use std::collections::{BTreeMap, BTreeSet};
31use std::io::{BufRead, BufReader, Write};
32use std::os::unix::net::UnixStream;
33use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::time::{Duration, Instant};
36use toml::Value as T;
37
38/// Where a run keeps its state (survives a reboot of the VM).
39pub const RUN: &str = "/var/lib/gym-run";
40const HOOKS_DIR: &str = "/run/planarian-gym";
41
42/// The VM's users, by catalog actor.
43fn user_for(actor: &str) -> &'static str {
44    match actor {
45        "root" => "root",
46        "user:1000" => "alice",
47        "user:1002" => "carol",
48        // user:1001 is the owner (planarian-admin).
49        _ => "bob",
50    }
51}
52
53#[derive(Clone, Debug, Default, Serialize, Deserialize)]
54pub struct Plan {
55    /// Per step: "vm", "host", "armed" (done by a crash point or watcher) or
56    /// "unsupported: …".
57    pub steps: Vec<String>,
58    /// Crash points to arm before the request at a step index.
59    pub crash_before: BTreeMap<usize, String>,
60    /// Steps a watcher performs when the journal reaches a state.
61    pub watched: BTreeMap<usize, String>,
62    pub hold: bool,
63    pub window_secs: u64,
64}
65
66#[derive(Clone, Debug, Default, Serialize, Deserialize)]
67struct Start {
68    at: Timestamp,
69    unix: u64,
70    base: GenerationId,
71    boot_entries: Vec<GenerationId>,
72    log_lines: usize,
73    pre_status: BTreeMap<UnitId, String>,
74    touched: BTreeSet<UnitId>,
75    #[serde(default)]
76    probe_defs: String,
77    #[serde(default)]
78    gc_roots: BTreeSet<String>,
79}
80
81#[derive(Clone, Debug, Default, Serialize, Deserialize)]
82struct Sample {
83    at: Timestamp,
84    status: BTreeMap<UnitId, String>,
85    incidents: Vec<Incident>,
86    notices: Vec<String>,
87}
88
89fn steps(entry: &T) -> Vec<T> {
90    ["setup", "inject", "after"]
91        .iter()
92        .flat_map(|k| match entry.get(k) {
93            Some(T::Array(a)) => a.clone(),
94            Some(t @ T::Table(_)) => vec![t.clone()],
95            _ => vec![],
96        })
97        .collect()
98}
99
100fn s<'a>(step: &'a T, k: &str) -> &'a str {
101    step.get(k).and_then(T::as_str).unwrap_or("")
102}
103
104fn is_request(step: &T) -> bool {
105    step.get("transition").is_some() || matches!(s(step, "raw"), "ask" | "cli" | "approve_pending")
106}
107
108/// How each step runs in a VM.
109pub fn plan(entry: &T) -> Plan {
110    let all = steps(entry);
111    let mut p = Plan { window_secs: crate::window(entry).0 / 1000, ..Default::default() };
112    let mut changed_generation = false;
113    // The gym VM's journal filesystem: ext4, or btrfs for an entry that asks
114    // for it (nix/tests/gym-vm.nix).
115    let layouts: Vec<&str> = entry
116        .get("layouts")
117        .and_then(T::as_array)
118        .map(|a| a.iter().filter_map(T::as_str).collect())
119        .unwrap_or_default();
120    if !layouts.is_empty() && !layouts.iter().any(|l| matches!(*l, "ext4" | "btrfs")) {
121        p.steps.push(format!("unsupported: a journal on {} (the gym VM's is ext4 or btrfs)", layouts.join(" or ")));
122        return p;
123    }
124    for (i, st) in all.iter().enumerate() {
125        if matches!(s(st, "transition"), "ApplyChange" | "SwitchGeneration" | "Rollback") {
126            changed_generation = true;
127        }
128        // The test VM boots its kernel directly, not through its boot menu,
129        // so a reboot always comes back to the system it was started with.
130        if s(st, "raw") == "reboot" && changed_generation {
131            p.steps.push("unsupported: booting a published generation (the test VM has no boot loader)".into());
132            continue;
133        }
134        let when = st.get("when").and_then(|w| w.get("journal")).and_then(T::as_str);
135        let op_called = st.get("when").and_then(|w| w.get("op_called")).and_then(T::as_str);
136        let after = st.get("when").and_then(|w| w.get("after"));
137        let raw = s(st, "raw");
138        let slow_activation = all.iter().any(|x| s(x, "raw") == "slow_backend" && s(x, "call") == "activate_live");
139        let kind = if raw == "fill_fs" && s(st, "fs") == "device:fs-nix" {
140            // The test VM's store is the host's, shared in under a tmpfs
141            // overlay: candidates are prebuilt (a build writes nothing) and
142            // collecting garbage cannot free the overlay.
143            "unsupported: a full Nix store (the test VM's store is the host's)".into()
144        } else if op_called == Some("activate_live") && raw == "kill_process" && slow_activation {
145            // The slow part runs in the activation's own unit: kill the daemon
146            // from outside once that unit is running (an in-daemon crash point
147            // would fire only after the slow call returned).
148            p.watched.insert(i, "unit:planarian-op-".into());
149            "armed".into()
150        } else if let Some(call) = op_called {
151            match (0..i).rev().find(|j| is_request(&all[*j])) {
152                Some(j) if raw == "kill_process" => {
153                    p.crash_before.insert(j, format!("OpCalled:{call}"));
154                    "armed".to_string()
155                }
156                _ => format!("unsupported: {raw} timed to {call}"),
157            }
158        } else if let Some(a) = after {
159            p.watched.insert(i, format!("after:{}", a.as_str().unwrap_or("0s")));
160            "armed".into()
161        } else if let Some(state) = when {
162            match raw {
163                // With a slow activation set up, "during Activating" means
164                // while its unit runs: kill the daemon from outside then.
165                "kill_process" if s(st, "process") == "planariad" && state == "Activating" && slow_activation => {
166                    p.watched.insert(i, "unit:planarian-op-".into());
167                    "armed".into()
168                }
169                "kill_process" if s(st, "process") == "planariad" => {
170                    let point =
171                        if state == "OpId" { "OpCalled:activate_live".to_string() } else { format!("State:{state}") };
172                    match (0..i).rev().find(|j| is_request(&all[*j])) {
173                        Some(j) => {
174                            p.crash_before.insert(j, point);
175                            "armed".to_string()
176                        }
177                        None => "unsupported: a timed kill with no request before it".into(),
178                    }
179                }
180                "edit_owner_source" | "block_network" | "fill_fs" | "kill_unit" | "edit_override_file" => {
181                    p.watched.insert(i, state.to_string());
182                    "armed".into()
183                }
184                other => format!("unsupported: {other} timed to the journal"),
185            }
186        } else if st.get("when").is_some() && raw != "break_journal_chain" {
187            format!("unsupported: {} timed by {:?}", raw, st.get("when"))
188        } else if st.get("transition").is_some() {
189            match s(st, "transition") {
190                "ApplyChange" | "RestartUnit" | "CollectGarbage" | "Rollback" | "SwitchGeneration" | "BindDriver"
191                | "SetOverride" => "vm".into(),
192                t => format!("unsupported: transition {t}"),
193            }
194        } else {
195            match raw {
196                "reboot" | "suspend" | "hotunplug" | "stop_host_service" | "start_host_service" => "host".into(),
197                "cli"
198                    if {
199                        // `internal` commands the harness runs itself (in run_step).
200                        let argv: Vec<&str> = st
201                            .get("argv")
202                            .and_then(T::as_array)
203                            .map(|a| a.iter().filter_map(T::as_str).collect())
204                            .unwrap_or_default();
205                        argv.first() == Some(&"internal")
206                            && !matches!(
207                                argv.get(1),
208                                Some(&"wifi-connect" | &"snapshot" | &"journal-churn" | &"present-token")
209                            )
210                    } =>
211                {
212                    format!("unsupported: {raw} {:?} (no such command)", st.get("argv"))
213                }
214                "kill_unit"
215                | "stop_unit_out_of_band"
216                | "set_knob"
217                | "set_persistent_knob"
218                | "corrupt_state"
219                | "unbind_driver"
220                | "block_network"
221                | "unblock_network"
222                | "kill_process"
223                | "hold_down"
224                | "release"
225                | "restart_process"
226                | "wait"
227                | "ask"
228                | "cli"
229                | "approve_pending"
230                | "greeter_login"
231                | "logout"
232                | "fill_fs"
233                | "edit_owner_source"
234                | "out_of_band_rebuild"
235                | "advance_clock"
236                | "slow_backend"
237                | "withhold_reply"
238                | "backend_result"
239                | "set_setting"
240                | "mask_unit"
241                | "unmask_unit"
242                | "ack_alarm"
243                | "corrupt_core_hash"
244                | "break_journal_chain"
245                | "plant_early_unit"
246                | "agent_run"
247                | "request_then_exit"
248                | "plant_test_build"
249                | "forge_proposal_fields"
250                | "delete_journal_record"
251                | "oom" => "vm".into(),
252                "force_compaction" => "vm".into(),
253                "interrupt_write" => format!("unsupported: {raw} needs to interrupt the daemon's own write"),
254                other => format!("unsupported: {other}"),
255            }
256        };
257        if raw == "hold_down" {
258            p.hold = true;
259        }
260        p.steps.push(kind);
261    }
262    p
263}
264
265fn config() -> FileConfig {
266    let path = std::env::var("PLANARIAN_CONFIG").unwrap_or_else(|_| CONFIG.into());
267    FileConfig::load(Path::new(&path)).unwrap_or_else(|e| die(&format!("{path}: {e}")))
268}
269
270fn real(cfg: &FileConfig) -> Real {
271    let paths = Paths {
272        state: cfg.state_dir.clone(),
273        owner: cfg.owner.clone(),
274        flake_attr: cfg.flake_attr.clone(),
275        ..Default::default()
276    };
277    Real::new(paths, cfg.devices.clone())
278}
279
280fn die(m: &str) -> ! {
281    eprintln!("planarian-gym vm: {m}");
282    std::process::exit(2)
283}
284
285fn sh(cmd: &str) -> bool {
286    Command::new("sh").arg("-c").arg(cmd).status().map(|s| s.success()).unwrap_or(false)
287}
288
289fn sh_out(cmd: &str) -> String {
290    Command::new("sh")
291        .arg("-c")
292        .arg(cmd)
293        .output()
294        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
295        .unwrap_or_default()
296}
297
298pub fn call(req: &Request) -> Result<Response, String> {
299    let path = std::env::var("PLANARIAN_SOCKET").unwrap_or_else(|_| SOCKET.into());
300    let mut c = UnixStream::connect(&path).map_err(|e| format!("{path}: {e}"))?;
301    // A daemon that stops answering is a finding, not a hung judge.
302    c.set_read_timeout(Some(std::time::Duration::from_secs(600))).map_err(|e| e.to_string())?;
303    c.write_all(req.encode().as_bytes()).map_err(|e| e.to_string())?;
304    let mut line = String::new();
305    BufReader::new(c).read_line(&mut line).map_err(|e| e.to_string())?;
306    serde_json::from_str(&line).map_err(|e| format!("bad answer: {e}"))
307}
308
309fn console() -> Option<ConsoleReport> {
310    match call(&Request::Console) {
311        Ok(Response::Console(r)) => Some(*r),
312        _ => None,
313    }
314}
315
316fn status_name(st: &UnitStatus) -> String {
317    format!("{st:?}").split('(').next().unwrap_or("").to_string()
318}
319
320fn statuses(r: &ConsoleReport) -> BTreeMap<UnitId, String> {
321    r.nodes.iter().map(|n| (n.unit.clone(), status_name(&n.health))).collect()
322}
323
324fn run_dir() -> PathBuf {
325    let d = PathBuf::from(RUN);
326    let _ = std::fs::create_dir_all(&d);
327    let _ = sh(&format!("chmod 1777 {RUN}"));
328    d
329}
330
331fn read_json<V: for<'de> Deserialize<'de>>(p: &Path) -> Option<V> {
332    serde_json::from_slice(&std::fs::read(p).ok()?).ok()
333}
334
335fn write_json<V: Serialize>(p: &Path, v: &V) {
336    let _ = std::fs::write(p, serde_json::to_vec_pretty(v).expect("serializes"));
337}
338
339/// The journal as the daemon left it: a copy, replayed.
340fn ledger(cfg: &FileConfig) -> (Ledger, bool) {
341    match journal_copy(cfg) {
342        Some(j) => (j.ledger().clone(), true),
343        None => (Ledger::default(), false),
344    }
345}
346
347/// The entries since the journal's last checkpoint.
348fn segment(cfg: &FileConfig) -> Vec<Entry> {
349    journal_copy(cfg).and_then(|mut j| j.segment().ok()).unwrap_or_default()
350}
351
352/// Journal entries saved before a forced compaction discarded them.
353const SAVED: &str = "entries-saved.json";
354
355/// Force a compaction and save what it discarded, from the daemon's own
356/// answer (`Response::Compacted`: the segment, read in the same core call as
357/// the compaction). Reading the segment here first left a gap the daemon's
358/// tick could journal into, so the saved history fell short of what the
359/// checkpoint holds (gate 41604917858, `replay_equivalent`).
360fn compact_and_save() -> Result<(), String> {
361    let resp = call(&Request::GymCompact)?;
362    if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(PathBuf::from(RUN).join("responses.jsonl")) {
363        let _ = writeln!(f, "{}", serde_json::to_string(&resp).expect("serializes"));
364    }
365    let entries = match resp {
366        Response::Compacted { entries } => entries,
367        Response::Error { message } => return Err(format!("GymCompact: {message}")),
368        _ => return Err("GymCompact: an unexpected answer".into()),
369    };
370    let path = PathBuf::from(RUN).join(SAVED);
371    let mut saved: Vec<Entry> = read_json(&path).unwrap_or_default();
372    for v in entries {
373        let e: Entry = serde_json::from_value(v).map_err(|e| format!("GymCompact: an entry: {e}"))?;
374        if saved.last().is_none_or(|l| l.seq < e.seq) {
375            saved.push(e);
376        }
377    }
378    write_json(&path, &saved);
379    Ok(())
380}
381
382fn journal_copy(cfg: &FileConfig) -> Option<Journal<FileStore>> {
383    let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
384    let tmp = PathBuf::from(format!("{RUN}/journal-copy"));
385    let _ = std::fs::remove_dir_all(&tmp);
386    let _ = sh(&format!(
387        "mkdir -p {t} && cp -a {j} {t}/journal && cp -a {x} {t}/txn 2>/dev/null; true",
388        t = tmp.display(),
389        j = paths.journal_dir().display(),
390        x = paths.txn_dir().display()
391    ));
392    match FileStore::open(&tmp.join("journal"), &tmp.join("txn"), cfg.journal_bytes).map(Journal::open) {
393        Ok(Ok(j)) => Some(j),
394        _ => None,
395    }
396}
397
398/// The probe definitions of the running generation, per unit.
399fn probe_defs(cfg: &FileConfig) -> String {
400    let b = real(cfg);
401    let g = Backends::generations(&b);
402    let eval = g.current().and_then(|c| g.path_of(c)).and_then(|p| g.evaluate_built(&p));
403    match eval {
404        Ok(e) => {
405            let probes: BTreeMap<String, Vec<ProbeDef>> =
406                e.units.iter().map(|(id, d)| (id.to_string(), d.probes.clone())).collect();
407            serde_json::to_string(&probes).expect("serializes")
408        }
409        Err(o) => format!("unreadable: {o:?}"),
410    }
411}
412
413/// The TCP ports a unit's network probes connect to.
414fn probe_ports(cfg: &FileConfig, u: &UnitId) -> Vec<u16> {
415    let b = real(cfg);
416    let g = Backends::generations(&b);
417    let Ok(e) = g.current().and_then(|c| g.path_of(c)).and_then(|p| g.evaluate_built(&p)) else { return vec![] };
418    let port = |hostport: &str| hostport.rsplit(':').next().and_then(|p| p.parse::<u16>().ok());
419    e.units
420        .get(u)
421        .map(|d| {
422            d.probes
423                .iter()
424                .filter_map(|p| match &p.kind {
425                    ProbeKind::Tcp { addr } => port(addr),
426                    ProbeKind::Http { url, .. } => {
427                        url.split("://").nth(1).and_then(|r| r.split('/').next()).and_then(port)
428                    }
429                    _ => None,
430                })
431                .collect()
432        })
433        .unwrap_or_default()
434}
435
436fn build_roots() -> BTreeSet<String> {
437    std::fs::read_dir("/nix/var/nix/gcroots/planarian/builds")
438        .map(|rd| rd.flatten().map(|e| e.path().display().to_string()).collect())
439        .unwrap_or_default()
440}
441
442/// Backend mutations, from the journal's calls: an activation runs as the
443/// transient unit `planarian-op-<txn>-<epoch>-<seq>` and lasts as long as
444/// systemd says it ran; any other call is an instant at its OpBegin. Calls
445/// the backend fenced, and calls never acknowledged that left no unit, are
446/// not effects.
447fn effects(entries: &[Entry], since_unix: u64) -> Vec<Effect> {
448    #[derive(Deserialize)]
449    struct J {
450        #[serde(rename = "UNIT")]
451        unit: Option<String>,
452        #[serde(rename = "MESSAGE")]
453        message: Option<String>,
454        #[serde(rename = "__REALTIME_TIMESTAMP")]
455        at: String,
456    }
457    let mut spans: BTreeMap<String, (Option<Timestamp>, Option<Timestamp>)> = BTreeMap::new();
458    for l in sh_out(&format!("journalctl -o json --no-pager --since @{since_unix} _PID=1")).lines() {
459        let Ok(j) = serde_json::from_str::<J>(l) else { continue };
460        let (Some(u), Some(m)) = (j.unit, j.message) else { continue };
461        let Some(name) = u.strip_suffix(".service").filter(|n| n.starts_with("planarian-op-")) else { continue };
462        let at = Timestamp(j.at.parse::<u64>().unwrap_or(0) / 1000);
463        let span = spans.entry(name.to_string()).or_default();
464        if m.starts_with("Starting") || m.starts_with("Started") {
465            span.0.get_or_insert(at);
466        } else if m.starts_with("Finished")
467            || m.starts_with("Stopped")
468            || m.starts_with("Failed")
469            || m.contains("Deactivated")
470        {
471            span.1 = Some(at);
472        }
473    }
474    let revert_name = |call: &str| -> String {
475        match call {
476            "activate_live" => "activate_live(candidate)".into(),
477            other => other.to_string(),
478        }
479    };
480    let acks: BTreeMap<OpId, Outcome> = entries
481        .iter()
482        .filter_map(|x| match &x.record {
483            Record::OpAck { op, outcome } => Some((*op, outcome.clone())),
484            _ => None,
485        })
486        .collect();
487    entries
488        .iter()
489        .filter_map(|x| {
490            let Record::OpBegin { op, call } = &x.record else { return None };
491            if acks.get(op).is_some_and(Outcome::is_fenced) {
492                return None;
493            }
494            let unit = format!("planarian-op-{}-{}-{}", op.txn.0, op.epoch, op.seq);
495            let (start, end) = match spans.get(&unit) {
496                Some((s, e)) => (s.unwrap_or(x.at), *e),
497                None if acks.contains_key(op) => (x.at, Some(x.at)),
498                None => return None,
499            };
500            Some(Effect { call: revert_name(call), txn: op.txn, epoch: op.epoch, seq: op.seq, start, end })
501        })
502        .collect()
503}
504
505fn journal_reached(cfg: &FileConfig, state: &str) -> bool {
506    let (l, _) = ledger(cfg);
507    l.live.values().any(|t| t.path.iter().any(|p| p == state))
508        || l.terminal.values().any(|t| t.path.iter().any(|p| p == state))
509}
510
511/// Wait for a healthy baseline: the daemon answers, every expected unit is
512/// up with no incident left over from boot, and this generation is the
513/// known-good one.
514fn begin(entry: &T) {
515    let cfg = config();
516    let dir = run_dir();
517    let p = plan(entry);
518    write_json(&dir.join("plan.json"), &p);
519    let _ = std::fs::remove_file(dir.join("third-party-override"));
520    let deadline = Instant::now() + Duration::from_secs(1200);
521    loop {
522        if let Some(r) = console() {
523            let unhealthy: Vec<String> = r
524                .nodes
525                .iter()
526                .filter(|n| n.expected && !matches!(n.health, UnitStatus::Running | UnitStatus::Bound))
527                .map(|n| format!("{} {:?}", n.unit, n.health))
528                .chain(
529                    r.status
530                        .incidents
531                        .iter()
532                        .filter(|i| i.state != IncidentState::Closed)
533                        .map(|i| format!("incident on {} ({:?})", i.target, i.state)),
534                )
535                .collect();
536            if unhealthy.is_empty() {
537                break;
538            }
539            if Instant::now() > deadline {
540                die(&format!("no healthy baseline: {unhealthy:?}"));
541            }
542        } else if Instant::now() > deadline {
543            die("planariad does not answer");
544        }
545        std::thread::sleep(Duration::from_secs(5));
546    }
547    if ledger(&cfg).0.known_good.is_none() {
548        sh("script -qec 'planarian mark-good' /dev/null");
549        let until = Instant::now() + Duration::from_secs(300);
550        while ledger(&cfg).0.known_good.is_none() && Instant::now() < until {
551            std::thread::sleep(Duration::from_secs(3));
552        }
553    }
554    if p.hold {
555        // A held-down daemon must stay down after a kill: no restart.
556        sh(
557            "mkdir -p /run/systemd/system/planariad.service.d && printf '[Service]\\nRestart=no\\n' > /run/systemd/system/planariad.service.d/gym-hold.conf && systemctl daemon-reload",
558        );
559    }
560    let r = console().unwrap_or_else(|| die("planariad stopped answering"));
561    let gens = real(&cfg);
562    let g = planarian_schema::Backends::generations(&gens);
563    let touched = steps(entry)
564        .iter()
565        .flat_map(|st| ["unit", "device"].map(|k| st.get(k).and_then(T::as_str).map(unit)))
566        .flatten()
567        .collect();
568    let start = Start {
569        at: r.now,
570        unix: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
571        base: g.current().unwrap_or(r.status.generation),
572        boot_entries: g.boot_entries().unwrap_or_default(),
573        log_lines: std::fs::read_to_string(cfg.state_dir.join("log/executor.jsonl"))
574            .map(|t| t.lines().count())
575            .unwrap_or(0),
576        pre_status: statuses(&r),
577        touched,
578        probe_defs: probe_defs(&cfg),
579        gc_roots: build_roots(),
580    };
581    write_json(&dir.join("start.json"), &start);
582    let _ = std::fs::write(dir.join("responses.jsonl"), "");
583    let _ = sh(&format!("chmod 666 {RUN}/responses.jsonl"));
584    let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
585    sh(&format!(
586        "systemctl stop gym-sampler 2>/dev/null; systemd-run --unit=gym-sampler -E PATH -E PLANARIAN_GYM_CATALOG {exe} vm sample"
587    ));
588    for state in p.watched.values() {
589        if let Some(call) = pause_call(state) {
590            hooks_set(call, serde_json::json!({ "pause": state }));
591        }
592    }
593    for (i, state) in &p.watched {
594        sh(&format!(
595            "systemd-run --unit=gym-watch-{i} -E PATH -E PLANARIAN_GYM_CATALOG {exe} vm step --id {} --index {i} --watched {state}",
596            s(entry, "id")
597        ));
598    }
599    println!("begin: base generation {}, {} units", start.base.0, start.pre_status.len());
600}
601
602/// The status sampler: every 2 s, what the daemon shows.
603fn sample() {
604    let path = PathBuf::from(RUN).join("samples.jsonl");
605    loop {
606        if let Some(r) = console()
607            && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path)
608        {
609            let smp = Sample {
610                at: r.now,
611                status: statuses(&r),
612                incidents: r.status.incidents.clone(),
613                notices: r.status.notices.clone(),
614            };
615            let _ = writeln!(f, "{}", serde_json::to_string(&smp).expect("serializes"));
616        }
617        // The posture runs while planariad is down, so it is sampled apart.
618        record_posture();
619        std::thread::sleep(Duration::from_secs(2));
620    }
621}
622
623/// One posture sample, appended to the run's record when a class is raised.
624fn record_posture() {
625    let (classes, responses) = posture_snapshot();
626    if !classes.is_empty()
627        && let Ok(mut f) =
628            std::fs::OpenOptions::new().create(true).append(true).open(PathBuf::from(RUN).join("posture.jsonl"))
629    {
630        let _ = writeln!(f, "{}", serde_json::to_string(&(classes, responses)).expect("serializes"));
631    }
632}
633
634/// The failure classes in force (nix/posture.nix's /run/planarian/failed-*)
635/// and the responses observable right now (§12d).
636fn posture_snapshot() -> (BTreeSet<String>, BTreeSet<String>) {
637    let classes: BTreeSet<String> = std::fs::read_dir("/run/planarian")
638        .into_iter()
639        .flatten()
640        .flatten()
641        .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_prefix("failed-")).map(String::from))
642        .collect();
643    let mut r = BTreeSet::new();
644    if classes.is_empty() {
645        return (classes, r);
646    }
647    if Path::new("/run/planarian/alarm").exists() && Path::new("/run/issue.d/50-planarian.issue").exists() {
648        r.insert("alarm".to_string());
649    }
650    if sh("journalctl -q -t planarian-posture -p alert --no-pager | grep -q 'failure class'") {
651        r.insert("notify_admins".into());
652    }
653    // The declared agent, and any the launcher created (in their slice, R05).
654    let launched = "systemctl list-units --no-legend --state=active,activating 'planarian-agent-*.service' | grep -q .";
655    if sh("systemctl is-active --quiet gym-agent.service") || sh(launched) {
656        r.insert("agents_running".into());
657    } else {
658        r.insert("disable_agents".into());
659    }
660    if Path::new("/run/planarian/sshd/posture.conf").exists() {
661        r.insert("restrict_remote".into());
662    }
663    if Path::new("/run/nologin").exists() || Path::new("/etc/nologin").exists() {
664        r.insert("restrict_login".into());
665    }
666    if sh("systemctl is-active --quiet rescue.target || systemctl is-active --quiet emergency.target") {
667        r.insert("halt_to_recovery".into());
668    }
669    (classes, r)
670}
671
672/// The posture's record of the run: the samples, and the posture units' and
673/// planariad's journal since the start.
674fn posture_seen(cfg: &FileConfig, since_unix: u64) -> crate::evidence::PostureSeen {
675    let mut p = crate::evidence::PostureSeen::default();
676    let mut responses = BTreeSet::new();
677    for line in std::fs::read_to_string(PathBuf::from(RUN).join("posture.jsonl")).unwrap_or_default().lines() {
678        if let Ok((c, r)) = serde_json::from_str::<(BTreeSet<String>, BTreeSet<String>)>(line) {
679            p.classes.extend(c);
680            responses.extend(r);
681        }
682    }
683    p.responses = Some(responses);
684    let actor = |name: &str| match name {
685        "root" => "root".to_string(),
686        "alice" => "user:1000".into(),
687        "carol" => "user:1002".into(),
688        _ => "user:1001".into(),
689    };
690    for l in sh_out(&format!("journalctl -q -o cat --no-pager --since @{since_unix} -t planarian-posture")).lines() {
691        if let Some(c) = l.strip_prefix("failure class ").and_then(|r| r.split(':').next()) {
692            p.classes.insert(c.to_string());
693        } else if let Some(who) = l.split("acknowledged by ").nth(1) {
694            p.acks.push(actor(who.trim()));
695        } else if let Some(who) = l.strip_prefix("acknowledgement refused for ").and_then(|r| r.split(' ').next()) {
696            p.refused_acks.push(actor(who));
697        } else if l.contains("restrictions lifted") {
698            p.lifted = true;
699        }
700    }
701    for line in std::fs::read_to_string(PathBuf::from(RUN).join("acks.jsonl")).unwrap_or_default().lines() {
702        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
703            && let Some(a) = v["actor"].as_str()
704        {
705            p.refused_acks.push(a.to_string());
706        }
707    }
708    p.finding = std::fs::read_to_string("/run/planarian/integrity").ok().map(|s| s.trim().to_string());
709    let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
710    p.chain_broken = matches!(
711        FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes).map(Journal::open),
712        Ok(Err(planarian_txn::journal::JournalError::Integrity(_)))
713    );
714    p
715}
716
717/// Send a request as `actor` from a process that has a controlling terminal
718/// (`script` allocates one) or that has none (`setsid`, a new session): the
719/// daemon reads which from the process (§11, B-3).
720fn request_at(actor: &str, req: &Request, tty: bool) -> Result<(), String> {
721    let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
722    let json = serde_json::to_string(req).expect("serializes");
723    let script = PathBuf::from(RUN).join("request.sh");
724    let user = user_for(actor);
725    let q = json.replace('\'', "'\\''");
726    let inner = if user == "root" {
727        format!("{exe} vm request --json '{q}'")
728    } else {
729        format!("runuser -u {user} -- {exe} vm request --json '{q}'")
730    };
731    std::fs::write(&script, format!("#!/bin/sh\nexec {inner}\n")).map_err(|e| e.to_string())?;
732    sh(&format!("chmod +x {}", script.display()));
733    let cmd = if tty {
734        // A new session whose controlling terminal is a fresh pty, whatever
735        // the step's own stdin is (the daemon reads the terminal from the
736        // peer's /proc, §11, B-3); the request's answer is copied out.
737        let helper = PathBuf::from(RUN).join("on-a-terminal.py");
738        std::fs::write(&helper, ON_A_TERMINAL).map_err(|e| e.to_string())?;
739        format!("python3 {} {} < /dev/null", helper.display(), script.display())
740    } else {
741        format!("setsid -w {} < /dev/null", script.display())
742    };
743    if sh(&cmd) { Ok(()) } else { Err(format!("request as {user} failed")) }
744}
745
746/// Run argv[1] as the leader of a new session on a new pty, print what it
747/// writes, and exit with its status.
748const ON_A_TERMINAL: &str = r#"import os, pty, sys
749pid, fd = pty.fork()
750if pid == 0:
751    os.execv(sys.argv[1], [sys.argv[1]])
752out = b""
753while True:
754    try:
755        b = os.read(fd, 4096)
756    except OSError:
757        break
758    if not b:
759        break
760    out += b
761sys.stdout.write(out.decode(errors="replace"))
762_, status = os.waitpid(pid, 0)
763sys.exit(os.waitstatus_to_exitcode(status))
764"#;
765
766/// Send a request as the step's actor and record the answer.
767fn request_as(actor: &str, req: &Request) -> Result<(), String> {
768    let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
769    let json = serde_json::to_string(req).expect("serializes");
770    let user = user_for(actor);
771    let q = json.replace('\'', "'\\''");
772    // An agent: a process in its unit's cgroup, under its dynamic uid, which
773    // is what makes a real agent process one (§11).
774    if let Some(name) = actor.strip_prefix("agent:") {
775        let unit = planarian_schema::backend::agent_unit(name);
776        let cmd = format!(
777            "uid=$(systemctl show -p UID --value {unit}); gid=$(systemctl show -p GID --value {unit}); \
778             [ -n \"$uid\" ] && [ \"$uid\" != \"[not set]\" ] || exit 1; \
779             cg=$(systemctl show -p ControlGroup --value {unit}); [ -n \"$cg\" ] || exit 1; \
780             sh -c 'echo $$ > /sys/fs/cgroup'\"$cg\"'/cgroup.procs && \
781             exec setpriv --reuid=$0 --regid=$1 --clear-groups {exe} vm request --json \"$2\"' \"$uid\" \"$gid\" '{q}'"
782        );
783        return if sh(&cmd) { Ok(()) } else { Err(format!("request as {actor} failed")) };
784    }
785    let cmd = if user == "root" {
786        format!("{exe} vm request --json '{q}'")
787    } else {
788        format!("runuser -u {user} -- {exe} vm request --json '{q}'")
789    };
790    if sh(&cmd) { Ok(()) } else { Err(format!("request as {user} failed")) }
791}
792
793fn request(json: &str) {
794    let req: Request = serde_json::from_str(json).unwrap_or_else(|e| die(&format!("request: {e}")));
795    let resp = call(&req).unwrap_or_else(|e| Response::Error { message: e });
796    if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(PathBuf::from(RUN).join("responses.jsonl")) {
797        let _ = writeln!(f, "{}", serde_json::to_string(&resp).expect("serializes"));
798    }
799    println!("{}", serde_json::to_string(&resp).expect("serializes"));
800}
801
802fn unit_cmd(u: &UnitId) -> (String, String) {
803    let suffix = match u.kind {
804        UnitKind::Target => "target",
805        _ => "service",
806    };
807    match u.scope {
808        Scope::User { uid, .. } => (
809            format!("systemctl --user --machine={}@", user_for(&format!("user:{uid}"))),
810            format!("{}.{suffix}", u.name),
811        ),
812        Scope::System => ("systemctl".into(), format!("{}.{suffix}", u.name)),
813    }
814}
815
816/// Run one step inside the VM.
817/// The backend call the daemon pauses before when a step is timed to this
818/// journal state (gym hooks `pause`). Staging and the build happen before
819/// the transaction is prepared, so `Staged` is never journaled: the build
820/// is where the source is staged. Publishing follows `Checking` at once.
821fn pause_call(state: &str) -> Option<&'static str> {
822    match state {
823        "Staged" | "Built" => Some("build"),
824        "Checking" => Some("publish_boot"),
825        // Promotion's compare-and-swap (§9 step 4): the daemon waits before
826        // taking the lock on planarian/.
827        "Published" => Some("swap_override"),
828        _ => None,
829    }
830}
831
832fn step(entry: &T, i: usize, watched: Option<&str>) {
833    let cfg = config();
834    let all = steps(entry);
835    let st = all.get(i).unwrap_or_else(|| die("no such step"));
836    let p: Plan = read_json(&PathBuf::from(RUN).join("plan.json")).unwrap_or_else(|| plan(entry));
837    if let Some(after) = watched.and_then(|w| w.strip_prefix("after:")) {
838        let d = dur(&T::String(after.into())).unwrap_or(Millis(0));
839        std::thread::sleep(Duration::from_millis(d.0));
840    } else if let Some(prefix) = watched.and_then(|w| w.strip_prefix("unit:")) {
841        let until = Instant::now() + Duration::from_secs(3600);
842        while sh_out(&format!("systemctl list-units --state=activating,active --no-legend --plain '{prefix}*'"))
843            .trim()
844            .is_empty()
845            && Instant::now() < until
846        {
847            std::thread::sleep(Duration::from_millis(200));
848        }
849    } else if let Some(state) = watched.filter(|w| pause_call(w).is_some()) {
850        // The daemon waits at the marker; inject, then let it go on.
851        let marker = PathBuf::from(HOOKS_DIR).join(format!("paused-{state}"));
852        let until = Instant::now() + Duration::from_secs(3600);
853        while !marker.exists() && Instant::now() < until {
854            std::thread::sleep(Duration::from_millis(100));
855        }
856        let actor = st.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
857        let r = run_step(&cfg, st, &actor);
858        eprintln!("gym: step {i} at the {state} pause: {r:?}");
859        let _ = std::fs::remove_file(&marker);
860        if let Err(e) = r {
861            die(&format!("step {i}: {e}"));
862        }
863        return;
864    } else if let Some(state) = watched {
865        let until = Instant::now() + Duration::from_secs(3600);
866        while !journal_reached(&cfg, state) && Instant::now() < until {
867            std::thread::sleep(Duration::from_millis(100));
868        }
869    } else if p.steps.get(i).map(String::as_str) == Some("armed") {
870        return;
871    }
872    if let Some(point) = p.crash_before.get(&i) {
873        sh(&format!("mkdir -p {HOOKS_DIR} && printf '%s' '{point}' > {HOOKS_DIR}/crash"));
874    }
875    let actor = st.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
876    let r = run_step(&cfg, st, &actor);
877    if let Err(e) = r {
878        die(&format!("step {i}: {e}"));
879    }
880}
881
882fn hooks_set(key: &str, value: serde_json::Value) {
883    let path = format!("{HOOKS_DIR}/hooks.json");
884    let mut v: serde_json::Value =
885        std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(serde_json::json!({}));
886    v[key] = value;
887    let _ = std::fs::create_dir_all(HOOKS_DIR);
888    let _ = std::fs::write(&path, serde_json::to_vec(&v).expect("serializes"));
889}
890
891fn run_step(cfg: &FileConfig, st: &T, actor: &str) -> Result<(), String> {
892    let get = |k: &str| s(st, k).to_string();
893    if let Some(t) = st.get("transition").and_then(T::as_str) {
894        let req = match t {
895            "ApplyChange" => Request::Apply {
896                change: ChangeId(get("change")),
897                args: st
898                    .get("args")
899                    .and_then(T::as_table)
900                    .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
901                    .unwrap_or_default(),
902            },
903            "RestartUnit" if st.get("expected").is_some() || st.get("observed").is_some() => Request::GymPropose {
904                transition: Transition::RestartUnit(unit(&get("unit"))),
905                stale_digest: st.get("observed").is_some(),
906                may_change: st
907                    .get("expected")
908                    .and_then(|e| e.get("may_change"))
909                    .and_then(T::as_array)
910                    .map(|m| m.iter().filter_map(T::as_str).map(unit).collect()),
911            },
912            "RestartUnit" => Request::Restart { unit: unit(&get("unit")) },
913            "CollectGarbage" => Request::Gc,
914            "SetOverride" => Request::SetOverride {
915                unit: unit(&get("unit")),
916                until: Until::Explicit,
917                desired: planarian_schema::OverrideDesired::LeaveAlone,
918            },
919            "Rollback" => {
920                Request::GymPropose { transition: Transition::Rollback, stale_digest: false, may_change: None }
921            }
922            "SwitchGeneration" => {
923                let g = Backends::generations(&real(cfg)).boot_entries().unwrap_or_default().into_iter().max();
924                let g = g.ok_or("no generation to switch to")?;
925                Request::GymPropose {
926                    transition: Transition::SwitchGeneration(g),
927                    stale_digest: false,
928                    may_change: None,
929                }
930            }
931            "BindDriver" => Request::GymPropose {
932                transition: Transition::BindDriver {
933                    device: unit(&get("device")),
934                    driver: DriverRef { name: get("driver"), module: None },
935                },
936                stale_digest: false,
937                may_change: None,
938            },
939            other => return Err(format!("transition {other}")),
940        };
941        return request_as(actor, &req);
942    }
943    match get("raw").as_str() {
944        "kill_unit" => {
945            let (ctl, name) = unit_cmd(&unit(&get("unit")));
946            let sig = st.get("signal").and_then(T::as_str).unwrap_or("SIGKILL");
947            sh(&format!("{ctl} kill --signal={sig} {name}"));
948        }
949        "oom" => {
950            // The kernel's OOM killer, in the unit's own cgroup: its limit set
951            // below what it uses (the VM has no swap), so systemd records
952            // Result=oom-kill. The limit goes with the cgroup when the unit
953            // dies; systemd's own MemoryMax is never touched.
954            let (ctl, name) = unit_cmd(&unit(&get("unit")));
955            let cg = sh_out(&format!("{ctl} show -p ControlGroup --value {name}")).trim().to_string();
956            if cg.is_empty() {
957                return Err(format!("oom: {name} has no cgroup"));
958            }
959            // NixOS test VMs panic on any OOM (vm.panic_on_oom=2), a cgroup's
960            // included: this one is the fault, so only it is let through.
961            sh("sysctl -w vm.panic_on_oom=0");
962            sh(&format!("echo 4096 > /sys/fs/cgroup{cg}/memory.max"));
963            if !sh(&format!(
964                "for _ in $(seq 100); do [ \"$({ctl} show -p Result --value {name})\" = oom-kill ] && exit 0; sleep 0.1; done; exit 1"
965            )) {
966                return Err(format!(
967                    "oom: {name} was not OOM-killed: {}",
968                    sh_out(&format!("{ctl} show -p Result,ActiveState {name}")).trim()
969                ));
970            }
971        }
972        "stop_unit_out_of_band" => {
973            let (ctl, name) = unit_cmd(&unit(&get("unit")));
974            sh(&format!("{ctl} stop {name}"));
975        }
976        "set_knob" | "set_persistent_knob" if get("knob") == "probe_delay" => {
977            // The probe itself stalls, connecting included: drop the packets
978            // to the unit's probe ports (a connect cannot be made slow from
979            // inside the service).
980            for port in probe_ports(cfg, &unit(&get("unit"))) {
981                sh(&format!("iptables -I INPUT -p tcp --dport {port} -j DROP"));
982            }
983        }
984        "set_knob" | "set_persistent_knob" => {
985            let u = unit(&get("unit"));
986            let v = match st.get("value") {
987                Some(T::String(x)) => x.clone(),
988                Some(other) => other.to_string(),
989                None => String::new(),
990            };
991            // A user unit's knobs are per user (cosmic-comp@1000).
992            let n = match u.scope {
993                Scope::User { uid, .. } => format!("{}@{uid}", u.name),
994                _ => u.name.clone(),
995            };
996            // A one-off knob is marked so the fixture drops it when it restarts.
997            let once = if get("raw") == "set_knob" {
998                format!(" && touch /var/lib/gym-knobs/{n}/{}.once", get("knob"))
999            } else {
1000                String::new()
1001            };
1002            sh(&format!(
1003                "mkdir -p /var/lib/gym-knobs/{n} && chmod 755 /var/lib/gym-knobs /var/lib/gym-knobs/{n} && printf '%s' '{v}' > /var/lib/gym-knobs/{n}/{k}{once}",
1004                k = get("knob")
1005            ));
1006        }
1007        "corrupt_state" => {
1008            sh(&format!("printf 'corrupt{{' > {}", get("path")));
1009        }
1010        "unbind_driver" => {
1011            let dev = unit(&get("device"));
1012            let sysfs = cfg.devices.get(&dev.name).ok_or(format!("no sysfs path for {dev}"))?;
1013            // A display device: detach fbcon from framebuffers first. Tearing
1014            // down a DRM driver fbcon is drawing on can deadlock the kernel
1015            // (it froze the VM in two catalog runs); the serial console the
1016            // tests read is not a framebuffer.
1017            sh(&format!(
1018                "d=$(readlink -f {s}); drv=$(readlink -f $d/driver); \
1019                 if [ -d $d/drm ]; then for v in /sys/class/vtconsole/vtcon*; do \
1020                 grep -q 'frame buffer' $v/name && echo 0 > $v/bind; done; fi; \
1021                 echo $(basename $d) > $drv/unbind",
1022                s = sysfs.display()
1023            ));
1024        }
1025        "block_network" => {
1026            let port = st.get("port").and_then(T::as_integer);
1027            let rule = match port {
1028                Some(p) => format!("-d {} -p tcp --dport {p}", get("dst")),
1029                None => format!("-d {}", get("dst")),
1030            };
1031            // A port blocked on this machine refuses connections (as the fake
1032            // models it); an unreachable remote host just stops answering.
1033            let ok = if get("dst").starts_with("127.") {
1034                let tcp = if port.is_some() { rule.clone() } else { format!("{rule} -p tcp") };
1035                sh(&format!("iptables -I OUTPUT {tcp} -j REJECT --reject-with tcp-reset"))
1036            } else {
1037                sh(&format!(
1038                    "iptables -I OUTPUT {rule} -j DROP && iptables -I INPUT {} -j DROP",
1039                    rule.replace("-d ", "-s ").replace("--dport", "--sport")
1040                ))
1041            };
1042            if !ok {
1043                return Err(format!("could not block {rule}"));
1044            }
1045        }
1046        "unblock_network" => {
1047            sh("iptables -F OUTPUT; iptables -F INPUT; true");
1048        }
1049        // Past its restart budget (5 in 300 s): crash it until systemd gives up.
1050        "kill_process" if get("process") == "planariad" && get("repeat") == "past-budget" => {
1051            // Kill it each time it is up again, until systemd gives up
1052            // (Result=start-limit-hit); a kill while it waits to restart
1053            // would hit nothing.
1054            let sig = if get("signal").is_empty() { "SIGSEGV".to_string() } else { get("signal") };
1055            let until = Instant::now() + Duration::from_secs(300);
1056            while Instant::now() < until
1057                && sh_out("systemctl show -p Result --value planariad.service").trim() != "start-limit-hit"
1058            {
1059                if sh("systemctl is-active --quiet planariad.service") {
1060                    sh(&format!("systemctl kill --signal={sig} planariad.service || true"));
1061                }
1062                std::thread::sleep(Duration::from_millis(500));
1063            }
1064        }
1065        "kill_process" => match get("process").as_str() {
1066            "planariad" => {
1067                sh("systemctl kill --signal=SIGKILL planariad");
1068            }
1069            "planarian-recovery" => {
1070                sh("systemctl kill --signal=SIGKILL planarian-recovery");
1071            }
1072            p => return Err(format!("kill_process {p}")),
1073        },
1074        "hold_down" => {
1075            sh(&format!(
1076                "systemctl stop {}",
1077                if get("process") == "planarian-recovery" { "planarian-recovery" } else { "planariad" }
1078            ));
1079        }
1080        "release" if get("process") == "gym-agent" => {
1081            sh("systemctl start gym-agent.service");
1082        }
1083        // Masked now and across reboots (the gym VM's gym-mask generator).
1084        "mask_unit" | "unmask_unit" => {
1085            let u = unit(&get("unit"));
1086            let file = format!("{}.service", u.name);
1087            let list = "/var/lib/gym-posture/masked";
1088            let mut v: Vec<String> =
1089                std::fs::read_to_string(list).unwrap_or_default().lines().map(String::from).collect();
1090            v.retain(|l| l != &file);
1091            if get("raw") == "mask_unit" {
1092                v.push(file.clone());
1093            }
1094            let _ = std::fs::create_dir_all("/var/lib/gym-posture");
1095            std::fs::write(list, v.join("\n") + "\n").map_err(|e| e.to_string())?;
1096            let verb = if get("raw") == "mask_unit" { "mask" } else { "unmask" };
1097            sh(&format!("systemctl {verb} --runtime {file}; systemctl daemon-reload"));
1098        }
1099        // `sudo planarian-ack` as the actor; a refusal (not an administrator,
1100        // or not a sudoer) is recorded for the judge.
1101        "ack_alarm" => {
1102            // What the posture applied, seen now: an acknowledgement right
1103            // after a reboot can come before the sampler's first sample.
1104            record_posture();
1105            let user = user_for(actor);
1106            let ok = if user == "root" {
1107                sh("planarian-ack")
1108            } else {
1109                sh(&format!("runuser -u {user} -- sudo -n planarian-ack"))
1110            };
1111            if !ok {
1112                let line = serde_json::json!({ "actor": actor, "ok": false }).to_string();
1113                let _ = std::fs::OpenOptions::new()
1114                    .create(true)
1115                    .append(true)
1116                    .open(PathBuf::from(RUN).join("acks.jsonl"))
1117                    .and_then(|mut f| writeln!(f, "{line}"));
1118            }
1119        }
1120        // The hash planarian-recovery checks the binary against (I2).
1121        // With `file`: only that file's line in the record no longer matches
1122        // (as if that one binary or script had been replaced, B-12).
1123        "corrupt_core_hash" => match st.get("file").and_then(T::as_str) {
1124            Some(f) => {
1125                let ok = sh(&format!(
1126                    "mkdir -p /var/lib/gym-posture && grep -q '/{f}$' /etc/planarian/core.sha256 && \
1127                     sed -E 's|^[0-9a-f]{{64}} (.*/{f})$|{zero} \\1|' /etc/planarian/core.sha256 > /var/lib/gym-posture/core.sha256",
1128                    zero = "0".repeat(64)
1129                ));
1130                if !ok {
1131                    return Err(format!("corrupt_core_hash: no {f} in the record"));
1132                }
1133            }
1134            None => {
1135                sh("mkdir -p /var/lib/gym-posture && printf '%064d\\n' 0 > /var/lib/gym-posture/core.sha256");
1136            }
1137        },
1138        // I18: a retained record's bytes edited in place (CRC recomputed).
1139        "break_journal_chain" => {
1140            let off = get("when") == "powered-off";
1141            if off {
1142                sh("systemctl stop planariad planarian-recovery");
1143            }
1144            let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
1145            let store = FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes)
1146                .map_err(|e| format!("journal: {e}"))?;
1147            let n = segment(cfg).len();
1148            if n < 3 {
1149                return Err("break_journal_chain: fewer than 3 records".into());
1150            }
1151            let edited = planarian_txn::journal::tamper(&store, n / 2, |p| {
1152                if let Some(b) = p.iter_mut().find(|b| b.is_ascii_digit()) {
1153                    *b = if *b == b'9' { b'1' } else { *b + 1 };
1154                }
1155            });
1156            if !matches!(edited, Ok(true)) {
1157                return Err(format!("break_journal_chain: {edited:?}"));
1158            }
1159        }
1160        // A unit from outside the generation's closure, started before
1161        // planarian-ready (§10 boot report). /etc/systemd/system is the
1162        // store's; system.control is on the unit path and writable.
1163        "plant_early_unit" => {
1164            let name = unit(&get("unit")).name;
1165            let dir = "/etc/systemd/system.control";
1166            sh(&format!(
1167                "mkdir -p {dir}/sysinit.target.wants && \
1168                 printf '[Unit]\\nDefaultDependencies=no\\nBefore=sysinit.target planariad.service\\n[Service]\\nExecStart=/run/current-system/sw/bin/sleep infinity\\n' > {dir}/{name}.service && \
1169                 ln -sf ../{name}.service {dir}/sysinit.target.wants/{name}.service && systemctl daemon-reload"
1170            ));
1171        }
1172        "release" | "restart_process" => {
1173            sh("rm -f /run/systemd/system/planariad.service.d/gym-hold.conf; systemctl daemon-reload");
1174            let name = if get("process") == "planarian-recovery" { "planarian-recovery" } else { "planariad" };
1175            // An administrator's restart: past a spent start budget too.
1176            sh(&format!("systemctl reset-failed {name}; systemctl restart {name}"));
1177        }
1178        "wait" => match st.get("for") {
1179            Some(T::Table(t)) if t.contains_key("journal") => {
1180                let state = t["journal"].as_str().unwrap_or("");
1181                let until = Instant::now() + Duration::from_secs(1800);
1182                while !journal_reached(cfg, state) && Instant::now() < until {
1183                    std::thread::sleep(Duration::from_secs(1));
1184                }
1185            }
1186            Some(v) => {
1187                let d = dur(v).ok_or(format!("wait {v}"))?;
1188                std::thread::sleep(Duration::from_millis(d.0));
1189            }
1190            None => {}
1191        },
1192        "ask" => request_as(actor, &Request::Ask { phrase: get("phrase"), choice: None })?,
1193        "cli" => {
1194            let argv: Vec<String> = st
1195                .get("argv")
1196                .and_then(T::as_array)
1197                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1198                .unwrap_or_default();
1199            let a: Vec<&str> = argv.iter().map(String::as_str).collect();
1200            let req = match a.as_slice() {
1201                ["override", u] => Request::SetOverride {
1202                    unit: unit(u),
1203                    until: Until::Explicit,
1204                    desired: planarian_schema::OverrideDesired::LeaveAlone,
1205                },
1206                ["override", u, "--stop" | "--stopped"] => Request::SetOverride {
1207                    unit: unit(u),
1208                    until: Until::Explicit,
1209                    desired: planarian_schema::OverrideDesired::Stopped,
1210                },
1211                ["stop", u] => Request::Stop { unit: unit(u) },
1212                ["restart", u] => Request::Restart { unit: unit(u) },
1213                ["ack"] => Request::Acknowledge { unit: None },
1214                ["ack", u] => Request::Acknowledge { unit: Some(unit(u)) },
1215                ["release", u] => Request::LiftOverride { unit: unit(u) },
1216                ["resolve", t, how] => {
1217                    // `resolve any` with nothing interlocked: a transaction that
1218                    // does not exist (authorization is decided first).
1219                    let txn = match (ledger(cfg).0.interlock.keys().next(), *t) {
1220                        (Some(x), _) => *x,
1221                        (None, "any") => TxnId(u64::MAX),
1222                        (None, _) => return Err("resolve: nothing interlocked".into()),
1223                    };
1224                    let resolution = match *how {
1225                        "--as-committed" => Resolution::AsCommitted,
1226                        "--as-reverted" => Resolution::AsReverted,
1227                        _ => Resolution::ManualRepaired,
1228                    };
1229                    Request::Resolve { txn, resolution }
1230                }
1231                ["diagnose", u, "--probe", p] => {
1232                    Request::Diagnose { unit: unit(u), probe: Some(ProbeId(p.to_string())) }
1233                }
1234                // Present the approvals held earlier (`approve_pending`
1235                // with `hold_token`), as the actor at a terminal.
1236                ["internal", "present-token", _] => {
1237                    let held = std::fs::read_to_string(Path::new(RUN).join("held-tokens")).unwrap_or_default();
1238                    for h in held.lines().filter_map(|l| Hash::from_hex(l.trim())) {
1239                        let _ = request_at(&get("actor"), &Request::Approve { hash: h }, true);
1240                    }
1241                    return Ok(());
1242                }
1243                // The admin connects to a network outside Planarian.
1244                // A snapshot of the journal's volume (btrfs): it shares every
1245                // extent of the journal, so the next write to each needs space.
1246                ["internal", "snapshot", "journal-volume"] => {
1247                    if !sh("btrfs subvolume snapshot -r /var/lib/planarian /var/lib/planarian/.gym-snapshot") {
1248                        return Err("could not snapshot the journal's volume".into());
1249                    }
1250                    return Ok(());
1251                }
1252                // Journal reuse: each wrap writes records over the start of
1253                // the preallocation (an override set and lifted) and compacts
1254                // (offset back to 0). A wrap here is one compaction cycle, not
1255                // a pass over the whole 64 MiB (docs/deviations.md).
1256                ["internal", "journal-churn", "--wraps", n] => {
1257                    let n: u32 = n.parse().map_err(|_| format!("journal-churn: {n}"))?;
1258                    let u = unit("service:gym-cache");
1259                    for _ in 0..n {
1260                        request_as(
1261                            "root",
1262                            &Request::SetOverride {
1263                                unit: u.clone(),
1264                                until: Until::Explicit,
1265                                desired: planarian_schema::OverrideDesired::LeaveAlone,
1266                            },
1267                        )?;
1268                        request_as("root", &Request::LiftOverride { unit: u.clone() })?;
1269                        compact_and_save()?;
1270                    }
1271                    return Ok(());
1272                }
1273                ["internal", "wifi-connect", ssid] => {
1274                    if !sh(&format!("nmcli connection up id {ssid}")) {
1275                        return Err(format!("nmcli could not connect to {ssid}"));
1276                    }
1277                    return Ok(());
1278                }
1279                other => return Err(format!("cli {other:?}")),
1280            };
1281            request_as(actor, &req)?;
1282        }
1283        "approve_pending" => {
1284            // As the step's actor, at a terminal unless `via = "no-tty"`.
1285            let actor = st.get("actor").and_then(T::as_str).unwrap_or("root");
1286            let tty = st.get("via").and_then(T::as_str) != Some("no-tty");
1287            let r = console().ok_or("daemon down")?;
1288            if st.get("hold_token").and_then(T::as_bool) == Some(true) {
1289                let held: String = r.status.pending.iter().map(|p| format!("{}\n", p.approve.to_hex())).collect();
1290                std::fs::write(Path::new(RUN).join("held-tokens"), held).map_err(|e| e.to_string())?;
1291                return Ok(());
1292            }
1293            for p in r.status.pending {
1294                // A refused approval is the step's outcome, not the harness's
1295                // error; the answer is in responses.jsonl and printed by
1296                // `vm request`, and a harness failure is said here.
1297                if let Err(e) = request_at(actor, &Request::Approve { hash: p.approve }, tty) {
1298                    eprintln!("approve_pending: {e}");
1299                }
1300            }
1301        }
1302        // I14: a request line carrying fields it must not set, sent to the
1303        // socket as is, so the daemon's own parser is what meets them.
1304        "forge_proposal_fields" => {
1305            let r = st.get("request").and_then(T::as_table).ok_or("request")?;
1306            let args: BTreeMap<String, Value> = r
1307                .get("args")
1308                .and_then(T::as_table)
1309                .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1310                .unwrap_or_default();
1311            let change = ChangeId(r.get("change").and_then(T::as_str).unwrap_or("").into());
1312            let mut line = serde_json::to_value(Request::Apply { change, args }).expect("serializes");
1313            let forged = serde_json::to_value(st.get("fields").cloned().unwrap_or(T::Table(Default::default())))
1314                .map_err(|e| e.to_string())?;
1315            if let (Some(o), Some(f)) = (line.as_object_mut(), forged.as_object()) {
1316                o.extend(f.clone());
1317            }
1318            let line = line.to_string().replace('\'', "'\\''");
1319            let user = user_for(&get("actor"));
1320            sh(&format!(
1321                "runuser -u {user} -- python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(sys.argv[2].encode()+b\"\\n\"); s.recv(65536)' {sock} '{line}'",
1322                sock = planarian_ipc::SOCKET
1323            ));
1324        }
1325        // I18: a retained record deleted (not edited), the rest re-framed so
1326        // every frame is intact. The daemon is stopped first, since it holds
1327        // the journal's write offset.
1328        "delete_journal_record" => {
1329            sh("systemctl stop planariad");
1330            let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
1331            let store = FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes)
1332                .map_err(|e| format!("journal: {e}"))?;
1333            let n = st.get("record").and_then(T::as_integer).unwrap_or(1) as usize;
1334            if !matches!(planarian_txn::journal::delete(&store, n), Ok(true)) {
1335                return Err(format!("delete_journal_record: no record {n}"));
1336            }
1337        }
1338        // §10 (B-6): a peer that has exited before the daemon resolves it.
1339        // The daemon is held still while the client connects, sends and
1340        // exits, so by the time it accepts, the process is gone.
1341        "request_then_exit" => {
1342            let req: Request = serde_json::from_value(
1343                serde_json::to_value(st.get("request").ok_or("request")?).map_err(|e| e.to_string())?,
1344            )
1345            .map_err(|e| format!("request_then_exit: {e}"))?;
1346            let line = serde_json::to_string(&req).expect("serializes").replace('\'', "'\\''");
1347            let user = user_for(&get("actor"));
1348            let ok = sh(&format!(
1349                "pid=$(systemctl show -p MainPID --value planariad); kill -STOP $pid; \
1350                 runuser -u {user} -- python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(sys.argv[2].encode()+b\"\\n\")' {sock} '{line}'; \
1351                 r=$?; kill -CONT $pid; exit $r",
1352                sock = planarian_ipc::SOCKET
1353            ));
1354            if !ok {
1355                return Err("request_then_exit: the client could not send".into());
1356            }
1357        }
1358        // §15 (B-13): the test build, as if installed outside the gym: the
1359        // same binaries, with the configuration's permission for a test
1360        // build withdrawn (a runtime drop-in), and planariad started again.
1361        "plant_test_build" => {
1362            let ok = sh("mkdir -p /run/planarian-gym /run/systemd/system/planariad.service.d && \
1363                 jq '.allowTestBuild = false' /etc/planarian/config.json > /run/planarian-gym/config.json && \
1364                 printf '[Service]\\nEnvironment=PLANARIAN_CONFIG=/run/planarian-gym/config.json\\n' \
1365                   > /run/systemd/system/planariad.service.d/test-build.conf && \
1366                 systemctl daemon-reload && systemctl restart planariad; true");
1367            if !ok {
1368                return Err("plant_test_build".into());
1369            }
1370        }
1371        "agent_run" => {
1372            let argv: Vec<String> = st
1373                .get("argv")
1374                .and_then(T::as_array)
1375                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1376                .unwrap_or_default();
1377            let by = st.get("as").and_then(T::as_str).unwrap_or("user:1001");
1378            request_as(by, &Request::AgentRun { name: get("name"), argv })?;
1379        }
1380        "greeter_login" => {
1381            let uid = st.get("uid").and_then(T::as_integer).unwrap_or(1000);
1382            let user = user_for(&format!("user:{uid}"));
1383            let tty = 2 + (uid - 1000).clamp(0, 5);
1384            sh(&format!(
1385                "systemd-run --unit=gym-session-{uid} -p PAMName=login -p User={user} -p TTYPath=/dev/tty{tty} \
1386                 -p StandardInput=tty -p StandardOutput=tty sleep infinity; sleep 5"
1387            ));
1388        }
1389        "logout" => {
1390            let uid = st.get("uid").and_then(T::as_integer).unwrap_or(1000);
1391            sh(&format!(
1392                "systemctl stop gym-session-{uid}; loginctl terminate-user {}; true",
1393                user_for(&format!("user:{uid}"))
1394            ));
1395        }
1396        "fill_fs" => {
1397            let path = match get("fs").as_str() {
1398                "device:fs-nix" => "/nix/.rw-store",
1399                "device:fs-boot" => "/boot",
1400                "device:fs-journal" => "/var/lib/planarian",
1401                _ => "/",
1402            };
1403            // Leave the journal's reserved headroom at the boundary; nothing when full.
1404            let leave = if get("until") == "reserved-boundary" { 1 << 20 } else { 0 };
1405            sh(&format!(
1406                "avail=$(df --output=avail -B1 {path} | tail -1); n=$((avail - {leave})); \
1407                 [ $n -gt 0 ] && fallocate -l $n {path}/.gym-fill || dd if=/dev/zero of={path}/.gym-fill bs=1M 2>/dev/null; true"
1408            ));
1409        }
1410        // I23 (R11): a cooperating writer. At the daemon's pause before its
1411        // compare-and-swap, it takes the lock on planarian/ (flock(1)), lets
1412        // the daemon go on, and replaces the file before letting go: the
1413        // daemon's swap waits for the lock and then sees the edit.
1414        "edit_override_file" => {
1415            let dir = cfg.owner.join("planarian");
1416            let mut set = OverrideSet::default();
1417            set.assignments.insert("gym.thirdParty".into(), Value::Bool(true));
1418            let staged = PathBuf::from(RUN).join("third-party.nix");
1419            std::fs::write(&staged, planarian_changes::render(&set)).map_err(|e| e.to_string())?;
1420            let held = PathBuf::from(RUN).join("override-lock-held");
1421            let _ = std::fs::remove_file(&held);
1422            let d = dir.display();
1423            let mut writer = Command::new("flock")
1424                .arg(dir.as_os_str())
1425                .args(["sh", "-c"])
1426                .arg(format!(
1427                    "touch {h} && sleep 3 && cp {staged} {d}/.gym-third-party && mv {d}/.gym-third-party {d}/overrides.nix",
1428                    h = held.display(),
1429                    staged = staged.display()
1430                ))
1431                .spawn()
1432                .map_err(|e| format!("flock: {e}"))?;
1433            let until = Instant::now() + Duration::from_secs(30);
1434            while !held.exists() && Instant::now() < until {
1435                std::thread::sleep(Duration::from_millis(50));
1436            }
1437            if !held.exists() {
1438                let _ = writer.kill();
1439                return Err("the cooperating writer never took the lock".into());
1440            }
1441            // Let the paused daemon go on while the lock is held.
1442            sh(&format!("rm -f {HOOKS_DIR}/paused-*"));
1443            let ok = writer.wait().map(|s| s.success()).unwrap_or(false);
1444            if !ok {
1445                return Err("the cooperating writer failed".into());
1446            }
1447            if let Ok((_, h)) = Backends::generations(&real(cfg)).read_overrides() {
1448                let _ = std::fs::write(PathBuf::from(RUN).join("third-party-override"), h.to_hex());
1449            }
1450            eprintln!("gym: a cooperating writer replaced {d}/overrides.nix under the lock");
1451        }
1452        "edit_owner_source" => {
1453            let root = cfg.owner.display().to_string();
1454            let path = get("path");
1455            if !sh(&format!("echo '# edited by the gym' >> {root}/{path}")) {
1456                return Err(format!("could not edit {root}/{path}"));
1457            }
1458            if st.get("tracked").and_then(T::as_bool) != Some(false) {
1459                sh(&format!("cd {root} && git add {path} 2>/dev/null; true"));
1460            }
1461            eprintln!("gym: edited {root}/{path}: {}", sh_out(&format!("git -C {root} status --short")).trim());
1462        }
1463        "out_of_band_rebuild" => {
1464            // The VM carries these as prebuilt specialisations (nix/tests/gym-vm.nix).
1465            let edit = get("edit");
1466            let name = if edit.contains("12 throwaway") {
1467                "throwaway"
1468            } else if edit.contains("sshd-tcp.timeout") {
1469                "weak-sshd-probe"
1470            } else if edit.contains("ExecStart=/bin/false") {
1471                "gym-web-false"
1472            } else if edit == "enable gym-bad-payload" {
1473                "bad-payload"
1474            } else if edit.starts_with("update input gym-modules") {
1475                "input-update"
1476            } else {
1477                return Err(format!("out_of_band_rebuild {edit}"));
1478            };
1479            // `nixos-rebuild switch`: the payload entry's owner switches to it.
1480            let activate =
1481                st.get("activate").and_then(T::as_bool).unwrap_or(matches!(name, "bad-payload" | "input-update"));
1482            let n = if name == "throwaway" { 12 } else { 1 };
1483            for k in 0..n {
1484                let spec = if name == "throwaway" { format!("throwaway{}", k % 2) } else { name.to_string() };
1485                sh(&format!(
1486                    "p=$(readlink -f /run/booted-system)/specialisation/{spec}; \
1487                     nix-env -p /nix/var/nix/profiles/system --set $(readlink -f $p)"
1488                ));
1489            }
1490            if activate {
1491                sh(&format!(
1492                    "$(readlink -f /run/booted-system)/specialisation/{name}/bin/switch-to-configuration test"
1493                ));
1494            }
1495        }
1496        "force_compaction" => compact_and_save()?,
1497        "advance_clock" => {
1498            let d = st.get("by").and_then(dur).ok_or("advance_clock")?;
1499            sh(&format!("timedatectl set-ntp false 2>/dev/null; date -s \"+{} seconds\"", d.0 / 1000));
1500        }
1501        "slow_backend" => {
1502            let d = st.get("delay").and_then(dur).unwrap_or(Millis::secs(120));
1503            let uncancellable = st.get("uncancellable").and_then(T::as_bool).unwrap_or(false);
1504            hooks_set(&get("call"), serde_json::json!({ "delay_secs": d.0 / 1000, "uncancellable": uncancellable }));
1505        }
1506        "withhold_reply" => {
1507            hooks_set(&get("call"), serde_json::json!({ "withhold": true }));
1508        }
1509        "backend_result" => {
1510            let key = match st.get("generation").and_then(T::as_str) {
1511                Some("revert_to") => {
1512                    let start: Start = read_json(&PathBuf::from(RUN).join("start.json")).unwrap_or_default();
1513                    format!("{}@{}", get("call"), start.base.0)
1514                }
1515                _ => get("call"),
1516            };
1517            hooks_set(&key, serde_json::json!({ "result": get("result") }));
1518        }
1519        "set_setting" => {
1520            // A copy of the module's config with the setting changed, and the
1521            // daemon restarted on it (PLANARIAN_CONFIG, in a runtime drop-in).
1522            let (path, value): (Vec<&str>, _) = match get("key").as_str() {
1523                "gc.generation_budget_percent" => {
1524                    let pct = st.get("value").and_then(T::as_float).ok_or("set_setting value")?;
1525                    (vec!["gc", "generationsPermille"], serde_json::json!((pct * 10.0).round() as u64))
1526                }
1527                "posture" => (vec!["posture"], serde_json::json!(get("value"))),
1528                // `services.planarian.userControllable.changes` (added to).
1529                "userControllableChanges" => {
1530                    let add: Vec<&str> =
1531                        st.get("value").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str).collect();
1532                    let mut have: Vec<String> = config().user_controllable_changes.into_iter().collect();
1533                    have.extend(add.iter().map(|s| s.to_string()));
1534                    (vec!["userControllableChanges"], serde_json::json!(have))
1535                }
1536                k => return Err(format!("set_setting {k}")),
1537            };
1538            let src = std::env::var("PLANARIAN_CONFIG").unwrap_or_else(|_| CONFIG.into());
1539            let mut v: serde_json::Value = std::fs::read(&src)
1540                .ok()
1541                .and_then(|b| serde_json::from_slice(&b).ok())
1542                .ok_or_else(|| format!("{src}: unreadable"))?;
1543            match path.as_slice() {
1544                [a] => v[*a] = value,
1545                [a, b] => v[*a][*b] = value,
1546                _ => unreachable!("one or two keys"),
1547            }
1548            let copy = format!("{HOOKS_DIR}/config.json");
1549            let _ = std::fs::create_dir_all(HOOKS_DIR);
1550            std::fs::write(&copy, v.to_string()).map_err(|e| e.to_string())?;
1551            let ok = sh(&format!(
1552                "mkdir -p /run/systemd/system/planariad.service.d && \
1553                 printf '[Service]\\nEnvironment=PLANARIAN_CONFIG={copy}\\n' > /run/systemd/system/planariad.service.d/gym-config.conf && \
1554                 systemctl daemon-reload && systemctl restart planariad"
1555            ));
1556            let until = Instant::now() + Duration::from_secs(60);
1557            while console().is_none() && Instant::now() < until {
1558                std::thread::sleep(Duration::from_millis(500));
1559            }
1560            if !ok || console().is_none() {
1561                return Err("planariad did not come back on the changed config".into());
1562            }
1563        }
1564        other => return Err(format!("raw {other}")),
1565    }
1566    Ok(())
1567}
1568
1569/// Gather what the run left behind.
1570fn evidence() -> Evidence {
1571    let cfg = config();
1572    let dir = PathBuf::from(RUN);
1573    let start: Start = read_json(&dir.join("start.json")).unwrap_or_default();
1574    let (ledger, journal_replays) = ledger(&cfg);
1575    let log: Vec<LogRecord> = std::fs::read_to_string(cfg.state_dir.join("log/executor.jsonl"))
1576        .unwrap_or_default()
1577        .lines()
1578        .skip(start.log_lines)
1579        .filter_map(|l| serde_json::from_str(l).ok())
1580        .collect();
1581    let samples: Vec<Sample> = std::fs::read_to_string(dir.join("samples.jsonl"))
1582        .unwrap_or_default()
1583        .lines()
1584        .filter_map(|l| serde_json::from_str(l).ok())
1585        .filter(|smp: &Sample| smp.at >= start.at)
1586        .collect();
1587    let responses: Vec<Response> = std::fs::read_to_string(dir.join("responses.jsonl"))
1588        .unwrap_or_default()
1589        .lines()
1590        .filter_map(|l| serde_json::from_str(l).ok())
1591        .collect();
1592    let notices: Vec<String> = sh_out(&format!("journalctl -u planariad -o cat --since @{} --no-pager", start.unix))
1593        .lines()
1594        .filter_map(|l| l.strip_prefix("planariad: ").map(String::from))
1595        .chain(samples.iter().flat_map(|smp| smp.notices.clone()))
1596        .collect();
1597    let mut seen_status: BTreeMap<UnitId, BTreeSet<String>> = BTreeMap::new();
1598    let mut seen_incidents: BTreeMap<IncidentId, Incident> = BTreeMap::new();
1599    let mut first_incident_at: BTreeMap<UnitId, Timestamp> = BTreeMap::new();
1600    for smp in &samples {
1601        for (u, st) in &smp.status {
1602            seen_status.entry(u.clone()).or_default().insert(st.clone());
1603        }
1604        for i in &smp.incidents {
1605            first_incident_at.entry(i.target.clone()).or_insert(smp.at);
1606            seen_incidents.insert(i.id, i.clone());
1607        }
1608    }
1609    let final_report = console();
1610    let finals = final_report
1611        .as_ref()
1612        .map(statuses)
1613        .unwrap_or_else(|| samples.last().map(|smp| smp.status.clone()).unwrap_or_default());
1614    let open_incidents = final_report
1615        .as_ref()
1616        .map(|r| r.status.incidents.iter().map(|i| (i.target.clone(), i.clone())).collect())
1617        .unwrap_or_default();
1618    let mut restarts: BTreeMap<UnitId, i64> = BTreeMap::new();
1619    for r in &log {
1620        if let (Transition::RestartUnit(u), Submitted::Finished { .. }) = (&r.proposal.transition, &r.result) {
1621            *restarts.entry(u.clone()).or_default() += 1;
1622        }
1623    }
1624    // The whole journal history: what a forced compaction discarded, then
1625    // the current segment.
1626    let saved: Option<Vec<Entry>> = read_json(&dir.join(SAVED));
1627    let mut entries = saved.clone().unwrap_or_default();
1628    for x in segment(&cfg) {
1629        if entries.last().is_none_or(|l| l.seq < x.seq) {
1630            entries.push(x);
1631        }
1632    }
1633    let full_replay = saved.map(|_| {
1634        let mut l = Ledger::default();
1635        for x in &entries {
1636            let _ = l.apply(x.clone());
1637        }
1638        l
1639    });
1640    let mut timeline: BTreeMap<UnitId, Vec<(Timestamp, String)>> = BTreeMap::new();
1641    for smp in &samples {
1642        for (u, st) in &smp.status {
1643            let line = timeline.entry(u.clone()).or_default();
1644            if line.last().is_none_or(|(_, n)| n != st) {
1645                line.push((smp.at, st.clone()));
1646            }
1647        }
1648    }
1649    // The evaluated options a committed change set, as Planarian evaluated
1650    // them (the derived facts' config diff), on top of the base.
1651    let mut options: BTreeMap<String, Value> = BTreeMap::new();
1652    for r in &log {
1653        if let (Submitted::Finished { state: TxnState::Committed, .. }, Some(f)) = (&r.result, &r.facts)
1654            && let Some(d) = &f.config_diff
1655        {
1656            for (k, (_, new)) in d.options.iter() {
1657                match new {
1658                    Some(v) => options.insert(k.clone(), v.clone()),
1659                    None => options.remove(k),
1660                };
1661            }
1662        }
1663    }
1664    let backend = real(&cfg);
1665    let drivers: BTreeMap<UnitId, String> = start
1666        .pre_status
1667        .keys()
1668        .filter(|u| u.kind == UnitKind::Device)
1669        .filter_map(|u| Backends::inventory(&backend).bound_driver(u).ok().flatten().map(|d| (u.clone(), d.name)))
1670        .collect();
1671    let g = Backends::generations(&backend);
1672    let boot_entries = g.boot_entries().unwrap_or_default();
1673    let collected = start.boot_entries.iter().copied().filter(|x| !boot_entries.contains(x)).collect();
1674    let candidate = ledger
1675        .live
1676        .values()
1677        .filter_map(|t| t.txn.candidate.as_ref().map(|(c, _)| *c))
1678        .max()
1679        .or_else(|| boot_entries.iter().copied().filter(|x| *x > start.base).max());
1680    Evidence {
1681        current: g.current().unwrap_or_default(),
1682        boot_default: g.boot_default().unwrap_or_default(),
1683        base: start.base,
1684        candidate,
1685        boot_entries,
1686        collected,
1687        work: None,
1688        overrides_empty: g.read_overrides().map(|(o, _)| o.assignments.is_empty()).unwrap_or(true),
1689        override_hash: g.read_overrides().ok().map(|(_, h)| h),
1690        agreement: None,
1691        third_party_override: std::fs::read_to_string(dir.join("third-party-override"))
1692            .ok()
1693            .and_then(|h| Hash::from_hex(h.trim())),
1694        ledger,
1695        journal_replays,
1696        log,
1697        pre_status: start.pre_status,
1698        finals,
1699        touched: start.touched,
1700        seen_status,
1701        seen_incidents,
1702        open_incidents,
1703        first_incident_at,
1704        start: start.at,
1705        restarts,
1706        notices,
1707        responses,
1708        entries: Some(entries.iter().filter(|x| x.at >= start.at).cloned().collect()),
1709        full_replay,
1710        effects: Some(effects(&entries, start.unix)),
1711        status_timeline: Some(timeline),
1712        foreground: None,
1713        drivers: Some(drivers),
1714        options: Some(options),
1715        probe_defs: Some((start.probe_defs, probe_defs(&cfg))),
1716        gc_roots: Some((start.gc_roots, build_roots())),
1717        posture: posture_seen(&cfg, start.unix),
1718    }
1719}
1720
1721/// Keys whose truth can change late in the window: judge them only at its end.
1722fn needs_full_window(post: &T) -> bool {
1723    post.as_table().is_some_and(|t| {
1724        t.keys().any(|k| {
1725            k.starts_with("no_")
1726                || matches!(k.as_str(), "others_unchanged" | "status_never" | "metrics" | "invariants" | "one_of")
1727        })
1728    })
1729}
1730
1731/// #9: a stalled activation fails the entry, whatever else it judges (a
1732/// 15-minute startup stall once passed a 20-minute window unnoticed): an
1733/// activation unit that ran into its start timeout, or one still activating
1734/// at the end for longer than the entry's own slow_backend delay plus 300 s.
1735fn stalls(entry: &T, failures: &mut Vec<String>) {
1736    let timed_out = sh_out(
1737        "journalctl -b --no-pager -o cat -u 'planarian-op-*' 2>/dev/null | grep -F 'start operation timed out' || true",
1738    );
1739    if let Some(line) = timed_out.lines().next() {
1740        failures.push(format!("harness: an activation unit hit its start timeout ({}): a stall", line.trim()));
1741    }
1742    let deliberate = entry
1743        .get("setup")
1744        .and_then(T::as_array)
1745        .into_iter()
1746        .flatten()
1747        .filter(|st| st.get("raw").and_then(T::as_str) == Some("slow_backend"))
1748        .filter_map(|st| st.get("delay").and_then(crate::sim::dur))
1749        .map(|d| d.0 / 1000)
1750        .max()
1751        .unwrap_or(0);
1752    let limit = deliberate + 300;
1753    let stuck = sh_out(&format!(
1754        "now=$(awk '{{printf \"%d\", $1}}' /proc/uptime); \
1755         for u in $(systemctl list-units --plain --no-legend --state=activating 'planarian-op-*' | awk '{{print $1}}'); do \
1756           t=$(systemctl show -P InactiveExitTimestampMonotonic \"$u\"); age=$((now - t / 1000000)); \
1757           [ \"$age\" -gt {limit} ] && echo \"$u activating for $age s\"; \
1758         done; true"
1759    ));
1760    if let Some(line) = stuck.lines().next() {
1761        failures.push(format!("harness: {} (allowed {limit} s): a stall", line.trim()));
1762    }
1763}
1764
1765/// Wait the window (ending early once the entry passes, if nothing in it
1766/// could still turn), then judge.
1767fn judge(entry: &T) -> i32 {
1768    let dir = PathBuf::from(RUN);
1769    let p: Plan = read_json(&dir.join("plan.json")).unwrap_or_else(|| plan(entry));
1770    let post = entry.get("required_post").cloned().unwrap_or(T::Table(Default::default()));
1771    let unsupported: Vec<String> = p.steps.iter().filter(|s| s.starts_with("unsupported")).cloned().collect();
1772    let full = needs_full_window(&post);
1773    let end = Instant::now() + Duration::from_secs(p.window_secs);
1774    if p.hold {
1775        // The daemon was held down so the watchdog would recover alone. Once
1776        // it has, let the daemon back: its view is what the statuses are
1777        // sampled from, and coming back after a recovery is part of the case.
1778        let cfg = config();
1779        let until = Instant::now() + Duration::from_secs((p.window_secs / 2).min(300));
1780        while !ledger(&cfg).0.live.is_empty() && Instant::now() < until {
1781            std::thread::sleep(Duration::from_secs(2));
1782        }
1783        sh(
1784            "rm -f /run/systemd/system/planariad.service.d/gym-hold.conf; systemctl daemon-reload; systemctl start planariad",
1785        );
1786        let until = Instant::now() + Duration::from_secs(60);
1787        while console().is_none() && Instant::now() < until {
1788            std::thread::sleep(Duration::from_millis(500));
1789        }
1790    }
1791    let min = Instant::now() + Duration::from_secs(60.min(p.window_secs));
1792    let (mut v, mut e);
1793    loop {
1794        e = evidence();
1795        v = check::judge(&e, &post);
1796        let now = Instant::now();
1797        if now >= end || (!full && now >= min && v.failures.is_empty()) {
1798            break;
1799        }
1800        std::thread::sleep(Duration::from_secs(20));
1801    }
1802    stalls(entry, &mut v.failures);
1803    sh("systemctl stop gym-sampler 2>/dev/null; true");
1804    // A run the sampler never saw proves nothing.
1805    if unsupported.is_empty() && e.seen_status.is_empty() {
1806        v.failures.push("harness: no status samples (gym-sampler)".into());
1807    }
1808    write_json(&dir.join("evidence.json"), &e);
1809    let result = if !unsupported.is_empty() {
1810        "UNSUPPORTED"
1811    } else if !v.failures.is_empty() {
1812        "FAIL"
1813    } else if !v.unchecked.is_empty() {
1814        "PARTIAL"
1815    } else {
1816        "PASS"
1817    };
1818    let notes: Vec<String> = unsupported
1819        .into_iter()
1820        .chain(v.failures.iter().cloned())
1821        .chain(v.unchecked.iter().map(|u| format!("unchecked: {u}")))
1822        .collect();
1823    let id = s(entry, "id");
1824    write_json(&dir.join("verdict.json"), &serde_json::json!({ "id": id, "result": result, "notes": notes }));
1825    println!("RESULT {result} {id}");
1826    for n in &notes {
1827        println!("  {n}");
1828    }
1829    0
1830}
1831
1832pub fn main(args: &[String], entries: &[T]) -> i32 {
1833    let arg = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
1834    let sub = args.first().map(String::as_str).unwrap_or("");
1835    let entry = || -> T {
1836        let id = arg("--id").unwrap_or_else(|| die("--id"));
1837        entries.iter().find(|e| s(e, "id") == id).cloned().unwrap_or_else(|| die(&format!("no entry {id}")))
1838    };
1839    match sub {
1840        "plan" => {
1841            println!("{}", serde_json::to_string(&plan(&entry())).expect("serializes"));
1842            0
1843        }
1844        "begin" => {
1845            begin(&entry());
1846            0
1847        }
1848        "step" => {
1849            let i: usize = arg("--index").and_then(|i| i.parse().ok()).unwrap_or_else(|| die("--index"));
1850            step(&entry(), i, arg("--watched").as_deref());
1851            0
1852        }
1853        "request" => {
1854            request(&arg("--json").unwrap_or_else(|| die("--json")));
1855            0
1856        }
1857        "sample" => {
1858            sample();
1859            0
1860        }
1861        "judge" => judge(&entry()),
1862        "evidence" => {
1863            println!("{}", serde_json::to_string_pretty(&evidence()).expect("serializes"));
1864            0
1865        }
1866        other => die(&format!("unknown vm command {other:?}")),
1867    }
1868}