An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 543 lines 16 kB view raw
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}