Skip to main content

ralph_core/
planning_session.rs

1//! Planning session management for human-in-the-loop workflows.
2//!
3//! Planning sessions enable collaborative planning through chat-style interactions.
4//! Each session has:
5//! - A unique ID (timestamp-based)
6//! - A conversation file (JSONL format for prompts/responses)
7//! - Session metadata (status, timestamps, etc.)
8//! - Artifacts directory (generated design docs, plans)
9
10use crate::loop_context::LoopContext;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use std::fs::{File, OpenOptions};
14use std::io::Write;
15use std::path::PathBuf;
16
17/// Error type for planning session operations.
18#[derive(Debug, thiserror::Error)]
19pub enum PlanningSessionError {
20    #[error("I/O error: {0}")]
21    Io(#[from] std::io::Error),
22
23    #[error("Serialization error: {0}")]
24    Serialization(#[from] serde_json::Error),
25
26    #[error("Session not found: {0}")]
27    NotFound(String),
28}
29
30/// Status of a planning session.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub enum SessionStatus {
33    /// Session is active and waiting for input or processing
34    Active,
35    /// Session is waiting for a user response to a specific prompt
36    WaitingForInput { prompt_id: String },
37    /// Session completed successfully
38    Completed,
39    /// Session timed out waiting for user input
40    TimedOut,
41    /// Session failed due to an error
42    Failed,
43}
44
45/// A single entry in the planning conversation.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ConversationEntry {
48    /// Entry type: either a prompt from the agent or response from user
49    #[serde(rename = "type")]
50    pub entry_type: ConversationType,
51    /// Unique identifier for this message
52    pub id: String,
53    /// The message text
54    pub text: String,
55    /// ISO 8601 timestamp of the message
56    pub ts: String,
57}
58
59/// Type of conversation entry.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum ConversationType {
63    /// A question/prompt from the agent
64    UserPrompt,
65    /// A response from the user
66    UserResponse,
67}
68
69/// Metadata for a planning session.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionMetadata {
72    /// Unique session identifier
73    pub id: String,
74    /// Original user prompt that started the session
75    pub prompt: String,
76    /// Current session status
77    pub status: SessionStatus,
78    /// ISO 8601 timestamp when the session was created
79    pub created_at: String,
80    /// ISO 8601 timestamp of last activity
81    pub updated_at: String,
82    /// Number of iterations completed
83    pub iterations: usize,
84    /// Config file used for this session (if any)
85    pub config: Option<String>,
86}
87
88/// A planning session for human-in-the-loop workflows.
89#[derive(Debug)]
90pub struct PlanningSession {
91    /// Session metadata
92    pub metadata: SessionMetadata,
93    /// Path to the session directory
94    pub session_dir: PathBuf,
95    /// Path to the conversation file
96    pub conversation_path: PathBuf,
97}
98
99impl PlanningSession {
100    /// Create a new planning session.
101    ///
102    /// # Arguments
103    ///
104    /// * `prompt` - The user's original prompt/idea
105    /// * `context` - The loop context for path resolution
106    /// * `config` - Optional config file to use
107    pub fn new(
108        prompt: &str,
109        context: &LoopContext,
110        config: Option<String>,
111    ) -> Result<Self, PlanningSessionError> {
112        let session_id = Self::generate_session_id();
113        let session_dir = context.planning_session_dir(&session_id);
114        let conversation_path = context.planning_conversation_path(&session_id);
115
116        // Create session directory
117        std::fs::create_dir_all(&session_dir)?;
118
119        // Create artifacts directory
120        let artifacts_dir = context.planning_artifacts_dir(&session_id);
121        std::fs::create_dir_all(&artifacts_dir)?;
122
123        let now = Utc::now().to_rfc3339();
124        let metadata = SessionMetadata {
125            id: session_id.clone(),
126            prompt: prompt.to_string(),
127            status: SessionStatus::Active,
128            created_at: now.clone(),
129            updated_at: now,
130            iterations: 0,
131            config,
132        };
133
134        // Save metadata
135        let metadata_path = context.planning_session_metadata_path(&session_id);
136        let metadata_json = serde_json::to_string_pretty(&metadata)?;
137        let mut file = File::create(&metadata_path)?;
138        file.write_all(metadata_json.as_bytes())?;
139
140        // Create empty conversation file
141        File::create(&conversation_path)?;
142
143        Ok(Self {
144            metadata,
145            session_dir,
146            conversation_path,
147        })
148    }
149
150    /// Load an existing planning session.
151    ///
152    /// # Arguments
153    ///
154    /// * `id` - The session ID
155    /// * `context` - The loop context for path resolution
156    pub fn load(id: &str, context: &LoopContext) -> Result<Self, PlanningSessionError> {
157        let session_dir = context.planning_session_dir(id);
158        let conversation_path = context.planning_conversation_path(id);
159        let metadata_path = context.planning_session_metadata_path(id);
160
161        if !session_dir.exists() {
162            return Err(PlanningSessionError::NotFound(id.to_string()));
163        }
164
165        // Load metadata
166        let metadata_json = std::fs::read_to_string(&metadata_path)?;
167        let metadata: SessionMetadata = serde_json::from_str(&metadata_json)?;
168
169        Ok(Self {
170            metadata,
171            session_dir,
172            conversation_path,
173        })
174    }
175
176    /// Generate a unique session ID based on timestamp.
177    fn generate_session_id() -> String {
178        let now = Utc::now();
179        let timestamp = now.format("%Y%m%d-%H%M%S").to_string();
180        // Use nanoseconds for uniqueness (take last 4 hex chars)
181        let nano_suffix = format!("{:x}", now.timestamp_subsec_nanos());
182        let random_suffix = &nano_suffix[nano_suffix.len().saturating_sub(4)..];
183        format!("{}-{}", timestamp, random_suffix)
184    }
185
186    /// Get the session ID.
187    pub fn id(&self) -> &str {
188        &self.metadata.id
189    }
190
191    /// Update the session status.
192    pub fn set_status(&mut self, status: SessionStatus) -> Result<(), PlanningSessionError> {
193        self.metadata.status = status;
194        self.metadata.updated_at = Utc::now().to_rfc3339();
195        self.save_metadata()
196    }
197
198    /// Increment the iteration counter.
199    pub fn increment_iterations(&mut self) -> Result<(), PlanningSessionError> {
200        self.metadata.iterations += 1;
201        self.metadata.updated_at = Utc::now().to_rfc3339();
202        self.save_metadata()
203    }
204
205    /// Save the session metadata to disk.
206    pub fn save_metadata(&self) -> Result<(), PlanningSessionError> {
207        let metadata_path = self.session_dir.join("session.json");
208        let metadata_json = serde_json::to_string_pretty(&self.metadata)?;
209        let mut file = File::create(&metadata_path)?;
210        file.write_all(metadata_json.as_bytes())?;
211        Ok(())
212    }
213
214    /// Append a prompt entry to the conversation.
215    ///
216    /// # Arguments
217    ///
218    /// * `id` - Unique prompt identifier (e.g., "q1", "q2")
219    /// * `text` - The prompt/question text
220    pub fn append_prompt(&self, id: &str, text: &str) -> Result<(), PlanningSessionError> {
221        let entry = ConversationEntry {
222            entry_type: ConversationType::UserPrompt,
223            id: id.to_string(),
224            text: text.to_string(),
225            ts: Utc::now().to_rfc3339(),
226        };
227        self.append_entry(&entry)
228    }
229
230    /// Append a response entry to the conversation.
231    ///
232    /// # Arguments
233    ///
234    /// * `id` - The prompt ID this responds to
235    /// * `text` - The user's response text
236    pub fn append_response(&mut self, id: &str, text: &str) -> Result<(), PlanningSessionError> {
237        let entry = ConversationEntry {
238            entry_type: ConversationType::UserResponse,
239            id: id.to_string(),
240            text: text.to_string(),
241            ts: Utc::now().to_rfc3339(),
242        };
243        self.append_entry(&entry)
244    }
245
246    /// Append an entry to the conversation file.
247    fn append_entry(&self, entry: &ConversationEntry) -> Result<(), PlanningSessionError> {
248        // Open file in append mode
249        let mut file = OpenOptions::new()
250            .append(true)
251            .create(true)
252            .open(&self.conversation_path)?;
253
254        // Write entry as JSONL
255        let json = serde_json::to_string(entry)?;
256        writeln!(file, "{}", json)?;
257
258        Ok(())
259    }
260
261    /// Find a response to a specific prompt in the conversation.
262    ///
263    /// # Arguments
264    ///
265    /// * `prompt_id` - The prompt ID to search for
266    ///
267    /// Returns the response text if found, None otherwise.
268    pub fn find_response(&self, prompt_id: &str) -> Result<Option<String>, PlanningSessionError> {
269        if !self.conversation_path.exists() {
270            return Ok(None);
271        }
272
273        let content = std::fs::read_to_string(&self.conversation_path)?;
274
275        for line in content.lines() {
276            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(line)
277                && entry.entry_type == ConversationType::UserResponse
278                && entry.id == prompt_id
279            {
280                return Ok(Some(entry.text));
281            }
282        }
283
284        Ok(None)
285    }
286
287    /// Load all conversation entries.
288    pub fn load_conversation(&self) -> Result<Vec<ConversationEntry>, PlanningSessionError> {
289        if !self.conversation_path.exists() {
290            return Ok(Vec::new());
291        }
292
293        let content = std::fs::read_to_string(&self.conversation_path)?;
294        let mut entries = Vec::new();
295
296        for line in content.lines() {
297            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(line) {
298                entries.push(entry);
299            }
300        }
301
302        Ok(entries)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use tempfile::TempDir;
310
311    fn create_test_context() -> (TempDir, LoopContext) {
312        let temp = TempDir::new().unwrap();
313        let ctx = LoopContext::primary(temp.path().to_path_buf());
314        (temp, ctx)
315    }
316
317    #[test]
318    fn test_generate_session_id() {
319        let id1 = PlanningSession::generate_session_id();
320        let id2 = PlanningSession::generate_session_id();
321
322        // IDs should be different
323        assert_ne!(id1, id2);
324
325        // IDs should have timestamp format
326        assert!(id1.len() > 10);
327        assert!(id1.contains('-'));
328    }
329
330    #[test]
331    fn test_create_new_session() {
332        let (_temp, ctx) = create_test_context();
333        let prompt = "Build a feature for user authentication";
334
335        let session = PlanningSession::new(prompt, &ctx, None).unwrap();
336
337        assert_eq!(session.metadata.prompt, prompt);
338        assert_eq!(session.metadata.status, SessionStatus::Active);
339        assert_eq!(session.metadata.iterations, 0);
340        assert!(session.session_dir.exists());
341        assert!(session.conversation_path.exists());
342    }
343
344    #[test]
345    fn test_load_existing_session() {
346        let (_temp, ctx) = create_test_context();
347        let prompt = "Build OAuth2 login";
348
349        // Create session
350        let session_id = PlanningSession::new(prompt, &ctx, None)
351            .unwrap()
352            .id()
353            .to_string();
354
355        // Load session
356        let loaded = PlanningSession::load(&session_id, &ctx).unwrap();
357
358        assert_eq!(loaded.metadata.prompt, prompt);
359        assert_eq!(loaded.metadata.id, session_id);
360    }
361
362    #[test]
363    fn test_load_nonexistent_session() {
364        let (_temp, ctx) = create_test_context();
365
366        let result = PlanningSession::load("nonexistent", &ctx);
367        assert!(matches!(result, Err(PlanningSessionError::NotFound(_))));
368    }
369
370    #[test]
371    fn test_append_prompt_and_response() {
372        let (_temp, ctx) = create_test_context();
373        let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
374
375        // Append a prompt
376        session
377            .append_prompt("q1", "What is the feature name?")
378            .unwrap();
379
380        // Append a response
381        session.append_response("q1", "OAuth Login").unwrap();
382
383        // Load conversation
384        let entries = session.load_conversation().unwrap();
385
386        assert_eq!(entries.len(), 2);
387        assert_eq!(entries[0].entry_type, ConversationType::UserPrompt);
388        assert_eq!(entries[0].id, "q1");
389        assert_eq!(entries[0].text, "What is the feature name?");
390
391        assert_eq!(entries[1].entry_type, ConversationType::UserResponse);
392        assert_eq!(entries[1].id, "q1");
393        assert_eq!(entries[1].text, "OAuth Login");
394    }
395
396    #[test]
397    fn test_find_response() {
398        let (_temp, ctx) = create_test_context();
399        let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
400
401        // No response initially
402        assert!(session.find_response("q1").unwrap().is_none());
403
404        // Add prompt and response
405        session.append_prompt("q1", "Question?").unwrap();
406        session.append_response("q1", "Answer").unwrap();
407
408        // Find the response
409        let response = session.find_response("q1").unwrap();
410        assert_eq!(response, Some("Answer".to_string()));
411
412        // Non-existent prompt returns None
413        assert!(session.find_response("q2").unwrap().is_none());
414    }
415
416    #[test]
417    fn test_set_status() {
418        let (_temp, ctx) = create_test_context();
419        let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
420
421        session
422            .set_status(SessionStatus::WaitingForInput {
423                prompt_id: "q1".to_string(),
424            })
425            .unwrap();
426
427        assert!(matches!(
428            session.metadata.status,
429            SessionStatus::WaitingForInput { .. }
430        ));
431
432        // Reload and verify status persisted
433        let session_id = session.id().to_string();
434        let loaded = PlanningSession::load(&session_id, &ctx).unwrap();
435        assert!(matches!(
436            loaded.metadata.status,
437            SessionStatus::WaitingForInput { .. }
438        ));
439    }
440
441    #[test]
442    fn test_increment_iterations() {
443        let (_temp, ctx) = create_test_context();
444        let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
445
446        assert_eq!(session.metadata.iterations, 0);
447
448        session.increment_iterations().unwrap();
449        assert_eq!(session.metadata.iterations, 1);
450
451        session.increment_iterations().unwrap();
452        assert_eq!(session.metadata.iterations, 2);
453    }
454
455    #[test]
456    fn test_artifacts_directory_created() {
457        let (_temp, ctx) = create_test_context();
458        let session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
459
460        let artifacts_dir = session.session_dir.join("artifacts");
461        assert!(artifacts_dir.exists());
462        assert!(artifacts_dir.is_dir());
463    }
464}