Skip to main content

vv_agent/runtime/backends/
recipe.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::distributed::DistributedCapabilities;
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub struct RuntimeRecipe {
8    pub settings_file: String,
9    pub backend: String,
10    pub model: String,
11    pub workspace: String,
12    pub timeout_seconds: f64,
13    pub log_preview_chars: Option<usize>,
14    pub capabilities: DistributedCapabilities,
15}
16
17impl RuntimeRecipe {
18    pub fn new(
19        settings_file: impl Into<String>,
20        backend: impl Into<String>,
21        model: impl Into<String>,
22        workspace: impl Into<String>,
23    ) -> Self {
24        Self {
25            settings_file: settings_file.into(),
26            backend: backend.into(),
27            model: model.into(),
28            workspace: workspace.into(),
29            timeout_seconds: 90.0,
30            log_preview_chars: None,
31            capabilities: DistributedCapabilities::default(),
32        }
33    }
34
35    pub fn to_dict(&self) -> Value {
36        serde_json::json!({
37            "settings_file": self.settings_file,
38            "backend": self.backend,
39            "model": self.model,
40            "workspace": self.workspace,
41            "timeout_seconds": self.timeout_seconds,
42            "log_preview_chars": self.log_preview_chars,
43            "capabilities": self.capabilities.to_dict(),
44        })
45    }
46
47    pub fn from_dict(data: &Value) -> Result<Self, String> {
48        let object = data
49            .as_object()
50            .ok_or_else(|| "RuntimeRecipe payload must be an object".to_string())?;
51        Ok(Self {
52            settings_file: read_required_string(object, "settings_file")?.to_string(),
53            backend: read_required_string(object, "backend")?.to_string(),
54            model: read_required_string(object, "model")?.to_string(),
55            workspace: read_required_string(object, "workspace")?.to_string(),
56            timeout_seconds: object
57                .get("timeout_seconds")
58                .and_then(Value::as_f64)
59                .unwrap_or(90.0),
60            log_preview_chars: object
61                .get("log_preview_chars")
62                .filter(|value| !value.is_null())
63                .and_then(Value::as_u64)
64                .and_then(|value| usize::try_from(value).ok()),
65            capabilities: DistributedCapabilities::from_dict(
66                object
67                    .get("capabilities")
68                    .ok_or_else(|| "capabilities must be an object".to_string())?,
69            )?,
70        })
71    }
72
73    pub fn validate(&self) -> Result<(), String> {
74        for (field_name, value) in [
75            ("settings_file", self.settings_file.as_str()),
76            ("backend", self.backend.as_str()),
77            ("model", self.model.as_str()),
78            ("workspace", self.workspace.as_str()),
79        ] {
80            if value.trim().is_empty() {
81                return Err(format!(
82                    "runtime_recipe.{field_name} must be a non-empty string"
83                ));
84            }
85        }
86        if !self.timeout_seconds.is_finite() || self.timeout_seconds <= 0.0 {
87            return Err(
88                "runtime_recipe.timeout_seconds must be a finite positive number".to_string(),
89            );
90        }
91        self.capabilities.validate()
92    }
93}
94
95fn read_required_string<'a>(
96    object: &'a serde_json::Map<String, Value>,
97    key: &str,
98) -> Result<&'a str, String> {
99    object
100        .get(key)
101        .and_then(Value::as_str)
102        .ok_or_else(|| format!("missing required string field {key:?}"))
103}