Skip to main content

nexus_core/app/
snapshot.rs

1//! The serde-shaped view of app state the Phase 4 nexus host API will
2//! consume: sessions, models, settings, tasks. Designed now so the wire
3//! shape is stable before the API lands; the TUI does not consume this
4//! (it reads fields directly until 2e).
5
6use anyhow::{Context as _, Result};
7use serde::{Deserialize, Serialize};
8
9use super::App;
10
11/// One snapshot of the app's domain state.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CoreSnapshot {
14    pub sessions: Vec<SessionSnapshot>,
15    pub models: Vec<ModelSnapshot>,
16    pub settings: SettingsSnapshot,
17    pub tasks: Vec<TaskSnapshot>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SessionSnapshot {
22    pub id: String,
23    pub title: String,
24    pub slug: Option<String>,
25    pub model: String,
26    pub kind: String,
27    pub web_mode: bool,
28    pub created_at: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ModelSnapshot {
33    /// Composite id (backend prefix + wire id) — what `current_model`,
34    /// favorites, and last-used store.
35    pub id: String,
36    pub name: String,
37    pub context_length: Option<u64>,
38    pub favorite: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SettingsSnapshot {
43    pub model: Option<String>,
44    pub verbosity: String,
45    pub web_mode: bool,
46    pub incognito: bool,
47    /// Sanitized `SearXNG` endpoint (credentials/query strings are removed).
48    pub searxng_url: String,
49    /// Whether the local `LangSearch` credential is configured. The key never
50    /// crosses the host API boundary.
51    #[serde(default)]
52    pub langsearch_configured: bool,
53    pub search_provider: String,
54    pub temperature: Option<f32>,
55    pub top_p: Option<f32>,
56    pub max_tokens: Option<u32>,
57    pub compact_threshold: u8,
58    pub memory_model: String,
59    pub transcriber_model: String,
60    pub ocr_model: String,
61    pub ocr_engine: String,
62    pub embedding_model: String,
63    pub image_gen_model: String,
64    pub video_gen_model: String,
65    pub blocked_domains: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TaskSnapshot {
70    pub id: u64,
71    pub session_id: String,
72    pub session_title: String,
73    pub model: String,
74    pub backend: String,
75    /// "tool" while a tool runs, "streaming" otherwise. Tasks only live in
76    /// the map while their loop is active, so there is no idle state.
77    pub status: String,
78    pub buffer_chars: usize,
79}
80
81/// Remove URL userinfo, query, and fragment components before an endpoint is
82/// exposed in a remote snapshot. Search endpoints are local configuration, not
83/// credentials storage, but URLs commonly grow accidental `?token=` values.
84fn sanitized_endpoint(value: &str) -> String {
85    let value = value.trim();
86    let value = value.split_once('#').map_or(value, |(prefix, _)| prefix);
87    let value = value.split_once('?').map_or(value, |(prefix, _)| prefix);
88    if let Some((scheme, authority)) = value.split_once("://")
89        && let Some((_, host)) = authority.rsplit_once('@')
90    {
91        return format!("{scheme}://{host}");
92    }
93    value.to_string()
94}
95
96impl App {
97    /// Serde-shaped state for API consumers (the Phase 4 host). Sessions
98    /// come from the picker cache when loaded, else a fresh db read — a
99    /// failed read is an error, never a silently-empty session list.
100    pub fn snapshot(&self) -> Result<CoreSnapshot> {
101        let sessions = if self.sessions_cache.is_empty() {
102            self.db
103                .list_sessions(&self.active_space.id)
104                .context("reading sessions for snapshot")?
105        } else {
106            self.sessions_cache.clone()
107        };
108        let favorite_ids = &self.favorites;
109        Ok(CoreSnapshot {
110            sessions: sessions
111                .into_iter()
112                .map(|s| SessionSnapshot {
113                    id: s.id.clone(),
114                    title: s.title.clone(),
115                    slug: s.slug.clone(),
116                    model: s.model.clone(),
117                    kind: s.kind.clone(),
118                    web_mode: s.web_mode,
119                    created_at: s.created_at.clone(),
120                })
121                .collect(),
122            models: self
123                .models
124                .iter()
125                .map(|m| ModelSnapshot {
126                    id: super::composite_id(m),
127                    name: m.name.clone(),
128                    context_length: m.context_length,
129                    favorite: favorite_ids.contains(&super::composite_id(m)),
130                })
131                .collect(),
132            settings: SettingsSnapshot {
133                model: self.current_model.clone(),
134                verbosity: self.verbosity.clone(),
135                web_mode: self.web_mode,
136                incognito: self.incognito,
137                searxng_url: sanitized_endpoint(&self.searxng_url),
138                langsearch_configured: !self.langsearch_key.trim().is_empty(),
139                search_provider: self.search_provider.clone(),
140                temperature: self.settings.temperature,
141                top_p: self.settings.top_p,
142                max_tokens: self.settings.max_tokens,
143                compact_threshold: self.settings.compact_threshold,
144                memory_model: self.memory_model.clone(),
145                transcriber_model: self.transcriber_model.clone(),
146                ocr_model: self.ocr_model.clone(),
147                ocr_engine: self.ocr_engine.clone(),
148                embedding_model: self.embedding_model.clone(),
149                image_gen_model: self.image_gen_model.clone(),
150                video_gen_model: self.video_gen_model.clone(),
151                blocked_domains: self.blocked_domains(),
152            },
153            tasks: self
154                .chat_tasks
155                .values()
156                .map(|t| TaskSnapshot {
157                    id: t.id,
158                    session_id: t.session_id.clone(),
159                    session_title: t.session_title.clone(),
160                    model: t.model.clone(),
161                    backend: t.backend.name().to_string(),
162                    status: if t.tool_status.is_some() {
163                        "tool".to_string()
164                    } else {
165                        "streaming".to_string()
166                    },
167                    buffer_chars: t.buffer.chars().count(),
168                })
169                .collect(),
170        })
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// Phase 5 clients (web/mobile/`--remote`) parse `CoreSnapshot` `JSON`
179    /// — this golden string locks the wire shape so a field rename or
180    /// reorder can never silently break a client. Update it deliberately
181    /// when the shape changes.
182    #[test]
183    fn golden_json_locks_wire_shape() {
184        let snap = CoreSnapshot {
185            sessions: vec![SessionSnapshot {
186                id: "s1".into(),
187                title: "hello".into(),
188                slug: Some("hello".into()),
189                model: "openrouter:anthropic/claude-sonnet-4".into(),
190                kind: "chat".into(),
191                web_mode: false,
192                created_at: "2025-01-01T00:00:00Z".into(),
193            }],
194            models: vec![ModelSnapshot {
195                id: "openrouter:anthropic/claude-sonnet-4".into(),
196                name: "Claude Sonnet 4".into(),
197                context_length: Some(200_000),
198                favorite: true,
199            }],
200            settings: SettingsSnapshot {
201                model: Some("openrouter:anthropic/claude-sonnet-4".into()),
202                verbosity: "high".into(),
203                web_mode: false,
204                incognito: false,
205                searxng_url: String::new(),
206                langsearch_configured: false,
207                search_provider: "searxng".into(),
208                temperature: Some(0.7),
209                top_p: None,
210                max_tokens: None,
211                compact_threshold: 60,
212                memory_model: String::new(),
213                transcriber_model: String::new(),
214                ocr_model: String::new(),
215                ocr_engine: "router".into(),
216                embedding_model: String::new(),
217                image_gen_model: String::new(),
218                video_gen_model: String::new(),
219                blocked_domains: Vec::new(),
220            },
221            tasks: vec![TaskSnapshot {
222                id: 1,
223                session_id: "s1".into(),
224                session_title: "hello".into(),
225                model: "openrouter:anthropic/claude-sonnet-4".into(),
226                backend: "OpenRouter".into(),
227                status: "streaming".into(),
228                buffer_chars: 12,
229            }],
230        };
231        let json = serde_json::to_string(&snap).expect("snapshot serializes");
232        assert_eq!(
233            json,
234            r#"{"sessions":[{"id":"s1","title":"hello","slug":"hello","model":"openrouter:anthropic/claude-sonnet-4","kind":"chat","web_mode":false,"created_at":"2025-01-01T00:00:00Z"}],"models":[{"id":"openrouter:anthropic/claude-sonnet-4","name":"Claude Sonnet 4","context_length":200000,"favorite":true}],"settings":{"model":"openrouter:anthropic/claude-sonnet-4","verbosity":"high","web_mode":false,"incognito":false,"searxng_url":"","langsearch_configured":false,"search_provider":"searxng","temperature":0.7,"top_p":null,"max_tokens":null,"compact_threshold":60,"memory_model":"","transcriber_model":"","ocr_model":"","ocr_engine":"router","embedding_model":"","image_gen_model":"","video_gen_model":"","blocked_domains":[]},"tasks":[{"id":1,"session_id":"s1","session_title":"hello","model":"openrouter:anthropic/claude-sonnet-4","backend":"OpenRouter","status":"streaming","buffer_chars":12}]}"#
235        );
236        // The golden string must also parse back into the same shape.
237        let back: CoreSnapshot = serde_json::from_str(&json).expect("golden parses");
238        assert_eq!(back.sessions[0].title, "hello");
239        assert_eq!(back.sessions[0].slug.as_deref(), Some("hello"));
240        assert_eq!(back.models[0].context_length, Some(200_000));
241        assert_eq!(back.settings.temperature, Some(0.7));
242        assert!(!back.settings.langsearch_configured);
243        assert_eq!(back.settings.blocked_domains, Vec::<String>::new());
244        assert_eq!(back.tasks[0].status, "streaming");
245        assert_eq!(back.tasks[0].buffer_chars, 12);
246    }
247
248    #[test]
249    fn sanitized_endpoint_drops_credentials_and_query() {
250        assert_eq!(
251            sanitized_endpoint("https://user:pass@example.test/search?token=secret#frag"),
252            "https://example.test/search"
253        );
254        assert_eq!(
255            sanitized_endpoint("http://localhost:8080"),
256            "http://localhost:8080"
257        );
258    }
259}