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(¶ms.session_id)?;
235
236 let mut metadata = self.read_metadata(¶ms.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(¶ms.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(¶ms.session_id)?;
281
282 if is_invalid_filename(¶ms.filename) {
283 return Err(ApiError::invalid_params(
284 "planning.get_artifact filename must be a plain file name",
285 ));
286 }
287
288 if !is_listed_artifact_name(¶ms.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(¶ms.session_id);
298 if !session_dir.exists() {
299 return Err(planning_session_not_found_error(¶ms.session_id));
300 }
301
302 let artifact_path = session_dir.join("artifacts").join(¶ms.filename);
303
304 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 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}