Skip to main content

ralph_api/
planning_domain.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use tracing::warn;
7
8use crate::errors::ApiError;
9use crate::loop_support::now_ts;
10
11#[derive(Debug, Clone, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct PlanningStartParams {
14    pub prompt: String,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct PlanningRespondParams {
20    pub session_id: String,
21    pub prompt_id: String,
22    pub response: String,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct PlanningGetArtifactParams {
28    pub session_id: String,
29    pub filename: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct PlanningSessionSummary {
35    pub id: String,
36    pub title: String,
37    pub prompt: String,
38    pub status: String,
39    pub created_at: String,
40    pub updated_at: String,
41    pub message_count: u64,
42    pub iterations: u64,
43}
44
45#[derive(Debug, Clone, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct PlanningSessionDetail {
48    pub id: String,
49    pub prompt: String,
50    pub title: String,
51    pub status: String,
52    pub created_at: String,
53    pub updated_at: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub completed_at: Option<String>,
56    pub conversation: Vec<FrontendConversationEntry>,
57    pub artifacts: Vec<String>,
58    pub message_count: u64,
59    pub iterations: u64,
60}
61
62#[derive(Debug, Clone, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PlanningSessionRecord {
65    pub id: String,
66    pub prompt: String,
67    pub status: String,
68    pub created_at: String,
69    pub updated_at: String,
70    pub iterations: u64,
71}
72
73#[derive(Debug, Clone, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ArtifactRecord {
76    pub filename: String,
77    pub content: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82struct SessionMetadata {
83    id: String,
84    prompt: String,
85    status: String,
86    created_at: String,
87    updated_at: String,
88    iterations: u64,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92struct ConversationEntry {
93    #[serde(rename = "type")]
94    entry_type: String,
95    id: String,
96    text: String,
97    ts: String,
98}
99
100#[derive(Debug, Clone, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct FrontendConversationEntry {
103    #[serde(rename = "type")]
104    entry_type: String,
105    id: String,
106    content: String,
107    timestamp: String,
108}
109
110pub struct PlanningDomain {
111    sessions_dir: PathBuf,
112}
113
114const MAX_SESSION_ID_LEN: usize = 120;
115
116impl PlanningDomain {
117    pub fn new(workspace_root: impl AsRef<Path>) -> Self {
118        Self {
119            sessions_dir: workspace_root.as_ref().join(".ralph/planning-sessions"),
120        }
121    }
122
123    pub fn list(&mut self) -> Result<Vec<PlanningSessionSummary>, ApiError> {
124        self.ensure_sessions_dir()?;
125
126        let entries = fs::read_dir(&self.sessions_dir).map_err(|error| {
127            ApiError::internal(format!(
128                "failed reading planning sessions directory '{}': {error}",
129                self.sessions_dir.display()
130            ))
131        })?;
132
133        let mut sessions = Vec::new();
134
135        for entry in entries {
136            let Ok(entry) = entry else {
137                continue;
138            };
139
140            let path = entry.path();
141            if !path.is_dir() {
142                continue;
143            }
144
145            let Some(session_id) = path.file_name().and_then(|value| value.to_str()) else {
146                continue;
147            };
148
149            let Ok(metadata) = self.read_metadata(session_id) else {
150                warn!(session_id, "skipping malformed planning session metadata");
151                continue;
152            };
153
154            let message_count = self.count_messages(session_id);
155            sessions.push(PlanningSessionSummary {
156                id: metadata.id.clone(),
157                title: generate_title(&metadata.prompt),
158                prompt: metadata.prompt.clone(),
159                status: to_frontend_status(&metadata.status),
160                created_at: metadata.created_at.clone(),
161                updated_at: metadata.updated_at.clone(),
162                message_count,
163                iterations: metadata.iterations,
164            });
165        }
166
167        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.id.cmp(&b.id)));
168        Ok(sessions)
169    }
170
171    pub fn get(&self, session_id: &str) -> Result<PlanningSessionDetail, ApiError> {
172        validate_session_id(session_id)?;
173
174        let metadata = self.read_metadata(session_id)?;
175        let conversation = self.read_conversation(session_id);
176        let artifacts = self.read_artifacts(session_id);
177
178        let completed_at = (metadata.status == "completed").then_some(metadata.updated_at.clone());
179
180        Ok(PlanningSessionDetail {
181            id: metadata.id,
182            prompt: metadata.prompt.clone(),
183            title: generate_title(&metadata.prompt),
184            status: to_frontend_status(&metadata.status),
185            created_at: metadata.created_at,
186            updated_at: metadata.updated_at,
187            completed_at,
188            conversation: conversation.clone(),
189            artifacts,
190            message_count: u64::try_from(conversation.len()).unwrap_or(u64::MAX),
191            iterations: metadata.iterations,
192        })
193    }
194
195    pub fn start(
196        &mut self,
197        params: PlanningStartParams,
198    ) -> Result<PlanningSessionRecord, ApiError> {
199        self.ensure_sessions_dir()?;
200
201        let (session_id, session_dir) = self.create_unique_session_dir()?;
202
203        fs::create_dir_all(session_dir.join("artifacts")).map_err(|error| {
204            ApiError::internal(format!(
205                "failed creating planning session directory '{}': {error}",
206                session_dir.display()
207            ))
208        })?;
209
210        let now = now_ts();
211        let metadata = SessionMetadata {
212            id: session_id.clone(),
213            prompt: params.prompt,
214            status: "active".to_string(),
215            created_at: now.clone(),
216            updated_at: now,
217            iterations: 0,
218        };
219
220        self.write_metadata(&metadata)?;
221        self.write_empty_conversation(&session_id)?;
222
223        Ok(PlanningSessionRecord {
224            id: metadata.id,
225            prompt: metadata.prompt,
226            status: metadata.status,
227            created_at: metadata.created_at,
228            updated_at: metadata.updated_at,
229            iterations: metadata.iterations,
230        })
231    }
232
233    pub fn respond(&mut self, params: PlanningRespondParams) -> Result<(), ApiError> {
234        validate_session_id(&params.session_id)?;
235
236        let mut metadata = self.read_metadata(&params.session_id)?;
237
238        let entry = ConversationEntry {
239            entry_type: "user_response".to_string(),
240            id: params.prompt_id,
241            text: params.response,
242            ts: now_ts(),
243        };
244        self.append_conversation(&params.session_id, &entry)?;
245
246        metadata.status = "active".to_string();
247        metadata.updated_at = now_ts();
248        self.write_metadata(&metadata)
249    }
250
251    pub fn resume(&mut self, session_id: &str) -> Result<(), ApiError> {
252        validate_session_id(session_id)?;
253
254        let mut metadata = self.read_metadata(session_id)?;
255        metadata.status = "active".to_string();
256        metadata.updated_at = now_ts();
257        self.write_metadata(&metadata)
258    }
259
260    pub fn delete(&mut self, session_id: &str) -> Result<(), ApiError> {
261        validate_session_id(session_id)?;
262
263        let session_dir = self.session_dir(session_id);
264        if !session_dir.exists() {
265            return Err(planning_session_not_found_error(session_id));
266        }
267
268        fs::remove_dir_all(&session_dir).map_err(|error| {
269            ApiError::internal(format!(
270                "failed deleting planning session '{}': {error}",
271                session_dir.display()
272            ))
273        })
274    }
275
276    pub fn get_artifact(
277        &self,
278        params: PlanningGetArtifactParams,
279    ) -> Result<ArtifactRecord, ApiError> {
280        validate_session_id(&params.session_id)?;
281
282        if is_invalid_filename(&params.filename) {
283            return Err(ApiError::invalid_params(
284                "planning.get_artifact filename must be a plain file name",
285            ));
286        }
287
288        // Keep get/list contract consistent: if a filename would not appear in
289        // `planning.get` artifact listings, reject direct access as not found.
290        if !is_listed_artifact_name(&params.filename) {
291            return Err(ApiError::not_found(format!(
292                "artifact '{}' not found for planning session '{}'",
293                params.filename, params.session_id
294            )));
295        }
296
297        let session_dir = self.session_dir(&params.session_id);
298        if !session_dir.exists() {
299            return Err(planning_session_not_found_error(&params.session_id));
300        }
301
302        let artifact_path = session_dir.join("artifacts").join(&params.filename);
303
304        // Use symlink_metadata so we inspect the path entry itself, not any
305        // target it may point to.  A symlink (or directory, device node, …)
306        // must be treated the same as "not found" so the API leaks nothing.
307        let fmeta = fs::symlink_metadata(&artifact_path).map_err(|_| {
308            ApiError::not_found(format!(
309                "artifact '{}' not found for planning session '{}'",
310                params.filename, params.session_id
311            ))
312        })?;
313        if !fmeta.is_file() {
314            return Err(ApiError::not_found(format!(
315                "artifact '{}' not found for planning session '{}'",
316                params.filename, params.session_id
317            )));
318        }
319
320        let content = fs::read_to_string(&artifact_path).map_err(|error| {
321            ApiError::not_found(format!(
322                "artifact '{}' not found for planning session '{}': {error}",
323                params.filename, params.session_id
324            ))
325        })?;
326
327        Ok(ArtifactRecord {
328            filename: params.filename,
329            content,
330        })
331    }
332
333    fn next_session_id(&self) -> String {
334        format!(
335            "{}-{}",
336            Utc::now().format("%Y%m%dT%H%M%S"),
337            uuid::Uuid::new_v4().simple()
338        )
339    }
340
341    fn create_unique_session_dir(&self) -> Result<(String, PathBuf), ApiError> {
342        for _ in 0..8 {
343            let session_id = self.next_session_id();
344            let session_dir = self.session_dir(&session_id);
345
346            match fs::create_dir(&session_dir) {
347                Ok(()) => return Ok((session_id, session_dir)),
348                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
349                Err(error) => {
350                    return Err(ApiError::internal(format!(
351                        "failed creating planning session directory '{}': {error}",
352                        session_dir.display()
353                    )));
354                }
355            }
356        }
357
358        Err(ApiError::internal(
359            "failed allocating unique planning session id after multiple attempts",
360        ))
361    }
362
363    fn ensure_sessions_dir(&self) -> Result<(), ApiError> {
364        fs::create_dir_all(&self.sessions_dir).map_err(|error| {
365            ApiError::internal(format!(
366                "failed creating planning sessions directory '{}': {error}",
367                self.sessions_dir.display()
368            ))
369        })
370    }
371
372    fn session_dir(&self, session_id: &str) -> PathBuf {
373        self.sessions_dir.join(session_id)
374    }
375
376    fn metadata_path(&self, session_id: &str) -> PathBuf {
377        self.session_dir(session_id).join("session.json")
378    }
379
380    fn conversation_path(&self, session_id: &str) -> PathBuf {
381        self.session_dir(session_id).join("conversation.jsonl")
382    }
383
384    fn read_metadata(&self, session_id: &str) -> Result<SessionMetadata, ApiError> {
385        validate_session_id(session_id)?;
386
387        let path = self.metadata_path(session_id);
388
389        let content =
390            fs::read_to_string(&path).map_err(|_| planning_session_not_found_error(session_id))?;
391
392        serde_json::from_str::<SessionMetadata>(&content).map_err(|error| {
393            ApiError::internal(format!(
394                "failed parsing planning metadata '{}': {error}",
395                path.display()
396            ))
397        })
398    }
399
400    fn write_metadata(&self, metadata: &SessionMetadata) -> Result<(), ApiError> {
401        let path = self.metadata_path(&metadata.id);
402        if let Some(parent) = path.parent() {
403            fs::create_dir_all(parent).map_err(|error| {
404                ApiError::internal(format!(
405                    "failed creating planning metadata directory '{}': {error}",
406                    parent.display()
407                ))
408            })?;
409        }
410
411        let payload = serde_json::to_string_pretty(metadata).map_err(|error| {
412            ApiError::internal(format!("failed serializing planning metadata: {error}"))
413        })?;
414
415        fs::write(&path, payload).map_err(|error| {
416            ApiError::internal(format!(
417                "failed writing planning metadata '{}': {error}",
418                path.display()
419            ))
420        })
421    }
422
423    fn write_empty_conversation(&self, session_id: &str) -> Result<(), ApiError> {
424        let path = self.conversation_path(session_id);
425        fs::write(&path, "").map_err(|error| {
426            ApiError::internal(format!(
427                "failed creating planning conversation '{}': {error}",
428                path.display()
429            ))
430        })
431    }
432
433    fn append_conversation(
434        &self,
435        session_id: &str,
436        entry: &ConversationEntry,
437    ) -> Result<(), ApiError> {
438        let path = self.conversation_path(session_id);
439        let mut payload = serde_json::to_string(entry).map_err(|error| {
440            ApiError::internal(format!("failed serializing conversation entry: {error}"))
441        })?;
442        payload.push('\n');
443
444        use std::io::Write;
445        let mut file = fs::OpenOptions::new()
446            .create(true)
447            .append(true)
448            .open(&path)
449            .map_err(|error| {
450                ApiError::internal(format!(
451                    "failed opening planning conversation '{}': {error}",
452                    path.display()
453                ))
454            })?;
455
456        file.write_all(payload.as_bytes()).map_err(|error| {
457            ApiError::internal(format!(
458                "failed appending planning conversation '{}': {error}",
459                path.display()
460            ))
461        })
462    }
463
464    fn read_conversation(&self, session_id: &str) -> Vec<FrontendConversationEntry> {
465        let path = self.conversation_path(session_id);
466        let Ok(content) = fs::read_to_string(path) else {
467            return Vec::new();
468        };
469
470        content
471            .lines()
472            .filter(|line| !line.trim().is_empty())
473            .filter_map(|line| serde_json::from_str::<ConversationEntry>(line).ok())
474            .map(|entry| FrontendConversationEntry {
475                entry_type: if entry.entry_type == "user_prompt" {
476                    "prompt".to_string()
477                } else {
478                    "response".to_string()
479                },
480                id: entry.id,
481                content: entry.text,
482                timestamp: entry.ts,
483            })
484            .collect()
485    }
486
487    fn count_messages(&self, session_id: &str) -> u64 {
488        let path = self.conversation_path(session_id);
489        let Ok(content) = fs::read_to_string(path) else {
490            return 0;
491        };
492
493        u64::try_from(
494            content
495                .lines()
496                .filter(|line| !line.trim().is_empty())
497                .count(),
498        )
499        .unwrap_or(u64::MAX)
500    }
501
502    fn read_artifacts(&self, session_id: &str) -> Vec<String> {
503        let artifacts_dir = self.session_dir(session_id).join("artifacts");
504        let Ok(entries) = fs::read_dir(artifacts_dir) else {
505            return Vec::new();
506        };
507
508        let mut artifacts: Vec<String> = entries
509            .filter_map(Result::ok)
510            .filter_map(|entry| {
511                // file_type() does NOT follow symlinks, so symlinks return
512                // is_symlink()=true / is_file()=false and are excluded here.
513                let ftype = entry.file_type().ok()?;
514                if !ftype.is_file() {
515                    return None;
516                }
517                entry
518                    .file_name()
519                    .to_str()
520                    .map(std::string::ToString::to_string)
521            })
522            .filter(|name| is_listed_artifact_name(name))
523            .collect();
524        artifacts.sort();
525        artifacts
526    }
527}
528
529fn validate_session_id(session_id: &str) -> Result<(), ApiError> {
530    if session_id.is_empty() || session_id.len() > MAX_SESSION_ID_LEN {
531        return Err(ApiError::invalid_params(format!(
532            "planning session id must be 1..={MAX_SESSION_ID_LEN} characters"
533        ))
534        .with_details(serde_json::json!({ "sessionId": session_id })));
535    }
536
537    if !session_id
538        .chars()
539        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
540    {
541        return Err(ApiError::invalid_params(
542            "planning session id may only contain ASCII letters, digits, '-' or '_'",
543        )
544        .with_details(serde_json::json!({ "sessionId": session_id })));
545    }
546
547    Ok(())
548}
549
550fn is_invalid_filename(filename: &str) -> bool {
551    let mut components = Path::new(filename).components();
552
553    match (components.next(), components.next()) {
554        (Some(Component::Normal(name)), None) => name.to_string_lossy().is_empty(),
555        _ => true,
556    }
557}
558
559fn is_listed_artifact_name(filename: &str) -> bool {
560    !filename.starts_with('.')
561        && filename.len() <= 255
562        && !filename.is_empty()
563        && filename
564            .chars()
565            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
566        && !is_invalid_filename(filename)
567}
568
569fn planning_session_not_found_error(session_id: &str) -> ApiError {
570    ApiError::planning_session_not_found(format!("Planning session '{session_id}' not found"))
571        .with_details(serde_json::json!({ "sessionId": session_id }))
572}
573
574fn to_frontend_status(status: &str) -> String {
575    if status == "waiting_for_input" {
576        return "paused".to_string();
577    }
578
579    status.to_string()
580}
581
582fn generate_title(prompt: &str) -> String {
583    let trimmed = prompt.trim();
584    if trimmed.chars().count() <= 60 {
585        return trimmed.to_string();
586    }
587
588    let mut shortened: String = trimmed.chars().take(57).collect();
589    shortened.push_str("...");
590    shortened
591}