Initial implementation of vault-os
Complete implementation across all 13 phases: - vault-core: types, YAML frontmatter parsing, entity classification, filesystem ops, config, prompt composition, validation, search - vault-watch: filesystem watcher with daemon write filtering, event classification - vault-scheduler: cron engine, process executor, task runner with retry logic and concurrency limiting - vault-api: Axum REST API (15 route modules), WebSocket with broadcast, AI assistant proxy, validation, templates - Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror editor, dynamic view system, AI chat sidebar - Nix flake with dev shell and NixOS module - Graceful shutdown, inotify overflow recovery, tracing instrumentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
f820a72b04
123 changed files with 18288 additions and 0 deletions
24
crates/vault-api/Cargo.toml
Normal file
24
crates/vault-api/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "vault-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
vault-core.workspace = true
|
||||
vault-watch.workspace = true
|
||||
vault-scheduler.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
rust-embed.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
futures-util = "0.3"
|
||||
reqwest.workspace = true
|
||||
44
crates/vault-api/src/error.rs
Normal file
44
crates/vault-api/src/error.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] vault_core::VaultError),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
||||
ApiError::Vault(e) => match e {
|
||||
vault_core::VaultError::NotFound(msg) => {
|
||||
(StatusCode::NOT_FOUND, msg.clone())
|
||||
}
|
||||
vault_core::VaultError::MissingFrontmatter(p) => {
|
||||
(StatusCode::BAD_REQUEST, format!("Missing frontmatter: {:?}", p))
|
||||
}
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"error": message,
|
||||
"status": status.as_u16(),
|
||||
});
|
||||
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
28
crates/vault-api/src/lib.rs
Normal file
28
crates/vault-api/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
pub mod error;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
pub mod ws;
|
||||
pub mod ws_protocol;
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub use state::AppState;
|
||||
|
||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let api = routes::api_routes();
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
.route("/ws", axum::routing::get(ws::ws_handler))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
111
crates/vault-api/src/routes/agents.rs
Normal file
111
crates/vault-api/src/routes/agents.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::AgentTask;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/agents", get(list_agents))
|
||||
.route("/agents/{name}", get(get_agent))
|
||||
.route(
|
||||
"/agents/{name}/trigger",
|
||||
axum::routing::post(trigger_agent),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_agents(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let agents = state.agents.read().unwrap();
|
||||
let list: Vec<Value> = agents
|
||||
.values()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"name": a.frontmatter.name,
|
||||
"executable": a.frontmatter.executable,
|
||||
"model": a.frontmatter.model,
|
||||
"skills": a.frontmatter.skills,
|
||||
"timeout": a.frontmatter.timeout,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
async fn get_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let agents = state.agents.read().unwrap();
|
||||
let agent = agents
|
||||
.get(&name)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Agent '{}' not found", name)))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"name": agent.frontmatter.name,
|
||||
"executable": agent.frontmatter.executable,
|
||||
"model": agent.frontmatter.model,
|
||||
"escalate_to": agent.frontmatter.escalate_to,
|
||||
"mcp_servers": agent.frontmatter.mcp_servers,
|
||||
"skills": agent.frontmatter.skills,
|
||||
"timeout": agent.frontmatter.timeout,
|
||||
"max_retries": agent.frontmatter.max_retries,
|
||||
"env": agent.frontmatter.env,
|
||||
"body": agent.body,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn trigger_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
body: Option<Json<Value>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let agents = state.agents.read().unwrap();
|
||||
if !agents.contains_key(&name) {
|
||||
return Err(ApiError::NotFound(format!("Agent '{}' not found", name)));
|
||||
}
|
||||
drop(agents);
|
||||
|
||||
let context = body
|
||||
.and_then(|b| b.get("context").and_then(|c| c.as_str().map(String::from)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = format!("Manual trigger: {}", name);
|
||||
let slug = filesystem::timestamped_slug(&title);
|
||||
let task_path = state
|
||||
.vault_root
|
||||
.join("todos/agent/queued")
|
||||
.join(format!("{}.md", slug));
|
||||
|
||||
let task = AgentTask {
|
||||
title,
|
||||
agent: name,
|
||||
priority: vault_core::types::Priority::Medium,
|
||||
task_type: Some("manual".into()),
|
||||
created: chrono::Utc::now(),
|
||||
started: None,
|
||||
completed: None,
|
||||
retry: 0,
|
||||
max_retries: 0,
|
||||
input: None,
|
||||
output: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let entity = vault_core::entity::VaultEntity {
|
||||
path: task_path.clone(),
|
||||
frontmatter: task,
|
||||
body: context,
|
||||
};
|
||||
|
||||
state.write_filter.register(task_path.clone());
|
||||
vault_core::filesystem::write_entity(&entity).map_err(ApiError::Vault)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "queued",
|
||||
"task_path": task_path.strip_prefix(&state.vault_root).unwrap_or(&task_path),
|
||||
})))
|
||||
}
|
||||
390
crates/vault-api/src/routes/assistant.rs
Normal file
390
crates/vault-api/src/routes/assistant.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
// --- Types ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub model: Option<String>,
|
||||
/// Optional path of the file being edited (for context)
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChatResponse {
|
||||
pub message: ChatMessage,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApplyDiffRequest {
|
||||
pub file_path: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/assistant/chat", post(chat))
|
||||
.route("/assistant/apply-diff", post(apply_diff))
|
||||
.route("/assistant/models", get(list_models))
|
||||
}
|
||||
|
||||
/// POST /api/assistant/chat — proxy chat to configured LLM
|
||||
async fn chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, ApiError> {
|
||||
let model = req
|
||||
.model
|
||||
.unwrap_or_else(|| state.config.assistant.default_model.clone());
|
||||
|
||||
// Build system prompt with vault context
|
||||
let mut system_parts = vec![
|
||||
"You are an AI assistant integrated into vault-os, a personal operations platform.".into(),
|
||||
"You help the user edit markdown files with YAML frontmatter.".into(),
|
||||
"When suggesting changes, output unified diffs that can be applied.".into(),
|
||||
];
|
||||
|
||||
// If a file path is provided, include its content as context
|
||||
if let Some(ref fp) = req.file_path {
|
||||
let full = state.vault_root.join(fp);
|
||||
if let Ok(content) = tokio::fs::read_to_string(&full).await {
|
||||
system_parts.push(format!("\n--- Current file: {} ---\n{}", fp, content));
|
||||
}
|
||||
}
|
||||
|
||||
let system_prompt = system_parts.join("\n");
|
||||
|
||||
// Build messages for the LLM
|
||||
let mut messages = vec![ChatMessage {
|
||||
role: "system".into(),
|
||||
content: system_prompt,
|
||||
}];
|
||||
messages.extend(req.messages);
|
||||
|
||||
// Determine backend from model string
|
||||
let response = if model.starts_with("claude") || model.starts_with("anthropic/") {
|
||||
call_anthropic(&state, &model, &messages).await?
|
||||
} else {
|
||||
// Default: OpenAI-compatible API (works with Ollama, vLLM, LM Studio, etc.)
|
||||
call_openai_compatible(&state, &model, &messages).await?
|
||||
};
|
||||
|
||||
Ok(Json(ChatResponse {
|
||||
message: response,
|
||||
model,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Call Anthropic Messages API
|
||||
async fn call_anthropic(
|
||||
_state: &AppState,
|
||||
model: &str,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<ChatMessage, ApiError> {
|
||||
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
||||
.map_err(|_| ApiError::BadRequest("ANTHROPIC_API_KEY not set".into()))?;
|
||||
|
||||
// Extract system message
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let user_messages: Vec<serde_json::Value> = messages
|
||||
.iter()
|
||||
.filter(|m| m.role != "system")
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let model_id = model.strip_prefix("anthropic/").unwrap_or(model);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model_id,
|
||||
"max_tokens": 4096,
|
||||
"system": system,
|
||||
"messages": user_messages,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Anthropic request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Internal(format!(
|
||||
"Anthropic API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse Anthropic response: {e}")))?;
|
||||
|
||||
let content = json["content"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|block| block["text"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
/// Call OpenAI-compatible API (Ollama, vLLM, LM Studio, etc.)
|
||||
async fn call_openai_compatible(
|
||||
state: &AppState,
|
||||
model: &str,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<ChatMessage, ApiError> {
|
||||
// Check for configured executor base_url, fall back to Ollama default
|
||||
let base_url = state
|
||||
.config
|
||||
.executors
|
||||
.values()
|
||||
.find_map(|e| e.base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".into());
|
||||
|
||||
let model_id = model.split('/').next_back().unwrap_or(model);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model_id,
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/chat/completions", base_url))
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("LLM request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Internal(format!(
|
||||
"LLM API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse LLM response: {e}")))?;
|
||||
|
||||
let content = json["choices"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|choice| choice["message"]["content"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /api/assistant/apply-diff — apply a unified diff to a file
|
||||
async fn apply_diff(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ApplyDiffRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let full_path = state.vault_root.join(&req.file_path);
|
||||
if !full_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("File not found: {}", req.file_path)));
|
||||
}
|
||||
|
||||
let original = tokio::fs::read_to_string(&full_path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read file: {e}")))?;
|
||||
|
||||
let patched = apply_unified_diff(&original, &req.diff)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to apply diff: {e}")))?;
|
||||
|
||||
// Register with write filter to prevent feedback loop
|
||||
state.write_filter.register(full_path.clone());
|
||||
|
||||
tokio::fs::write(&full_path, &patched)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to write file: {e}")))?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "ok", "path": req.file_path })))
|
||||
}
|
||||
|
||||
/// Simple unified diff applier
|
||||
fn apply_unified_diff(original: &str, diff: &str) -> Result<String, String> {
|
||||
let mut result_lines: Vec<String> = original.lines().map(String::from).collect();
|
||||
let mut offset: i64 = 0;
|
||||
|
||||
for hunk in parse_hunks(diff) {
|
||||
let start = ((hunk.old_start as i64) - 1 + offset) as usize;
|
||||
let end = start + hunk.old_count;
|
||||
|
||||
if end > result_lines.len() {
|
||||
return Err(format!(
|
||||
"Hunk at line {} extends beyond file (file has {} lines)",
|
||||
hunk.old_start,
|
||||
result_lines.len()
|
||||
));
|
||||
}
|
||||
|
||||
result_lines.splice(start..end, hunk.new_lines);
|
||||
|
||||
offset += hunk.new_count as i64 - hunk.old_count as i64;
|
||||
}
|
||||
|
||||
let mut result = result_lines.join("\n");
|
||||
if original.ends_with('\n') && !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
struct Hunk {
|
||||
old_start: usize,
|
||||
old_count: usize,
|
||||
new_count: usize,
|
||||
new_lines: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_hunks(diff: &str) -> Vec<Hunk> {
|
||||
let mut hunks = Vec::new();
|
||||
let mut lines = diff.lines().peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
if line.starts_with("@@") {
|
||||
// Parse @@ -old_start,old_count +new_start,new_count @@
|
||||
if let Some(hunk) = parse_hunk_header(line) {
|
||||
let mut old_count = 0;
|
||||
let mut new_lines = Vec::new();
|
||||
let mut new_count = 0;
|
||||
|
||||
while old_count < hunk.0 || new_count < hunk.1 {
|
||||
match lines.next() {
|
||||
Some(l) if l.starts_with('-') => {
|
||||
old_count += 1;
|
||||
}
|
||||
Some(l) if l.starts_with('+') => {
|
||||
new_lines.push(l[1..].to_string());
|
||||
new_count += 1;
|
||||
}
|
||||
Some(l) => {
|
||||
// Context line (starts with ' ' or no prefix)
|
||||
let content = l.strip_prefix(' ').unwrap_or(l);
|
||||
new_lines.push(content.to_string());
|
||||
old_count += 1;
|
||||
new_count += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
hunks.push(Hunk {
|
||||
old_start: hunk.2,
|
||||
old_count: hunk.0,
|
||||
new_count: new_lines.len(),
|
||||
new_lines,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hunks
|
||||
}
|
||||
|
||||
/// Parse "@@ -start,count +start,count @@" returning (old_count, new_count, old_start)
|
||||
fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize)> {
|
||||
let stripped = line.trim_start_matches("@@").trim_end_matches("@@").trim();
|
||||
let parts: Vec<&str> = stripped.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let old_part = parts[0].trim_start_matches('-');
|
||||
let new_part = parts[1].trim_start_matches('+');
|
||||
|
||||
let (old_start, old_count) = parse_range(old_part)?;
|
||||
let (_new_start, new_count) = parse_range(new_part)?;
|
||||
|
||||
Some((old_count, new_count, old_start))
|
||||
}
|
||||
|
||||
fn parse_range(s: &str) -> Option<(usize, usize)> {
|
||||
if let Some((start, count)) = s.split_once(',') {
|
||||
Some((start.parse().ok()?, count.parse().ok()?))
|
||||
} else {
|
||||
Some((s.parse().ok()?, 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/assistant/models — list available models from config
|
||||
async fn list_models(State(state): State<Arc<AppState>>) -> Json<Vec<ModelInfo>> {
|
||||
let mut models: Vec<ModelInfo> = state
|
||||
.config
|
||||
.assistant
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| ModelInfo {
|
||||
id: m.clone(),
|
||||
name: m.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Always include the default model
|
||||
let default = &state.config.assistant.default_model;
|
||||
if !models.iter().any(|m| m.id == *default) {
|
||||
models.insert(
|
||||
0,
|
||||
ModelInfo {
|
||||
id: default.clone(),
|
||||
name: format!("{} (default)", default),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Json(models)
|
||||
}
|
||||
127
crates/vault-api/src/routes/crons.rs
Normal file
127
crates/vault-api/src/routes/crons.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::CronJob;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/crons", get(list_crons))
|
||||
.route("/crons/{name}/trigger", post(trigger_cron))
|
||||
.route("/crons/{name}/pause", post(pause_cron))
|
||||
.route("/crons/{name}/resume", post(resume_cron))
|
||||
}
|
||||
|
||||
async fn list_crons(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let mut crons = Vec::new();
|
||||
|
||||
for subdir in &["active", "paused"] {
|
||||
let dir = state.vault_root.join("crons").join(subdir);
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
for file in files {
|
||||
match filesystem::read_entity::<CronJob>(&file) {
|
||||
Ok(entity) => {
|
||||
crons.push(json!({
|
||||
"name": file.file_stem().and_then(|s| s.to_str()),
|
||||
"title": entity.frontmatter.title,
|
||||
"schedule": entity.frontmatter.schedule,
|
||||
"agent": entity.frontmatter.agent,
|
||||
"enabled": *subdir == "active" && entity.frontmatter.enabled,
|
||||
"status": subdir,
|
||||
"last_run": entity.frontmatter.last_run,
|
||||
"last_status": entity.frontmatter.last_status,
|
||||
"next_run": entity.frontmatter.next_run,
|
||||
"run_count": entity.frontmatter.run_count,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = ?file, error = %e, "Failed to read cron");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(json!(crons)))
|
||||
}
|
||||
|
||||
async fn trigger_cron(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let cron_path = state
|
||||
.vault_root
|
||||
.join("crons/active")
|
||||
.join(format!("{}.md", name));
|
||||
|
||||
if !cron_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Cron '{}' not found in active/", name)));
|
||||
}
|
||||
|
||||
let mut engine = state.cron_engine.lock().unwrap();
|
||||
let task_path = engine
|
||||
.fire_cron(&cron_path, &state.write_filter)
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "fired",
|
||||
"task_path": task_path.strip_prefix(&state.vault_root).unwrap_or(&task_path),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn pause_cron(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let from = state
|
||||
.vault_root
|
||||
.join("crons/active")
|
||||
.join(format!("{}.md", name));
|
||||
let to = state
|
||||
.vault_root
|
||||
.join("crons/paused")
|
||||
.join(format!("{}.md", name));
|
||||
|
||||
if !from.exists() {
|
||||
return Err(ApiError::NotFound(format!("Cron '{}' not found in active/", name)));
|
||||
}
|
||||
|
||||
state.write_filter.register(to.clone());
|
||||
filesystem::move_file(&from, &to).map_err(ApiError::Vault)?;
|
||||
|
||||
let mut engine = state.cron_engine.lock().unwrap();
|
||||
engine.remove_cron(&from);
|
||||
|
||||
Ok(Json(json!({ "status": "paused" })))
|
||||
}
|
||||
|
||||
async fn resume_cron(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let from = state
|
||||
.vault_root
|
||||
.join("crons/paused")
|
||||
.join(format!("{}.md", name));
|
||||
let to = state
|
||||
.vault_root
|
||||
.join("crons/active")
|
||||
.join(format!("{}.md", name));
|
||||
|
||||
if !from.exists() {
|
||||
return Err(ApiError::NotFound(format!("Cron '{}' not found in paused/", name)));
|
||||
}
|
||||
|
||||
state.write_filter.register(to.clone());
|
||||
filesystem::move_file(&from, &to).map_err(ApiError::Vault)?;
|
||||
|
||||
let mut engine = state.cron_engine.lock().unwrap();
|
||||
if let Err(e) = engine.upsert_cron(&to) {
|
||||
tracing::warn!(error = %e, "Failed to schedule resumed cron");
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "status": "active" })))
|
||||
}
|
||||
126
crates/vault-api/src/routes/files.rs
Normal file
126
crates/vault-api/src/routes/files.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/files/{*path}", get(read_file).put(write_file).patch(patch_file).delete(delete_file))
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join(&path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("File '{}' not found", path)));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
// Try to split frontmatter
|
||||
if let Ok((yaml, body)) = vault_core::frontmatter::split_frontmatter(&content) {
|
||||
let frontmatter: Value = serde_yaml::from_str(yaml).unwrap_or(Value::Null);
|
||||
Ok(Json(json!({
|
||||
"path": path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body,
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(json!({
|
||||
"path": path,
|
||||
"frontmatter": null,
|
||||
"body": content,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WriteFileBody {
|
||||
#[serde(default)]
|
||||
frontmatter: Option<Value>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
raw: Option<String>,
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
Json(data): Json<WriteFileBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join(&path);
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, parent)))?;
|
||||
}
|
||||
|
||||
let content = if let Some(raw) = data.raw {
|
||||
raw
|
||||
} else {
|
||||
let body = data.body.unwrap_or_default();
|
||||
if let Some(fm) = data.frontmatter {
|
||||
let yaml = serde_yaml::to_string(&fm)
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
format!("---\n{}---\n{}", yaml, body)
|
||||
} else {
|
||||
body
|
||||
}
|
||||
};
|
||||
|
||||
state.write_filter.register(file_path.clone());
|
||||
std::fs::write(&file_path, content)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "written", "path": path })))
|
||||
}
|
||||
|
||||
async fn patch_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
Json(updates): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join(&path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("File '{}' not found", path)));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
let updated =
|
||||
vault_core::frontmatter::update_frontmatter_fields(&content, &file_path, &updates)
|
||||
.map_err(ApiError::Vault)?;
|
||||
|
||||
state.write_filter.register(file_path.clone());
|
||||
std::fs::write(&file_path, updated)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "patched", "path": path })))
|
||||
}
|
||||
|
||||
async fn delete_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join(&path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("File '{}' not found", path)));
|
||||
}
|
||||
|
||||
std::fs::remove_file(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "deleted", "path": path })))
|
||||
}
|
||||
126
crates/vault-api/src/routes/knowledge.rs
Normal file
126
crates/vault-api/src/routes/knowledge.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use pulldown_cmark::{html, Parser};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::entity::VaultEntity;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::KnowledgeNote;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/knowledge", get(list_knowledge))
|
||||
.route("/knowledge/{*path}", get(get_knowledge))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SearchQuery {
|
||||
#[serde(default)]
|
||||
q: Option<String>,
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_knowledge(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let dir = state.vault_root.join("knowledge");
|
||||
let files = filesystem::list_md_files_recursive(&dir).map_err(ApiError::Vault)?;
|
||||
|
||||
let mut notes = Vec::new();
|
||||
for file in files {
|
||||
// Try parsing with frontmatter
|
||||
let content = std::fs::read_to_string(&file)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file)))?;
|
||||
|
||||
let (title, tags) = if let Ok(entity) = VaultEntity::<KnowledgeNote>::from_content(file.clone(), &content) {
|
||||
(
|
||||
entity.frontmatter.title.unwrap_or_else(|| {
|
||||
file.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("untitled")
|
||||
.to_string()
|
||||
}),
|
||||
entity.frontmatter.tags,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
file.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("untitled")
|
||||
.to_string(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref q) = query.q {
|
||||
let q_lower = q.to_lowercase();
|
||||
if !title.to_lowercase().contains(&q_lower)
|
||||
&& !content.to_lowercase().contains(&q_lower)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref tag) = query.tag {
|
||||
if !tags.iter().any(|t| t == tag) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let relative = file.strip_prefix(&state.vault_root).unwrap_or(&file);
|
||||
notes.push(json!({
|
||||
"path": relative,
|
||||
"title": title,
|
||||
"tags": tags,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(json!(notes)))
|
||||
}
|
||||
|
||||
async fn get_knowledge(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join("knowledge").join(&path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Knowledge note '{}' not found", path)));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
let (frontmatter, body) = if let Ok(entity) = VaultEntity::<KnowledgeNote>::from_content(file_path.clone(), &content) {
|
||||
(
|
||||
json!({
|
||||
"title": entity.frontmatter.title,
|
||||
"tags": entity.frontmatter.tags,
|
||||
"source": entity.frontmatter.source,
|
||||
"created": entity.frontmatter.created,
|
||||
"related": entity.frontmatter.related,
|
||||
}),
|
||||
entity.body,
|
||||
)
|
||||
} else {
|
||||
(json!({}), content.clone())
|
||||
};
|
||||
|
||||
// Render markdown to HTML
|
||||
let parser = Parser::new(&body);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
Ok(Json(json!({
|
||||
"path": path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body,
|
||||
"html": html_output,
|
||||
})))
|
||||
}
|
||||
36
crates/vault-api/src/routes/mod.rs
Normal file
36
crates/vault-api/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
pub mod agents;
|
||||
pub mod assistant;
|
||||
pub mod crons;
|
||||
pub mod files;
|
||||
pub mod knowledge;
|
||||
pub mod skills;
|
||||
pub mod stats;
|
||||
pub mod suggest;
|
||||
pub mod templates;
|
||||
pub mod todos_agent;
|
||||
pub mod todos_human;
|
||||
pub mod tree;
|
||||
pub mod validate;
|
||||
pub mod views;
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn api_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.merge(agents::routes())
|
||||
.merge(skills::routes())
|
||||
.merge(crons::routes())
|
||||
.merge(todos_human::routes())
|
||||
.merge(todos_agent::routes())
|
||||
.merge(knowledge::routes())
|
||||
.merge(files::routes())
|
||||
.merge(tree::routes())
|
||||
.merge(suggest::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(views::routes())
|
||||
.merge(assistant::routes())
|
||||
.merge(validate::routes())
|
||||
.merge(templates::routes())
|
||||
}
|
||||
62
crates/vault-api/src/routes/skills.rs
Normal file
62
crates/vault-api/src/routes/skills.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/skills", get(list_skills))
|
||||
.route("/skills/{name}", get(get_skill))
|
||||
.route("/skills/{name}/used-by", get(skill_used_by))
|
||||
}
|
||||
|
||||
async fn list_skills(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let skills = state.skills.read().unwrap();
|
||||
let list: Vec<Value> = skills
|
||||
.values()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"name": s.frontmatter.name,
|
||||
"description": s.frontmatter.description,
|
||||
"version": s.frontmatter.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
async fn get_skill(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let skills = state.skills.read().unwrap();
|
||||
let skill = skills
|
||||
.get(&name)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Skill '{}' not found", name)))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"name": skill.frontmatter.name,
|
||||
"description": skill.frontmatter.description,
|
||||
"version": skill.frontmatter.version,
|
||||
"requires_mcp": skill.frontmatter.requires_mcp,
|
||||
"inputs": skill.frontmatter.inputs,
|
||||
"outputs": skill.frontmatter.outputs,
|
||||
"body": skill.body,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn skill_used_by(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let agents = state.agents.read().unwrap();
|
||||
let users: Vec<String> = agents
|
||||
.values()
|
||||
.filter(|a| a.frontmatter.skills.contains(&name))
|
||||
.map(|a| a.frontmatter.name.clone())
|
||||
.collect();
|
||||
Ok(Json(json!(users)))
|
||||
}
|
||||
112
crates/vault-api/src/routes/stats.rs
Normal file
112
crates/vault-api/src/routes/stats.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::filesystem;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/stats", get(get_stats))
|
||||
.route("/activity", get(get_activity))
|
||||
.route("/health", get(health_check))
|
||||
}
|
||||
|
||||
async fn get_stats(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let agents_count = state.agents.read().unwrap().len();
|
||||
let skills_count = state.skills.read().unwrap().len();
|
||||
let crons_scheduled = state.cron_engine.lock().unwrap().scheduled_count();
|
||||
|
||||
let mut task_counts = serde_json::Map::new();
|
||||
for status in &["urgent", "open", "in-progress", "done"] {
|
||||
let dir = state.vault_root.join("todos/harald").join(status);
|
||||
let count = filesystem::list_md_files(&dir)
|
||||
.map(|f| f.len())
|
||||
.unwrap_or(0);
|
||||
task_counts.insert(status.to_string(), json!(count));
|
||||
}
|
||||
|
||||
let mut agent_task_counts = serde_json::Map::new();
|
||||
for status in &["queued", "running", "done", "failed"] {
|
||||
let dir = state.vault_root.join("todos/agent").join(status);
|
||||
let count = filesystem::list_md_files(&dir)
|
||||
.map(|f| f.len())
|
||||
.unwrap_or(0);
|
||||
agent_task_counts.insert(status.to_string(), json!(count));
|
||||
}
|
||||
|
||||
let knowledge_count = filesystem::list_md_files_recursive(&state.vault_root.join("knowledge"))
|
||||
.map(|f| f.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let runtime_state = state.runtime_state.lock().unwrap();
|
||||
|
||||
Ok(Json(json!({
|
||||
"agents": agents_count,
|
||||
"skills": skills_count,
|
||||
"crons_scheduled": crons_scheduled,
|
||||
"human_tasks": task_counts,
|
||||
"agent_tasks": agent_task_counts,
|
||||
"knowledge_notes": knowledge_count,
|
||||
"total_tasks_executed": runtime_state.total_tasks_executed,
|
||||
"total_cron_fires": runtime_state.total_cron_fires,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_activity(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
// Collect recently modified files across the vault as activity items
|
||||
let mut activity = Vec::new();
|
||||
|
||||
let dirs = [
|
||||
("todos/harald", "human_task"),
|
||||
("todos/agent", "agent_task"),
|
||||
("knowledge", "knowledge"),
|
||||
];
|
||||
|
||||
for (dir, kind) in &dirs {
|
||||
if let Ok(files) = filesystem::list_md_files_recursive(&state.vault_root.join(dir)) {
|
||||
for file in files.iter().rev().take(20) {
|
||||
if let Ok(metadata) = std::fs::metadata(file) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let relative = file.strip_prefix(&state.vault_root).unwrap_or(file);
|
||||
activity.push(json!({
|
||||
"path": relative,
|
||||
"kind": kind,
|
||||
"modified": chrono::DateTime::<chrono::Utc>::from(modified),
|
||||
"name": file.file_stem().and_then(|s| s.to_str()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modification time, newest first
|
||||
activity.sort_by(|a, b| {
|
||||
let a_time = a.get("modified").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let b_time = b.get("modified").and_then(|t| t.as_str()).unwrap_or("");
|
||||
b_time.cmp(a_time)
|
||||
});
|
||||
|
||||
activity.truncate(50);
|
||||
|
||||
Ok(Json(json!(activity)))
|
||||
}
|
||||
|
||||
async fn health_check(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let runtime_state = state.runtime_state.lock().unwrap();
|
||||
let uptime = chrono::Utc::now() - state.startup_time;
|
||||
let crons = state.cron_engine.lock().unwrap().scheduled_count();
|
||||
let agents = state.agents.read().unwrap().len();
|
||||
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_secs": uptime.num_seconds(),
|
||||
"agents": agents,
|
||||
"crons_scheduled": crons,
|
||||
"total_tasks_executed": runtime_state.total_tasks_executed,
|
||||
}))
|
||||
}
|
||||
141
crates/vault-api/src/routes/suggest.rs
Normal file
141
crates/vault-api/src/routes/suggest.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use crate::state::AppState;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::{HumanTask, KnowledgeNote};
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/suggest/agents", get(suggest_agents))
|
||||
.route("/suggest/skills", get(suggest_skills))
|
||||
.route("/suggest/tags", get(suggest_tags))
|
||||
.route("/suggest/repos", get(suggest_repos))
|
||||
.route("/suggest/labels", get(suggest_labels))
|
||||
.route("/suggest/files", get(suggest_files))
|
||||
.route("/suggest/models", get(suggest_models))
|
||||
.route("/suggest/mcp-servers", get(suggest_mcp_servers))
|
||||
}
|
||||
|
||||
async fn suggest_agents(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let agents = state.agents.read().unwrap();
|
||||
let names: Vec<&str> = agents.keys().map(|s| s.as_str()).collect();
|
||||
Json(json!(names))
|
||||
}
|
||||
|
||||
async fn suggest_skills(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let skills = state.skills.read().unwrap();
|
||||
let names: Vec<&str> = skills.keys().map(|s| s.as_str()).collect();
|
||||
Json(json!(names))
|
||||
}
|
||||
|
||||
async fn suggest_tags(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let mut tags = HashSet::new();
|
||||
|
||||
// Collect from knowledge notes
|
||||
if let Ok(files) = filesystem::list_md_files_recursive(&state.vault_root.join("knowledge")) {
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<KnowledgeNote>(&file) {
|
||||
for tag in &entity.frontmatter.tags {
|
||||
tags.insert(tag.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags: Vec<String> = tags.into_iter().collect();
|
||||
tags.sort();
|
||||
Json(json!(tags))
|
||||
}
|
||||
|
||||
async fn suggest_repos(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let mut repos = HashSet::new();
|
||||
|
||||
for status in &["urgent", "open", "in-progress", "done"] {
|
||||
let dir = state.vault_root.join("todos/harald").join(status);
|
||||
if let Ok(files) = filesystem::list_md_files(&dir) {
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<HumanTask>(&file) {
|
||||
if let Some(repo) = &entity.frontmatter.repo {
|
||||
repos.insert(repo.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut repos: Vec<String> = repos.into_iter().collect();
|
||||
repos.sort();
|
||||
Json(json!(repos))
|
||||
}
|
||||
|
||||
async fn suggest_labels(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let mut labels = HashSet::new();
|
||||
|
||||
for status in &["urgent", "open", "in-progress", "done"] {
|
||||
let dir = state.vault_root.join("todos/harald").join(status);
|
||||
if let Ok(files) = filesystem::list_md_files(&dir) {
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<HumanTask>(&file) {
|
||||
for label in &entity.frontmatter.labels {
|
||||
labels.insert(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels: Vec<String> = labels.into_iter().collect();
|
||||
labels.sort();
|
||||
Json(json!(labels))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct FileQuery {
|
||||
#[serde(default)]
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
async fn suggest_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<FileQuery>,
|
||||
) -> Json<Value> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
if let Ok(all_files) = filesystem::list_md_files_recursive(&state.vault_root) {
|
||||
for file in all_files {
|
||||
if let Ok(relative) = file.strip_prefix(&state.vault_root) {
|
||||
let rel_str = relative.to_string_lossy().to_string();
|
||||
|
||||
// Skip .vault internal files
|
||||
if rel_str.starts_with(".vault") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref q) = query.q {
|
||||
if !rel_str.to_lowercase().contains(&q.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
files.push(rel_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Json(json!(files))
|
||||
}
|
||||
|
||||
async fn suggest_models(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
Json(json!(state.config.assistant.models))
|
||||
}
|
||||
|
||||
async fn suggest_mcp_servers(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let servers: Vec<&str> = state.config.mcp_servers.keys().map(|s| s.as_str()).collect();
|
||||
Json(json!(servers))
|
||||
}
|
||||
144
crates/vault-api/src/routes/templates.rs
Normal file
144
crates/vault-api/src/routes/templates.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use crate::state::AppState;
|
||||
use axum::extract::Path;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/templates", get(list_templates))
|
||||
.route("/templates/{name}", get(get_template))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct TemplateInfo {
|
||||
name: String,
|
||||
description: String,
|
||||
category: String,
|
||||
}
|
||||
|
||||
async fn list_templates() -> Json<Vec<TemplateInfo>> {
|
||||
Json(vec![
|
||||
TemplateInfo {
|
||||
name: "agent".into(),
|
||||
description: "New AI agent definition".into(),
|
||||
category: "agents".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "skill".into(),
|
||||
description: "New agent skill".into(),
|
||||
category: "skills".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "cron".into(),
|
||||
description: "New cron schedule".into(),
|
||||
category: "crons".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "human-task".into(),
|
||||
description: "New human task".into(),
|
||||
category: "todos/harald".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "agent-task".into(),
|
||||
description: "New agent task".into(),
|
||||
category: "todos/agent".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "knowledge".into(),
|
||||
description: "New knowledge note".into(),
|
||||
category: "knowledge".into(),
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "view-page".into(),
|
||||
description: "New dashboard view page".into(),
|
||||
category: "views/pages".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async fn get_template(Path(name): Path<String>) -> Json<serde_json::Value> {
|
||||
let template = match name.as_str() {
|
||||
"agent" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"name": "new-agent",
|
||||
"executable": "claude-code",
|
||||
"model": "",
|
||||
"skills": [],
|
||||
"mcp_servers": [],
|
||||
"timeout": 600,
|
||||
"max_retries": 0,
|
||||
"env": {}
|
||||
},
|
||||
"body": "You are an AI agent.\n\nDescribe your agent's purpose and behavior here.\n"
|
||||
}),
|
||||
"skill" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"name": "new-skill",
|
||||
"description": "Describe what this skill does",
|
||||
"version": 1,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"requires_mcp": []
|
||||
},
|
||||
"body": "## Instructions\n\nDescribe the skill instructions here.\n"
|
||||
}),
|
||||
"cron" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"title": "New Cron Job",
|
||||
"schedule": "0 9 * * *",
|
||||
"agent": "",
|
||||
"enabled": true
|
||||
},
|
||||
"body": "Optional context for the cron job execution.\n"
|
||||
}),
|
||||
"human-task" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"title": "New Task",
|
||||
"priority": "medium",
|
||||
"labels": [],
|
||||
"created": chrono::Utc::now().to_rfc3339()
|
||||
},
|
||||
"body": "Task description goes here.\n"
|
||||
}),
|
||||
"agent-task" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"title": "New Agent Task",
|
||||
"agent": "",
|
||||
"priority": "medium",
|
||||
"created": chrono::Utc::now().to_rfc3339(),
|
||||
"retry": 0,
|
||||
"max_retries": 0
|
||||
},
|
||||
"body": "Task instructions for the agent.\n"
|
||||
}),
|
||||
"knowledge" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"title": "New Note",
|
||||
"tags": [],
|
||||
"created": chrono::Utc::now().to_rfc3339()
|
||||
},
|
||||
"body": "Write your knowledge note here.\n"
|
||||
}),
|
||||
"view-page" => serde_json::json!({
|
||||
"frontmatter": {
|
||||
"type": "page",
|
||||
"title": "New View",
|
||||
"icon": "",
|
||||
"route": "/view/new-view",
|
||||
"position": 10,
|
||||
"layout": "single",
|
||||
"regions": {
|
||||
"main": []
|
||||
}
|
||||
},
|
||||
"body": ""
|
||||
}),
|
||||
_ => serde_json::json!({
|
||||
"frontmatter": {},
|
||||
"body": ""
|
||||
}),
|
||||
};
|
||||
|
||||
Json(template)
|
||||
}
|
||||
149
crates/vault-api/src/routes/todos_agent.rs
Normal file
149
crates/vault-api/src/routes/todos_agent.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::entity::VaultEntity;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::AgentTask;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/todos/agent", get(list_all).post(create_task))
|
||||
.route("/todos/agent/{id}", get(get_task))
|
||||
}
|
||||
|
||||
async fn list_all(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let mut tasks = Vec::new();
|
||||
for status in &["queued", "running", "done", "failed"] {
|
||||
let dir = state.vault_root.join("todos/agent").join(status);
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<AgentTask>(&file) {
|
||||
tasks.push(agent_task_to_json(&entity, status));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(json!(tasks)))
|
||||
}
|
||||
|
||||
async fn get_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
for status in &["queued", "running", "done", "failed"] {
|
||||
let path = state
|
||||
.vault_root
|
||||
.join("todos/agent")
|
||||
.join(status)
|
||||
.join(format!("{}.md", id));
|
||||
if path.exists() {
|
||||
let entity = filesystem::read_entity::<AgentTask>(&path).map_err(ApiError::Vault)?;
|
||||
return Ok(Json(agent_task_to_json(&entity, status)));
|
||||
}
|
||||
}
|
||||
Err(ApiError::NotFound(format!("Agent task '{}' not found", id)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateAgentTaskBody {
|
||||
title: String,
|
||||
agent: String,
|
||||
#[serde(default)]
|
||||
priority: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
task_type: Option<String>,
|
||||
#[serde(default)]
|
||||
max_retries: Option<u32>,
|
||||
#[serde(default)]
|
||||
input: Option<Value>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<CreateAgentTaskBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
// Verify agent exists
|
||||
{
|
||||
let agents = state.agents.read().unwrap();
|
||||
if !agents.contains_key(&body.agent) {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Agent '{}' not found",
|
||||
body.agent
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let priority = match body.priority.as_deref() {
|
||||
Some("urgent") => vault_core::types::Priority::Urgent,
|
||||
Some("high") => vault_core::types::Priority::High,
|
||||
Some("low") => vault_core::types::Priority::Low,
|
||||
_ => vault_core::types::Priority::Medium,
|
||||
};
|
||||
|
||||
let slug = filesystem::timestamped_slug(&body.title);
|
||||
let path = state
|
||||
.vault_root
|
||||
.join("todos/agent/queued")
|
||||
.join(format!("{}.md", slug));
|
||||
|
||||
let task = AgentTask {
|
||||
title: body.title,
|
||||
agent: body.agent,
|
||||
priority,
|
||||
task_type: body.task_type,
|
||||
created: chrono::Utc::now(),
|
||||
started: None,
|
||||
completed: None,
|
||||
retry: 0,
|
||||
max_retries: body.max_retries.unwrap_or(0),
|
||||
input: body.input,
|
||||
output: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let entity = VaultEntity {
|
||||
path: path.clone(),
|
||||
frontmatter: task,
|
||||
body: body.body.unwrap_or_default(),
|
||||
};
|
||||
|
||||
state.write_filter.register(path.clone());
|
||||
filesystem::write_entity(&entity).map_err(ApiError::Vault)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "queued",
|
||||
"path": path.strip_prefix(&state.vault_root).unwrap_or(&path),
|
||||
})))
|
||||
}
|
||||
|
||||
fn agent_task_to_json(entity: &VaultEntity<AgentTask>, status: &str) -> Value {
|
||||
let id = entity
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
json!({
|
||||
"id": id,
|
||||
"title": entity.frontmatter.title,
|
||||
"agent": entity.frontmatter.agent,
|
||||
"priority": entity.frontmatter.priority,
|
||||
"type": entity.frontmatter.task_type,
|
||||
"status": status,
|
||||
"created": entity.frontmatter.created,
|
||||
"started": entity.frontmatter.started,
|
||||
"completed": entity.frontmatter.completed,
|
||||
"retry": entity.frontmatter.retry,
|
||||
"max_retries": entity.frontmatter.max_retries,
|
||||
"input": entity.frontmatter.input,
|
||||
"output": entity.frontmatter.output,
|
||||
"error": entity.frontmatter.error,
|
||||
"body": entity.body,
|
||||
})
|
||||
}
|
||||
205
crates/vault-api/src/routes/todos_human.rs
Normal file
205
crates/vault-api/src/routes/todos_human.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::entity::VaultEntity;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::HumanTask;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/todos/harald", get(list_all).post(create_task))
|
||||
.route("/todos/harald/{status}", get(list_by_status))
|
||||
.route("/todos/harald/{status}/{id}/move", patch(move_task))
|
||||
.route(
|
||||
"/todos/harald/{status}/{id}",
|
||||
axum::routing::delete(delete_task),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_all(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let mut tasks = Vec::new();
|
||||
for status in &["urgent", "open", "in-progress", "done"] {
|
||||
let dir = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(status);
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<HumanTask>(&file) {
|
||||
tasks.push(task_to_json(&entity, status));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(json!(tasks)))
|
||||
}
|
||||
|
||||
async fn list_by_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(status): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let dir = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(&status);
|
||||
if !dir.exists() {
|
||||
return Err(ApiError::NotFound(format!("Status '{}' not found", status)));
|
||||
}
|
||||
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
let mut tasks = Vec::new();
|
||||
for file in files {
|
||||
if let Ok(entity) = filesystem::read_entity::<HumanTask>(&file) {
|
||||
tasks.push(task_to_json(&entity, &status));
|
||||
}
|
||||
}
|
||||
Ok(Json(json!(tasks)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateTaskBody {
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
priority: Option<String>,
|
||||
#[serde(default)]
|
||||
labels: Vec<String>,
|
||||
#[serde(default)]
|
||||
repo: Option<String>,
|
||||
#[serde(default)]
|
||||
due: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<CreateTaskBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let priority = match body.priority.as_deref() {
|
||||
Some("urgent") => vault_core::types::Priority::Urgent,
|
||||
Some("high") => vault_core::types::Priority::High,
|
||||
Some("low") => vault_core::types::Priority::Low,
|
||||
_ => vault_core::types::Priority::Medium,
|
||||
};
|
||||
|
||||
let status_dir = match priority {
|
||||
vault_core::types::Priority::Urgent => "urgent",
|
||||
_ => "open",
|
||||
};
|
||||
|
||||
let slug = filesystem::timestamped_slug(&body.title);
|
||||
let path = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(status_dir)
|
||||
.join(format!("{}.md", slug));
|
||||
|
||||
let due = body
|
||||
.due
|
||||
.and_then(|d| chrono::DateTime::parse_from_rfc3339(&d).ok())
|
||||
.map(|d| d.with_timezone(&chrono::Utc));
|
||||
|
||||
let task = HumanTask {
|
||||
title: body.title,
|
||||
priority,
|
||||
source: Some("dashboard".into()),
|
||||
repo: body.repo,
|
||||
labels: body.labels,
|
||||
created: chrono::Utc::now(),
|
||||
due,
|
||||
};
|
||||
|
||||
let entity = VaultEntity {
|
||||
path: path.clone(),
|
||||
frontmatter: task,
|
||||
body: body.body.unwrap_or_default(),
|
||||
};
|
||||
|
||||
state.write_filter.register(path.clone());
|
||||
filesystem::write_entity(&entity).map_err(ApiError::Vault)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "created",
|
||||
"path": path.strip_prefix(&state.vault_root).unwrap_or(&path),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MoveBody {
|
||||
to: String,
|
||||
}
|
||||
|
||||
async fn move_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((status, id)): Path<(String, String)>,
|
||||
Json(body): Json<MoveBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let from = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(&status)
|
||||
.join(format!("{}.md", id));
|
||||
|
||||
if !from.exists() {
|
||||
return Err(ApiError::NotFound(format!("Task '{}' not found in {}", id, status)));
|
||||
}
|
||||
|
||||
let to = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(&body.to)
|
||||
.join(format!("{}.md", id));
|
||||
|
||||
state.write_filter.register(to.clone());
|
||||
filesystem::move_file(&from, &to).map_err(ApiError::Vault)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "moved",
|
||||
"from": status,
|
||||
"to": body.to,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn delete_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((status, id)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let path = state
|
||||
.vault_root
|
||||
.join("todos/harald")
|
||||
.join(&status)
|
||||
.join(format!("{}.md", id));
|
||||
|
||||
if !path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Task '{}' not found", id)));
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "deleted" })))
|
||||
}
|
||||
|
||||
fn task_to_json(entity: &VaultEntity<HumanTask>, status: &str) -> Value {
|
||||
let id = entity
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
json!({
|
||||
"id": id,
|
||||
"title": entity.frontmatter.title,
|
||||
"priority": entity.frontmatter.priority,
|
||||
"status": status,
|
||||
"source": entity.frontmatter.source,
|
||||
"repo": entity.frontmatter.repo,
|
||||
"labels": entity.frontmatter.labels,
|
||||
"created": entity.frontmatter.created,
|
||||
"due": entity.frontmatter.due,
|
||||
"body": entity.body,
|
||||
})
|
||||
}
|
||||
93
crates/vault-api/src/routes/tree.rs
Normal file
93
crates/vault-api/src/routes/tree.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/tree", get(get_tree))
|
||||
.route("/tree/{*path}", post(create_dir).delete(delete_dir))
|
||||
}
|
||||
|
||||
async fn get_tree(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let tree = build_tree(&state.vault_root, &state.vault_root)?;
|
||||
Ok(Json(tree))
|
||||
}
|
||||
|
||||
fn build_tree(root: &std::path::Path, dir: &std::path::Path) -> Result<Value, ApiError> {
|
||||
let mut children = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, dir)))?;
|
||||
|
||||
let mut entries: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// Skip hidden files/dirs
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
|
||||
if path.is_dir() {
|
||||
let subtree = build_tree(root, &path)?;
|
||||
children.push(json!({
|
||||
"name": name,
|
||||
"path": relative,
|
||||
"type": "directory",
|
||||
"children": subtree.get("children").unwrap_or(&json!([])),
|
||||
}));
|
||||
} else {
|
||||
children.push(json!({
|
||||
"name": name,
|
||||
"path": relative,
|
||||
"type": "file",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"name": dir.file_name().and_then(|n| n.to_str()).unwrap_or("vault"),
|
||||
"path": dir.strip_prefix(root).unwrap_or(dir),
|
||||
"type": "directory",
|
||||
"children": children,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn create_dir(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let dir_path = state.vault_root.join(&path);
|
||||
|
||||
std::fs::create_dir_all(&dir_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &dir_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "created", "path": path })))
|
||||
}
|
||||
|
||||
async fn delete_dir(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let dir_path = state.vault_root.join(&path);
|
||||
|
||||
if !dir_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Directory '{}' not found", path)));
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&dir_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &dir_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "deleted", "path": path })))
|
||||
}
|
||||
78
crates/vault-api/src/routes/validate.rs
Normal file
78
crates/vault-api/src/routes/validate.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use vault_core::validation;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidateRequest {
|
||||
/// Relative path within the vault
|
||||
pub path: String,
|
||||
/// Raw file content to validate (optional; if omitted, reads from disk)
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/validate", post(validate))
|
||||
}
|
||||
|
||||
async fn validate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ValidateRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let relative = Path::new(&req.path);
|
||||
|
||||
let content = if let Some(c) = req.content {
|
||||
c
|
||||
} else {
|
||||
let full = state.vault_root.join(&req.path);
|
||||
tokio::fs::read_to_string(&full)
|
||||
.await
|
||||
.map_err(|e| ApiError::NotFound(format!("File not found: {} ({})", req.path, e)))?
|
||||
};
|
||||
|
||||
let issues = validation::validate(relative, &content);
|
||||
|
||||
// Also check references
|
||||
let agent_names: HashSet<String> = state
|
||||
.agents
|
||||
.read()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let skill_names: HashSet<String> = state
|
||||
.skills
|
||||
.read()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let ref_issues = validation::validate_references(&state.vault_root, &agent_names, &skill_names);
|
||||
|
||||
let mut all_issues: Vec<serde_json::Value> = issues
|
||||
.into_iter()
|
||||
.map(|i| serde_json::to_value(i).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
for (entity, issue) in ref_issues {
|
||||
let mut val = serde_json::to_value(&issue).unwrap_or_default();
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert("entity".into(), serde_json::Value::String(entity));
|
||||
}
|
||||
all_issues.push(val);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"path": req.path,
|
||||
"issues": all_issues,
|
||||
"valid": all_issues.iter().all(|i|
|
||||
i.get("level").and_then(|l| l.as_str()) != Some("error")
|
||||
),
|
||||
})))
|
||||
}
|
||||
214
crates/vault-api/src/routes/views.rs
Normal file
214
crates/vault-api/src/routes/views.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::{Notification, ViewDefinition};
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/views/pages", get(list_pages))
|
||||
.route("/views/widgets", get(list_widgets))
|
||||
.route("/views/layouts", get(list_layouts))
|
||||
.route("/views/{*path}", get(get_view).put(put_view).delete(delete_view))
|
||||
.route("/notifications", get(list_notifications))
|
||||
.route(
|
||||
"/notifications/{id}",
|
||||
axum::routing::delete(dismiss_notification),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_pages(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
list_view_dir(&state, "views/pages").await
|
||||
}
|
||||
|
||||
async fn list_widgets(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
list_view_dir(&state, "views/widgets").await
|
||||
}
|
||||
|
||||
async fn list_layouts(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
list_view_dir(&state, "views/layouts").await
|
||||
}
|
||||
|
||||
async fn list_view_dir(state: &AppState, subdir: &str) -> Result<Json<Value>, ApiError> {
|
||||
let dir = state.vault_root.join(subdir);
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
|
||||
let mut views = Vec::new();
|
||||
for file in files {
|
||||
match filesystem::read_entity::<ViewDefinition>(&file) {
|
||||
Ok(entity) => {
|
||||
let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
|
||||
views.push(json!({
|
||||
"name": name,
|
||||
"type": entity.frontmatter.view_type,
|
||||
"title": entity.frontmatter.title,
|
||||
"icon": entity.frontmatter.icon,
|
||||
"route": entity.frontmatter.route,
|
||||
"position": entity.frontmatter.position,
|
||||
"layout": entity.frontmatter.layout,
|
||||
"component": entity.frontmatter.component,
|
||||
"description": entity.frontmatter.description,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = ?file, error = %e, "Failed to read view definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
views.sort_by_key(|v| v.get("position").and_then(|p| p.as_i64()).unwrap_or(999));
|
||||
Ok(Json(json!(views)))
|
||||
}
|
||||
|
||||
async fn get_view(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join("views").join(&path);
|
||||
let file_path = if file_path.extension().is_none() {
|
||||
file_path.with_extension("md")
|
||||
} else {
|
||||
file_path
|
||||
};
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("View '{}' not found", path)));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
if let Ok((yaml, body)) = vault_core::frontmatter::split_frontmatter(&content) {
|
||||
let frontmatter: Value = serde_yaml::from_str(yaml).unwrap_or(Value::Null);
|
||||
Ok(Json(json!({
|
||||
"path": path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body,
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(json!({
|
||||
"path": path,
|
||||
"frontmatter": null,
|
||||
"body": content,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_view(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
Json(data): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join("views").join(&path);
|
||||
let file_path = if file_path.extension().is_none() {
|
||||
file_path.with_extension("md")
|
||||
} else {
|
||||
file_path
|
||||
};
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, parent)))?;
|
||||
}
|
||||
|
||||
let content = if let Some(raw) = data.get("raw").and_then(|r| r.as_str()) {
|
||||
raw.to_string()
|
||||
} else {
|
||||
let body = data.get("body").and_then(|b| b.as_str()).unwrap_or("");
|
||||
if let Some(fm) = data.get("frontmatter") {
|
||||
let yaml = serde_yaml::to_string(fm).map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
format!("---\n{}---\n{}", yaml, body)
|
||||
} else {
|
||||
body.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
state.write_filter.register(file_path.clone());
|
||||
std::fs::write(&file_path, content)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "saved", "path": path })))
|
||||
}
|
||||
|
||||
async fn delete_view(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file_path = state.vault_root.join("views").join(&path);
|
||||
let file_path = if file_path.extension().is_none() {
|
||||
file_path.with_extension("md")
|
||||
} else {
|
||||
file_path
|
||||
};
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("View '{}' not found", path)));
|
||||
}
|
||||
|
||||
std::fs::remove_file(&file_path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &file_path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "deleted", "path": path })))
|
||||
}
|
||||
|
||||
async fn list_notifications(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
|
||||
let dir = state.vault_root.join("views/notifications");
|
||||
let files = filesystem::list_md_files(&dir).map_err(ApiError::Vault)?;
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
for file in files {
|
||||
match filesystem::read_entity::<Notification>(&file) {
|
||||
Ok(entity) => {
|
||||
// Skip expired notifications
|
||||
if let Some(expires) = entity.frontmatter.expires {
|
||||
if expires < now {
|
||||
// Auto-clean expired
|
||||
let _ = std::fs::remove_file(&file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let id = file.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
|
||||
notifications.push(json!({
|
||||
"id": id,
|
||||
"title": entity.frontmatter.title,
|
||||
"message": entity.frontmatter.message,
|
||||
"level": entity.frontmatter.level,
|
||||
"source": entity.frontmatter.source,
|
||||
"created": entity.frontmatter.created,
|
||||
"expires": entity.frontmatter.expires,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = ?file, error = %e, "Failed to read notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(json!(notifications)))
|
||||
}
|
||||
|
||||
async fn dismiss_notification(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let path = state
|
||||
.vault_root
|
||||
.join("views/notifications")
|
||||
.join(format!("{}.md", id));
|
||||
|
||||
if !path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Notification '{}' not found", id)));
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| ApiError::Vault(vault_core::VaultError::io(e, &path)))?;
|
||||
|
||||
Ok(Json(json!({ "status": "dismissed" })))
|
||||
}
|
||||
111
crates/vault-api/src/state.rs
Normal file
111
crates/vault-api/src/state.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use vault_core::config::VaultConfig;
|
||||
use vault_core::entity::VaultEntity;
|
||||
use vault_core::filesystem;
|
||||
use vault_core::types::{Agent, Skill};
|
||||
use vault_scheduler::cron_engine::CronEngine;
|
||||
use vault_scheduler::executor::Executor;
|
||||
use vault_scheduler::executors::process::GenericProcessExecutor;
|
||||
use vault_scheduler::state::RuntimeState;
|
||||
use vault_scheduler::task_runner::TaskRunner;
|
||||
use vault_watch::events::VaultEvent;
|
||||
use vault_watch::write_filter::DaemonWriteFilter;
|
||||
|
||||
pub struct AppState {
|
||||
pub vault_root: PathBuf,
|
||||
pub config: VaultConfig,
|
||||
pub cron_engine: Mutex<CronEngine>,
|
||||
pub write_filter: Arc<DaemonWriteFilter>,
|
||||
pub event_tx: tokio::sync::broadcast::Sender<Arc<VaultEvent>>,
|
||||
pub agents: RwLock<HashMap<String, VaultEntity<Agent>>>,
|
||||
pub skills: RwLock<HashMap<String, VaultEntity<Skill>>>,
|
||||
pub runtime_state: Mutex<RuntimeState>,
|
||||
pub startup_time: chrono::DateTime<chrono::Utc>,
|
||||
executor: Arc<dyn Executor>,
|
||||
max_parallel: usize,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(vault_root: PathBuf, config: VaultConfig, max_parallel: usize) -> Self {
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(256);
|
||||
let write_filter = Arc::new(DaemonWriteFilter::new());
|
||||
let executor: Arc<dyn Executor> =
|
||||
Arc::new(GenericProcessExecutor::new(vault_root.clone()));
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut runtime_state = RuntimeState::load(&vault_root).unwrap_or_default();
|
||||
runtime_state.last_startup = Some(now);
|
||||
let _ = runtime_state.save(&vault_root);
|
||||
|
||||
Self {
|
||||
cron_engine: Mutex::new(CronEngine::new(vault_root.clone())),
|
||||
vault_root,
|
||||
config,
|
||||
write_filter,
|
||||
event_tx,
|
||||
agents: RwLock::new(HashMap::new()),
|
||||
skills: RwLock::new(HashMap::new()),
|
||||
runtime_state: Mutex::new(runtime_state),
|
||||
startup_time: now,
|
||||
executor,
|
||||
max_parallel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_runner(&self) -> TaskRunner {
|
||||
TaskRunner::new(
|
||||
self.vault_root.clone(),
|
||||
self.max_parallel,
|
||||
self.executor.clone(),
|
||||
self.write_filter.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Load all agent and skill definitions from disk.
|
||||
pub fn reload_definitions(&self) -> Result<(), vault_core::VaultError> {
|
||||
// Load agents
|
||||
let agent_files = filesystem::list_md_files(&self.vault_root.join("agents"))?;
|
||||
let mut agents = HashMap::new();
|
||||
for path in agent_files {
|
||||
match filesystem::read_entity::<Agent>(&path) {
|
||||
Ok(entity) => {
|
||||
agents.insert(entity.frontmatter.name.clone(), entity);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = ?path, error = %e, "Failed to load agent");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(count = agents.len(), "Loaded agents");
|
||||
*self.agents.write().unwrap() = agents;
|
||||
|
||||
// Load skills
|
||||
let skill_files =
|
||||
filesystem::list_md_files_recursive(&self.vault_root.join("skills"))?;
|
||||
let mut skills = HashMap::new();
|
||||
for path in skill_files {
|
||||
match filesystem::read_entity::<Skill>(&path) {
|
||||
Ok(entity) => {
|
||||
skills.insert(entity.frontmatter.name.clone(), entity);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = ?path, error = %e, "Failed to load skill");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(count = skills.len(), "Loaded skills");
|
||||
*self.skills.write().unwrap() = skills;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, event: VaultEvent) {
|
||||
let _ = self.event_tx.send(Arc::new(event));
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Arc<VaultEvent>> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
}
|
||||
129
crates/vault-api/src/ws.rs
Normal file
129
crates/vault-api/src/ws.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use crate::state::AppState;
|
||||
use crate::ws_protocol::{WsAction, WsEvent};
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::extract::{State, WebSocketUpgrade};
|
||||
use axum::response::Response;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
let mut event_rx = state.subscribe();
|
||||
|
||||
// Send task: forward vault events to the client
|
||||
let send_state = state.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Ok(event) = event_rx.recv().await {
|
||||
let ws_event = WsEvent::from_vault_event(&event, &send_state.vault_root);
|
||||
match serde_json::to_string(&ws_event) {
|
||||
Ok(json) => {
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to serialize WS event");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Receive task: handle client actions
|
||||
let recv_state = state.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(msg) = receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
match serde_json::from_str::<WsAction>(&text) {
|
||||
Ok(action) => handle_action(&recv_state, action).await,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, text = %text, "Invalid WS action");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "WebSocket error");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either task to finish
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
tracing::debug!("WebSocket connection closed");
|
||||
}
|
||||
|
||||
async fn handle_action(state: &AppState, action: WsAction) {
|
||||
match action {
|
||||
WsAction::MoveTask { from, to } => {
|
||||
let from_path = state.vault_root.join(&from);
|
||||
let to_path = state.vault_root.join(&to);
|
||||
state.write_filter.register(to_path.clone());
|
||||
if let Err(e) = vault_core::filesystem::move_file(&from_path, &to_path) {
|
||||
tracing::error!(error = %e, "WS move_task failed");
|
||||
}
|
||||
}
|
||||
WsAction::TriggerCron { name } => {
|
||||
let cron_path = state
|
||||
.vault_root
|
||||
.join("crons/active")
|
||||
.join(format!("{}.md", name));
|
||||
let mut engine = state.cron_engine.lock().unwrap();
|
||||
if let Err(e) = engine.fire_cron(&cron_path, &state.write_filter) {
|
||||
tracing::error!(error = %e, "WS trigger_cron failed");
|
||||
}
|
||||
}
|
||||
WsAction::TriggerAgent { name, context } => {
|
||||
let title = format!("WS trigger: {}", name);
|
||||
let slug = vault_core::filesystem::timestamped_slug(&title);
|
||||
let task_path = state
|
||||
.vault_root
|
||||
.join("todos/agent/queued")
|
||||
.join(format!("{}.md", slug));
|
||||
|
||||
let task = vault_core::types::AgentTask {
|
||||
title,
|
||||
agent: name,
|
||||
priority: vault_core::types::Priority::Medium,
|
||||
task_type: Some("ws-trigger".into()),
|
||||
created: chrono::Utc::now(),
|
||||
started: None,
|
||||
completed: None,
|
||||
retry: 0,
|
||||
max_retries: 0,
|
||||
input: None,
|
||||
output: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let entity = vault_core::entity::VaultEntity {
|
||||
path: task_path.clone(),
|
||||
frontmatter: task,
|
||||
body: context.unwrap_or_default(),
|
||||
};
|
||||
|
||||
state.write_filter.register(task_path.clone());
|
||||
if let Err(e) = vault_core::filesystem::write_entity(&entity) {
|
||||
tracing::error!(error = %e, "WS trigger_agent failed");
|
||||
}
|
||||
}
|
||||
WsAction::Ping => {
|
||||
tracing::debug!("WS ping received");
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/vault-api/src/ws_protocol.rs
Normal file
76
crates/vault-api/src/ws_protocol.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use vault_watch::events::VaultEvent;
|
||||
|
||||
/// Server -> Client event
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WsEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub area: String,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
impl WsEvent {
|
||||
pub fn from_vault_event(event: &VaultEvent, vault_root: &Path) -> Self {
|
||||
let path = event.path();
|
||||
let relative = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Derive area from relative path (first two components)
|
||||
let area = relative
|
||||
.split('/')
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
// Try to read frontmatter data
|
||||
let data = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| {
|
||||
vault_core::frontmatter::split_frontmatter(&content)
|
||||
.ok()
|
||||
.and_then(|(yaml, _)| serde_yaml::from_str::<Value>(yaml).ok())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
event_type: event.event_type().to_string(),
|
||||
area,
|
||||
path: relative,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client -> Server action
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action")]
|
||||
pub enum WsAction {
|
||||
#[serde(rename = "move_task")]
|
||||
MoveTask {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
#[serde(rename = "trigger_cron")]
|
||||
TriggerCron {
|
||||
name: String,
|
||||
},
|
||||
#[serde(rename = "trigger_agent")]
|
||||
TriggerAgent {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
context: Option<String>,
|
||||
},
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue