Skip to main content

vv_agent/runtime/backends/
recipe.rs

1use std::io;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::runtime::state::{StateStore, StateStoreSpec};
8
9use super::distributed::DistributedCapabilities;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct RuntimeRecipe {
13    pub settings_file: String,
14    pub backend: String,
15    pub model: String,
16    pub workspace: String,
17    pub timeout_seconds: f64,
18    pub log_preview_chars: Option<usize>,
19    #[serde(default)]
20    pub state_store: Option<StateStoreSpec>,
21    pub capabilities: DistributedCapabilities,
22}
23
24impl RuntimeRecipe {
25    pub fn new(
26        settings_file: impl Into<String>,
27        backend: impl Into<String>,
28        model: impl Into<String>,
29        workspace: impl Into<String>,
30    ) -> Self {
31        Self {
32            settings_file: settings_file.into(),
33            backend: backend.into(),
34            model: model.into(),
35            workspace: workspace.into(),
36            timeout_seconds: 90.0,
37            log_preview_chars: None,
38            state_store: None,
39            capabilities: DistributedCapabilities::default(),
40        }
41    }
42
43    pub fn to_dict(&self) -> Value {
44        serde_json::json!({
45            "settings_file": self.settings_file,
46            "backend": self.backend,
47            "model": self.model,
48            "workspace": self.workspace,
49            "timeout_seconds": self.timeout_seconds,
50            "log_preview_chars": self.log_preview_chars,
51            "state_store": self.state_store.as_ref().map(StateStoreSpec::to_dict),
52            "capabilities": self.capabilities.to_dict(),
53        })
54    }
55
56    pub fn from_dict(data: &Value) -> Result<Self, String> {
57        let object = data
58            .as_object()
59            .ok_or_else(|| "RuntimeRecipe payload must be an object".to_string())?;
60        Ok(Self {
61            settings_file: read_required_string(object, "settings_file")?.to_string(),
62            backend: read_required_string(object, "backend")?.to_string(),
63            model: read_required_string(object, "model")?.to_string(),
64            workspace: read_required_string(object, "workspace")?.to_string(),
65            timeout_seconds: object
66                .get("timeout_seconds")
67                .and_then(Value::as_f64)
68                .unwrap_or(90.0),
69            log_preview_chars: object
70                .get("log_preview_chars")
71                .filter(|value| !value.is_null())
72                .and_then(Value::as_u64)
73                .and_then(|value| usize::try_from(value).ok()),
74            state_store: object
75                .get("state_store")
76                .filter(|value| !value.is_null())
77                .map(StateStoreSpec::from_dict)
78                .transpose()
79                .map_err(|error| error.to_string())?,
80            capabilities: DistributedCapabilities::from_dict(
81                object
82                    .get("capabilities")
83                    .ok_or_else(|| "capabilities must be an object".to_string())?,
84            )?,
85        })
86    }
87
88    pub fn validate(&self) -> Result<(), String> {
89        for (field_name, value) in [
90            ("settings_file", self.settings_file.as_str()),
91            ("backend", self.backend.as_str()),
92            ("model", self.model.as_str()),
93            ("workspace", self.workspace.as_str()),
94        ] {
95            if value.trim().is_empty() {
96                return Err(format!(
97                    "runtime_recipe.{field_name} must be a non-empty string"
98                ));
99            }
100        }
101        if !self.timeout_seconds.is_finite() || self.timeout_seconds <= 0.0 {
102            return Err(
103                "runtime_recipe.timeout_seconds must be a finite positive number".to_string(),
104            );
105        }
106        self.capabilities.validate()
107    }
108
109    pub fn default_sqlite_checkpoint_path(&self) -> PathBuf {
110        PathBuf::from(&self.workspace)
111            .join(".vv-agent-state")
112            .join("checkpoints.db")
113    }
114
115    pub fn build_state_store(&self) -> io::Result<std::sync::Arc<dyn StateStore>> {
116        let spec = self.state_store.as_ref().ok_or_else(|| {
117            io::Error::new(
118                io::ErrorKind::InvalidInput,
119                "distributed RuntimeRecipe is missing state_store",
120            )
121        })?;
122        spec.build()
123    }
124}
125
126fn read_required_string<'a>(
127    object: &'a serde_json::Map<String, Value>,
128    key: &str,
129) -> Result<&'a str, String> {
130    object
131        .get(key)
132        .and_then(Value::as_str)
133        .ok_or_else(|| format!("missing required string field {key:?}"))
134}