1use 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
20pub const SUSPEND_STATE_SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SuspendLifecycleState {
27 Suspended,
28}
29
30#[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 #[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#[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 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 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 pub fn clear_suspend_state(&self) -> Result<bool, SuspendStateStoreError> {
138 remove_if_exists(&self.suspend_state_path(), "clear suspend-state")
139 }
140
141 pub fn write_resume_requested(&self) -> Result<(), SuspendStateStoreError> {
143 self.write_atomic_file(&self.resume_requested_path(), b"")
144 }
145
146 #[must_use]
148 pub fn is_resume_requested(&self) -> bool {
149 self.resume_requested_path().exists()
150 }
151
152 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#[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}