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:
2026-02-28 10:36:50 +00:00
parent 1122ab27dd
commit 7edbbee471
43 changed files with 7164 additions and 83 deletions

12
src/api/mod.rs Normal file
View 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
View 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
View 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))
}