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::skill_record::{RECORD_PATH, SkillRecord};
16use crate::services::skill_installer::{AGENTS_ROOT, CLAUDE_ROOT, SHARED_ROOT, home};
17
18/// How a failure weighs at the doctor level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22    /// No install under the user's home can work without this.
23    Hard,
24    /// Needed only by some commands or some tasks.
25    Soft,
26}
27
28/// What a probe found.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32    /// The probe passed.
33    Ok,
34    /// The probe failed; the remediation says what fixes it.
35    Failed,
36}
37
38/// One probe's answer.
39#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41    /// The probe's stable name.
42    pub id: &'static str,
43    /// How the failure weighs.
44    pub class: ProbeClass,
45    /// What was found.
46    pub status: ProbeStatus,
47    /// What was found, one line.
48    pub message: String,
49    /// The exact fix, when the probe failed.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56        Self {
57            id,
58            class,
59            status: ProbeStatus::Ok,
60            message: message.into(),
61            remediation: None,
62        }
63    }
64
65    fn failed(
66        id: &'static str,
67        class: ProbeClass,
68        message: impl Into<String>,
69        remediation: impl Into<String>,
70    ) -> Self {
71        Self {
72            id,
73            class,
74            status: ProbeStatus::Failed,
75            message: message.into(),
76            remediation: Some(remediation.into()),
77        }
78    }
79}
80
81/// The probes judging the skill installation itself, in catalog order.
82///
83/// Declared here rather than derived by running the catalog: the shared
84/// pre-flight gate must name each of these, and the test holding it to that
85/// must not have to write into the operator's home to learn what they are.
86pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
87
88/// Run the whole catalog, in its stable order.
89#[must_use]
90pub fn run_all() -> Vec<ProbeResult> {
91    vec![
92        state_root(),
93        skill_roots(),
94        skill_gate(),
95        skill_payload(),
96        tool(
97            "git",
98            "SDD_GIT_BIN",
99            "git",
100            "git; retiring a migrated document is safe only where version control restores it",
101            &["--version"],
102        ),
103        tool(
104            "pre-commit",
105            "SDD_PRE_COMMIT_BIN",
106            "pre-commit",
107            "pre-commit; the delivered gates run through it",
108            &["--version"],
109        ),
110    ]
111}
112
113/// A helper binary answers its version call. `env_override` names the
114/// substitute, which is also what keeps tests hermetic; presence is the
115/// whole question, because the tools here take no configuration.
116fn tool(
117    id: &'static str,
118    env_override: &str,
119    default_bin: &str,
120    label: &str,
121    args: &[&str],
122) -> ProbeResult {
123    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
124    match Command::new(&bin).args(args).output() {
125        Ok(out) if out.status.success() => {
126            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
127        }
128        Ok(_) => ProbeResult::failed(
129            id,
130            ProbeClass::Soft,
131            format!("{default_bin} does not answer {}", args.join(" ")),
132            format!("repair {label}"),
133        ),
134        Err(_) => ProbeResult::failed(
135            id,
136            ProbeClass::Soft,
137            format!("{default_bin} is not on PATH"),
138            format!("install {label}"),
139        ),
140    }
141}
142
143/// The state root accepts writes; the skill record and the shared artifacts
144/// live under it.
145fn state_root() -> ProbeResult {
146    let id = "state-root";
147    let Ok(home) = home() else {
148        return ProbeResult::failed(
149            id,
150            ProbeClass::Hard,
151            "HOME is not set, so no state root resolves",
152            "export HOME",
153        );
154    };
155    let root = home.join(".local/state/spec-driven-docs");
156    let probe = root.join(format!(".probe-{}", std::process::id()));
157    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
158    let _ = std::fs::remove_file(&probe);
159    match written {
160        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{root} is writable")),
161        Err(source) => ProbeResult::failed(
162            id,
163            ProbeClass::Hard,
164            format!("{root} is not writable: {source}"),
165            format!("make {root} writable"),
166        ),
167    }
168}
169
170/// The destinations `sdd skill install` writes accept writes: the two agent
171/// roots and the shared root, all under the invoking user's home.
172///
173/// A root can exist and still refuse, which is what a read-only bind of an
174/// agent directory produces, so what is tested is the nearest existing
175/// ancestor — the directory an install would actually have to write
176/// through. The probe creates nothing: a preview must still be able to
177/// report a root as absent, and a probe that made it exist would take that
178/// answer away.
179fn skill_roots() -> ProbeResult {
180    let id = SKILL_PROBES[0];
181    let Ok(home) = home() else {
182        return ProbeResult::failed(
183            id,
184            ProbeClass::Soft,
185            "HOME is not set, so no skill root resolves",
186            "export HOME",
187        );
188    };
189    let mut refused = Vec::new();
190    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
191        let root = home.join(root);
192        let Some(existing) = nearest_existing(&root) else {
193            refused.push(format!("no ancestor of {root} exists"));
194            continue;
195        };
196        if let Err(source) = accepts_a_write(&existing) {
197            refused.push(format!("{existing} is not writable: {source}"));
198        }
199    }
200    if refused.is_empty() {
201        ProbeResult::ok(
202            id,
203            ProbeClass::Soft,
204            format!("the skill roots under {home} accept writes"),
205        )
206    } else {
207        ProbeResult::failed(
208            id,
209            ProbeClass::Soft,
210            refused.join("; "),
211            format!("make the skill roots under {home} writable"),
212        )
213    }
214}
215
216/// The artifacts every skill shares are installed, and are this binary's.
217///
218/// This is the probe that answers the one failure a shared home produces.
219/// The agent roots and the shared root are separate directories, so a
220/// container, a sandbox, or a sync that carries one and not the other
221/// leaves every skill resolvable by name and unable to read the gates it is
222/// told to read first. A skill that cannot read them runs neither its
223/// pre-flight nor its plan phase, which is the whole reason they are files
224/// rather than prose.
225fn skill_gate() -> ProbeResult {
226    let id = SKILL_PROBES[1];
227    let Ok(home) = home() else {
228        return ProbeResult::failed(
229            id,
230            ProbeClass::Soft,
231            "HOME is not set, so the shared root does not resolve",
232            "export HOME",
233        );
234    };
235    if let Some(link) = shared_chain_symlink(&home) {
236        return ProbeResult::failed(
237            id,
238            ProbeClass::Soft,
239            format!("the shared root is reached through a symlink: {link}"),
240            "remove the symlink; sdd skill install refuses to write through it",
241        );
242    }
243    let root = home.join(SHARED_ROOT);
244    let record = SkillRecord::load(&home.join(RECORD_PATH));
245    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::embedded::shared_artifacts()
246        .into_iter()
247        .map(|(path, bytes)| (root.join(path), bytes))
248        .collect();
249    let found = judge(planned, &record);
250    if let Some(first) = found.missing.first() {
251        // The remediation still honours what the rest of the set holds: an
252        // absence beside an edit the record cannot vouch for needs the
253        // force the edit needs, or the named command refuses.
254        return ProbeResult::failed(
255            id,
256            ProbeClass::Soft,
257            format!("a shared artifact every skill reads before acting is not installed: {first}"),
258            reinstall(found.all_recorded),
259        );
260    }
261    if !found.differing.is_empty() {
262        return ProbeResult::failed(
263            id,
264            ProbeClass::Soft,
265            format!(
266                "{} shared artifact(s) under {root} are not this binary's",
267                found.differing.len()
268            ),
269            reinstall(found.all_recorded),
270        );
271    }
272    ProbeResult::ok(
273        id,
274        ProbeClass::Soft,
275        format!("{root} holds this binary's shared artifacts"),
276    )
277}
278
279/// The skills installed under this home are the ones this binary carries.
280///
281/// One binary serves every repository, so a skill under an agent root and
282/// the `sdd` on PATH are two artifacts that can be updated apart: a home
283/// shared with a container, a sandbox, or another machine can hold skills
284/// some other build installed. The probe names that drift rather than
285/// leaving an agent to follow instructions the binary no longer answers.
286fn skill_payload() -> ProbeResult {
287    let id = SKILL_PROBES[2];
288    let Ok(home) = home() else {
289        return ProbeResult::failed(
290            id,
291            ProbeClass::Soft,
292            "HOME is not set, so no agent root resolves",
293            "export HOME",
294        );
295    };
296    let record = SkillRecord::load(&home.join(RECORD_PATH));
297    let mut planned = Vec::new();
298    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
299        let root = home.join(root);
300        // An absent agent root is a choice, not a defect: `--agent` selects
301        // one family and leaves the other's root untouched.
302        if !root.is_dir() {
303            continue;
304        }
305        for name in crate::embedded::skill_names() {
306            let Some(text) = crate::embedded::skill(name) else {
307                return ProbeResult::failed(
308                    id,
309                    ProbeClass::Soft,
310                    "this binary's embedded skills do not read",
311                    "reinstall sdd; the payload it was built from is defective",
312                );
313            };
314            planned.push((root.join(name).join("SKILL.md"), text.as_bytes()));
315        }
316    }
317    if planned.is_empty() {
318        return ProbeResult::failed(
319            id,
320            ProbeClass::Soft,
321            format!("no agent skill root exists under {home}"),
322            "sdd skill install --apply",
323        );
324    }
325    let found = judge(planned, &record);
326    if let Some(first) = found.missing.first() {
327        // As in the gate probe: an absence beside an unvouched edit needs
328        // the force the edit needs, or the named command refuses.
329        return ProbeResult::failed(
330            id,
331            ProbeClass::Soft,
332            format!(
333                "{} of this binary's skills are not installed, the first at {first}",
334                found.missing.len()
335            ),
336            reinstall(found.all_recorded),
337        );
338    }
339    if !found.differing.is_empty() {
340        return ProbeResult::failed(
341            id,
342            ProbeClass::Soft,
343            format!(
344                "{} installed skill(s) are not this binary's; sdd is {}",
345                found.differing.len(),
346                env!("CARGO_PKG_VERSION")
347            ),
348            reinstall(found.all_recorded),
349        );
350    }
351    ProbeResult::ok(
352        id,
353        ProbeClass::Soft,
354        format!(
355            "{} installed skill destination(s) are this binary's",
356            found.matching
357        ),
358    )
359}
360
361/// What sits at each destination the payload names.
362struct Installed {
363    /// Destinations the payload names that hold no readable file.
364    missing: Vec<Utf8PathBuf>,
365    /// Destinations holding bytes that are not this binary's.
366    differing: Vec<Utf8PathBuf>,
367    /// How many destinations hold exactly this binary's bytes.
368    matching: usize,
369    /// Whether the record vouches for every differing destination, which
370    /// makes the difference a stale install rather than the operator's own
371    /// edit — and decides whether the fix needs `--force`.
372    all_recorded: bool,
373}
374
375/// Judge each destination the payload names against what sits on disk.
376fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &SkillRecord) -> Installed {
377    let mut found = Installed {
378        missing: Vec::new(),
379        differing: Vec::new(),
380        matching: 0,
381        all_recorded: true,
382    };
383    for (destination, bytes) in planned {
384        match std::fs::read(&destination) {
385            Ok(held) if held == bytes => found.matching += 1,
386            Ok(held) => {
387                if !record.wrote(&destination, &Sha256::of(&held)) {
388                    found.all_recorded = false;
389                }
390                found.differing.push(destination);
391            }
392            Err(_) => found.missing.push(destination),
393        }
394    }
395    found
396}
397
398/// The install that corrects a difference. Bytes the record vouches for are
399/// an older release's and go without asking; bytes it cannot account for are
400/// the operator's own, and overwriting those is what `--force` is.
401const fn reinstall(all_recorded: bool) -> &'static str {
402    if all_recorded {
403        "sdd skill install --apply"
404    } else {
405        "sdd skill install --apply --force"
406    }
407}
408
409/// A symlink in the tool-owned chain from the state directory down to the
410/// shared root. The installer refuses to write through one, so a probe that
411/// passed it would report a host whose prescribed install cannot run.
412fn shared_chain_symlink(home: &Utf8Path) -> Option<Utf8PathBuf> {
413    let record = home.join(RECORD_PATH);
414    let state_dir = record.parent()?;
415    let shared = home.join(SHARED_ROOT);
416    let mut current = Some(shared.as_path());
417    while let Some(dir) = current {
418        if !dir.starts_with(state_dir) {
419            break;
420        }
421        if dir.is_symlink() {
422            return Some(dir.to_owned());
423        }
424        current = dir.parent();
425    }
426    None
427}
428
429/// The nearest ancestor of `path`, itself included, that exists as a
430/// directory.
431fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
432    let mut current = Some(path);
433    while let Some(dir) = current {
434        if dir.is_dir() {
435            return Some(dir.to_owned());
436        }
437        current = dir.parent();
438    }
439    None
440}
441
442/// A directory accepts a write, leaving nothing behind.
443fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
444    let probe = dir.join(format!(".sdd-probe-{}", std::process::id()));
445    let written = std::fs::write(&probe, b"probe");
446    let _ = std::fs::remove_file(&probe);
447    written
448}
449
450#[cfg(test)]
451mod tests {
452    #![allow(
453        clippy::unwrap_used,
454        reason = "a test panics as its failure signal, not as control flow"
455    )]
456
457    use super::*;
458
459    fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
460        Utf8PathBuf::from(dir.path().to_str().unwrap())
461    }
462
463    #[test]
464    fn nearest_existing_walks_up_to_the_first_directory() {
465        let dir = tempfile::tempdir().unwrap();
466        let root = utf8(&dir);
467        assert_eq!(nearest_existing(&root).as_deref(), Some(root.as_path()));
468        assert_eq!(
469            nearest_existing(&root.join("a/b/c")).as_deref(),
470            Some(root.as_path())
471        );
472    }
473
474    #[test]
475    fn a_write_probe_leaves_nothing_behind() {
476        let dir = tempfile::tempdir().unwrap();
477        let root = utf8(&dir);
478        accepts_a_write(&root).unwrap();
479        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
480    }
481
482    /// The judge sorts every destination into exactly one bucket, and the
483    /// record decides whether a differing one still counts as the tool's.
484    #[test]
485    fn the_judge_tells_stale_bytes_from_the_users_own() {
486        let dir = tempfile::tempdir().unwrap();
487        let root = utf8(&dir);
488        let matching = root.join("matching.md");
489        let stale = root.join("stale.md");
490        let edited = root.join("edited.md");
491        let missing = root.join("missing.md");
492        std::fs::write(&matching, b"payload").unwrap();
493        std::fs::write(&stale, b"older release").unwrap();
494        std::fs::write(&edited, b"the user's own").unwrap();
495
496        let mut record = SkillRecord::new();
497        record
498            .written
499            .insert(stale.clone(), Sha256::of(b"older release"));
500
501        let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![
502            (matching, b"payload"),
503            (stale, b"payload"),
504            (missing.clone(), b"payload"),
505        ];
506        let found = judge(planned, &record);
507        assert_eq!(found.matching, 1);
508        assert_eq!(found.differing.len(), 1);
509        assert_eq!(found.missing, vec![missing]);
510        assert!(found.all_recorded, "the record vouches for the stale copy");
511
512        let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![(edited, b"payload")];
513        let found = judge(planned, &record);
514        assert!(
515            !found.all_recorded,
516            "bytes the record cannot account for are the user's"
517        );
518    }
519
520    #[test]
521    fn the_reinstall_needs_force_only_over_the_users_bytes() {
522        assert_eq!(reinstall(true), "sdd skill install --apply");
523        assert_eq!(reinstall(false), "sdd skill install --apply --force");
524    }
525}