Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-shared/src/registry/mod.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Wire types for `/registry/backend/list` and friends. All fields `snake_case`.
3
4
use serde::{Deserialize, Serialize};
5
6
pub mod events;
7
8
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
9
#[derive(Debug, Clone, Serialize, Deserialize)]
10
pub struct RegistryListResponse {
11
    pub schema_version: u32,
12
    pub generated_at: String,
13
    pub backends: Vec<RegistryBackend>,
14
}
15
16
// A flat mirror of the `/registry/backend/list` JSON. The lint wants related flags
17
// grouped into a sub-struct, which here would reshape the wire payload to suit
18
// an internal API guideline.
19
#[allow(clippy::struct_excessive_bools)]
20
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21
#[derive(Debug, Clone, Serialize, Deserialize)]
22
pub struct RegistryBackend {
23
    pub id: String,
24
    /// The backend's reverse-DNS identifier, or `None` when the registry entry
25
    /// predates it. Names the install directory.
26
    #[serde(default)]
27
    pub backend_id: Option<String>,
28
    pub source: String,
29
    pub version: String,
30
    pub name: String,
31
    #[serde(default, skip_serializing_if = "Option::is_none")]
32
    pub description: Option<String>,
33
    pub license: String,
34
    pub kind: String,
35
    /// The contract generation the backend declares, as published. Carried
36
    /// as a string so a client lists an entry whose generation it does not
37
    /// know; `compatibility` says whether this daemon can drive it.
38
    pub contract: String,
39
    /// The Super STT release that first understood `contract`, as stamped by
40
    /// the indexer. `None` for an index that predates the stamp.
41
    #[serde(default, skip_serializing_if = "Option::is_none")]
42
    pub min_client: Option<String>,
43
    #[serde(default)]
44
    pub allowed_hosts: Vec<String>,
45
    pub online: bool,
46
    pub supports_gpu: bool,
47
    pub supports_cpu: bool,
48
    pub models: Vec<RegistryModel>,
49
    pub secrets: Vec<RegistrySecret>,
50
    pub options: Vec<RegistryOption>,
51
    pub compatibility: Compatibility,
52
    #[serde(default, skip_serializing_if = "Option::is_none")]
53
    pub installed_version: Option<String>,
54
    /// Whether `version` is newer than `installed_version`, decided by the
55
    /// daemon.
56
    ///
57
    /// The comparison is semver, and it belongs here rather than in each client
58
    /// for the same reason `installed_version` does: the daemon is what reads
59
    /// the installed manifest and owns the index, so it is the one place that
60
    /// can answer without a client re-deriving it. A client that wants to
61
    /// present the versions still has both.
62
    ///
63
    /// `false` when nothing is installed, when the installed version is at or
64
    /// ahead of the index's, or when either version does not parse.
65
    #[serde(default)]
66
    pub update_available: bool,
67
    #[serde(default, skip_serializing_if = "Option::is_none")]
68
    pub index_stale: Option<IndexStale>,
69
}
70
71
// The `/registry/backend/list` model/secret/option leaves are field-identical to
72
// the `index.json` leaves, so they share one canonical definition rather than
73
// drifting. Re-exported under the historical `Registry*` names.
74
pub use super_stt_registry_types::index::{
75
    IndexModel as RegistryModel, IndexOption as RegistryOption, IndexSecret as RegistrySecret,
76
};
77
78
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
79
#[derive(Debug, Clone, Serialize, Deserialize)]
80
pub struct Compatibility {
81
    pub compatible: bool,
82
    #[serde(default, skip_serializing_if = "Option::is_none")]
83
    pub selected_asset: Option<SelectedAsset>,
84
    #[serde(default, skip_serializing_if = "Option::is_none")]
85
    pub reason: Option<String>,
86
    /// Whether the block is "this Super STT is too old" rather than "this
87
    /// machine cannot run it".
88
    ///
89
    /// The two are hidden differently. A host that lacks the right GPU will
90
    /// never run the asset, so Browse tucks it behind "Show incompatible"; a
91
    /// Super STT one version behind is a thing the user can fix in a minute,
92
    /// and hiding it hides the only notice they would get. `false` on an older
93
    /// daemon that does not send the field, which lists as it always did.
94
    #[serde(default)]
95
    pub needs_client_update: bool,
96
}
97
98
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99
#[derive(Debug, Clone, Serialize, Deserialize)]
100
pub struct SelectedAsset {
101
    pub target: String,
102
    /// Acceleration backends the selected build carries. A single entry is
103
    /// both read and written as a bare string, a list of two or more as an
104
    /// array — a client that declares this field as a plain `String` still
105
    /// parses the catalog for every asset that carries one runtime.
106
    #[serde(
107
        deserialize_with = "super_stt_registry_types::index::one_or_many_string",
108
        serialize_with = "super_stt_registry_types::index::one_or_many_string_ser"
109
    )]
