Skip to main content

clawft_kernel/model_manifest/
advertise.rs

1//! Storage tiers and the capabilities a node advertises for the models it
2//! holds (ADR-101 section 3; ADR-099 sections 2 and 6).
3//!
4//! `model.present` carries a `shards` list holding the BLAKE3 of every shard
5//! the node has, plus one `model:<package id>` marker naming the attested
6//! manifest, so a requirement can match one exact model with a single
7//! `has` predicate. Only a complete, present model is `Available`; a partial
8//! holding or a detached store is `Degraded` (not offered to the placer, but
9//! visible, and the partial `shards` list still tells the fetch planner what
10//! this node can serve). Paths, drive labels and mount points are never
11//! advertised.
12
13use std::path::{Path, PathBuf};
14
15use clawft_types::placement::{AttrValue, Capability, CapabilityState, Provenance};
16
17use super::body::MODEL_MARKER_PREFIX;
18use super::check::{CheckMode, ModelState};
19use super::registry::ModelRegistry;
20use crate::node_facts::probe::{cap, str_list};
21
22/// Which kind of storage a model root lies on.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum StoreTier {
25    /// The machine's own disk.
26    Internal,
27    /// A removable or attached drive that can vanish.
28    External,
29}
30
31/// Classifies a path into a [`StoreTier`] and finds its mount point.
32///
33/// A path under one of the `external_roots` (for example `/Volumes`) lies on
34/// an external drive whose mount point is that root plus one component.
35#[derive(Debug, Clone)]
36pub struct TierResolver {
37    external_roots: Vec<PathBuf>,
38}
39
40impl TierResolver {
41    /// The conventional external mount parents for this OS.
42    pub fn system_default() -> Self {
43        let roots: &[&str] = if cfg!(target_os = "macos") {
44            &["/Volumes"]
45        } else {
46            &["/media", "/mnt", "/run/media"]
47        };
48        Self::with_external_roots(roots.iter().map(PathBuf::from).collect())
49    }
50
51    /// Explicit external mount parents (tests use a temp directory).
52    pub fn with_external_roots(external_roots: Vec<PathBuf>) -> Self {
53        Self { external_roots }
54    }
55
56    /// The tier of `path` and, for an external path, its mount point.
57    pub fn classify(&self, path: &Path) -> (StoreTier, Option<PathBuf>) {
58        for root in &self.external_roots {
59            if let Ok(rest) = path.strip_prefix(root)
60                && let Some(first) = rest.components().next()
61            {
62                return (StoreTier::External, Some(root.join(first)));
63            }
64        }
65        (StoreTier::Internal, None)
66    }
67
68    /// True when the mount point of an external path is present. `Volumes`
69    /// entries disappear when a drive is ejected.
70    pub fn mounted(&self, mount: &Path) -> bool {
71        mount.is_dir()
72    }
73}
74
75impl Default for TierResolver {
76    fn default() -> Self {
77        Self::system_default()
78    }
79}
80
81/// The capabilities for every adopted model, from existence and size checks
82/// only (this never reads weight content, so it is cheap enough for every
83/// facts refresh). Also adds `store.tier.external` with `mounted = false`
84/// (state `Degraded`) when a model's external drive is detached, so the
85/// advertised state changes when the drive vanishes. All are `Probed`.
86pub fn model_capabilities(registry: &ModelRegistry, tiers: &TierResolver) -> Vec<Capability> {
87    let mut out = Vec::new();
88    let mut detached = false;
89    for (id, entry) in registry.entries() {
90        if entry.hidden {
91            // The operator opted this model out of advertising.
92            continue;
93        }
94        let Ok(check) = registry.check(&id, CheckMode::Stat) else {
95            continue;
96        };
97        // A refused model (hash or size mismatch, escaping link, failed
98        // attestation, revocation) advertises no shard hashes: only an
99        // impaired, empty `model.present`, which the placer never offers.
100        let untrusted = registry.untrusted(&id, &entry);
101        if matches!(check.state, ModelState::Refused { .. }) || entry.refused.is_some() || untrusted {
102            if let Some(c) = cap("model.present", Provenance::Probed) {
103                out.push(
104                    c.with_attr("shards", str_list::<&str>(&[]))
105                        .with_state(CapabilityState::Degraded),
106                );
107            }
108            continue;
109        }
110        let (tier, mount) = tiers.classify(&entry.root);
111        let drive_gone = tier == StoreTier::External
112            && mount.as_deref().is_some_and(|m| !tiers.mounted(m));
113        detached |= drive_gone;
114        let complete = check.present_shards.len() == check.total_shards;
115        let state = match (&check.state, complete) {
116            (ModelState::Ready, true) if !drive_gone => CapabilityState::Available,
117            _ => CapabilityState::Degraded,
118        };
119        let mut shards: Vec<String> = Vec::new();
120        if state == CapabilityState::Available || drive_gone {
121            shards.push(format!("{MODEL_MARKER_PREFIX}{id}"));
122        }
123        if drive_gone && let Ok(body) = entry.body() {
124            // Offline, but still the node's attested holding.
125            shards.extend(body.shard_hashes().map(String::from));
126        } else {
127            shards.extend(check.present_shards.iter().cloned());
128        }
129        if shards.is_empty() {
130            continue;
131        }
132        if let Some(c) = cap("model.present", Provenance::Probed) {
133            out.push(c.with_attr("shards", str_list(&shards)).with_state(state));
134        }
135    }
136    if detached
137        && let Some(c) = cap("store.tier.external", Provenance::Probed)
138    {
139        out.push(
140            c.with_attr("mounted", AttrValue::Bool(false))
141                .with_state(CapabilityState::Degraded),
142        );
143    }
144    out
145}