Skip to main content

vifu_runtime/
manifest.rs

1use std::collections::{BTreeMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::{AgentDefinition, EndpointDefinition, RuntimeError, MAX_ENDPOINT_TIMEOUT_MS};
8
9pub const RUNTIME_MANIFEST_SCHEMA_VERSION: u32 = 1;
10
11const MAX_PROVIDERS: usize = 64;
12const MAX_AGENTS: usize = 1_024;
13const MAX_ENDPOINTS: usize = 2_048;
14const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
15const SENSITIVE_KEYS: &[&str] = &[
16    "apikey",
17    "authorization",
18    "credential",
19    "password",
20    "privatekey",
21    "secret",
22    "token",
23];
24
25/// A logical provider required by a runtime release.
26///
27/// Device credentials and filesystem paths are intentionally excluded. Hosts
28/// resolve this logical requirement to a local provider installation.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ProviderRequirement {
32    pub id: String,
33    pub provider_type: String,
34    #[serde(default)]
35    pub capabilities: Vec<String>,
36    #[serde(default)]
37    pub settings: Value,
38    #[serde(default)]
39    pub resources: BTreeMap<String, String>,
40}
41
42/// Portable runtime configuration shared by embedded and connected runtimes.
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RuntimeManifest {
46    pub schema_version: u32,
47    pub project_id: String,
48    #[serde(default)]
49    pub providers: Vec<ProviderRequirement>,
50    #[serde(default)]
51    pub agents: Vec<AgentDefinition>,
52    #[serde(default)]
53    pub endpoints: Vec<EndpointDefinition>,
54    #[serde(default)]
55    pub metadata: Value,
56}
57
58/// Portable provider, agent, and endpoint settings for one Vifu project.
59///
60/// The runtime stores project settings as releases when a store is available.
61/// JSON is an import/export artifact for backup, migration, and embedded hosts;
62/// it is not live runtime configuration.
63pub type ProjectSettings = RuntimeManifest;
64
65impl RuntimeManifest {
66    pub fn new(project_id: impl Into<String>) -> Self {
67        Self {
68            schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
69            project_id: project_id.into(),
70            providers: Vec::new(),
71            agents: Vec::new(),
72            endpoints: Vec::new(),
73            metadata: Value::Object(Default::default()),
74        }
75    }
76
77    pub fn from_json(bytes: &[u8]) -> Result<Self, RuntimeError> {
78        if bytes.len() > MAX_MANIFEST_BYTES {
79            return Err(RuntimeError::InvalidDefinition(
80                "runtime manifest is too large".to_string(),
81            ));
82        }
83        let manifest = serde_json::from_slice::<Self>(bytes)
84            .map_err(|error| RuntimeError::InvalidDefinition(error.to_string()))?;
85        manifest.validate()?;
86        Ok(manifest)
87    }
88
89    pub fn to_json(&self) -> Result<Vec<u8>, RuntimeError> {
90        self.validate()?;
91        serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
92    }
93
94    pub fn content_hash(&self) -> Result<String, RuntimeError> {
95        let bytes = self.to_json()?;
96        let digest = Sha256::digest(bytes);
97        let mut encoded = String::with_capacity(7 + digest.len() * 2);
98        encoded.push_str("sha256:");
99        for byte in digest {
100            use std::fmt::Write;
101            write!(&mut encoded, "{byte:02x}").map_err(|_error| RuntimeError::Internal)?;
102        }
103        Ok(encoded)
104    }
105
106    pub fn validate(&self) -> Result<(), RuntimeError> {
107        if self.schema_version != RUNTIME_MANIFEST_SCHEMA_VERSION {
108            return Err(RuntimeError::InvalidDefinition(format!(
109                "unsupported runtime manifest schema version {}",
110                self.schema_version
111            )));
112        }
113        validate_portable_identifier("project", &self.project_id)?;
114        validate_count("providers", self.providers.len(), MAX_PROVIDERS)?;
115        validate_count("agents", self.agents.len(), MAX_AGENTS)?;
116        validate_count("endpoints", self.endpoints.len(), MAX_ENDPOINTS)?;
117
118        let mut provider_ids = HashSet::with_capacity(self.providers.len());
119        for provider in &self.providers {
120            validate_portable_identifier("provider", &provider.id)?;
121            validate_portable_identifier("provider type", &provider.provider_type)?;
122            if !provider_ids.insert(provider.id.as_str()) {
123                return Err(duplicate("provider", &provider.id));
124            }
125            validate_capabilities(&provider.capabilities)?;
126            validate_portable_provider_value(&provider.settings, "settings")?;
127            for (name, reference) in &provider.resources {
128                validate_portable_identifier("resource", name)?;
129                validate_resource_reference(reference)?;
130            }
131        }
132
133        let mut agent_ids = HashSet::with_capacity(self.agents.len());
134        for agent in &self.agents {
135            validate_portable_identifier("agent", &agent.id)?;
136            validate_portable_identifier("provider", &agent.provider)?;
137            if agent.name.trim().is_empty() {
138                return Err(RuntimeError::InvalidDefinition(
139                    "agent name is required".to_string(),
140                ));
141            }
142            if !provider_ids.contains(agent.provider.as_str()) {
143                return Err(RuntimeError::ProviderNotFound(agent.provider.clone()));
144            }
145            if !agent_ids.insert(agent.id.as_str()) {
146                return Err(duplicate("agent", &agent.id));
147            }
148            validate_capabilities(&agent.capabilities)?;
149            validate_portable_value(&agent.metadata, "agent metadata")?;
150        }
151
152        let mut endpoint_names = HashSet::with_capacity(self.endpoints.len());
153        for endpoint in &self.endpoints {
154            validate_portable_identifier("endpoint", &endpoint.name)?;
155            validate_portable_identifier("agent", &endpoint.agent)?;
156            validate_portable_identifier("capability", &endpoint.capability)?;
157            if !agent_ids.contains(endpoint.agent.as_str()) {
158                return Err(RuntimeError::AgentNotFound(endpoint.agent.clone()));
159            }
160            if !endpoint_names.insert(endpoint.name.as_str()) {
161                return Err(duplicate("endpoint", &endpoint.name));
162            }
163            if !(1..=MAX_ENDPOINT_TIMEOUT_MS).contains(&endpoint.timeout_ms) {
164                return Err(RuntimeError::InvalidDefinition(format!(
165                    "endpoint timeout must be between 1 and {MAX_ENDPOINT_TIMEOUT_MS} milliseconds"
166                )));
167            }
168            let agent = self
169                .agents
170                .iter()
171                .find(|agent| agent.id == endpoint.agent)
172                .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
173            if !agent
174                .capabilities
175                .iter()
176                .any(|capability| capability == &endpoint.capability)
177            {
178                return Err(RuntimeError::CapabilityUnavailable {
179                    provider: agent.provider.clone(),
180                    capability: endpoint.capability.clone(),
181                });
182            }
183        }
184
185        validate_portable_value(&self.metadata, "manifest metadata")?;
186
187        if self.to_unchecked_json()?.len() > MAX_MANIFEST_BYTES {
188            return Err(RuntimeError::InvalidDefinition(
189                "runtime manifest is too large".to_string(),
190            ));
191        }
192        Ok(())
193    }
194
195    fn to_unchecked_json(&self) -> Result<Vec<u8>, RuntimeError> {
196        serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
197    }
198}
199
200/// One immutable, content-addressed project runtime release.
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct RuntimeRelease {
204    pub version: u64,
205    pub content_hash: String,
206    pub manifest: RuntimeManifest,
207}
208
209impl RuntimeRelease {
210    pub fn new(version: u64, manifest: RuntimeManifest) -> Result<Self, RuntimeError> {
211        if version == 0 {
212            return Err(RuntimeError::InvalidDefinition(
213                "runtime release version must be greater than zero".to_string(),
214            ));
215        }
216        manifest.validate()?;
217        let content_hash = manifest.content_hash()?;
218        Ok(Self {
219            version,
220            content_hash,
221            manifest,
222        })
223    }
224
225    pub fn validate(&self) -> Result<(), RuntimeError> {
226        if self.version == 0 {
227            return Err(RuntimeError::InvalidDefinition(
228                "runtime release version must be greater than zero".to_string(),
229            ));
230        }
231        self.manifest.validate()?;
232        if self.content_hash != self.manifest.content_hash()? {
233            return Err(RuntimeError::InvalidDefinition(
234                "runtime release content hash does not match its manifest".to_string(),
235            ));
236        }
237        Ok(())
238    }
239}
240
241/// Local-only provider configuration kept outside portable releases.
242#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct LocalProviderBinding {
245    pub provider_id: String,
246    #[serde(default)]
247    pub configuration: Value,
248}
249
250/// Redacted invocation metadata waiting to be uploaded to an optional server.
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase", deny_unknown_fields)]
253pub struct RuntimeTraceRecord {
254    pub id: String,
255    pub project_id: String,
256    pub invocation_id: String,
257    pub endpoint: String,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub agent: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub provider: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub capability: Option<String>,
264    pub status: String,
265    pub duration_ms: u64,
266    pub created_at_ms: u64,
267}
268
269impl RuntimeTraceRecord {
270    pub fn validate(&self) -> Result<(), RuntimeError> {
271        validate_trace_identifier("trace", &self.id)?;
272        validate_portable_identifier("project", &self.project_id)?;
273        validate_trace_identifier("invocation", &self.invocation_id)?;
274        validate_portable_identifier("endpoint", &self.endpoint)?;
275        for (kind, value) in [
276            ("agent", self.agent.as_deref()),
277            ("provider", self.provider.as_deref()),
278            ("capability", self.capability.as_deref()),
279        ] {
280            if let Some(value) = value {
281                validate_portable_identifier(kind, value)?;
282            }
283        }
284        if !matches!(self.status.as_str(), "completed" | "cancelled" | "error") {
285            return Err(RuntimeError::InvalidDefinition(
286                "runtime trace status is invalid".to_string(),
287            ));
288        }
289        if self.duration_ms > 24 * 60 * 60 * 1_000 {
290            return Err(RuntimeError::InvalidDefinition(
291                "runtime trace duration is invalid".to_string(),
292            ));
293        }
294        if self.created_at_ms == 0 {
295            return Err(RuntimeError::InvalidDefinition(
296                "runtime trace timestamp is invalid".to_string(),
297            ));
298        }
299        Ok(())
300    }
301}
302
303fn validate_count(kind: &str, count: usize, maximum: usize) -> Result<(), RuntimeError> {
304    if count > maximum {
305        return Err(RuntimeError::InvalidDefinition(format!(
306            "runtime manifest contains too many {kind}"
307        )));
308    }
309    Ok(())
310}
311
312fn validate_capabilities(capabilities: &[String]) -> Result<(), RuntimeError> {
313    if capabilities.is_empty() {
314        return Err(RuntimeError::InvalidDefinition(
315            "at least one capability is required".to_string(),
316        ));
317    }
318    for capability in capabilities {
319        validate_portable_identifier("capability", capability)?;
320    }
321    Ok(())
322}
323
324fn validate_resource_reference(reference: &str) -> Result<(), RuntimeError> {
325    if reference.is_empty() || reference.len() > 512 {
326        return Err(RuntimeError::InvalidDefinition(
327            "resource reference must be between 1 and 512 bytes".to_string(),
328        ));
329    }
330    if is_absolute_or_file_path(reference) {
331        return Err(RuntimeError::InvalidDefinition(
332            "runtime manifests cannot contain filesystem paths".to_string(),
333        ));
334    }
335    Ok(())
336}
337
338fn validate_portable_provider_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
339    validate_portable_value(value, &format!("provider {path}"))
340}
341
342fn validate_portable_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
343    match value {
344        Value::Object(object) => {
345            for (key, value) in object {
346                let normalized = key
347                    .chars()
348                    .filter(|character| character.is_ascii_alphanumeric())
349                    .flat_map(char::to_lowercase)
350                    .collect::<String>();
351                if SENSITIVE_KEYS
352                    .iter()
353                    .any(|sensitive| normalized == *sensitive)
354                {
355                    return Err(RuntimeError::InvalidDefinition(format!(
356                        "runtime manifest {path} contains a credential field"
357                    )));
358                }
359                validate_portable_value(value, path)?;
360            }
361        }
362        Value::Array(values) => {
363            for value in values {
364                validate_portable_value(value, path)?;
365            }
366        }
367        Value::String(value) if is_absolute_or_file_path(value) => {
368            return Err(RuntimeError::InvalidDefinition(format!(
369                "runtime manifest {path} contains a filesystem path"
370            )));
371        }
372        _ => {}
373    }
374    Ok(())
375}
376
377fn is_absolute_or_file_path(value: &str) -> bool {
378    value.starts_with('/')
379        || value.starts_with("file://")
380        || (value.len() >= 3
381            && value.as_bytes()[0].is_ascii_alphabetic()
382            && value.as_bytes()[1] == b':'
383            && matches!(value.as_bytes()[2], b'/' | b'\\'))
384}
385
386fn validate_portable_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
387    if value.is_empty()
388        || value.len() > 128
389        || !value
390            .bytes()
391            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
392    {
393        return Err(RuntimeError::InvalidDefinition(format!(
394            "{kind} must be a portable identifier"
395        )));
396    }
397    Ok(())
398}
399
400fn validate_trace_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
401    if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
402        return Err(RuntimeError::InvalidDefinition(format!(
403            "{kind} must be a valid trace identifier"
404        )));
405    }
406    Ok(())
407}
408
409fn duplicate(kind: &str, id: &str) -> RuntimeError {
410    RuntimeError::InvalidDefinition(format!("duplicate {kind} {id}"))
411}
412
413#[cfg(test)]
414mod tests {
415    use serde_json::json;
416
417    use super::*;
418
419    fn manifest() -> RuntimeManifest {
420        RuntimeManifest {
421            schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
422            project_id: "moon-train".to_string(),
423            providers: vec![ProviderRequirement {
424                id: "local-model".to_string(),
425                provider_type: "openai-compatible".to_string(),
426                capabilities: vec!["chat".to_string()],
427                settings: json!({ "model": "small-chat" }),
428                resources: BTreeMap::from([("model".to_string(), "model:small-chat".to_string())]),
429            }],
430            agents: vec![AgentDefinition {
431                id: "guide".to_string(),
432                name: "Guide".to_string(),
433                provider: "local-model".to_string(),
434                capabilities: vec!["chat".to_string()],
435                metadata: json!({ "persona": "A calm guide." }),
436            }],
437            endpoints: vec![EndpointDefinition {
438                name: "guide".to_string(),
439                agent: "guide".to_string(),
440                capability: "chat".to_string(),
441                timeout_ms: 30_000,
442            }],
443            metadata: json!({ "title": "Last Train to the Moon" }),
444        }
445    }
446
447    #[test]
448    fn content_hash_is_stable_for_the_same_manifest() {
449        let manifest = manifest();
450        assert_eq!(
451            manifest.content_hash().unwrap(),
452            manifest.content_hash().unwrap()
453        );
454    }
455
456    #[test]
457    fn provider_credentials_are_rejected() {
458        let mut manifest = manifest();
459        manifest.providers[0].settings = json!({ "apiKey": "not-portable" });
460        let error = manifest.validate().unwrap_err();
461        assert!(error.to_string().contains("credential field"));
462    }
463
464    #[test]
465    fn provider_filesystem_paths_are_rejected() {
466        let mut manifest = manifest();
467        manifest.providers[0]
468            .resources
469            .insert("model".to_string(), "/Users/example/model.gguf".to_string());
470        let error = manifest.validate().unwrap_err();
471        assert!(error.to_string().contains("filesystem paths"));
472    }
473
474    #[test]
475    fn metadata_credentials_are_rejected() {
476        let mut manifest = manifest();
477        manifest.agents[0].metadata = json!({ "credentials": { "token": "not-portable" } });
478        let error = manifest.validate().unwrap_err();
479        assert!(error.to_string().contains("credential field"));
480    }
481
482    #[test]
483    fn endpoint_timeouts_are_bounded() {
484        let mut manifest = manifest();
485        manifest.endpoints[0].timeout_ms = 0;
486        let error = manifest.validate().unwrap_err();
487        assert!(error.to_string().contains("endpoint timeout"));
488    }
489
490    #[test]
491    fn endpoint_timeout_accepts_slow_local_model_inference() {
492        let mut manifest = manifest();
493        manifest.endpoints[0].timeout_ms = 300_000;
494
495        assert!(
496            manifest.validate().is_ok(),
497            "five-minute local inference should be supported"
498        );
499    }
500
501    #[test]
502    fn endpoint_timeout_rejects_values_above_the_runtime_bound() {
503        let mut manifest = manifest();
504        manifest.endpoints[0].timeout_ms = MAX_ENDPOINT_TIMEOUT_MS + 1;
505
506        let error = manifest.validate().unwrap_err();
507        assert!(error.to_string().contains("900000 milliseconds"));
508    }
509
510    #[test]
511    fn trace_records_reject_unknown_statuses() {
512        let trace = RuntimeTraceRecord {
513            id: "trace-1".to_string(),
514            project_id: "moon-train".to_string(),
515            invocation_id: "invocation-1".to_string(),
516            endpoint: "guide".to_string(),
517            agent: Some("guide".to_string()),
518            provider: Some("local-model".to_string()),
519            capability: Some("chat".to_string()),
520            status: "pending".to_string(),
521            duration_ms: 1,
522            created_at_ms: 1,
523        };
524        let error = trace.validate().unwrap_err();
525        assert!(error.to_string().contains("trace status"));
526    }
527}