Add git commit rule to orientation
Adds new rule: "Commit changes to each service's git repo after modifications" to remind agents to maintain version control. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d62a179b9
commit
556a297d71
1 changed files with 501 additions and 219 deletions
720
src/main.rs
720
src/main.rs
|
|
@ -1,264 +1,546 @@
|
||||||
//! Egregore Docs Service
|
//! Egregore Orient Service
|
||||||
//!
|
//!
|
||||||
//! A lightweight Rust microservice for managing documentation files.
|
//! A Rust microservice for AI agent orientation and task management.
|
||||||
//! Provides HTTP API for reading and updating STATUS.md, HISTORY.md, etc.
|
//! Provides JSON API for status, tickets, context, and history.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rouille::{router, Request, Response};
|
use rouille::{router, Request, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
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";
|
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)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
struct DocInfo {
|
struct ServiceStatus {
|
||||||
name: String,
|
name: String,
|
||||||
path: String,
|
port: u16,
|
||||||
size: u64,
|
|
||||||
modified: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct DocContent {
|
|
||||||
name: String,
|
|
||||||
content: String,
|
|
||||||
modified: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct UpdateRequest {
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ApiResponse<T> {
|
|
||||||
success: bool,
|
|
||||||
data: Option<T>,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct HealthResponse {
|
|
||||||
status: String,
|
status: String,
|
||||||
service: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_doc_path(name: &str) -> Option<PathBuf> {
|
#[derive(Serialize, Deserialize)]
|
||||||
let upper_name = name.to_uppercase();
|
struct Status {
|
||||||
if ALLOWED_DOCS.contains(&upper_name.as_str()) {
|
state: String,
|
||||||
Some(PathBuf::from(DOCS_DIR).join(format!("{}.md", upper_name)))
|
services: Vec<ServiceStatus>,
|
||||||
} else {
|
issues: Vec<String>,
|
||||||
None
|
updated_at: String,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_docs() -> Response {
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
let mut docs = Vec::new();
|
struct Ticket {
|
||||||
|
id: String,
|
||||||
for name in ALLOWED_DOCS {
|
title: String,
|
||||||
let path = PathBuf::from(DOCS_DIR).join(format!("{}.md", name));
|
description: String,
|
||||||
if let Ok(metadata) = fs::metadata(&path) {
|
priority: u8,
|
||||||
let modified = metadata
|
status: String,
|
||||||
.modified()
|
context: Vec<String>,
|
||||||
.map(|t| {
|
acceptance: Vec<String>,
|
||||||
let datetime: chrono::DateTime<Utc> = t.into();
|
notes: Vec<String>,
|
||||||
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
created_at: String,
|
||||||
})
|
claimed_at: Option<String>,
|
||||||
.unwrap_or_else(|_| "unknown".to_string());
|
completed_at: Option<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::<String>,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_doc(name: &str) -> Response {
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
let Some(path) = get_doc_path(name) else {
|
struct HistoryEvent {
|
||||||
return Response::json(&ApiResponse::<()> {
|
timestamp: String,
|
||||||
success: false,
|
#[serde(rename = "type")]
|
||||||
data: None,
|
event_type: String,
|
||||||
error: Some(format!("Document '{}' not allowed", name)),
|
summary: String,
|
||||||
})
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
.with_status_code(400);
|
ticket_id: Option<String>,
|
||||||
};
|
|
||||||
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(content) => {
|
|
||||||
let modified = fs::metadata(&path)
|
|
||||||
.and_then(|m| m.modified())
|
|
||||||
.map(|t| {
|
|
||||||
let datetime: chrono::DateTime<Utc> = 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::<String>,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => Response::json(&ApiResponse::<()> {
|
|
||||||
success: false,
|
|
||||||
data: None,
|
|
||||||
error: Some(format!("Failed to read document: {}", e)),
|
|
||||||
})
|
|
||||||
.with_status_code(404),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_doc(name: &str, request: &Request) -> Response {
|
#[derive(Serialize, Deserialize)]
|
||||||
let Some(path) = get_doc_path(name) else {
|
struct History {
|
||||||
return Response::json(&ApiResponse::<()> {
|
events: Vec<HistoryEvent>,
|
||||||
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::<String>,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_to_history(request: &Request) -> Response {
|
#[derive(Serialize)]
|
||||||
let path = PathBuf::from(DOCS_DIR).join("HISTORY.md");
|
struct OrientResponse {
|
||||||
|
project: ProjectInfo,
|
||||||
#[derive(Deserialize)]
|
status: Status,
|
||||||
struct AppendRequest {
|
current_ticket: Option<Ticket>,
|
||||||
entry: String,
|
suggested_ticket: Option<Ticket>,
|
||||||
}
|
rules: Vec<String>,
|
||||||
|
available_context: Vec<String>,
|
||||||
let body: AppendRequest = match rouille::input::json_input(request) {
|
recent_history: Vec<HistoryEvent>,
|
||||||
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::<String>,
|
|
||||||
}),
|
|
||||||
Err(e) => Response::json(&ApiResponse::<()> {
|
|
||||||
success: false,
|
|
||||||
data: None,
|
|
||||||
error: Some(format!("Failed to write: {}", e)),
|
|
||||||
})
|
|
||||||
.with_status_code(500),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectInfo {
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// File Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn read_json<T: for<'de> Deserialize<'de>>(path: &PathBuf) -> Option<T> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json<T: Serialize>(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<T: Serialize>(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 {
|
fn health() -> Response {
|
||||||
Response::json(&HealthResponse {
|
json_response(json!({
|
||||||
status: "ok".to_string(),
|
"status": "ok",
|
||||||
service: "docs".to_string(),
|
"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<Ticket> = None;
|
||||||
|
let mut suggested_ticket: Option<Ticket> = 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::<Ticket>(&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<String> = 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<HistoryEvent> = 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::<Status>(&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<Ticket> = vec![];
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&tickets_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Some(ticket) = read_json::<Ticket>(&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<u8>,
|
||||||
|
context: Option<Vec<String>>,
|
||||||
|
acceptance: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<u32>() {
|
||||||
|
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::<Ticket>(&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<String>,
|
||||||
|
}
|
||||||
|
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<String> = 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::<Value>(&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::<History>(&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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
fn handle_request(request: &Request) -> Response {
|
||||||
|
// Handle CORS preflight
|
||||||
|
if request.method() == "OPTIONS" {
|
||||||
|
return Response::empty_204();
|
||||||
|
}
|
||||||
|
|
||||||
router!(request,
|
router!(request,
|
||||||
|
// Core
|
||||||
(GET) ["/health"] => { health() },
|
(GET) ["/health"] => { health() },
|
||||||
(GET) ["/docs"] => { list_docs() },
|
(GET) ["/orient"] => { orient() },
|
||||||
(GET) ["/docs/{name}", name: String] => { get_doc(&name) },
|
|
||||||
(PUT) ["/docs/{name}", name: String] => { update_doc(&name, request) },
|
// Status
|
||||||
(POST) ["/docs/history/append"] => { append_to_history(request) },
|
(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()
|
_ => Response::empty_404()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("Egregore Docs Service starting on {}", BIND_ADDR);
|
println!("Egregore Orient Service starting on {}", BIND_ADDR);
|
||||||
println!("Serving documents from: {}", DOCS_DIR);
|
println!("Data directory: {}", DATA_DIR);
|
||||||
|
|
||||||
rouille::start_server(BIND_ADDR, move |request| {
|
rouille::start_server(BIND_ADDR, move |request| {
|
||||||
let response = handle_request(request);
|
let response = handle_request(request);
|
||||||
|
|
||||||
// Add CORS headers for local development
|
// Add CORS headers
|
||||||
response
|
response
|
||||||
.with_additional_header("Access-Control-Allow-Origin", "*")
|
.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")
|
.with_additional_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue