Skip to main content

rch_common/
worker_facts.rs

1//! Worker capability fact schema — exact host/user/path inventory.
2//!
3//! [`WorkerFacts`] is the authoritative, exhaustive description of what a worker
4//! actually is and can do: its OS/arch/libc, the remote user and its home/temp/
5//! build roots, the exact `rch-wkr` path + version/protocol, the Rust
6//! toolchains/targets and cargo path, the JS runtimes, the disk roots, and the
7//! artifact platforms it can execute. Unlike the lightweight routing hints in
8//! [`crate::types::WorkerCapabilities`], facts are *exact* and path-scoped, so
9//! capability refresh, selection, fleet update, admission, status, and incident
10//! events can all reason from one schema instead of re-probing ad hoc.
11//!
12//! The schema is versioned via [`SchemaComponent::WorkerFacts`] and exports a
13//! JSON Schema through [`worker_facts_schema`].
14
15use schemars::generate::SchemaSettings;
16use schemars::{JsonSchema, Schema};
17use serde::{Deserialize, Serialize};
18
19use crate::schema_versions::{SchemaComponent, current_version};
20
21/// Host-level facts (OS, arch, libc, target triple, runnable platforms).
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23pub struct HostFacts {
24    /// OS family: `linux`, `macos`, `windows`, …
25    pub os: String,
26    /// CPU arch: `x86_64`, `aarch64`, …
27    pub arch: String,
28    /// libc flavor where it matters: `gnu`, `musl` (`None` on non-linux).
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub libc: Option<String>,
31    /// Login shell, if known.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub shell: Option<String>,
34    /// The worker's own Rust target triple (e.g. `x86_64-unknown-linux-gnu`).
35    pub target_triple: String,
36    /// Target triples whose artifacts this worker can *execute* (its own triple
37    /// plus any it is binary-compatible with). Drives fleet-update artifact
38    /// selection and wrong-arch refusal.
39    #[serde(default)]
40    pub artifact_platforms: Vec<String>,
41}
42
43/// Remote-user and filesystem-root facts.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45pub struct UserFacts {
46    /// The SSH login user the facts were collected as.
47    pub remote_user: String,
48    /// That user's home directory.
49    pub home: String,
50    /// Resolved temp root (`$TMPDIR` → `/data/tmp` → `/tmp`).
51    pub temp_root: String,
52    /// Build roots this user offloads into (e.g. pooled target dirs).
53    #[serde(default)]
54    pub build_roots: Vec<String>,
55}
56
57/// Facts about the deployed `rch-wkr` binary at its exact path.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59pub struct WorkerBinaryFacts {
60    /// Exact path to the `rch-wkr` binary that answered.
61    pub rch_wkr_path: String,
62    /// `rch-wkr --version` string.
63    pub version: String,
64    /// Wire protocol version the worker speaks.
65    pub protocol_version: u32,
66}
67
68/// Rust toolchain facts.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
70pub struct RustFacts {
71    /// Exact `cargo` path, if present.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub cargo_path: Option<String>,
74    /// `rustc --version`, if present.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub rustc_version: Option<String>,
77    /// Installed toolchains (`rustup toolchain list`), e.g. `stable`,
78    /// `nightly-2026-05-22`.
79    #[serde(default)]
80    pub toolchains: Vec<String>,
81    /// Installed rustup targets, e.g. `wasm32-unknown-unknown`.
82    #[serde(default)]
83    pub targets: Vec<String>,
84    /// Installed rustup components, qualified by the toolchain that owns them:
85    /// `"<toolchain>:<component>"`, e.g.
86    /// `"nightly-2026-07-05-x86_64-unknown-linux-gnu:clippy"`.
87    ///
88    /// Qualified rather than bare because components are installed against a
89    /// specific toolchain: a worker holding `clippy` for `stable` still cannot
90    /// run `cargo clippy` under a project's pinned nightly (bd-vc61a).
91    #[serde(default)]
92    pub components: Vec<String>,
93}
94
95/// JS-runtime facts.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
97pub struct RuntimeFacts {
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub bun_version: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub node_version: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub npm_version: Option<String>,
104    /// Nix version (from `nix --version`, gated on a populated `/nix/store`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub nix_version: Option<String>,
107    /// Go toolchain version (from `go version`).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub go_version: Option<String>,
110    /// Zig version (from `zig version`). Gates `cargo zigbuild` routing together
111    /// with `cargo_zigbuild_version` — both must be present.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub zig_version: Option<String>,
114    /// cargo-zigbuild version (from `cargo-zigbuild --version`).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub cargo_zigbuild_version: Option<String>,
117}
118
119/// One filesystem root's capacity (exact, path-scoped).
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121pub struct DiskRootFacts {
122    pub path: String,
123    pub total_bytes: u64,
124    pub available_bytes: u64,
125    pub available_inodes: u64,
126}
127
128/// All exact disk roots a worker reports (temp root, build roots, cargo home).
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
130pub struct DiskFacts {
131    #[serde(default)]
132    pub roots: Vec<DiskRootFacts>,
133}
134
135/// The exhaustive worker capability fact record.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137pub struct WorkerFacts {
138    /// Schema version (`SchemaComponent::WorkerFacts`).
139    pub schema_version: String,
140    /// Worker id these facts describe.
141    pub worker_id: String,
142    /// Collection time as Unix epoch milliseconds. Caller-supplied so the
143    /// schema is deterministic in tests / golden artifacts.
144    pub collected_at_unix_ms: u64,
145    pub host: HostFacts,
146    pub user: UserFacts,
147    pub worker: WorkerBinaryFacts,
148    #[serde(default)]
149    pub rust: RustFacts,
150    #[serde(default)]
151    pub runtimes: RuntimeFacts,
152    #[serde(default)]
153    pub disk: DiskFacts,
154}
155
156impl WorkerFacts {
157    /// Construct facts with the current schema version stamped in.
158    #[must_use]
159    pub fn new(
160        worker_id: impl Into<String>,
161        collected_at_unix_ms: u64,
162        host: HostFacts,
163        user: UserFacts,
164        worker: WorkerBinaryFacts,
165    ) -> Self {
166        Self {
167            schema_version: worker_facts_schema_version().to_string(),
168            worker_id: worker_id.into(),
169            collected_at_unix_ms,
170            host,
171            user,
172            worker,
173            rust: RustFacts::default(),
174            runtimes: RuntimeFacts::default(),
175            disk: DiskFacts::default(),
176        }
177    }
178
179    /// Whether this worker can *execute* artifacts for `triple` (its own triple
180    /// or a declared compatible platform). Used by fleet update / admission /
181    /// wrong-arch refusal.
182    #[must_use]
183    pub fn supports_target(&self, triple: &str) -> bool {
184        self.host.target_triple == triple
185            || self.host.artifact_platforms.iter().any(|p| p == triple)
186    }
187
188    /// Whether a named rustup toolchain is installed.
189    ///
190    /// Matches the exact channel/pinned name OR a `-`-suffixed host-triple form
191    /// (so `nightly` matches `nightly-2026-05-22` and `nightly-x86_64-...`). The
192    /// `-` boundary is required so `nightly` does NOT match a hypothetical
193    /// `nightlyfoo`, and `nightly-2025-11` does not match `nightly-2025-110`
194    /// (bd-review-toolchain-prefix).
195    #[must_use]
196    pub fn has_toolchain(&self, name: &str) -> bool {
197        self.rust
198            .toolchains
199            .iter()
200            .any(|t| t == name || t.starts_with(&format!("{name}-")))
201    }
202
203    /// Whether a rustup target is installed (e.g. `wasm32-unknown-unknown`).
204    #[must_use]
205    pub fn has_target(&self, target: &str) -> bool {
206        self.rust.targets.iter().any(|t| t == target)
207    }
208
209    /// Whether `component` is installed for `toolchain` (e.g. `clippy` for
210    /// `nightly`).
211    ///
212    /// The toolchain half is matched with the same `-` boundary rule as
213    /// [`WorkerFacts::has_toolchain`], so `nightly` matches a pinned
214    /// `nightly-2026-07-05-x86_64-unknown-linux-gnu` but never a hypothetical
215    /// `nightlyfoo`. The component half must match exactly: `clippy` must not be
216    /// satisfied by `clippy-preview`, because the two are not interchangeable at
217    /// the `cargo clippy` call site.
218    ///
219    /// Absence means "not known to be installed", never "known to be absent".
220    /// A worker whose component listing could not be read reports nothing here,
221    /// so callers must treat `false` as a reason to avoid routing, not as
222    /// evidence about the host (bd-vc61a).
223    #[must_use]
224    pub fn has_component(&self, toolchain: &str, component: &str) -> bool {
225        let prefix = format!("{toolchain}-");
226        self.rust.components.iter().any(|entry| {
227            let Some((owner, name)) = entry.split_once(':') else {
228                return false;
229            };
230            name == component && (owner == toolchain || owner.starts_with(&prefix))
231        })
232    }
233}
234
235/// The current worker-facts schema version string.
236#[must_use]
237pub fn worker_facts_schema_version() -> &'static str {
238    current_version(SchemaComponent::WorkerFacts)
239}
240
241/// Export the JSON Schema for [`WorkerFacts`] (for `--schema` surfaces and
242/// contract drift tests).
243#[must_use]
244pub fn worker_facts_schema() -> Schema {
245    SchemaSettings::draft07()
246        .into_generator()
247        .into_root_schema_for::<WorkerFacts>()
248}
249
250/// Derive a Rust target triple from coarse host facts. Helper for facts
251/// collectors and the fleet-update artifact resolver.
252#[must_use]
253pub fn derive_target_triple(os: &str, arch: &str, libc: Option<&str>) -> String {
254    match os {
255        "linux" => {
256            let env = libc.unwrap_or("gnu");
257            format!("{arch}-unknown-linux-{env}")
258        }
259        "macos" => format!("{arch}-apple-darwin"),
260        "windows" => format!("{arch}-pc-windows-msvc"),
261        other => format!("{arch}-unknown-{other}"),
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn sample() -> WorkerFacts {
270        WorkerFacts::new(
271            "css",
272            1_700_000_000_000,
273            HostFacts {
274                os: "linux".to_string(),
275                arch: "x86_64".to_string(),
276                libc: Some("gnu".to_string()),
277                shell: Some("/bin/bash".to_string()),
278                target_triple: "x86_64-unknown-linux-gnu".to_string(),
279                artifact_platforms: vec!["x86_64-unknown-linux-gnu".to_string()],
280            },
281            UserFacts {
282                remote_user: "rch".to_string(),
283                home: "/home/rch".to_string(),
284                temp_root: "/data/tmp".to_string(),
285                build_roots: vec!["/data/tmp/rch-targets".to_string()],
286            },
287            WorkerBinaryFacts {
288                rch_wkr_path: "/home/rch/.local/bin/rch-wkr".to_string(),
289                version: "1.0.41".to_string(),
290                protocol_version: 3,
291            },
292        )
293    }
294
295    #[test]
296    fn new_stamps_current_schema_version() {
297        assert_eq!(sample().schema_version, "1.0.0");
298        assert_eq!(worker_facts_schema_version(), "1.0.0");
299    }
300
301    #[test]
302    fn serde_roundtrips() {
303        let f = sample();
304        let json = serde_json::to_string(&f).unwrap();
305        let back: WorkerFacts = serde_json::from_str(&json).unwrap();
306        assert_eq!(f, back);
307    }
308
309    #[test]
310    fn top_level_field_names_are_stable() {
311        let v = serde_json::to_value(sample()).unwrap();
312        let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
313        keys.sort_unstable();
314        assert_eq!(
315            keys,
316            vec![
317                "collected_at_unix_ms",
318                "disk",
319                "host",
320                "runtimes",
321                "rust",
322                "schema_version",
323                "user",
324                "worker",
325                "worker_id",
326            ]
327        );
328    }
329
330    #[test]
331    fn host_facts_omit_optional_fields_when_absent() {
332        let v = serde_json::to_value(HostFacts {
333            os: "linux".to_string(),
334            arch: "aarch64".to_string(),
335            libc: None,
336            shell: None,
337            target_triple: "aarch64-unknown-linux-musl".to_string(),
338            artifact_platforms: vec![],
339        })
340        .unwrap();
341        assert!(v.get("libc").is_none());
342        assert!(v.get("shell").is_none());
343        // Required fields are always present.
344        assert_eq!(v["os"], "linux");
345        assert_eq!(v["target_triple"], "aarch64-unknown-linux-musl");
346    }
347
348    #[test]
349    fn supports_target_matches_own_triple_and_platforms() {
350        let mut f = sample();
351        assert!(f.supports_target("x86_64-unknown-linux-gnu"));
352        assert!(!f.supports_target("aarch64-apple-darwin"));
353        f.host
354            .artifact_platforms
355            .push("x86_64-unknown-linux-musl".to_string());
356        assert!(f.supports_target("x86_64-unknown-linux-musl"));
357    }
358
359    #[test]
360    fn has_toolchain_prefix_and_target_exact() {
361        let mut f = sample();
362        f.rust.toolchains = vec!["stable".to_string(), "nightly-2026-05-22".to_string()];
363        f.rust.targets = vec!["wasm32-unknown-unknown".to_string()];
364        assert!(f.has_toolchain("stable"));
365        assert!(f.has_toolchain("nightly")); // prefix
366        assert!(!f.has_toolchain("beta"));
367        assert!(f.has_target("wasm32-unknown-unknown"));
368        assert!(!f.has_target("wasm32-wasi"));
369    }
370
371    #[test]
372    fn has_component_is_toolchain_scoped_and_component_exact() {
373        let mut f = sample();
374        f.rust.components = vec![
375            "stable:clippy".to_string(),
376            "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
377            "nightlyfoo:clippy".to_string(),
378            "beta:clippy-preview".to_string(),
379        ];
380
381        // Exact toolchain, and the `-` boundary form a pinned nightly takes.
382        assert!(f.has_component("stable", "clippy"));
383        assert!(f.has_component("nightly", "rustfmt"));
384        assert!(f.has_component("nightly-2026-07-05-x86_64-unknown-linux-gnu", "rustfmt"));
385
386        // The exact failure this fact exists to catch: clippy installed for
387        // SOME toolchain must not answer for a different pinned one. This is the
388        // state that let workers report healthy while voiding a lint gate.
389        assert!(!f.has_component("nightly", "clippy"));
390
391        // Same `-` boundary discipline as `has_toolchain`: `nightly` must not be
392        // satisfied by a toolchain merely starting with those letters.
393        assert!(!f.has_component("nightl", "clippy"));
394
395        // Component half is exact: `clippy-preview` is not `clippy` at the
396        // `cargo clippy` call site.
397        assert!(!f.has_component("beta", "clippy"));
398
399        // Unknown toolchain/component answers false rather than panicking.
400        assert!(!f.has_component("stable", "rust-analyzer"));
401        assert!(!f.has_component("does-not-exist", "clippy"));
402    }
403
404    #[test]
405    fn derive_target_triple_covers_platforms() {
406        assert_eq!(
407            derive_target_triple("linux", "x86_64", Some("gnu")),
408            "x86_64-unknown-linux-gnu"
409        );
410        assert_eq!(
411            derive_target_triple("linux", "aarch64", Some("musl")),
412            "aarch64-unknown-linux-musl"
413        );
414        assert_eq!(
415            derive_target_triple("linux", "x86_64", None),
416            "x86_64-unknown-linux-gnu"
417        );
418        assert_eq!(
419            derive_target_triple("macos", "aarch64", None),
420            "aarch64-apple-darwin"
421        );
422    }
423
424    #[test]
425    fn json_schema_is_generatable_and_describes_the_record() {
426        // "Tests must validate JSON schema" — the schema must generate and
427        // mention WorkerFacts' fields. Assert on the serialized schema text so
428        // the check is robust to schemars' inline-vs-definitions layout.
429        let schema = worker_facts_schema();
430        let text = serde_json::to_string(&schema).expect("schema serializes");
431        assert!(text.contains("WorkerFacts"));
432        for field in [
433            "schema_version",
434            "worker_id",
435            "collected_at_unix_ms",
436            "target_triple",
437            "artifact_platforms",
438            "rch_wkr_path",
439            "protocol_version",
440        ] {
441            assert!(text.contains(field), "schema omits field {field}");
442        }
443    }
444}