Tori: AI agent workflow manager - initial implementation
Rust (Axum) + Vue 3 + SQLite. Features: - Project CRUD REST API with proper error handling - Per-project agent loop (mpsc + broadcast channels) - LLM-driven plan generation and replan on user feedback - SSH command execution with status streaming - WebSocket real-time updates to frontend - Four-zone UI: requirement, plan (left), execution (right), comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
src/api/mod.rs
Normal file
12
src/api/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
mod projects;
|
||||
mod workflows;
|
||||
|
||||
use std::sync::Arc;
|
||||
use axum::Router;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.merge(projects::router(state.clone()))
|
||||
.merge(workflows::router(state))
|
||||
}
|
||||
118
src/api/projects.rs
Normal file
118
src/api/projects.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::db::Project;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProject {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProject {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/projects", get(list_projects).post(create_project))
|
||||
.route("/projects/{id}", get(get_project).put(update_project).delete(delete_project))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> ApiResult<Vec<Project>> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects ORDER BY updated_at DESC")
|
||||
.fetch_all(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(input): Json<CreateProject>,
|
||||
) -> ApiResult<Project> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (id, name, description) VALUES (?, ?, ?) RETURNING *"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.fetch_one(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn get_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Option<Project>> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn update_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<UpdateProject>,
|
||||
) -> ApiResult<Option<Project>> {
|
||||
if let Some(name) = &input.name {
|
||||
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(name)
|
||||
.bind(&id)
|
||||
.execute(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
if let Some(desc) = &input.description {
|
||||
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(desc)
|
||||
.bind(&id)
|
||||
.execute(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn delete_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<bool> {
|
||||
sqlx::query("DELETE FROM projects WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db.pool)
|
||||
.await
|
||||
.map(|r| Json(r.rows_affected() > 0))
|
||||
.map_err(db_err)
|
||||
}
|
||||
136
src/api/workflows.rs
Normal file
136
src/api/workflows.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::db::{Workflow, PlanStep, Comment};
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateWorkflow {
|
||||
pub requirement: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateComment {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/projects/{id}/workflows", get(list_workflows).post(create_workflow))
|
||||
.route("/workflows/{id}/steps", get(list_steps))
|
||||
.route("/workflows/{id}/comments", get(list_comments).post(create_comment))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list_workflows(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(project_id): Path<String>,
|
||||
) -> ApiResult<Vec<Workflow>> {
|
||||
sqlx::query_as::<_, Workflow>(
|
||||
"SELECT * FROM workflows WHERE project_id = ? ORDER BY created_at DESC"
|
||||
)
|
||||
.bind(&project_id)
|
||||
.fetch_all(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn create_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(project_id): Path<String>,
|
||||
Json(input): Json<CreateWorkflow>,
|
||||
) -> ApiResult<Workflow> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let workflow = sqlx::query_as::<_, Workflow>(
|
||||
"INSERT INTO workflows (id, project_id, requirement) VALUES (?, ?, ?) RETURNING *"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&project_id)
|
||||
.bind(&input.requirement)
|
||||
.fetch_one(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
state.agent_mgr.send_event(&project_id, AgentEvent::NewRequirement {
|
||||
workflow_id: workflow.id.clone(),
|
||||
requirement: workflow.requirement.clone(),
|
||||
}).await;
|
||||
|
||||
Ok(Json(workflow))
|
||||
}
|
||||
|
||||
async fn list_steps(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workflow_id): Path<String>,
|
||||
) -> ApiResult<Vec<PlanStep>> {
|
||||
sqlx::query_as::<_, PlanStep>(
|
||||
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order"
|
||||
)
|
||||
.bind(&workflow_id)
|
||||
.fetch_all(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn list_comments(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workflow_id): Path<String>,
|
||||
) -> ApiResult<Vec<Comment>> {
|
||||
sqlx::query_as::<_, Comment>(
|
||||
"SELECT * FROM comments WHERE workflow_id = ? ORDER BY created_at"
|
||||
)
|
||||
.bind(&workflow_id)
|
||||
.fetch_all(&state.db.pool)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
async fn create_comment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workflow_id): Path<String>,
|
||||
Json(input): Json<CreateComment>,
|
||||
) -> ApiResult<Comment> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let comment = sqlx::query_as::<_, Comment>(
|
||||
"INSERT INTO comments (id, workflow_id, content) VALUES (?, ?, ?) RETURNING *"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&workflow_id)
|
||||
.bind(&input.content)
|
||||
.fetch_one(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Notify agent about the comment
|
||||
if let Ok(Some(wf)) = sqlx::query_as::<_, Workflow>(
|
||||
"SELECT * FROM workflows WHERE id = ?"
|
||||
)
|
||||
.bind(&workflow_id)
|
||||
.fetch_optional(&state.db.pool)
|
||||
.await
|
||||
{
|
||||
state.agent_mgr.send_event(&wf.project_id, AgentEvent::Comment {
|
||||
workflow_id: workflow_id.clone(),
|
||||
content: input.content,
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(Json(comment))
|
||||
}
|
||||
Reference in New Issue
Block a user