Skip to main content

spec_driven_docs/
probes.rs

1//! The environment probe catalog.
2//!
3//! One catalog, read by every caller that needs to know whether this host
4//! is ready: `sdd doctor` runs it whole and reports by class. Each probe
5//! answers with a status, a message, and — on failure — the remediation
6//! printed verbatim wherever the probe is consulted. A probe failure is a
7//! result, not an error, so nothing here returns `Err`.
8
9use std::process::Command;
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use crate::domain::ownership::Sha256;
15use crate::domain::paths::{
16    AgentId, LEGACY_SHARED_ROOT, OFFLINE_VAR, SKILL_FILE, SKILL_RECEIPT_FILE, SKILL_REFERENCES_DIR,
17    UserEnv,
18};
19use crate::domain::skill_record::SkillRecord;
20use crate::services::skill_installer::home;
21
22/// Every agent skill root under this home, resolved through the table.
23///
24/// The table is the one place that knows a variable moved a root, so a
25/// probe that joined the defaults instead would report a directory the
26/// install never writes.
27fn agent_roots() -> Vec<Utf8PathBuf> {
28    UserEnv::from_process()
29        .agent_roots(&[AgentId::Claude, AgentId::Agents])
30        .into_iter()
31        .map(|entry| entry.path)
32        .collect()
33}
34
35/// The state root this host resolves.
36fn resolved_state_root() -> Option<Utf8PathBuf> {
37    UserEnv::from_process().state_root().map(|entry| entry.path)
38}
39
40/// The receipt, read from the resolved path or the home-relative one.
41fn receipt() -> SkillRecord {
42    let env = UserEnv::from_process();
43    let Some(state) = env.state_root() else {
44        return SkillRecord::new();
45    };
46    let legacy = env
47        .legacy_state_root()
48        .map_or_else(|| state.path.clone(), |root| root.join(SKILL_RECEIPT_FILE));
49    SkillRecord::load_with_fallback(&state.path.join(SKILL_RECEIPT_FILE), &legacy)
50}
51
52/// Every installed package file this binary carries, under every agent root
53/// that exists.
54fn installed_packages() -> Vec<(Utf8PathBuf, &'static [u8])> {
55    let mut planned = Vec::new();
56    for root in agent_roots() {
57        // An absent agent root is a choice, not a defect: `--agent` selects
58        // one family and leaves the other's root untouched.
59        if !root.is_dir() {
60            continue;
61        }
62        for name in crate::embedded::skill_names() {
63            let Some(package) = crate::embedded::skill_package(name) else {
64                continue;
65            };
66            for (relative, bytes) in package {
67                planned.push((root.join(name).join(relative), bytes));
68            }
69        }
70    }
71    planned
72}
73
74/// How a failure weighs at the doctor level.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum ProbeClass {
78    /// No install under the user's home can work without this.
79    Hard,
80    /// Needed only by some commands or some tasks.
81    Soft,
82}
83
84/// What a probe found.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum ProbeStatus {
88    /// The probe passed.
89    Ok,
90    /// The probe failed; the remediation says what fixes it.
91    Failed,
92}
93
94/// One probe's answer.
95#[derive(Debug, Serialize)]
96pub struct ProbeResult {
97    /// The probe's stable name.
98    pub id: &'static str,
99    /// How the failure weighs.
100    pub class: ProbeClass,
101    /// What was found.
102    pub status: ProbeStatus,
103    /// What was found, one line.
104    pub message: String,
105    /// The exact fix, when the probe failed.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub remediation: Option<String>,
108}
109
110impl ProbeResult {
111    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
112        Self {
113            id,
114            class,
115            status: ProbeStatus::Ok,
116            message: message.into(),
117            remediation: None,
118        }
119    }
120
121    fn failed(
122        id: &'static str,
123        class: ProbeClass,
124        message: impl Into<String>,
125        remediation: impl Into<String>,
126    ) -> Self {
127        Self {
128            id,
129            class,
130            status: ProbeStatus::Failed,
131            message: message.into(),
132            remediation: Some(remediation.into()),
133        }
134    }
135}
136
137/// The probes judging the skill installation itself, in catalog order.
138///
139/// Declared here rather than derived by running the catalog: the shared
140/// pre-flight gate must name each of these, and the test holding it to that
141/// must not have to write into the operator's home to learn what they are.
142pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
143
144/// Run the whole catalog, in its stable order.
145#[must_use]
146pub fn run_all() -> Vec<ProbeResult> {
147    vec![
148        state_root(),
149        skill_roots(),
150        skill_gate(),
151        skill_payload(),
152        registry(),
153        tool(
154            "git",
155            "SDD_GIT_BIN",
156            "git",
157            "git; retiring a migrated document is safe only where version control restores it",
158            &["--version"],
159        ),
160        tool(
161            "pre-commit",
162            "SDD_PRE_COMMIT_BIN",
163            "pre-commit",
164            "pre-commit; the delivered gates run through it",
165            &["--version"],
166        ),
167    ]
168}
169
170/// A helper binary answers its version call. `env_override` names the
171/// substitute, which is also what keeps tests hermetic; presence is the
172/// whole question, because the tools here take no configuration.
173fn tool(
174    id: &'static str,
175    env_override: &str,
176    default_bin: &str,
177    label: &str,
178    args: &[&str],
179) -> ProbeResult {
180    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
181    match Command::new(&bin).args(args).output() {
182        Ok(out) if out.status.success() => {
183            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
184        }
185        Ok(_) => ProbeResult::failed(
186            id,
187            ProbeClass::Soft,
188            format!("{default_bin} does not answer {}", args.join(" ")),
189            format!("repair {label}"),
190        ),
191        Err(_) => ProbeResult::failed(
192            id,
193            ProbeClass::Soft,
194            format!("{default_bin} is not on PATH"),
195            format!("install {label}"),
196        ),
197    }
198}
199
200/// The registry the release resolver reads is reachable.
201///
202/// Soft, because a host that cannot fetch is a constraint on a plan rather
203/// than a broken install: every default path reads the embedded release,
204/// and only a plan toward another release needs this. The probe reads the
205/// registry's own configuration, which is the smallest document the
206/// protocol defines, and writes nothing.
207fn registry() -> ProbeResult {
208    let id = "release-registry";
209    let url = format!("{}/config.json", crate::self_depend::registry::INDEX_ROOT);
210    // One variable turns the read off, for a host that is deliberately
211    // offline and for every test in this repository's own suite.
212    if crate::domain::paths::variable(OFFLINE_VAR).is_some() {
213        return ProbeResult::ok(
214            id,
215            ProbeClass::Soft,
216            "SDD_OFFLINE is set, so no release beyond the embedded one is planned",
217        );
218    }
219    // A probe's budget is not a fetch's budget: an unreachable registry
220    // must answer this question in seconds, not in the minute a bounded
221    // archive read is allowed.
222    let agent: ureq::Agent = ureq::Agent::config_builder()
223        .timeout_connect(Some(std::time::Duration::from_secs(3)))
224        .timeout_global(Some(std::time::Duration::from_secs(5)))
225        .user_agent(format!("sdd/{}", env!("CARGO_PKG_VERSION")))
226        .build()
227        .into();
228    match agent.get(&url).call() {
229        Ok(response) if response.status().as_u16() == 200 => {
230            ProbeResult::ok(id, ProbeClass::Soft, format!("{url} answers"))
231        }
232        Ok(response) => ProbeResult::failed(
233            id,
234            ProbeClass::Soft,
235            format!("{url} answered {}", response.status().as_u16()),
236            "plan toward the embedded release, or retry when the registry answers",
237        ),
238        Err(source) => ProbeResult::failed(
239            id,
240            ProbeClass::Soft,
241            format!("{url} could not be read: {source}"),
242            "plan toward the embedded release; only a plan toward another release needs the registry",
243        ),
244    }
245}
246
247/// The state root accepts writes; the receipt, the lock, and the journal
248/// live under it.
249fn state_root() -> ProbeResult {
250    let id = "state-root";
251    let Some(root) = resolved_state_root() else {
252        return ProbeResult::failed(
253            id,
254            ProbeClass::Hard,
255            "HOME is not set, so no state root resolves",
256            "export HOME",
257        );
258    };
259    let probe = root.join(format!(".probe-{}", std::process::id()));
260    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
261    let _ = std::fs::remove_file(&probe);
262    match written {
263        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{root} is writable")),
264        Err(source) => ProbeResult::failed(
265            id,
266            ProbeClass::Hard,
267            format!("{root} is not writable: {source}"),
268            format!("make {root} writable"),
269        ),
270    }
271}
272
273/// The destinations `sdd skill install` writes accept writes: the two agent
274/// roots and the resolved state root.
275///
276/// A root can exist and still refuse, which is what a read-only bind of an
277/// agent directory produces, so what is tested is the nearest existing
278/// ancestor — the directory an install would actually have to write
279/// through. The probe creates nothing: a preview must still be able to
280/// report a root as absent, and a probe that made it exist would take that
281/// answer away.
282fn skill_roots() -> ProbeResult {
283    let id = SKILL_PROBES[0];
284    let Ok(home) = home() else {
285        return ProbeResult::failed(
286            id,
287            ProbeClass::Soft,
288            "HOME is not set, so no skill root resolves",
289            "export HOME",
290        );
291    };
292    let mut refused = Vec::new();
293    let mut roots = agent_roots();
294    roots.extend(resolved_state_root());
295    for root in roots {
296        let Some(existing) = nearest_existing(&root) else {
297            refused.push(format!("no ancestor of {root} exists"));
298            continue;
299        };
300        if let Err(source) = accepts_a_write(&existing) {
301            refused.push(format!("{existing} is not writable: {source}"));
302        }
303    }
304    if refused.is_empty() {
305        ProbeResult::ok(
306            id,
307            ProbeClass::Soft,
308            format!("the skill roots under {home} accept writes"),
309        )
310    } else {
311        ProbeResult::failed(
312            id,
313            ProbeClass::Soft,
314            refused.join("; "),
315            format!("make the skill roots under {home} writable"),
316        )
317    }
318}
319
320/// Every installed package carries the two gates it is told to read first.
321///
322/// This is the probe that answers the one failure a shared home produces. A
323/// container, a sandbox, or a sync that carries a `SKILL.md` without the
324/// `references/` beside it leaves the skill resolvable by name and unable
325/// to read the gates its first section names. A skill that cannot read them
326/// runs neither its pre-flight nor its plan phase, which is the whole
327/// reason they are files rather than prose.
328fn skill_gate() -> ProbeResult {
329    let id = SKILL_PROBES[1];
330    let Ok(home) = home() else {
331        return ProbeResult::failed(
332            id,
333            ProbeClass::Soft,
334            "HOME is not set, so no skill package resolves",
335            "export HOME",
336        );
337    };
338    let record = receipt();
339    let references: Vec<(Utf8PathBuf, &'static [u8])> = installed_packages()
340        .into_iter()
341        .filter(|(path, _)| {
342            path.parent()
343                .is_some_and(|parent| parent.file_name() == Some(SKILL_REFERENCES_DIR))
344        })
345        // Only a package that landed is judged: an absent skill is the
346        // payload probe's finding, not this one's.
347        .filter(|(path, _)| {
348            path.parent()
349                .and_then(Utf8Path::parent)
350                .is_some_and(|package| package.join(SKILL_FILE).is_file())
351        })
352        .collect();
353    if references.is_empty() {
354        return ProbeResult::failed(
355            id,
356            ProbeClass::Soft,
357            format!("no installed skill package under {home} carries its gates"),
358            "sdd skill install --apply",
359        );
360    }
361    let found = judge(references, &record);
362    if let Some(first) = found.missing.first() {
363        // The remediation still honours what the rest of the set holds: an
364        // absence beside an edit the record cannot vouch for needs the
365        // force the edit needs, or the named command refuses.
366        return ProbeResult::failed(
367            id,
368            ProbeClass::Soft,
369            format!("a gate every skill reads before acting is not installed: {first}"),
370            reinstall(found.all_recorded),
371        );
372    }
373    if !found.differing.is_empty() {
374        return ProbeResult::failed(
375            id,
376            ProbeClass::Soft,
377            format!(
378                "{} installed gate reference(s) are not this binary's",
379                found.differing.len()
380            ),
381            reinstall(found.all_recorded),
382        );
383    }
384    if let Some(leftover) = retired_shared_leftover(&home, &record) {
385        return ProbeResult::failed(
386            id,
387            ProbeClass::Soft,
388            format!("the retired shared root holds a file no receipt vouches for: {leftover}"),
389            format!("read {leftover}, then remove it; every skill now carries its own gates"),
390        );
391    }
392    ProbeResult::ok(
393        id,
394        ProbeClass::Soft,
395        format!(
396            "{} installed gate reference(s) are this binary's",
397            found.matching
398        ),
399    )
400}
401
402/// A file under the retired shared root that no receipt accounts for.
403///
404/// A sweep takes back what the receipt vouches for. What it leaves is the
405/// operator's, and naming it is the only honest thing a probe can do with a
406/// file this tool refuses to delete.
407fn retired_shared_leftover(home: &Utf8Path, record: &SkillRecord) -> Option<Utf8PathBuf> {
408    let retired = home.join(LEGACY_SHARED_ROOT);
409    let mut found: Vec<Utf8PathBuf> = walkdir::WalkDir::new(retired.as_std_path())
410        .into_iter()
411        .filter_map(Result::ok)
412        .filter(|entry| entry.file_type().is_file())
413        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.into_path()).ok())
414        .filter(|path| {
415            !std::fs::read(path).is_ok_and(|held| record.wrote(path, &Sha256::of(&held)))
416        })
417        .collect();
418    found.sort();
419    found.into_iter().next()
420}
421
422/// The skills installed under this home are the ones this binary carries.
423///
424/// One binary serves every repository, so a skill under an agent root and
425/// the `sdd` on PATH are two artifacts that can be updated apart: a home
426/// shared with a container, a sandbox, or another machine can hold skills
427/// some other build installed. The probe names that drift rather than
428/// leaving an agent to follow instructions the binary no longer answers.
429fn skill_payload() -> ProbeResult {
430    let id = SKILL_PROBES[2];
431    let Ok(home) = home() else {
432        return ProbeResult::failed(
433            id,
434            ProbeClass::Soft,
435            "HOME is not set, so no agent root resolves",
436            "export HOME",
437        );
438    };
439    let record = receipt();
440    let planned = installed_packages();
441    if planned.is_empty() {
442        return ProbeResult::failed(
443            id,
444            ProbeClass::Soft,
445            format!("no agent skill root exists under {home}"),
446            "sdd skill install --apply",
447        );
448    }
449    let found = judge(planned, &record);
450    if let Some(first) = found.missing.first() {
451        // As in the gate probe: an absence beside an unvouched edit needs
452        // the force the edit needs, or the named command refuses.
453        return ProbeResult::failed(
454            id,
455            ProbeClass::Soft,
456            format!(
457                "{} of this binary's package files are not installed, the first at {first}",
458                found.missing.len()
459            ),
460            reinstall(found.all_recorded),
461        );
462    }
463    if !found.differing.is_empty() {
464        return ProbeResult::failed(
465            id,
466            ProbeClass::Soft,
467            format!(
468                "{} installed package file(s) are not this binary's; sdd is {}",
469                found.differing.len(),
470                env!("CARGO_PKG_VERSION")
471            ),
472            reinstall(found.all_recorded),
473        );
474    }
475    ProbeResult::ok(
476        id,
477        ProbeClass::Soft,
478        format!(
479            "{} installed skill destination(s) are this binary's",
480            found.matching
481        ),
482    )
483}
484
485/// What sits at each destination the payload names.
486struct Installed {
487    /// Destinations the payload names that hold no readable file.
488    missing: Vec<Utf8PathBuf>,
489    /// Destinations holding bytes that are not this binary's.
490    differing: Vec<Utf8PathBuf>,
491    /// How many destinations hold exactly this binary's bytes.
492    matching: usize,
493    /// Whether the record vouches for every differing destination, which
494    /// makes the difference a stale install rather than the operator's own
495    /// edit — and decides whether the fix needs `--force`.
496    all_recorded: bool,
497}
498
499/// Judge each destination the payload names against what sits on disk.
500fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &SkillRecord) -> Installed {
501    let mut found = Installed {
502        missing: Vec::new(),
503        differing: Vec::new(),
504        matching: 0,
505        all_recorded: true,
506    };
507    for (destination, bytes) in planned {
508        match std::fs::read(&destination) {
509            Ok(held) if held == bytes => found.matching += 1,
510            Ok(held) => {
511                if !record.wrote(&destination, &Sha256::of(&held)) {
512                    found.all_recorded = false;
513                }
514                found.differing.push(destination);
515            }
516            Err(_) => found.missing.push(destination),
517        }
518    }
519    found
520}
521
522/// The install that corrects a difference. Bytes the record vouches for are
523/// an older release's and go without asking; bytes it cannot account for are
524/// the operator's own, and overwriting those is what `--force` is.
525const fn reinstall(all_recorded: bool) -> &'static str {
526    if all_recorded {
527        "sdd skill install --apply"
528    } else {
529        "sdd skill install --apply --force"
530    }
531}
532
533/// The nearest ancestor of `path`, itself included, that exists as a
534/// directory.
535fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
536    let mut current = Some(path);
537    while let Some(dir) = current {
538        if dir.is_dir() {
539            return Some(dir.to_owned());
540        }
541        current = dir.parent();
542    }
543    None
544}
545
546/// A directory accepts a write, leaving nothing behind.
547fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
548    let probe = dir.join(format!(".sdd-probe-{}", std::process::id()));
549    let written = std::fs::write(&probe, b"probe");
550    let _ = std::fs::remove_file(&probe);
551    written
552}
553
554#[cfg(test)]
555mod tests {
556    #![allow(
557        clippy::unwrap_used,
558        reason = "a test panics as its failure signal, not as control flow"
559    )]
560
561    use super::*;
562
563    fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
564        Utf8PathBuf::from(dir.path().to_str().unwrap())
565    }
566
567    #[test]
568    fn nearest_existing_walks_up_to_the_first_directory() {
569        let dir = tempfile::tempdir().unwrap();
570        let root = utf8(&dir);
571        assert_eq!(nearest_existing(&root).as_deref(), Some(root.as_path()));
572        assert_eq!(
573            nearest_existing(&root.join("a/b/c")).as_deref(),
574            Some(root.as_path())
575        );
576    }
577
578    #[test]
579    fn a_write_probe_leaves_nothing_behind() {
580        let dir = tempfile::tempdir().unwrap();
581        let root = utf8(&dir);
582        accepts_a_write(&root).unwrap();
583        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
584    }
585
586    /// The judge sorts every destination into exactly one bucket, and the
587    /// record decides whether a differing one still counts as the tool's.
588    #[test]
589    fn the_judge_tells_stale_bytes_from_the_users_own() {
590        let dir = tempfile::tempdir().unwrap();
591        let root = utf8(&dir);
592        let matching = root.join("matching.md");
593        let stale = root.join("stale.md");
594        let edited = root.join("edited.md");
595        let missing = root.join("missing.md");
596        std::fs::write(&matching, b"payload").unwrap();
597        std::fs::write(&stale, b"older release").unwrap();
598        std::fs::write(&edited, b"the user's own").unwrap();
599
600        let mut record = SkillRecord::new();
601        record
602            .written
603            .insert(stale.clone(), Sha256::of(b"older release"));
604
605        let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![
606            (matching, b"payload"),
607            (stale, b"payload"),
608            (missing.clone(), b"payload"),
609        ];
610        let found = judge(planned, &record);
611        assert_eq!(found.matching, 1);
612        assert_eq!(found.differing.len(), 1);
613        assert_eq!(found.missing, vec![missing]);
614        assert!(found.all_recorded, "the record vouches for the stale copy");
615
616        let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![(edited, b"payload")];
617        let found = judge(planned, &record);
618        assert!(
619            !found.all_recorded,
620            "bytes the record cannot account for are the user's"
621        );
622    }
623
624    #[test]
625    fn the_reinstall_needs_force_only_over_the_users_bytes() {
626        assert_eq!(reinstall(true), "sdd skill install --apply");
627        assert_eq!(reinstall(false), "sdd skill install --apply --force");
628    }
629}