An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
1use super::{ApiError, AppState};
2use crate::graph::store::{EdgeDirection, NodeQuery};
3use crate::graph::{self, GraphEdge, GraphNode, NodeStatus, NodeType, Priority};
4use crate::graph::{interchange, session::SessionStore};
5use axum::Json;
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use serde::{Deserialize, Serialize};
9
10/// Resolve a project path parameter (name or ID) to the actual project ID
11pub(super) async fn resolve_project_id(
12 state: &AppState,
13 id_or_name: &str,
14) -> Result<String, ApiError> {
15 if let Some(p) = state.project_store.get_by_name(id_or_name).await? {
16 return Ok(p.id);
17 }
18 if let Some(p) = state.project_store.get_by_id(id_or_name).await? {
19 return Ok(p.id);
20 }
21 Err(ApiError::NotFound(format!(
22 "Project '{}' not found",
23 id_or_name
24 )))
25}
26
27// ===== Request types =====
28
29#[derive(Deserialize)]
30pub struct CreateGoalRequest {
31 pub title: String,
32 pub description: String,
33 pub priority: Option<String>,
34}
35
36#[derive(Deserialize)]
37pub struct UpdateNodeRequest {
38 pub status: Option<String>,
39 pub title: Option<String>,
40 pub description: Option<String>,
41 pub blocked_reason: Option<String>,
42 pub metadata: Option<std::collections::HashMap<String, String>>,
43}
44
45#[derive(Deserialize)]
46pub struct CreateChildRequest {
47 pub node_type: String,
48 pub title: String,
49 pub description: String,
50 pub priority: Option<String>,
51 pub metadata: Option<std::collections::HashMap<String, String>>,
52}
53
54#[derive(Deserialize)]
55pub struct CreateEdgeRequest {
56 pub edge_type: String,
57 pub from_node: String,
58 pub to_node: String,
59 pub label: Option<String>,
60}
61
62#[derive(Deserialize)]
63pub struct ImportRequest {
64 pub toml: String,
65 pub strategy: Option<String>,
66}
67
68// ===== Response types =====
69
70#[derive(Serialize)]
71pub struct NodeWithEdges {
72 pub node: GraphNode,
73 pub incoming_edges: Vec<(GraphEdge, GraphNode)>,
74 pub outgoing_edges: Vec<(GraphEdge, GraphNode)>,
75}
76
77#[derive(Serialize)]
78pub struct GoalTree {
79 pub nodes: Vec<GraphNode>,
80 pub edges: Vec<GraphEdge>,
81}
82
83#[derive(Serialize)]
84pub struct DecisionHistory {
85 pub nodes: Vec<GraphNode>,
86 pub edges: Vec<GraphEdge>,
87}
88
89#[derive(Serialize)]
90pub struct ExportResult {
91 pub goal_id: String,
92 pub toml: String,
93}
94
95// ===== Goal endpoints =====
96
97/// GET /api/projects/:id/goals
98pub async fn list_goals(
99 State(state): State<AppState>,
100 Path(id_or_name): Path<String>,
101) -> Result<Json<Vec<GraphNode>>, ApiError> {
102 let project_id = resolve_project_id(&state, &id_or_name).await?;
103 let query = NodeQuery {
104 node_type: Some(NodeType::Goal),
105 project_id: Some(project_id),
106 ..Default::default()
107 };
108 let goals = state.graph_store.query_nodes(&query).await?;
109 Ok(Json(goals))
110}
111
112/// POST /api/projects/:id/goals
113pub async fn create_goal(
114 State(state): State<AppState>,
115 Path(id_or_name): Path<String>,
116 Json(body): Json<CreateGoalRequest>,
117) -> Result<(StatusCode, Json<GraphNode>), ApiError> {
118 let project_id = resolve_project_id(&state, &id_or_name).await?;
119
120 let priority = body
121 .priority
122 .map(|p| p.parse::<Priority>())
123 .transpose()
124 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
125
126 let node = GraphNode {
127 id: graph::generate_goal_id(),
128 project_id,
129 node_type: NodeType::Goal,
130 title: body.title,
131 description: body.description,
132 status: NodeStatus::Active,
133 priority,
134 assigned_to: None,
135 created_by: None,
136 labels: vec![],
137 created_at: chrono::Utc::now(),
138 started_at: None,
139 completed_at: None,
140 blocked_reason: None,
141 metadata: std::collections::HashMap::new(),
142 };
143
144 state.graph_store.create_node(&node).await?;
145 Ok((StatusCode::CREATED, Json(node)))
146}
147
148// ===== Node endpoints =====
149
150/// GET /api/nodes/:id
151pub async fn get_node(
152 State(state): State<AppState>,
153 Path(id): Path<String>,
154) -> Result<Json<NodeWithEdges>, ApiError> {
155 let node = state
156 .graph_store
157 .get_node(&id)
158 .await?
159 .ok_or_else(|| ApiError::NotFound(format!("Node '{}' not found", id)))?;
160
161 let incoming = state
162 .graph_store
163 .get_edges(&id, EdgeDirection::Incoming)
164 .await?;
165 let outgoing = state
166 .graph_store
167 .get_edges(&id, EdgeDirection::Outgoing)
168 .await?;
169
170 Ok(Json(NodeWithEdges {
171 node,
172 incoming_edges: incoming,
173 outgoing_edges: outgoing,
174 }))
175}
176
177/// PATCH /api/nodes/:id
178pub async fn update_node(
179 State(state): State<AppState>,
180 Path(id): Path<String>,
181 Json(body): Json<UpdateNodeRequest>,
182) -> Result<Json<GraphNode>, ApiError> {
183 let existing = state
184 .graph_store
185 .get_node(&id)
186 .await?
187 .ok_or_else(|| ApiError::NotFound(format!("Node '{}' not found", id)))?;
188
189 let status = body
190 .status
191 .map(|s| s.parse::<NodeStatus>())
192 .transpose()
193 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
194
195 if let Some(ref s) = status {
196 graph::validate_status(&existing.node_type, s)
197 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
198 }
199
200 state
201 .graph_store
202 .update_node(
203 &id,
204 status,
205 body.title.as_deref(),
206 body.description.as_deref(),
207 body.blocked_reason.as_deref(),
208 body.metadata.as_ref(),
209 )
210 .await?;
211
212 let updated = state
213 .graph_store
214 .get_node(&id)
215 .await?
216 .ok_or_else(|| ApiError::Internal("Node disappeared after update".to_string()))?;
217
218 Ok(Json(updated))
219}
220
221/// POST /api/nodes/:id/children
222pub async fn create_child(
223 State(state): State<AppState>,
224 Path(parent_id): Path<String>,
225 Json(body): Json<CreateChildRequest>,
226) -> Result<(StatusCode, Json<GraphNode>), ApiError> {
227 let parent = state
228 .graph_store
229 .get_node(&parent_id)
230 .await?
231 .ok_or_else(|| ApiError::NotFound(format!("Parent node '{}' not found", parent_id)))?;
232
233 let node_type: NodeType = body
234 .node_type
235 .parse()
236 .map_err(|e: anyhow::Error| ApiError::BadRequest(e.to_string()))?;
237
238 let priority = body
239 .priority
240 .map(|p| p.parse::<Priority>())
241 .transpose()
242 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
243
244 let seq = state.graph_store.next_child_seq(&parent_id).await?;
245 let child_id = graph::generate_child_id(&parent_id, seq);
246
247 let node = GraphNode {
248 id: child_id,
249 project_id: parent.project_id,
250 node_type,
251 title: body.title,
252 description: body.description,
253 status: NodeStatus::Pending,
254 priority,
255 assigned_to: None,
256 created_by: None,
257 labels: vec![],
258 created_at: chrono::Utc::now(),
259 started_at: None,
260 completed_at: None,
261 blocked_reason: None,
262 metadata: body.metadata.unwrap_or_default(),
263 };
264
265 // SqliteGraphStore::create_node() auto-creates a Contains edge
266 state.graph_store.create_node(&node).await?;
267 Ok((StatusCode::CREATED, Json(node)))
268}
269
270// ===== Edge endpoints =====
271
272/// POST /api/edges
273pub async fn create_edge(
274 State(state): State<AppState>,
275 Json(body): Json<CreateEdgeRequest>,
276) -> Result<(StatusCode, Json<GraphEdge>), ApiError> {
277 let edge_type: graph::EdgeType = body
278 .edge_type
279 .parse()
280 .map_err(|e: anyhow::Error| ApiError::BadRequest(e.to_string()))?;
281
282 state
283 .graph_store
284 .get_node(&body.from_node)
285 .await?
286 .ok_or_else(|| ApiError::BadRequest(format!("From node '{}' not found", body.from_node)))?;
287 state
288 .graph_store
289 .get_node(&body.to_node)
290 .await?
291 .ok_or_else(|| ApiError::BadRequest(format!("To node '{}' not found", body.to_node)))?;
292
293 let edge = GraphEdge {
294 id: graph::generate_edge_id(),
295 edge_type,
296 from_node: body.from_node,
297 to_node: body.to_node,
298 label: body.label,
299 created_at: chrono::Utc::now(),
300 };
301
302 state.graph_store.add_edge(&edge).await?;
303 Ok((StatusCode::CREATED, Json(edge)))
304}
305
306/// DELETE /api/edges/:id
307pub async fn delete_edge(
308 State(state): State<AppState>,
309 Path(id): Path<String>,
310) -> Result<StatusCode, ApiError> {
311 state.graph_store.remove_edge(&id).await?;
312 Ok(StatusCode::NO_CONTENT)
313}
314
315// ===== Goal tree =====
316
317/// GET /api/goals/:id/tree
318pub async fn get_goal_tree(
319 State(state): State<AppState>,
320 Path(goal_id): Path<String>,
321) -> Result<Json<GoalTree>, ApiError> {
322 state
323 .graph_store
324 .get_node(&goal_id)
325 .await?
326 .ok_or_else(|| ApiError::NotFound(format!("Goal '{}' not found", goal_id)))?;
327
328 let graph = state.graph_store.get_full_graph(&goal_id).await?;
329 Ok(Json(GoalTree {
330 nodes: graph.nodes,
331 edges: graph.edges,
332 }))
333}
334
335// ===== Task views =====
336
337/// GET /api/goals/:id/tasks
338pub async fn list_tasks(
339 State(state): State<AppState>,
340 Path(goal_id): Path<String>,
341) -> Result<Json<Vec<GraphNode>>, ApiError> {
342 let subtree = state.graph_store.get_subtree(&goal_id).await?;
343 let tasks: Vec<GraphNode> = subtree
344 .into_iter()
345 .filter(|n| n.node_type == NodeType::Task)
346 .collect();
347 Ok(Json(tasks))
348}
349
350/// GET /api/goals/:id/tasks/ready
351pub async fn list_ready_tasks(
352 State(state): State<AppState>,
353 Path(goal_id): Path<String>,
354) -> Result<Json<Vec<GraphNode>>, ApiError> {
355 let tasks = state.graph_store.get_ready_tasks(&goal_id).await?;
356 Ok(Json(tasks))
357}
358
359/// GET /api/goals/:id/tasks/next
360pub async fn next_task(
361 State(state): State<AppState>,
362 Path(goal_id): Path<String>,
363) -> Result<Json<Option<GraphNode>>, ApiError> {
364 let task = state.graph_store.get_next_task(&goal_id).await?;
365 Ok(Json(task))
366}
367
368// ===== Decision views =====
369
370/// GET /api/projects/:id/decisions
371pub async fn list_decisions(
372 State(state): State<AppState>,
373 Path(id_or_name): Path<String>,
374) -> Result<Json<Vec<GraphNode>>, ApiError> {
375 let project_id = resolve_project_id(&state, &id_or_name).await?;
376 let decisions = state.graph_store.get_active_decisions(&project_id).await?;
377 Ok(Json(decisions))
378}
379
380/// GET /api/projects/:id/decisions/history
381pub async fn decisions_history(
382 State(state): State<AppState>,
383 Path(id_or_name): Path<String>,
384) -> Result<Json<DecisionHistory>, ApiError> {
385 let project_id = resolve_project_id(&state, &id_or_name).await?;
386 let decision_types = [
387 NodeType::Decision,
388 NodeType::Option,
389 NodeType::Outcome,
390 NodeType::Revisit,
391 ];
392 let mut all_nodes = Vec::new();
393
394 for node_type in &decision_types {
395 let query = NodeQuery {
396 node_type: Some(*node_type),
397 project_id: Some(project_id.clone()),
398 ..Default::default()
399 };
400 let mut nodes = state.graph_store.query_nodes(&query).await?;
401 all_nodes.append(&mut nodes);
402 }
403
404 let node_ids: std::collections::HashSet<String> =
405 all_nodes.iter().map(|n| n.id.clone()).collect();
406 let mut edges = Vec::new();
407 for node in &all_nodes {
408 let outgoing = state
409 .graph_store
410 .get_edges(&node.id, EdgeDirection::Outgoing)
411 .await?;
412 for (edge, target) in outgoing {
413 if node_ids.contains(&target.id) {
414 edges.push(edge);
415 }
416 }
417 }
418
419 Ok(Json(DecisionHistory {
420 nodes: all_nodes,
421 edges,
422 }))
423}
424
425/// POST /api/projects/:id/decisions/export
426pub async fn export_decisions(
427 State(state): State<AppState>,
428 Path(id_or_name): Path<String>,
429) -> Result<Json<Vec<String>>, ApiError> {
430 let project = match state.project_store.get_by_name(&id_or_name).await? {
431 Some(p) => Some(p),
432 None => state.project_store.get_by_id(&id_or_name).await?,
433 }
434 .ok_or_else(|| ApiError::NotFound(format!("Project '{}' not found", id_or_name)))?;
435
436 let output_dir = project.path.join("decisions");
437 let files =
438 crate::graph::export::export_adrs(state.graph_store.as_ref(), &project.id, &output_dir)
439 .await?;
440
441 let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
442 Ok(Json(paths))
443}
444
445// ===== Session endpoints =====
446
447/// GET /api/goals/:id/sessions
448pub async fn list_sessions(
449 State(state): State<AppState>,
450 Path(goal_id): Path<String>,
451) -> Result<Json<Vec<crate::graph::session::Session>>, ApiError> {
452 let session_store = SessionStore::new(state.db.clone());
453 let sessions = session_store.list_sessions(&goal_id).await?;
454 Ok(Json(sessions))
455}
456
457/// GET /api/sessions/:id
458pub async fn get_session(
459 State(state): State<AppState>,
460 Path(id): Path<String>,
461) -> Result<Json<crate::graph::session::Session>, ApiError> {
462 let session_store = SessionStore::new(state.db.clone());
463 let session = session_store
464 .get_session(&id)
465 .await?
466 .ok_or_else(|| ApiError::NotFound(format!("Session '{}' not found", id)))?;
467 Ok(Json(session))
468}
469
470// ===== Graph import/export =====
471
472/// GET /api/projects/:id/graph/export
473pub async fn export_all_goals(
474 State(state): State<AppState>,
475 Path(id_or_name): Path<String>,
476) -> Result<Json<Vec<ExportResult>>, ApiError> {
477 let project_id = resolve_project_id(&state, &id_or_name).await?;
478 let query = NodeQuery {
479 node_type: Some(NodeType::Goal),
480 project_id: Some(project_id.clone()),
481 ..Default::default()
482 };
483 let goals = state.graph_store.query_nodes(&query).await?;
484
485 let mut results = Vec::new();
486 for goal in goals {
487 let toml_content =
488 interchange::export_goal(state.graph_store.as_ref(), &goal.id, &project_id).await?;
489 results.push(ExportResult {
490 goal_id: goal.id,
491 toml: toml_content,
492 });
493 }
494
495 Ok(Json(results))
496}
497
498/// GET /api/goals/:id/export
499pub async fn export_goal_toml(
500 State(state): State<AppState>,
501 Path(goal_id): Path<String>,
502) -> Result<Json<ExportResult>, ApiError> {
503 let node = state
504 .graph_store
505 .get_node(&goal_id)
506 .await?
507 .ok_or_else(|| ApiError::NotFound(format!("Goal '{}' not found", goal_id)))?;
508
509 let toml_content =
510 interchange::export_goal(state.graph_store.as_ref(), &goal_id, &node.project_id).await?;
511
512 Ok(Json(ExportResult {
513 goal_id,
514 toml: toml_content,
515 }))
516}
517
518/// POST /api/projects/:id/graph/import
519pub async fn import_graph(
520 State(state): State<AppState>,
521 Path(_project_id): Path<String>,
522 Json(body): Json<ImportRequest>,
523) -> Result<Json<interchange::ImportResult>, ApiError> {
524 let strategy = match body.strategy.as_deref() {
525 Some("theirs") => interchange::ImportStrategy::Theirs,
526 Some("ours") => interchange::ImportStrategy::Ours,
527 _ => interchange::ImportStrategy::Merge,
528 };
529
530 let result = interchange::import_goal(state.graph_store.as_ref(), &body.toml, strategy).await?;
531
532 Ok(Json(result))
533}
534
535/// POST /api/projects/:id/graph/diff
536pub async fn diff_graph(
537 State(state): State<AppState>,
538 Path(_project_id): Path<String>,
539 Json(body): Json<ImportRequest>,
540) -> Result<Json<interchange::DiffResult>, ApiError> {
541 let result = interchange::diff_goal(state.graph_store.as_ref(), &body.toml).await?;
542 Ok(Json(result))
543}