Skip to main content

ralph/contracts/
session.rs

1//! Session state contract for crash recovery.
2//!
3//! Responsibilities:
4//! - Define the session state schema for run loop recovery.
5//! - Provide serialization/deserialization for session persistence.
6//!
7//! Not handled here:
8//! - Session persistence operations (see crate::session).
9//! - Session validation logic (see crate::session).
10//!
11//! Invariants/assumptions:
12//! - Session state is written atomically to prevent corruption.
13//! - Timestamps are RFC3339 UTC format.
14//! - Per-phase settings are display-only; crash recovery recomputes from CLI+config+task.
15
16use crate::constants::versions::SESSION_STATE_VERSION;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use super::{ReasoningEffort, Runner};
21
22/// Per-phase settings persisted for display/logging purposes.
23///
24/// These fields are informational only - crash recovery recomputes settings
25/// from CLI flags, config, and task overrides to ensure consistency.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub struct PhaseSettingsSnapshot {
28    /// Runner for this phase
29    pub runner: Runner,
30    /// Model for this phase
31    pub model: String,
32    /// Reasoning effort for this phase (if applicable)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub reasoning_effort: Option<ReasoningEffort>,
35}
36
37/// Session state persisted to enable crash recovery.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct SessionState {
41    /// Schema version for forward compatibility.
42    pub version: u32,
43
44    /// Unique session ID (UUID v4) for this run session.
45    pub session_id: String,
46
47    /// The task currently being executed.
48    pub task_id: String,
49
50    /// When the session/run started (RFC3339 UTC).
51    pub run_started_at: String,
52
53    /// When the session state was last updated (RFC3339 UTC).
54    pub last_updated_at: String,
55
56    /// Total number of iterations planned for the current task.
57    pub iterations_planned: u8,
58
59    /// Number of iterations completed so far.
60    pub iterations_completed: u8,
61
62    /// Current phase being executed (1, 2, or 3).
63    pub current_phase: u8,
64
65    /// Runner being used for this session.
66    pub runner: Runner,
67
68    /// Model being used for this session.
69    pub model: String,
70
71    /// Number of tasks completed in this loop session (for loop progress tracking).
72    pub tasks_completed_in_loop: u32,
73
74    /// Maximum tasks to run in this loop (0 = no limit).
75    pub max_tasks: u32,
76
77    /// Git HEAD commit at session start (for advanced recovery validation).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub git_head_commit: Option<String>,
80
81    /// Phase 1 settings (planning) - display/logging only.
82    /// Crash recovery recomputes from CLI+config+task.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub phase1_settings: Option<PhaseSettingsSnapshot>,
85
86    /// Phase 2 settings (implementation) - display/logging only.
87    /// Crash recovery recomputes from CLI+config+task.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub phase2_settings: Option<PhaseSettingsSnapshot>,
90
91    /// Phase 3 settings (review) - display/logging only.
92    /// Crash recovery recomputes from CLI+config+task.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub phase3_settings: Option<PhaseSettingsSnapshot>,
95}
96
97impl SessionState {
98    /// Create a new session state for the given task.
99    #[allow(clippy::too_many_arguments)]
100    pub fn new(
101        session_id: String,
102        task_id: String,
103        run_started_at: String,
104        iterations_planned: u8,
105        runner: Runner,
106        model: String,
107        max_tasks: u32,
108        git_head_commit: Option<String>,
109        phase_settings: Option<(
110            PhaseSettingsSnapshot,
111            PhaseSettingsSnapshot,
112            PhaseSettingsSnapshot,
113        )>,
114    ) -> Self {
115        let (phase1_settings, phase2_settings, phase3_settings) = phase_settings
116            .map(|(p1, p2, p3)| (Some(p1), Some(p2), Some(p3)))
117            .unwrap_or((None, None, None));
118
119        Self {
120            version: SESSION_STATE_VERSION,
121            session_id,
122            task_id,
123            run_started_at: run_started_at.clone(),
124            last_updated_at: run_started_at,
125            iterations_planned,
126            iterations_completed: 0,
127            current_phase: 1,
128            runner,
129            model,
130            tasks_completed_in_loop: 0,
131            max_tasks,
132            git_head_commit,
133            phase1_settings,
134            phase2_settings,
135            phase3_settings,
136        }
137    }
138
139    /// Update the session after iteration completion.
140    pub fn mark_iteration_complete(&mut self, completed_at: String) {
141        self.iterations_completed += 1;
142        self.last_updated_at = completed_at;
143    }
144
145    /// Update the session after phase completion.
146    pub fn set_phase(&mut self, phase: u8, updated_at: String) {
147        self.current_phase = phase;
148        self.last_updated_at = updated_at;
149    }
150
151    /// Update the session after task completion.
152    pub fn mark_task_complete(&mut self, updated_at: String) {
153        self.tasks_completed_in_loop += 1;
154        self.last_updated_at = updated_at;
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn test_session() -> SessionState {
163        SessionState::new(
164            "test-session-id".to_string(),
165            "RQ-0001".to_string(),
166            "2026-01-30T00:00:00.000000000Z".to_string(),
167            2,
168            Runner::Claude,
169            "sonnet".to_string(),
170            10,
171            Some("abc123".to_string()),
172            None, // phase_settings
173        )
174    }
175
176    #[test]
177    fn session_new_sets_defaults() {
178        let session = test_session();
179
180        assert_eq!(session.version, SESSION_STATE_VERSION);
181        assert_eq!(session.session_id, "test-session-id");
182        assert_eq!(session.task_id, "RQ-0001");
183        assert_eq!(session.iterations_planned, 2);
184        assert_eq!(session.iterations_completed, 0);
185        assert_eq!(session.current_phase, 1);
186        assert_eq!(session.tasks_completed_in_loop, 0);
187        assert_eq!(session.max_tasks, 10);
188        assert_eq!(session.git_head_commit, Some("abc123".to_string()));
189    }
190
191    #[test]
192    fn session_mark_iteration_complete_increments_count() {
193        let mut session = test_session();
194
195        session.mark_iteration_complete("2026-01-30T00:01:00.000000000Z".to_string());
196
197        assert_eq!(session.iterations_completed, 1);
198        assert_eq!(session.last_updated_at, "2026-01-30T00:01:00.000000000Z");
199    }
200
201    #[test]
202    fn session_set_phase_updates_phase() {
203        let mut session = test_session();
204
205        session.set_phase(2, "2026-01-30T00:02:00.000000000Z".to_string());
206
207        assert_eq!(session.current_phase, 2);
208        assert_eq!(session.last_updated_at, "2026-01-30T00:02:00.000000000Z");
209    }
210
211    #[test]
212    fn session_mark_task_complete_increments_count() {
213        let mut session = test_session();
214
215        session.mark_task_complete("2026-01-30T00:03:00.000000000Z".to_string());
216
217        assert_eq!(session.tasks_completed_in_loop, 1);
218        assert_eq!(session.last_updated_at, "2026-01-30T00:03:00.000000000Z");
219    }
220
221    #[test]
222    fn session_serialization_roundtrip() {
223        let session = test_session();
224
225        let json = serde_json::to_string(&session).expect("serialize");
226        let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
227
228        assert_eq!(deserialized.session_id, session.session_id);
229        assert_eq!(deserialized.task_id, session.task_id);
230        assert_eq!(deserialized.iterations_planned, session.iterations_planned);
231        assert_eq!(deserialized.runner, session.runner);
232        assert_eq!(deserialized.model, session.model);
233    }
234
235    #[test]
236    fn session_deserialization_ignores_optional_git_commit_when_none() {
237        let session = SessionState::new(
238            "test-id".to_string(),
239            "RQ-0001".to_string(),
240            "2026-01-30T00:00:00.000000000Z".to_string(),
241            1,
242            Runner::Claude,
243            "sonnet".to_string(),
244            0,
245            None,
246            None, // phase_settings
247        );
248
249        let json = serde_json::to_string(&session).expect("serialize");
250        assert!(!json.contains("git_head_commit"));
251
252        let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
253        assert_eq!(deserialized.git_head_commit, None);
254    }
255
256    #[test]
257    fn session_new_with_phase_settings() {
258        let phase_settings = (
259            PhaseSettingsSnapshot {
260                runner: Runner::Claude,
261                model: "sonnet".to_string(),
262                reasoning_effort: None,
263            },
264            PhaseSettingsSnapshot {
265                runner: Runner::Codex,
266                model: "o3-mini".to_string(),
267                reasoning_effort: Some(ReasoningEffort::High),
268            },
269            PhaseSettingsSnapshot {
270                runner: Runner::Claude,
271                model: "haiku".to_string(),
272                reasoning_effort: None,
273            },
274        );
275
276        let session = SessionState::new(
277            "test-id".to_string(),
278            "RQ-0001".to_string(),
279            "2026-01-30T00:00:00.000000000Z".to_string(),
280            1,
281            Runner::Claude,
282            "sonnet".to_string(),
283            0,
284            None,
285            Some(phase_settings),
286        );
287
288        assert!(session.phase1_settings.is_some());
289        assert!(session.phase2_settings.is_some());
290        assert!(session.phase3_settings.is_some());
291
292        let p1 = session.phase1_settings.unwrap();
293        assert_eq!(p1.runner, Runner::Claude);
294        assert_eq!(p1.model, "sonnet");
295        assert_eq!(p1.reasoning_effort, None);
296
297        let p2 = session.phase2_settings.unwrap();
298        assert_eq!(p2.runner, Runner::Codex);
299        assert_eq!(p2.model, "o3-mini");
300        assert_eq!(p2.reasoning_effort, Some(ReasoningEffort::High));
301
302        let p3 = session.phase3_settings.unwrap();
303        assert_eq!(p3.runner, Runner::Claude);
304        assert_eq!(p3.model, "haiku");
305        assert_eq!(p3.reasoning_effort, None);
306    }
307
308    #[test]
309    fn session_serialization_with_phase_settings() {
310        let phase_settings = (
311            PhaseSettingsSnapshot {
312                runner: Runner::Claude,
313                model: "sonnet".to_string(),
314                reasoning_effort: None,
315            },
316            PhaseSettingsSnapshot {
317                runner: Runner::Codex,
318                model: "o3-mini".to_string(),
319                reasoning_effort: Some(ReasoningEffort::Medium),
320            },
321            PhaseSettingsSnapshot {
322                runner: Runner::Claude,
323                model: "haiku".to_string(),
324                reasoning_effort: None,
325            },
326        );
327
328        let session = SessionState::new(
329            "test-id".to_string(),
330            "RQ-0001".to_string(),
331            "2026-01-30T00:00:00.000000000Z".to_string(),
332            1,
333            Runner::Claude,
334            "sonnet".to_string(),
335            0,
336            None,
337            Some(phase_settings),
338        );
339
340        let json = serde_json::to_string(&session).expect("serialize");
341        let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
342
343        assert_eq!(deserialized.phase1_settings, session.phase1_settings);
344        assert_eq!(deserialized.phase2_settings, session.phase2_settings);
345        assert_eq!(deserialized.phase3_settings, session.phase3_settings);
346    }
347
348    #[test]
349    fn session_deserialization_backward_compatible_without_phase_settings() {
350        // Simulate old session JSON without phase settings
351        // Note: runner uses kebab-case serialization
352        let json = r#"{
353            "version": 1,
354            "session_id": "test-id",
355            "task_id": "RQ-0001",
356            "run_started_at": "2026-01-30T00:00:00.000000000Z",
357            "last_updated_at": "2026-01-30T00:00:00.000000000Z",
358            "iterations_planned": 1,
359            "iterations_completed": 0,
360            "current_phase": 1,
361            "runner": "claude",
362            "model": "sonnet",
363            "tasks_completed_in_loop": 0,
364            "max_tasks": 0
365        }"#;
366
367        let session: SessionState = serde_json::from_str(json).expect("deserialize old format");
368        assert_eq!(session.phase1_settings, None);
369        assert_eq!(session.phase2_settings, None);
370        assert_eq!(session.phase3_settings, None);
371    }
372
373    #[test]
374    fn session_serialization_skips_none_phase_settings() {
375        let session = SessionState::new(
376            "test-id".to_string(),
377            "RQ-0001".to_string(),
378            "2026-01-30T00:00:00.000000000Z".to_string(),
379            1,
380            Runner::Claude,
381            "sonnet".to_string(),
382            0,
383            None,
384            None, // no phase settings
385        );
386
387        let json = serde_json::to_string(&session).expect("serialize");
388        // Phase settings fields should not be present when None
389        assert!(!json.contains("phase1_settings"));
390        assert!(!json.contains("phase2_settings"));
391        assert!(!json.contains("phase3_settings"));
392    }
393}