Skip to main content

cinch_web/
snapshot.rs

1//! Serializable projection of [`UiState`] for WebSocket and REST transport.
2//!
3//! [`UiState`] contains non-serializable types (`Instant`, `Box<dyn UiExtension>`)
4//! and potentially large collections. [`UiStateSnapshot`] converts these into
5//! wire-friendly representations: `Instant` → seconds remaining, extension → JSON,
6//! logs capped to the most recent entries.
7
8use std::time::Instant;
9
10use cinch_rs::ui::{AgentEntry, LogLine, UiState, UserQuestion};
11use serde::Serialize;
12
13/// Maximum number of log lines included in a snapshot.
14const SNAPSHOT_MAX_LOGS: usize = 200;
15
16/// Serializable view of [`UiState`] sent over WebSocket or REST.
17///
18/// Converts `Instant` fields to "seconds remaining" and domain extension
19/// state to JSON via [`UiExtension::to_json()`].
20#[derive(Debug, Serialize)]
21pub struct UiStateSnapshot {
22    // ── Agent progress ──
23    pub phase: String,
24    pub round: u32,
25    pub max_rounds: u32,
26    pub context_pct: f64,
27    pub model: String,
28    pub cycle: u32,
29
30    // ── Agent output ──
31    pub agent_output: Vec<AgentEntry>,
32    pub streaming_buffer: String,
33
34    // ── Logs (capped) ──
35    pub logs: Vec<LogLine>,
36
37    // ── Lifecycle ──
38    pub running: bool,
39
40    // ── Scheduling ──
41    /// Seconds until the next cycle starts, or `null` if not scheduled.
42    pub next_cycle_secs: Option<f64>,
43
44    // ── Active question ──
45    pub active_question: Option<ActiveQuestionSnapshot>,
46
47    // ── Domain extension ──
48    pub extension: Option<serde_json::Value>,
49}
50
51/// Serializable view of an in-flight question.
52#[derive(Debug, Serialize)]
53pub struct ActiveQuestionSnapshot {
54    pub question: UserQuestion,
55    /// Seconds remaining before timeout, or `null` if no deadline.
56    pub remaining_secs: Option<f64>,
57    pub done: bool,
58}
59
60impl UiStateSnapshot {
61    /// Build a snapshot from the current `UiState`.
62    ///
63    /// This reads all fields and converts non-serializable types. Should be
64    /// called while holding the `UiState` lock.
65    pub fn from_ui_state(state: &UiState) -> Self {
66        let now = Instant::now();
67
68        let next_cycle_secs = state.next_cycle_at.map(|t| {
69            if t > now {
70                t.duration_since(now).as_secs_f64()
71            } else {
72                0.0
73            }
74        });
75
76        let active_question = state.active_question.as_ref().map(|aq| {
77            let remaining_secs = aq.deadline.map(|d| {
78                if d > now {
79                    d.duration_since(now).as_secs_f64()
80                } else {
81                    0.0
82                }
83            });
84
85            ActiveQuestionSnapshot {
86                question: aq.question.clone(),
87                remaining_secs,
88                done: aq.done,
89            }
90        });
91
92        // Take only the most recent logs to limit payload size.
93        let log_start = state.logs.len().saturating_sub(SNAPSHOT_MAX_LOGS);
94        let logs: Vec<LogLine> = state.logs[log_start..].to_vec();
95
96        Self {
97            phase: state.phase.clone(),
98            round: state.round,
99            max_rounds: state.max_rounds,
100            context_pct: state.context_pct,
101            model: state.model.clone(),
102            cycle: state.cycle,
103            agent_output: state.agent_output.clone(),
104            streaming_buffer: state.streaming_buffer.clone(),
105            logs,
106            running: state.running,
107            next_cycle_secs,
108            active_question,
109            extension: state.extensions.to_json(),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn snapshot_from_default_state() {
120        let state = UiState::default();
121        let snap = UiStateSnapshot::from_ui_state(&state);
122
123        assert_eq!(snap.phase, "Initializing");
124        assert_eq!(snap.round, 0);
125        assert_eq!(snap.max_rounds, 0);
126        assert!(snap.running);
127        assert!(snap.agent_output.is_empty());
128        assert!(snap.logs.is_empty());
129        assert!(snap.active_question.is_none());
130        assert!(snap.next_cycle_secs.is_none());
131        assert!(snap.extension.is_none());
132    }
133
134    #[test]
135    fn snapshot_serializes_to_json() {
136        let state = UiState::default();
137        let snap = UiStateSnapshot::from_ui_state(&state);
138
139        let json = serde_json::to_value(&snap).unwrap();
140        assert_eq!(json["phase"], "Initializing");
141        assert_eq!(json["running"], true);
142        assert!(json["next_cycle_secs"].is_null());
143    }
144
145    #[test]
146    fn snapshot_caps_logs() {
147        let mut state = UiState::default();
148        for i in 0..300 {
149            state.logs.push(LogLine {
150                time: format!("{i:03}"),
151                level: cinch_rs::ui::LogLevel::Info,
152                message: format!("msg {i}"),
153            });
154        }
155
156        let snap = UiStateSnapshot::from_ui_state(&state);
157        assert_eq!(snap.logs.len(), 200);
158        // Should contain the *last* 200 entries.
159        assert_eq!(snap.logs[0].time, "100");
160        assert_eq!(snap.logs[199].time, "299");
161    }
162}