diff --git a/src/main.rs b/src/main.rs index 914f665..9885755 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,264 +1,546 @@ -//! Egregore Docs Service +//! Egregore Orient Service //! -//! A lightweight Rust microservice for managing documentation files. -//! Provides HTTP API for reading and updating STATUS.md, HISTORY.md, etc. +//! A Rust microservice for AI agent orientation and task management. +//! Provides JSON API for status, tickets, context, and history. use chrono::Utc; use rouille::{router, Request, Response}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::fs; -use std::io::Write; use std::path::PathBuf; -const DOCS_DIR: &str = "/home/admin/docs"; +const DATA_DIR: &str = "/home/admin/data"; const BIND_ADDR: &str = "127.0.0.1:8083"; -/// List of allowed document names (without .md extension) -const ALLOWED_DOCS: &[&str] = &["STATUS", "HISTORY", "RUNBOOK"]; +// ============================================================================ +// Data Structures +// ============================================================================ -#[derive(Serialize)] -struct DocInfo { +#[derive(Serialize, Deserialize, Clone)] +struct ServiceStatus { name: String, - path: String, - size: u64, - modified: String, -} - -#[derive(Serialize)] -struct DocContent { - name: String, - content: String, - modified: String, -} - -#[derive(Deserialize)] -struct UpdateRequest { - content: String, -} - -#[derive(Serialize)] -struct ApiResponse { - success: bool, - data: Option, - error: Option, -} - -#[derive(Serialize)] -struct HealthResponse { + port: u16, status: String, - service: String, } -fn get_doc_path(name: &str) -> Option { - let upper_name = name.to_uppercase(); - if ALLOWED_DOCS.contains(&upper_name.as_str()) { - Some(PathBuf::from(DOCS_DIR).join(format!("{}.md", upper_name))) - } else { - None - } +#[derive(Serialize, Deserialize)] +struct Status { + state: String, + services: Vec, + issues: Vec, + updated_at: String, } -fn list_docs() -> Response { - let mut docs = Vec::new(); - - for name in ALLOWED_DOCS { - let path = PathBuf::from(DOCS_DIR).join(format!("{}.md", name)); - if let Ok(metadata) = fs::metadata(&path) { - let modified = metadata - .modified() - .map(|t| { - let datetime: chrono::DateTime = t.into(); - datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string() - }) - .unwrap_or_else(|_| "unknown".to_string()); - - docs.push(DocInfo { - name: name.to_string(), - path: path.to_string_lossy().to_string(), - size: metadata.len(), - modified, - }); - } - } - - Response::json(&ApiResponse { - success: true, - data: Some(docs), - error: None::, - }) +#[derive(Serialize, Deserialize, Clone)] +struct Ticket { + id: String, + title: String, + description: String, + priority: u8, + status: String, + context: Vec, + acceptance: Vec, + notes: Vec, + created_at: String, + claimed_at: Option, + completed_at: Option, } -fn get_doc(name: &str) -> Response { - let Some(path) = get_doc_path(name) else { - return Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Document '{}' not allowed", name)), - }) - .with_status_code(400); - }; - - match fs::read_to_string(&path) { - Ok(content) => { - let modified = fs::metadata(&path) - .and_then(|m| m.modified()) - .map(|t| { - let datetime: chrono::DateTime = t.into(); - datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string() - }) - .unwrap_or_else(|_| "unknown".to_string()); - - Response::json(&ApiResponse { - success: true, - data: Some(DocContent { - name: name.to_uppercase(), - content, - modified, - }), - error: None::, - }) - } - Err(e) => Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Failed to read document: {}", e)), - }) - .with_status_code(404), - } +#[derive(Serialize, Deserialize, Clone)] +struct HistoryEvent { + timestamp: String, + #[serde(rename = "type")] + event_type: String, + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + ticket_id: Option, } -fn update_doc(name: &str, request: &Request) -> Response { - let Some(path) = get_doc_path(name) else { - return Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Document '{}' not allowed", name)), - }) - .with_status_code(400); - }; - - // Parse JSON body - let body: UpdateRequest = match rouille::input::json_input(request) { - Ok(b) => b, - Err(e) => { - return Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Invalid JSON: {}", e)), - }) - .with_status_code(400); - } - }; - - // Write to file - match fs::File::create(&path) { - Ok(mut file) => match file.write_all(body.content.as_bytes()) { - Ok(_) => { - let modified = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - Response::json(&ApiResponse { - success: true, - data: Some(DocContent { - name: name.to_uppercase(), - content: body.content, - modified, - }), - error: None::, - }) - } - Err(e) => Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Failed to write: {}", e)), - }) - .with_status_code(500), - }, - Err(e) => Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Failed to create file: {}", e)), - }) - .with_status_code(500), - } +#[derive(Serialize, Deserialize)] +struct History { + events: Vec, } -fn append_to_history(request: &Request) -> Response { - let path = PathBuf::from(DOCS_DIR).join("HISTORY.md"); - - #[derive(Deserialize)] - struct AppendRequest { - entry: String, - } - - let body: AppendRequest = match rouille::input::json_input(request) { - Ok(b) => b, - Err(e) => { - return Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Invalid JSON: {}", e)), - }) - .with_status_code(400); - } - }; - - // Read existing content - let mut content = fs::read_to_string(&path).unwrap_or_default(); - - // Find the marker line and insert before it - let marker = "*Log significant changes with date, actions, and outcomes*"; - if let Some(pos) = content.find(marker) { - let date = Utc::now().format("%Y-%m-%d").to_string(); - let new_entry = format!("\n## {} - {}\n\n---\n\n", date, body.entry); - content.insert_str(pos, &new_entry); - } else { - // Append at end if marker not found - content.push_str(&format!("\n## {}\n\n", body.entry)); - } - - match fs::write(&path, &content) { - Ok(_) => Response::json(&ApiResponse { - success: true, - data: Some("Entry added to HISTORY.md"), - error: None::, - }), - Err(e) => Response::json(&ApiResponse::<()> { - success: false, - data: None, - error: Some(format!("Failed to write: {}", e)), - }) - .with_status_code(500), - } +#[derive(Serialize)] +struct OrientResponse { + project: ProjectInfo, + status: Status, + current_ticket: Option, + suggested_ticket: Option, + rules: Vec, + available_context: Vec, + recent_history: Vec, } +#[derive(Serialize)] +struct ProjectInfo { + name: String, + url: String, + description: String, +} + +// ============================================================================ +// File Operations +// ============================================================================ + +fn read_json Deserialize<'de>>(path: &PathBuf) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) +} + +fn write_json(path: &PathBuf, data: &T) -> Result<(), String> { + let json = serde_json::to_string_pretty(data).map_err(|e| e.to_string())?; + fs::write(path, json).map_err(|e| e.to_string()) +} + +fn json_response(data: T) -> Response { + Response::json(&data) +} + +fn error_response(status: u16, message: &str) -> Response { + Response::json(&json!({ "error": message })).with_status_code(status) +} + +// ============================================================================ +// Handlers +// ============================================================================ + fn health() -> Response { - Response::json(&HealthResponse { - status: "ok".to_string(), - service: "docs".to_string(), - }) + json_response(json!({ + "status": "ok", + "service": "orient" + })) } +fn orient() -> Response { + let status_path = PathBuf::from(DATA_DIR).join("status.json"); + let history_path = PathBuf::from(DATA_DIR).join("history.json"); + let tickets_dir = PathBuf::from(DATA_DIR).join("tickets"); + let context_dir = PathBuf::from(DATA_DIR).join("context"); + + // Load status + let status: Status = read_json(&status_path).unwrap_or(Status { + state: "unknown".to_string(), + services: vec![], + issues: vec![], + updated_at: "unknown".to_string(), + }); + + // Load tickets and find current/suggested + let mut current_ticket: Option = None; + let mut suggested_ticket: Option = None; + let mut best_priority: u8 = 255; + + if let Ok(entries) = fs::read_dir(&tickets_dir) { + for entry in entries.flatten() { + if let Some(ticket) = read_json::(&entry.path()) { + if ticket.status == "claimed" { + current_ticket = Some(ticket); + } else if ticket.status == "open" && ticket.priority < best_priority { + best_priority = ticket.priority; + suggested_ticket = Some(ticket); + } + } + } + } + + // List available context topics + let available_context: Vec = fs::read_dir(&context_dir) + .map(|entries| { + entries + .flatten() + .filter_map(|e| { + e.path() + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + // Load recent history (last 5 events) + let history: History = read_json(&history_path).unwrap_or(History { events: vec![] }); + let recent_history: Vec = history + .events + .into_iter() + .rev() + .take(5) + .collect(); + + let response = OrientResponse { + project: ProjectInfo { + name: "Egregore".to_string(), + url: "https://egregore.leaf.ninja".to_string(), + description: "Personal AI chat web application".to_string(), + }, + status, + current_ticket, + suggested_ticket, + rules: vec![ + "Claim a ticket before starting work".to_string(), + "Never commit secrets to git".to_string(), + "Always use the Python venv (~/.venv)".to_string(), + "Log completions to history".to_string(), + "Load context topics relevant to your ticket".to_string(), + "Commit changes to each service's git repo after modifications".to_string(), + ], + available_context, + recent_history, + }; + + json_response(response) +} + +fn get_status() -> Response { + let path = PathBuf::from(DATA_DIR).join("status.json"); + match read_json::(&path) { + Some(status) => json_response(status), + None => error_response(404, "Status not found"), + } +} + +fn update_status(request: &Request) -> Response { + let path = PathBuf::from(DATA_DIR).join("status.json"); + + let mut status: Status = match rouille::input::json_input(request) { + Ok(s) => s, + Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), + }; + + status.updated_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + match write_json(&path, &status) { + Ok(_) => json_response(status), + Err(e) => error_response(500, &e), + } +} + +fn list_tickets() -> Response { + let tickets_dir = PathBuf::from(DATA_DIR).join("tickets"); + let mut tickets: Vec = vec![]; + + if let Ok(entries) = fs::read_dir(&tickets_dir) { + for entry in entries.flatten() { + if let Some(ticket) = read_json::(&entry.path()) { + tickets.push(ticket); + } + } + } + + // Sort by priority + tickets.sort_by_key(|t| t.priority); + json_response(tickets) +} + +fn create_ticket(request: &Request) -> Response { + let tickets_dir = PathBuf::from(DATA_DIR).join("tickets"); + + #[derive(Deserialize)] + struct CreateTicket { + title: String, + description: String, + priority: Option, + context: Option>, + acceptance: Option>, + } + + let body: CreateTicket = match rouille::input::json_input(request) { + Ok(b) => b, + Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), + }; + + // Generate new ID + let mut max_id: u32 = 0; + if let Ok(entries) = fs::read_dir(&tickets_dir) { + for entry in entries.flatten() { + if let Some(stem) = entry.path().file_stem() { + if let Ok(id) = stem.to_string_lossy().parse::() { + max_id = max_id.max(id); + } + } + } + } + let new_id = format!("{:03}", max_id + 1); + + let ticket = Ticket { + id: new_id.clone(), + title: body.title, + description: body.description, + priority: body.priority.unwrap_or(5), + status: "open".to_string(), + context: body.context.unwrap_or_default(), + acceptance: body.acceptance.unwrap_or_default(), + notes: vec![], + created_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + claimed_at: None, + completed_at: None, + }; + + let path = tickets_dir.join(format!("{}.json", new_id)); + match write_json(&path, &ticket) { + Ok(_) => Response::json(&ticket).with_status_code(201), + Err(e) => error_response(500, &e), + } +} + +fn get_ticket(id: &str) -> Response { + let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + match read_json::(&path) { + Some(ticket) => json_response(ticket), + None => error_response(404, &format!("Ticket {} not found", id)), + } +} + +fn update_ticket(id: &str, request: &Request) -> Response { + let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + + let mut ticket: Ticket = match read_json(&path) { + Some(t) => t, + None => return error_response(404, &format!("Ticket {} not found", id)), + }; + + let updates: Value = match rouille::input::json_input(request) { + Ok(u) => u, + Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), + }; + + // Apply updates + if let Some(title) = updates.get("title").and_then(|v| v.as_str()) { + ticket.title = title.to_string(); + } + if let Some(desc) = updates.get("description").and_then(|v| v.as_str()) { + ticket.description = desc.to_string(); + } + if let Some(priority) = updates.get("priority").and_then(|v| v.as_u64()) { + ticket.priority = priority as u8; + } + if let Some(status) = updates.get("status").and_then(|v| v.as_str()) { + ticket.status = status.to_string(); + } + + match write_json(&path, &ticket) { + Ok(_) => json_response(ticket), + Err(e) => error_response(500, &e), + } +} + +fn claim_ticket(id: &str) -> Response { + let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + + let mut ticket: Ticket = match read_json(&path) { + Some(t) => t, + None => return error_response(404, &format!("Ticket {} not found", id)), + }; + + if ticket.status != "open" { + return error_response(400, &format!("Ticket {} is not open (status: {})", id, ticket.status)); + } + + ticket.status = "claimed".to_string(); + ticket.claimed_at = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()); + + match write_json(&path, &ticket) { + Ok(_) => json_response(ticket), + Err(e) => error_response(500, &e), + } +} + +fn complete_ticket(id: &str, request: &Request) -> Response { + let tickets_path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + let history_path = PathBuf::from(DATA_DIR).join("history.json"); + + let mut ticket: Ticket = match read_json(&tickets_path) { + Some(t) => t, + None => return error_response(404, &format!("Ticket {} not found", id)), + }; + + if ticket.status != "claimed" { + return error_response(400, &format!("Ticket {} is not claimed (status: {})", id, ticket.status)); + } + + // Optional summary in request body + #[derive(Deserialize, Default)] + struct CompleteRequest { + summary: Option, + } + let body: CompleteRequest = rouille::input::json_input(request).unwrap_or_default(); + + ticket.status = "completed".to_string(); + ticket.completed_at = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()); + + // Add to history + let mut history: History = read_json(&history_path).unwrap_or(History { events: vec![] }); + history.events.push(HistoryEvent { + timestamp: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + event_type: "ticket_completed".to_string(), + summary: body.summary.unwrap_or_else(|| ticket.title.clone()), + ticket_id: Some(ticket.id.clone()), + }); + + if let Err(e) = write_json(&history_path, &history) { + return error_response(500, &format!("Failed to update history: {}", e)); + } + + match write_json(&tickets_path, &ticket) { + Ok(_) => json_response(ticket), + Err(e) => error_response(500, &e), + } +} + +fn add_ticket_note(id: &str, request: &Request) -> Response { + let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + + let mut ticket: Ticket = match read_json(&path) { + Some(t) => t, + None => return error_response(404, &format!("Ticket {} not found", id)), + }; + + #[derive(Deserialize)] + struct NoteRequest { + note: String, + } + + let body: NoteRequest = match rouille::input::json_input(request) { + Ok(b) => b, + Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), + }; + + let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + ticket.notes.push(format!("[{}] {}", timestamp, body.note)); + + match write_json(&path, &ticket) { + Ok(_) => json_response(ticket), + Err(e) => error_response(500, &e), + } +} + +fn list_context() -> Response { + let context_dir = PathBuf::from(DATA_DIR).join("context"); + let topics: Vec = fs::read_dir(&context_dir) + .map(|entries| { + entries + .flatten() + .filter_map(|e| { + e.path() + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + json_response(json!({ "topics": topics })) +} + +fn get_context(topic: &str) -> Response { + let path = PathBuf::from(DATA_DIR).join("context").join(format!("{}.json", topic)); + match fs::read_to_string(&path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(data) => json_response(data), + Err(e) => error_response(500, &format!("Invalid JSON in context file: {}", e)), + } + } + Err(_) => error_response(404, &format!("Context '{}' not found", topic)), + } +} + +fn get_history() -> Response { + let path = PathBuf::from(DATA_DIR).join("history.json"); + match read_json::(&path) { + Some(history) => json_response(history), + None => json_response(History { events: vec![] }), + } +} + +fn add_log(request: &Request) -> Response { + let path = PathBuf::from(DATA_DIR).join("history.json"); + + #[derive(Deserialize)] + struct LogRequest { + #[serde(rename = "type")] + event_type: String, + summary: String, + ticket_id: Option, + } + + let body: LogRequest = match rouille::input::json_input(request) { + Ok(b) => b, + Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), + }; + + let mut history: History = read_json(&path).unwrap_or(History { events: vec![] }); + + let event = HistoryEvent { + timestamp: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + event_type: body.event_type, + summary: body.summary, + ticket_id: body.ticket_id, + }; + + history.events.push(event.clone()); + + match write_json(&path, &history) { + Ok(_) => Response::json(&event).with_status_code(201), + Err(e) => error_response(500, &e), + } +} + +// ============================================================================ +// Router +// ============================================================================ + fn handle_request(request: &Request) -> Response { + // Handle CORS preflight + if request.method() == "OPTIONS" { + return Response::empty_204(); + } + router!(request, + // Core (GET) ["/health"] => { health() }, - (GET) ["/docs"] => { list_docs() }, - (GET) ["/docs/{name}", name: String] => { get_doc(&name) }, - (PUT) ["/docs/{name}", name: String] => { update_doc(&name, request) }, - (POST) ["/docs/history/append"] => { append_to_history(request) }, + (GET) ["/orient"] => { orient() }, + + // Status + (GET) ["/status"] => { get_status() }, + (PUT) ["/status"] => { update_status(request) }, + + // Tickets + (GET) ["/tickets"] => { list_tickets() }, + (POST) ["/tickets"] => { create_ticket(request) }, + (GET) ["/tickets/{id}", id: String] => { get_ticket(&id) }, + (PATCH) ["/tickets/{id}", id: String] => { update_ticket(&id, request) }, + (POST) ["/tickets/{id}/claim", id: String] => { claim_ticket(&id) }, + (POST) ["/tickets/{id}/complete", id: String] => { complete_ticket(&id, request) }, + (POST) ["/tickets/{id}/note", id: String] => { add_ticket_note(&id, request) }, + + // Context + (GET) ["/context"] => { list_context() }, + (GET) ["/context/{topic}", topic: String] => { get_context(&topic) }, + + // History + (GET) ["/history"] => { get_history() }, + (POST) ["/log"] => { add_log(request) }, + _ => Response::empty_404() ) } fn main() { - println!("Egregore Docs Service starting on {}", BIND_ADDR); - println!("Serving documents from: {}", DOCS_DIR); + println!("Egregore Orient Service starting on {}", BIND_ADDR); + println!("Data directory: {}", DATA_DIR); rouille::start_server(BIND_ADDR, move |request| { let response = handle_request(request); - // Add CORS headers for local development + // Add CORS headers response .with_additional_header("Access-Control-Allow-Origin", "*") - .with_additional_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS") + .with_additional_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, OPTIONS") .with_additional_header("Access-Control-Allow-Headers", "Content-Type") }); }