Skip to main content

flow_application/
settings.rs

1use parking_lot::RwLock;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum SettingsError {
10    #[error(transparent)]
11    Io(#[from] std::io::Error),
12    #[error(transparent)]
13    Json(#[from] serde_json::Error),
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Settings {
18    /// Master toggle for cloud-AI nodes. Defaults to false - Flow's posture is
19    /// zero-egress local inference. Cloud AI is an explicit, opt-in carve-out
20    /// (see docs/adr/0004-cloud-ai-carve-out.md).
21    #[serde(default)]
22    pub allow_cloud_ai: bool,
23
24    /// Master toggle for the local OpenAI-compatible provider (on-device
25    /// inference server: Ollama / LM Studio / llama.cpp). Defaults to true -
26    /// a localhost call is not network egress, so it stays usable even with
27    /// `allow_cloud_ai` off. The `local` provider is still subject to the
28    /// per-provider `providers_enabled` map.
29    #[serde(default = "default_allow_local_ai")]
30    pub allow_local_ai: bool,
31
32    /// Base address of the local OpenAI-compatible server. Stored as an
33    /// origin (e.g. `http://127.0.0.1:1234`); the runtime appends
34    /// `/v1/chat/completions` (and `/v1/models` for discovery). A fully
35    /// qualified URL is also accepted and normalised. Defaults to LM
36    /// Studio's address.
37    #[serde(default = "default_local_ai_base_url")]
38    pub local_ai_base_url: Option<String>,
39
40    /// Path to the `llama-server` (llama.cpp) binary flow-studio launches to
41    /// host an LLM locally. None until the user configures it (or a bundled
42    /// sidecar is added in a later phase).
43    #[serde(default)]
44    pub llama_server_binary: Option<String>,
45
46    /// Path to the LLM the managed server should load. Set by the
47    /// Model Hub when a downloaded LLM is selected as active.
48    #[serde(default)]
49    pub llama_server_model: Option<String>,
50
51    /// Per-model `llama-server` load parameters (the Model Hub "Load settings"
52    /// panel), keyed by catalog model id. Each model remembers its own config.
53    #[serde(default)]
54    pub llama_params: HashMap<String, crate::llm_server::LlamaParams>,
55
56    /// Model ids discovered from the local server's `/v1/models`, cached so
57    /// the node inspector's model dropdown can offer them without a live
58    /// probe. Populated by the Settings "Test connection" action; empty
59    /// until then (the provider's static default is used as a fallback).
60    #[serde(default)]
61    pub local_ai_models: Vec<String>,
62
63    /// Per-provider enable map. Even when `allow_cloud_ai` is true, individual
64    /// providers can be disabled here.
65    #[serde(default = "default_providers_enabled")]
66    pub providers_enabled: HashMap<String, bool>,
67
68    /// UI theme. `"dark"`, `"light"`, or `"dark-projector"` - passed to
69    /// Precision's `setTheme` at startup and on toggle. Defaults to `"dark"`
70    /// to match Flow Studio's design language.
71    #[serde(default = "default_theme")]
72    pub theme: String,
73
74    /// Per-step destructive-action confirmation gate (roadmap E1). When on,
75    /// a run pauses before any node that performs a destructive operation
76    /// (file delete, `rm`, `git push`, …) and asks the user to confirm or
77    /// cancel. Defaults to `true` - a safe default the user can opt out of.
78    #[serde(default = "default_confirm_destructive")]
79    pub confirm_destructive: bool,
80
81    /// Master switch for the background scheduler. Defaults on so persisted
82    /// timers keep firing unless the user explicitly pauses scheduling.
83    #[serde(default = "default_scheduler_enabled")]
84    pub scheduler_enabled: bool,
85
86    /// Poll interval in seconds for desktop/server scheduler loops.
87    #[serde(default = "default_scheduler_poll_secs")]
88    pub scheduler_poll_secs: u64,
89
90    /// Safety cap on auto-accepted fixes in one autonomous run - the single
91    /// budget shared by the backend convergence loop (`agentic_run_loop`) and
92    /// the frontend monitor auto-fix loop, so neither path hardcodes its own
93    /// (roadmap E1). Defaults to [`crate::MAX_AGENTIC_ITERATIONS`].
94    #[serde(default = "default_max_agentic_iterations")]
95    pub max_agentic_iterations: u32,
96
97    /// Wall-clock ceiling (seconds) for one autonomous convergence run
98    /// (`agentic_run_loop`); `0` = unlimited (only the iteration cap bounds
99    /// the loop). The loop stops starting new iterations once this elapses
100    /// (roadmap E1 autonomous run budgets).
101    #[serde(default = "default_max_agentic_seconds")]
102    pub max_agentic_seconds: u32,
103
104    /// Cumulative token ceiling for one autonomous convergence run - the sum of
105    /// input+output tokens across the executed flows' AI nodes; `0` = unlimited.
106    /// The loop stops starting new iterations once it's exceeded (roadmap E1
107    /// autonomous run budgets).
108    #[serde(default = "default_max_agentic_tokens")]
109    pub max_agentic_tokens: u32,
110}
111
112fn default_providers_enabled() -> HashMap<String, bool> {
113    let mut m = HashMap::new();
114    m.insert("claude".into(), true);
115    m.insert("openai".into(), true);
116    m.insert("gemini".into(), true);
117    m.insert("nvidia".into(), true);
118    m.insert("deepseek".into(), true);
119    m.insert("local".into(), true);
120    m
121}
122
123fn default_max_agentic_iterations() -> u32 {
124    crate::MAX_AGENTIC_ITERATIONS
125}
126
127fn default_max_agentic_seconds() -> u32 {
128    0
129}
130
131fn default_max_agentic_tokens() -> u32 {
132    0
133}
134
135fn default_allow_local_ai() -> bool {
136    true
137}
138
139fn default_local_ai_base_url() -> Option<String> {
140    // No external default. The base URL is owned by the managed local LLM
141    // server: set when a model is Loaded in the Model Hub, cleared on Stop.
142    None
143}
144
145fn default_theme() -> String {
146    "dark".into()
147}
148
149fn default_confirm_destructive() -> bool {
150    true
151}
152
153fn default_scheduler_enabled() -> bool {
154    true
155}
156
157fn default_scheduler_poll_secs() -> u64 {
158    30
159}
160
161impl Default for Settings {
162    fn default() -> Self {
163        Self {
164            allow_cloud_ai: false,
165            allow_local_ai: default_allow_local_ai(),
166            local_ai_base_url: default_local_ai_base_url(),
167            llama_server_binary: None,
168            llama_server_model: None,
169            llama_params: HashMap::new(),
170            local_ai_models: Vec::new(),
171            providers_enabled: default_providers_enabled(),
172            theme: default_theme(),
173            confirm_destructive: default_confirm_destructive(),
174            scheduler_enabled: default_scheduler_enabled(),
175            scheduler_poll_secs: default_scheduler_poll_secs(),
176            max_agentic_iterations: default_max_agentic_iterations(),
177            max_agentic_seconds: default_max_agentic_seconds(),
178            max_agentic_tokens: default_max_agentic_tokens(),
179        }
180    }
181}
182
183#[derive(Clone)]
184pub struct SettingsStore {
185    path: Option<PathBuf>,
186    inner: Arc<RwLock<Settings>>,
187}
188
189impl SettingsStore {
190    pub fn open(path: PathBuf) -> Self {
191        let inner = match std::fs::read_to_string(&path) {
192            Ok(body) => serde_json::from_str::<Settings>(&body).unwrap_or_default(),
193            Err(_) => Settings::default(),
194        };
195        Self {
196            path: Some(path),
197            inner: Arc::new(RwLock::new(inner)),
198        }
199    }
200
201    pub fn in_memory() -> Self {
202        Self {
203            path: None,
204            inner: Arc::new(RwLock::new(Settings::default())),
205        }
206    }
207
208    pub fn snapshot(&self) -> Settings {
209        self.inner.read().clone()
210    }
211
212    pub fn allow_cloud_ai(&self) -> bool {
213        self.inner.read().allow_cloud_ai
214    }
215
216    pub fn allow_local_ai(&self) -> bool {
217        self.inner.read().allow_local_ai
218    }
219
220    pub fn local_ai_base_url(&self) -> Option<String> {
221        // Treat an empty string as unset, so the Stop path (which patches the
222        // URL to "") reads as "no managed server running".
223        self.inner
224            .read()
225            .local_ai_base_url
226            .clone()
227            .filter(|s| !s.trim().is_empty())
228    }
229
230    pub fn local_ai_models(&self) -> Vec<String> {
231        self.inner.read().local_ai_models.clone()
232    }
233
234    pub fn llama_server_binary(&self) -> Option<String> {
235        self.inner.read().llama_server_binary.clone()
236    }
237
238    pub fn llama_server_model(&self) -> Option<String> {
239        self.inner.read().llama_server_model.clone()
240    }
241
242    pub fn provider_enabled(&self, name: &str) -> bool {
243        self.inner
244            .read()
245            .providers_enabled
246            .get(name)
247            .copied()
248            .unwrap_or(true)
249    }
250
251    pub fn theme(&self) -> String {
252        self.inner.read().theme.clone()
253    }
254
255    pub fn confirm_destructive(&self) -> bool {
256        self.inner.read().confirm_destructive
257    }
258
259    pub fn scheduler_enabled(&self) -> bool {
260        self.inner.read().scheduler_enabled
261    }
262
263    pub fn scheduler_poll_secs(&self) -> u64 {
264        self.inner
265            .read()
266            .scheduler_poll_secs
267            .clamp(5, 24 * 60 * 60)
268    }
269
270    pub fn max_agentic_iterations(&self) -> u32 {
271        self.inner.read().max_agentic_iterations.clamp(1, 50)
272    }
273
274    /// Wall-clock ceiling in seconds for one autonomous run; `0` = unlimited.
275    /// Clamped to 24h.
276    pub fn max_agentic_seconds(&self) -> u32 {
277        self.inner.read().max_agentic_seconds.min(24 * 60 * 60)
278    }
279
280    /// Cumulative token ceiling for one autonomous run; `0` = unlimited.
281    pub fn max_agentic_tokens(&self) -> u32 {
282        self.inner.read().max_agentic_tokens
283    }
284
285    pub fn update(&self, patch: SettingsPatch) -> Result<Settings, SettingsError> {
286        let mut guard = self.inner.write();
287        if let Some(v) = patch.allow_cloud_ai {
288            guard.allow_cloud_ai = v;
289        }
290        if let Some(v) = patch.allow_local_ai {
291            guard.allow_local_ai = v;
292        }
293        if let Some(u) = patch.local_ai_base_url {
294            guard.local_ai_base_url = Some(u);
295        }
296        if let Some(models) = patch.local_ai_models {
297            guard.local_ai_models = models;
298        }
299        if let Some(b) = patch.llama_server_binary {
300            guard.llama_server_binary = Some(b);
301        }
302        if let Some(m) = patch.llama_server_model {
303            guard.llama_server_model = Some(m);
304        }
305        if let Some(map) = patch.llama_params {
306            for (k, v) in map {
307                guard.llama_params.insert(k, v);
308            }
309        }
310        if let Some(map) = patch.providers_enabled {
311            for (k, v) in map {
312                guard.providers_enabled.insert(k, v);
313            }
314        }
315        if let Some(t) = patch.theme {
316            guard.theme = t;
317        }
318        if let Some(v) = patch.confirm_destructive {
319            guard.confirm_destructive = v;
320        }
321        if let Some(v) = patch.scheduler_enabled {
322            guard.scheduler_enabled = v;
323        }
324        if let Some(v) = patch.scheduler_poll_secs {
325            guard.scheduler_poll_secs = v.clamp(5, 24 * 60 * 60);
326        }
327        if let Some(v) = patch.max_agentic_iterations {
328            guard.max_agentic_iterations = v.clamp(1, 50);
329        }
330        if let Some(v) = patch.max_agentic_seconds {
331            guard.max_agentic_seconds = v.min(24 * 60 * 60);
332        }
333        if let Some(v) = patch.max_agentic_tokens {
334            guard.max_agentic_tokens = v;
335        }
336        let snapshot = guard.clone();
337        drop(guard);
338
339        if let Some(path) = &self.path {
340            if let Some(parent) = path.parent() {
341                let _ = std::fs::create_dir_all(parent);
342            }
343            std::fs::write(path, serde_json::to_string_pretty(&snapshot)?)?;
344        }
345        Ok(snapshot)
346    }
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
350pub struct SettingsPatch {
351    pub allow_cloud_ai: Option<bool>,
352    pub allow_local_ai: Option<bool>,
353    pub local_ai_base_url: Option<String>,
354    pub local_ai_models: Option<Vec<String>>,
355    pub llama_server_binary: Option<String>,
356    pub llama_server_model: Option<String>,
357    pub llama_params: Option<HashMap<String, crate::llm_server::LlamaParams>>,
358    pub providers_enabled: Option<HashMap<String, bool>>,
359    pub theme: Option<String>,
360    pub confirm_destructive: Option<bool>,
361    pub scheduler_enabled: Option<bool>,
362    pub scheduler_poll_secs: Option<u64>,
363    pub max_agentic_iterations: Option<u32>,
364    pub max_agentic_seconds: Option<u32>,
365    pub max_agentic_tokens: Option<u32>,
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn default_is_zero_egress() {
374        let s = Settings::default();
375        assert!(!s.allow_cloud_ai);
376    }
377
378    #[test]
379    fn default_theme_is_dark() {
380        assert_eq!(Settings::default().theme, "dark");
381    }
382
383    #[test]
384    fn update_theme_persists() {
385        let store = SettingsStore::in_memory();
386        store
387            .update(SettingsPatch {
388                theme: Some("light".into()),
389                ..Default::default()
390            })
391            .unwrap();
392        assert_eq!(store.theme(), "light");
393    }
394
395    #[test]
396    fn max_agentic_iterations_defaults_and_clamps() {
397        let store = SettingsStore::in_memory();
398        assert_eq!(store.max_agentic_iterations(), crate::MAX_AGENTIC_ITERATIONS);
399        // 0 clamps up to 1 so autonomy can't be silently disabled.
400        store
401            .update(SettingsPatch {
402                max_agentic_iterations: Some(0),
403                ..Default::default()
404            })
405            .unwrap();
406        assert_eq!(store.max_agentic_iterations(), 1);
407        store
408            .update(SettingsPatch {
409                max_agentic_iterations: Some(12),
410                ..Default::default()
411            })
412            .unwrap();
413        assert_eq!(store.max_agentic_iterations(), 12);
414    }
415
416    #[test]
417    fn max_agentic_seconds_defaults_unlimited_and_clamps() {
418        let store = SettingsStore::in_memory();
419        assert_eq!(store.max_agentic_seconds(), 0); // unlimited by default
420        store
421            .update(SettingsPatch {
422                max_agentic_seconds: Some(120),
423                ..Default::default()
424            })
425            .unwrap();
426        assert_eq!(store.max_agentic_seconds(), 120);
427        store
428            .update(SettingsPatch {
429                max_agentic_seconds: Some(999_999),
430                ..Default::default()
431            })
432            .unwrap();
433        assert_eq!(store.max_agentic_seconds(), 24 * 60 * 60); // clamped to 24h
434    }
435
436    #[test]
437    fn max_agentic_tokens_defaults_unlimited() {
438        let store = SettingsStore::in_memory();
439        assert_eq!(store.max_agentic_tokens(), 0); // unlimited by default
440        store
441            .update(SettingsPatch {
442                max_agentic_tokens: Some(50_000),
443                ..Default::default()
444            })
445            .unwrap();
446        assert_eq!(store.max_agentic_tokens(), 50_000);
447    }
448
449    #[test]
450    fn update_persists_to_disk_when_path_set() {
451        let tmp = std::env::temp_dir().join(format!("flow-settings-{}.json", uuid::Uuid::new_v4()));
452        let store = SettingsStore::open(tmp.clone());
453        store
454            .update(SettingsPatch {
455                allow_cloud_ai: Some(true),
456                ..Default::default()
457            })
458            .unwrap();
459        let body = std::fs::read_to_string(&tmp).unwrap();
460        assert!(body.contains("\"allow_cloud_ai\": true"));
461        let _ = std::fs::remove_file(&tmp);
462    }
463
464    #[test]
465    fn provider_enabled_defaults_to_true_for_unknown_names() {
466        let store = SettingsStore::in_memory();
467        assert!(store.provider_enabled("custom"));
468    }
469
470    #[test]
471    fn update_can_disable_a_provider() {
472        let store = SettingsStore::in_memory();
473        let mut patch = HashMap::new();
474        patch.insert("openai".into(), false);
475        store
476            .update(SettingsPatch {
477                allow_cloud_ai: Some(true),
478                providers_enabled: Some(patch),
479                ..Default::default()
480            })
481            .unwrap();
482        assert!(!store.provider_enabled("openai"));
483        assert!(store.provider_enabled("claude"));
484    }
485}