110
    pub accel: Vec<String>,
111
    #[serde(default, skip_serializing_if = "Option::is_none")]
112
    pub cuda_major: Option<u32>,
113
    #[serde(default, skip_serializing_if = "Option::is_none")]
114
    pub cuda_sm: Option<u32>,
115
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116
    pub cudnn: bool,
117
}
118
119
pub use super_stt_registry_types::index::IndexStale;
120
121
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
122
#[derive(Debug, Clone, Serialize, Deserialize)]
123
#[serde(untagged)]
124
pub enum InstallRequest {
125
    BySource { source: String },
126
    ByRepoUrl { repo_url: String },
127
    ByLocalPath { local_path: String },
128
}
129
130
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
131
#[derive(Debug, Clone, Serialize, Deserialize)]
132
pub struct InstallAccepted {
133
    pub install_id: String,
134
    pub source: String,
135
    pub version: String,
136
    pub selected_asset: SelectedAsset,
137
    #[serde(default, skip_serializing_if = "Option::is_none")]
138
    pub warning: Option<String>,
139
}
140
141
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
142
#[derive(Debug, Clone, Serialize, Deserialize)]
143
pub struct UpdateRequest {
144
    pub source: String,
145
}
146
147
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148
#[derive(Debug, Clone, Serialize, Deserialize)]
149
pub struct UpdateResponse {
150
    #[serde(default, skip_serializing_if = "Option::is_none")]
151
    pub install_id: Option<String>,
152
    pub from_version: String,
153
    pub to_version: String,
154
    pub noop: bool,
155
}
156
157
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
158
#[derive(Debug, Clone, Serialize, Deserialize)]
159
pub struct RefreshResponse {
160
    pub schema_version: u32,
161
    pub generated_at: String,
162
    pub backend_count: usize,
163
}
164
165
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
166
#[derive(Debug, Clone, Serialize, Deserialize)]
167
pub struct UninstallResponse {
168
    pub uninstalled: bool,
169
    /// The backend was filling stage 1, which was emptied before the files
170
    /// went.
171
    pub was_active: bool,
172
    /// The backend was filling stage 2 — selected as the post-processor
173
    /// backend, loaded or not — which was emptied before the files went.
174
    /// Absent from an older daemon's answer, which reads as `false`.
175
    #[serde(default)]
176
    pub was_post_processor: bool,
177
}
178
179
pub use super_stt_registry_types::{is_safe_component, is_safe_relative_path};
180
181
#[cfg(test)]
182
mod tests {
183
    use super::RegistryBackend;
184
185
    /// The minimal `/registry/backend/list` entry every test below starts from,
186
    /// extending it with `serde_json::Value` indexing for the field each test
187
    /// cares about. One fixture rather than three keeps them from drifting
188
    /// apart on the fields that are merely required to parse at all.
189
8
    fn minimal_backend_json() -> serde_json::Value {
190
8
        serde_json::json!({
191
8
            "id": "openai",
192
8
            "source": "github.com/super-stt/openai",
193
8
            "version": "0.1.1",
194
8
            "name": "OpenAI",
195
8
            "license": "Apache-2.0",
196
8
            "kind": "wasm",
197
8
            "contract": "v1",
198
8
            "online": true,
199
8
            "supports_gpu": false,
200
8
            "supports_cpu": false,
201
8
            "models": [],
202
8
            "secrets": [],
203
8
            "options": [],
204
8
            "compatibility": { "compatible": true },
205
        })
206
8
    }
207
208
    /// A daemon that predates `update_available` still deserializes here. The
209
    /// field moved the update decision from the client to the daemon; a client
210
    /// that hard-required it would fail to list anything at all against a
211
    /// daemon that has not rolled over, which is worse than not knowing about
212
    /// an update.
213
    #[test]
214
2
    fn a_registry_entry_without_update_available_still_parses() {
215
2
        let mut v = minimal_backend_json();
216
2
        v["installed_version"] = serde_json::json!("0.1.0");
217
2
        let b: RegistryBackend = serde_json::from_value(v).expect("older payload must parse");
218
2
        assert!(
219
2
            !b.update_available,
220
            "an absent flag reads as no update, never as one"
221
        );
222
2
    }
223
224
    /// Unknown keys are ignored, so a newer daemon adding a field does not
225
    /// break a client built against this shape. The compatibility runs both
226
    /// ways or it is not compatibility.
227
    #[test]
228
2
    fn a_registry_entry_with_an_unknown_field_still_parses() {
229
2
        let mut v = minimal_backend_json();
230
2
        v["update_available"] = serde_json::json!(true);
231
2
        v["a_field_from_a_later_daemon"] = serde_json::json!(42);
232
2
        let b: RegistryBackend = serde_json::from_value(v).expect("newer payload must parse");
233
2
        assert!(b.update_available);
234
2
    }
235
236
    /// A daemon that predates `backend_id` still deserializes here, and a
237
    /// response carrying one round-trips.
238
    #[test]
239
2
    fn backend_id_is_optional_on_the_wire() {
240
2
        let without: RegistryBackend =
241
2
            serde_json::from_value(minimal_backend_json()).expect("parses without backend_id");
242
2
        assert!(without.backend_id.is_none());
243
244
2
        let mut v = minimal_backend_json();
245
2
        v["backend_id"] = serde_json::json!("app.super-stt.voxtral");
246
2
        let with: RegistryBackend = serde_json::from_value(v).expect("parses with backend_id");
247
2
        assert_eq!(with.backend_id.as_deref(), Some("app.super-stt.voxtral"));
248
2
    }
249
250
4
    fn selected(accel: &[&str]) -> super::SelectedAsset {
251
        super::SelectedAsset {
252
4
            target: "x86_64-unknown-linux-gnu".into(),
253
6
            accel: 
accel4
.
iter4
().
map4
(|a| (*a).to_string()).
collect4
(),
254
4
            cuda_major: Some(12),
255
4
            cuda_sm: Some(86),
256
            cudnn: false,
257
        }
258
4
    }
259
260
    /// An app built before the list form declares `accel` as a required
261
    /// `String` and fails to parse the *whole* `/registry/backend/list` response
262
    /// when it turns into an array. Every asset carrying one runtime — which
263
    /// is every asset a backend can publish — therefore keeps the bare-string
264
    /// shape on the wire.
265
    #[test]
266
2
    fn a_single_accel_selection_still_serializes_as_a_bare_string() {
267
2
        let json = serde_json::to_string(&selected(&["cuda"])).expect("serializes");
268
2
        assert!(
269
2
            json.contains(r#""accel":"cuda""#),
270
            "accel is no longer a bare string; an older app cannot parse the catalog: {json}"
271
        );
272
273
        #[derive(serde::Deserialize)]
274
        struct DeployedSelectedAsset {
275
            accel: String,
276
        }
277
2
        let deployed: DeployedSelectedAsset =
278
2
            serde_json::from_str(&json).expect("an older app must still parse this");
279
2
        assert_eq!(deployed.accel, "cuda");
280
2
    }
281
282
    /// A build carrying two runtimes has no bare-string spelling, so it is
283
    /// written as the array it is, and read back unchanged.
284
    #[test]
285
2
    fn a_multi_accel_selection_serializes_as_an_array_and_round_trips() {
286
2
        let json = serde_json::to_string(&selected(&["cuda", "rocm"])).expect("serializes");
287
2
        assert!(
288
2
            json.contains(r#""accel":["cuda","rocm"]"#),
289
            "a multi-runtime build must keep its list: {json}"
290
        );
291
2
        let back: super::SelectedAsset = serde_json::from_str(&json).expect("round-trips");
292
2
        assert_eq!(back.accel, vec!["cuda".to_string(), "rocm".to_string()]);
293
2
    }
294
295
    /// Read leniency is unchanged: a payload written either way parses.
296
    #[test]
297
2
    fn a_selection_parses_from_either_shape() {
298
2
        let scalar: super::SelectedAsset =
299
2
            serde_json::from_str(r#"{"target":"t","accel":"cuda"}"#).expect("scalar parses");
300
2
        assert_eq!(scalar.accel, vec!["cuda".to_string()]);
301
2
        let list: super::SelectedAsset =
302
2
            serde_json::from_str(r#"{"target":"t","accel":["cuda"]}"#).expect("list parses");
303
2
        assert_eq!(list.accel, vec!["cuda".to_string()]);
304
2
    }
305
}