Skip to main content

ralph_core/hooks/
suspend_state.rs

1//! Durable suspend-state artifacts for hook-driven suspension.
2//!
3//! Step 7.1 groundwork:
4//! - `.ralph/suspend-state.json` stores structured suspend context.
5//! - `.ralph/resume-requested` is a single-use operator resume signal.
6//!
7//! Writes use a temp-file + rename strategy so readers never observe partial
8//! content for `suspend-state.json`.
9
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17
18use crate::config::{HookPhaseEvent, HookSuspendMode};
19
20/// Current suspend-state schema version.
21pub const SUSPEND_STATE_SCHEMA_VERSION: u32 = 1;
22
23/// Runtime lifecycle state persisted in `.ralph/suspend-state.json`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SuspendLifecycleState {
27    Suspended,
28}
29
30/// Durable suspend-state payload written when a hook yields `on_error: suspend`.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct SuspendStateRecord {
34    pub schema_version: u32,
35    pub state: SuspendLifecycleState,
36    pub loop_id: String,
37    pub phase_event: HookPhaseEvent,
38    pub hook_name: String,
39    pub reason: String,
40    pub suspend_mode: HookSuspendMode,
41    pub suspended_at: DateTime<Utc>,
42}
43
44impl SuspendStateRecord {
45    /// Construct a v1 suspend-state payload.
46    #[must_use]
47    pub fn new(
48        loop_id: impl Into<String>,
49        phase_event: HookPhaseEvent,
50        hook_name: impl Into<String>,
51        reason: impl Into<String>,
52        suspend_mode: HookSuspendMode,
53        suspended_at: DateTime<Utc>,
54    ) -> Self {
55        Self {
56            schema_version: SUSPEND_STATE_SCHEMA_VERSION,
57            state: SuspendLifecycleState::Suspended,
58            loop_id: loop_id.into(),
59            phase_event,
60            hook_name: hook_name.into(),
61            reason: reason.into(),
62            suspend_mode,
63            suspended_at,
64        }
65    }
66}
67
68/// File-store for suspend/resume artifacts under a loop workspace.
69#[derive(Debug, Clone)]
70pub struct SuspendStateStore {
71    workspace_root: PathBuf,
72}
73
74impl SuspendStateStore {
75    const RALPH_DIR: &'static str = ".ralph";
76    const SUSPEND_STATE_FILE: &'static str = "suspend-state.json";
77    const RESUME_REQUESTED_FILE: &'static str = "resume-requested";
78
79    #[must_use]
80    pub fn new(workspace_root: impl AsRef<Path>) -> Self {
81        Self {
82            workspace_root: workspace_root.as_ref().to_path_buf(),
83        }
84    }
85
86    #[must_use]
87    pub fn workspace_root(&self) -> &Path {
88        &self.workspace_root
89    }
90
91    #[must_use]
92    pub fn ralph_dir(&self) -> PathBuf {
93        self.workspace_root.join(Self::RALPH_DIR)
94    }
95
96    #[must_use]
97    pub fn suspend_state_path(&self) -> PathBuf {
98        self.ralph_dir().join(Self::SUSPEND_STATE_FILE)
99    }
100
101    #[must_use]
102    pub fn resume_requested_path(&self) -> PathBuf {
103        self.ralph_dir().join(Self::RESUME_REQUESTED_FILE)
104    }
105
106    /// Atomically write suspend-state JSON.
107    pub fn write_suspend_state(
108        &self,
109        state: &SuspendStateRecord,
110    ) -> Result<(), SuspendStateStoreError> {
111        let bytes = serde_json::to_vec_pretty(state)
112            .map_err(|source| SuspendStateStoreError::SerializeState { source })?;
113        self.write_atomic_file(&self.suspend_state_path(), &bytes)
114    }
115
116    /// Read suspend-state JSON if present.
117    pub fn read_suspend_state(&self) -> Result<Option<SuspendStateRecord>, SuspendStateStoreError> {
118        let path = self.suspend_state_path();
119        let bytes = match fs::read(&path) {
120            Ok(bytes) => bytes,
121            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
122            Err(source) => {
123                return Err(SuspendStateStoreError::Io {
124                    action: "read suspend-state",
125                    path,
126                    source,
127                });
128            }
129        };
130
131        serde_json::from_slice(&bytes)
132            .map(Some)
133            .map_err(|source| SuspendStateStoreError::DeserializeState { path, source })
134    }
135
136    /// Remove suspend-state if present.
137    pub fn clear_suspend_state(&self) -> Result<bool, SuspendStateStoreError> {
138        remove_if_exists(&self.suspend_state_path(), "clear suspend-state")
139    }
140
141    /// Atomically write a resume signal file.
142    pub fn write_resume_requested(&self) -> Result<(), SuspendStateStoreError> {
143        self.write_atomic_file(&self.resume_requested_path(), b"")
144    }
145
146    /// True when a resume signal file exists.
147    #[must_use]
148    pub fn is_resume_requested(&self) -> bool {
149        self.resume_requested_path().exists()
150    }
151
152    /// Consume a single-use resume signal file.
153    pub fn consume_resume_requested(&self) -> Result<bool, SuspendStateStoreError> {
154        remove_if_exists(&self.resume_requested_path(), "consume resume signal")
155    }
156
157    fn write_atomic_file(
158        &self,
159        destination: &Path,
160        bytes: &[u8],
161    ) -> Result<(), SuspendStateStoreError> {
162        let parent = destination
163            .parent()
164            .ok_or_else(|| SuspendStateStoreError::Io {
165                action: "resolve atomic write parent",
166                path: destination.to_path_buf(),
167                source: io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
168            })?;
169
170        fs::create_dir_all(parent).map_err(|source| SuspendStateStoreError::Io {
171            action: "create suspend-state directory",
172            path: parent.to_path_buf(),
173            source,
174        })?;
175
176        let temp_path = parent.join(temp_file_name(destination));
177
178        fs::write(&temp_path, bytes).map_err(|source| SuspendStateStoreError::Io {
179            action: "write temporary suspend-state artifact",
180            path: temp_path.clone(),
181            source,
182        })?;
183
184        if let Err(source) = fs::rename(&temp_path, destination) {
185            let _ = fs::remove_file(&temp_path);
186            return Err(SuspendStateStoreError::Io {
187                action: "atomically replace suspend-state artifact",
188                path: destination.to_path_buf(),
189                source,
190            });
191        }
192
193        Ok(())
194    }
195}
196
197/// Suspend-state store operations that can fail.
198#[derive(Debug, thiserror::Error)]
199pub enum SuspendStateStoreError {
200    #[error("I/O error while {action} at {path}: {source}")]
201    Io {
202        action: &'static str,
203        path: PathBuf,
204        #[source]
205        source: io::Error,
206    },
207
208    #[error("failed to serialize suspend-state JSON: {source}")]
209    SerializeState {
210        #[source]
211        source: serde_json::Error,
212    },
213
214    #[error("failed to parse suspend-state JSON at {path}: {source}")]
215    DeserializeState {
216        path: PathBuf,
217        #[source]
218        source: serde_json::Error,
219    },
220}
221
222fn remove_if_exists(path: &Path, action: &'static str) -> Result<bool, SuspendStateStoreError> {
223    match fs::remove_file(path) {
224        Ok(()) => Ok(true),
225        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
226        Err(source) => Err(SuspendStateStoreError::Io {
227            action,
228            path: path.to_path_buf(),
229            source,
230        }),
231    }
232}
233
234fn temp_file_name(destination: &Path) -> String {
235    let file_name = destination
236        .file_name()
237        .and_then(|value| value.to_str())
238        .unwrap_or("suspend-artifact");
239
240    format!(
241        ".{file_name}.tmp-{}-{}",
242        std::process::id(),
243        unix_epoch_nanos()
244    )
245}
246
247fn unix_epoch_nanos() -> u128 {
248    SystemTime::now()
249        .duration_since(UNIX_EPOCH)
250        .unwrap_or_default()
251        .as_nanos()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use chrono::TimeZone;
258
259    fn fixed_time() -> DateTime<Utc> {
260        Utc.with_ymd_and_hms(2026, 2, 28, 15, 31, 0)
261            .single()
262            .expect("valid timestamp")
263    }
264
265    fn sample_record() -> SuspendStateRecord {
266        SuspendStateRecord::new(
267            "loop-1234-abcd",
268            HookPhaseEvent::PreIterationStart,
269            "manual-gate",
270            "operator approval required",
271            HookSuspendMode::WaitForResume,
272            fixed_time(),
273        )
274    }
275
276    #[test]
277    fn test_suspend_state_record_serializes_v1_schema_shape() {
278        let value = serde_json::to_value(sample_record()).expect("serialize state");
279
280        assert_eq!(value["schema_version"], 1);
281        assert_eq!(value["state"], "suspended");
282        assert_eq!(value["loop_id"], "loop-1234-abcd");
283        assert_eq!(value["phase_event"], "pre.iteration.start");
284        assert_eq!(value["hook_name"], "manual-gate");
285        assert_eq!(value["reason"], "operator approval required");
286        assert_eq!(value["suspend_mode"], "wait_for_resume");
287        assert_eq!(value["suspended_at"], "2026-02-28T15:31:00Z");
288    }
289
290    #[test]
291    fn test_paths_resolve_under_workspace_ralph_dir() {
292        let temp_dir = tempfile::tempdir().expect("temp dir");
293        let store = SuspendStateStore::new(temp_dir.path());
294
295        assert_eq!(
296            store.suspend_state_path(),
297            temp_dir.path().join(".ralph/suspend-state.json")
298        );
299        assert_eq!(
300            store.resume_requested_path(),
301            temp_dir.path().join(".ralph/resume-requested")
302        );
303    }
304
305    #[test]
306    fn test_write_and_read_suspend_state_round_trip() {
307        let temp_dir = tempfile::tempdir().expect("temp dir");
308        let store = SuspendStateStore::new(temp_dir.path());
309        let state = sample_record();
310
311        store
312            .write_suspend_state(&state)
313            .expect("write suspend state");
314
315        let read_back = store
316            .read_suspend_state()
317            .expect("read state")
318            .expect("state present");
319
320        assert_eq!(read_back, state);
321    }
322
323    #[test]
324    fn test_write_suspend_state_replaces_file_without_leaking_temp_files() {
325        let temp_dir = tempfile::tempdir().expect("temp dir");
326        let store = SuspendStateStore::new(temp_dir.path());
327
328        let mut first = sample_record();
329        first.hook_name = "first-hook".to_string();
330        store
331            .write_suspend_state(&first)
332            .expect("write first state");
333
334        let mut second = sample_record();
335        second.hook_name = "second-hook".to_string();
336        store
337            .write_suspend_state(&second)
338            .expect("write second state");
339
340        let contents =
341            fs::read_to_string(store.suspend_state_path()).expect("read suspend-state contents");
342        assert!(contents.contains("\"hook_name\": \"second-hook\""));
343
344        let temp_files: Vec<String> = fs::read_dir(store.ralph_dir())
345            .expect("read .ralph dir")
346            .filter_map(Result::ok)
347            .map(|entry| entry.file_name().to_string_lossy().to_string())
348            .filter(|name| name.contains(".tmp-"))
349            .collect();
350        assert!(temp_files.is_empty(), "temp files leaked: {temp_files:?}");
351    }
352
353    #[test]
354    fn test_resume_signal_is_single_use() {
355        let temp_dir = tempfile::tempdir().expect("temp dir");
356        let store = SuspendStateStore::new(temp_dir.path());
357
358        assert!(!store.is_resume_requested());
359        assert!(
360            !store
361                .consume_resume_requested()
362                .expect("consume absent signal")
363        );
364
365        store.write_resume_requested().expect("write resume signal");
366        assert!(store.is_resume_requested());
367
368        assert!(
369            store
370                .consume_resume_requested()
371                .expect("consume present signal")
372        );
373        assert!(!store.is_resume_requested());
374        assert!(
375            !store
376                .consume_resume_requested()
377                .expect("consume absent signal again")
378        );
379    }
380
381    #[test]
382    fn test_clear_suspend_state_is_idempotent() {
383        let temp_dir = tempfile::tempdir().expect("temp dir");
384        let store = SuspendStateStore::new(temp_dir.path());
385
386        assert!(
387            !store
388                .clear_suspend_state()
389                .expect("clear absent suspend state")
390        );
391
392        store
393            .write_suspend_state(&sample_record())
394            .expect("write suspend state");
395        assert!(
396            store
397                .clear_suspend_state()
398                .expect("clear present suspend state")
399        );
400
401        assert!(store.read_suspend_state().expect("read state").is_none());
402    }
403
404    #[test]
405    fn test_read_suspend_state_invalid_json_returns_deserialize_error() {
406        let temp_dir = tempfile::tempdir().expect("temp dir");
407        let store = SuspendStateStore::new(temp_dir.path());
408
409        fs::create_dir_all(store.ralph_dir()).expect("create .ralph dir");
410        fs::write(store.suspend_state_path(), "not-json").expect("write invalid json");
411
412        let err = store
413            .read_suspend_state()
414            .expect_err("invalid json should fail");
415
416        assert!(matches!(
417            err,
418            SuspendStateStoreError::DeserializeState { .. }
419        ));
420    }
421}