From 55b6a20c037a0d236be2f5c67baf646fc42f6a2a Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Mon, 16 Jun 2025 18:37:19 -0400 Subject: [PATCH] Add concept of executors --- backend/Cargo.toml | 1 + .../migrations/007_add_executor_config.sql | 2 + backend/src/bin/generate_types.rs | 3 + backend/src/execution_monitor.rs | 26 +++++-- backend/src/executor.rs | 72 +++++++++++++++++++ backend/src/executors/echo.rs | 40 +++++++++++ backend/src/executors/mod.rs | 3 + backend/src/lib.rs | 2 + backend/src/main.rs | 2 + backend/src/models/task_attempt.rs | 45 ++++++++++-- .../components/tasks/TaskDetailsDialog.tsx | 3 +- shared/types.ts | 4 +- 12 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/007_add_executor_config.sql create mode 100644 backend/src/executor.rs create mode 100644 backend/src/executors/echo.rs create mode 100644 backend/src/executors/mod.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3bcc2775..8d9fead2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -27,6 +27,7 @@ jsonwebtoken = "9.2" ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } dirs = "5.0" git2 = "0.18" +async-trait = "0.1" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/migrations/007_add_executor_config.sql b/backend/migrations/007_add_executor_config.sql new file mode 100644 index 00000000..fbd58151 --- /dev/null +++ b/backend/migrations/007_add_executor_config.sql @@ -0,0 +1,2 @@ +-- Add executor_config column to task_attempts table +ALTER TABLE task_attempts ADD COLUMN executor_config JSONB; diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 5758c2ea..32a6a652 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -69,8 +69,11 @@ export {} export {} +export {} + export {}"#, bloop_backend::models::ApiResponse::<()>::decl(), + bloop_backend::executor::ExecutorConfig::decl(), bloop_backend::models::project::CreateProject::decl(), bloop_backend::models::project::Project::decl(), bloop_backend::models::project::UpdateProject::decl(), diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index d27fc09a..61b5622c 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -2,12 +2,12 @@ use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::process::Command; + use uuid::Uuid; use crate::models::{ task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, - task_attempt::TaskAttemptStatus + task_attempt::{TaskAttempt, TaskAttemptStatus} }; #[derive(Debug)] @@ -86,13 +86,25 @@ pub async fn execution_monitor(app_state: AppState) { } } - // Spawn the process - let child = match Command::new("echo") - .arg("hello world") - .spawn() { + // Get the task attempt to access the executor + let task_attempt = match TaskAttempt::find_by_id(&app_state.db_pool, attempt_id).await { + Ok(Some(attempt)) => attempt, + Ok(None) => { + tracing::error!("Task attempt {} not found", attempt_id); + continue; + } + Err(e) => { + tracing::error!("Failed to fetch task attempt {}: {}", attempt_id, e); + continue; + } + }; + + // Get the executor and spawn the process + let executor = task_attempt.get_executor(); + let child = match executor.spawn(&app_state.db_pool, task_attempt.task_id, &task_attempt.worktree_path).await { Ok(child) => child, Err(e) => { - tracing::error!("Failed to spawn echo command: {}", e); + tracing::error!("Failed to spawn command for task attempt {}: {}", attempt_id, e); continue; } }; diff --git a/backend/src/executor.rs b/backend/src/executor.rs new file mode 100644 index 00000000..5ff82056 --- /dev/null +++ b/backend/src/executor.rs @@ -0,0 +1,72 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::process::Child; +use ts_rs::TS; +use uuid::Uuid; + +use crate::executors::EchoExecutor; + +#[derive(Debug)] +pub enum ExecutorError { + SpawnFailed(std::io::Error), + TaskNotFound, + DatabaseError(sqlx::Error), +} + +impl std::fmt::Display for ExecutorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExecutorError::SpawnFailed(e) => write!(f, "Failed to spawn process: {}", e), + ExecutorError::TaskNotFound => write!(f, "Task not found"), + ExecutorError::DatabaseError(e) => write!(f, "Database error: {}", e), + } + } +} + +impl std::error::Error for ExecutorError {} + +impl From for ExecutorError { + fn from(err: sqlx::Error) -> Self { + ExecutorError::DatabaseError(err) + } +} + +/// Trait for defining CLI commands that can be executed for task attempts +#[async_trait] +pub trait Executor: Send + Sync { + /// Get the unique identifier for this executor type + fn executor_type(&self) -> &'static str; + + /// Spawn the command for a given task attempt + async fn spawn(&self, pool: &sqlx::PgPool, task_id: Uuid, worktree_path: &str) -> Result; + + /// Get a human-readable description of what this executor does + fn description(&self) -> &'static str; +} + +/// Configuration for different executor types +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "lowercase")] +#[ts(export)] +pub enum ExecutorConfig { + Echo, + // Future executors can be added here + // Shell { command: String }, + // Docker { image: String, command: String }, +} + +impl ExecutorConfig { + pub fn create_executor(&self) -> Box { + match self { + ExecutorConfig::Echo => Box::new(EchoExecutor), + } + } + + pub fn executor_type(&self) -> &'static str { + match self { + ExecutorConfig::Echo => "echo", + } + } +} + + diff --git a/backend/src/executors/echo.rs b/backend/src/executors/echo.rs new file mode 100644 index 00000000..12d7fc5f --- /dev/null +++ b/backend/src/executors/echo.rs @@ -0,0 +1,40 @@ +use async_trait::async_trait; +use tokio::process::{Child, Command}; +use uuid::Uuid; + +use crate::executor::{Executor, ExecutorError}; +use crate::models::task::Task; + +/// A dummy executor that echoes the task title and description +pub struct EchoExecutor; + +#[async_trait] +impl Executor for EchoExecutor { + fn executor_type(&self) -> &'static str { + "echo" + } + + async fn spawn(&self, pool: &sqlx::PgPool, task_id: Uuid, _worktree_path: &str) -> Result { + // Get the task to fetch its description + let task = Task::find_by_id(pool, task_id) + .await? + .ok_or(ExecutorError::TaskNotFound)?; + + let message = format!( + "Executing task: {} - {}", + task.title, + task.description.as_deref().unwrap_or("No description") + ); + + let child = Command::new("echo") + .arg(&message) + .spawn() + .map_err(ExecutorError::SpawnFailed)?; + + Ok(child) + } + + fn description(&self) -> &'static str { + "Echoes the task title and description" + } +} diff --git a/backend/src/executors/mod.rs b/backend/src/executors/mod.rs new file mode 100644 index 00000000..e937bc7c --- /dev/null +++ b/backend/src/executors/mod.rs @@ -0,0 +1,3 @@ +pub mod echo; + +pub use echo::EchoExecutor; diff --git a/backend/src/lib.rs b/backend/src/lib.rs index cdcd9d85..1c31dcd2 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,3 +1,5 @@ pub mod auth; +pub mod executor; +pub mod executors; pub mod models; pub mod routes; diff --git a/backend/src/main.rs b/backend/src/main.rs index a9370e26..897f06c2 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -12,6 +12,8 @@ use tower_http::cors::CorsLayer; mod auth; mod execution_monitor; +mod executor; +mod executors; mod models; mod routes; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index c9cce356..cb95f3ad 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -8,6 +8,7 @@ use std::path::Path; use super::task::Task; use super::project::Project; +use crate::executor::ExecutorConfig; #[derive(Debug)] pub enum TaskAttemptError { @@ -60,6 +61,9 @@ pub struct TaskAttempt { pub worktree_path: String, pub base_commit: Option, pub merge_commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(skip)] + pub executor_config: Option, // JSON field for ExecutorConfig pub created_at: DateTime, pub updated_at: DateTime, } @@ -71,6 +75,7 @@ pub struct CreateTaskAttempt { pub worktree_path: String, pub base_commit: Option, pub merge_commit: Option, + pub executor_config: Option, } #[derive(Debug, Deserialize, TS)] @@ -82,10 +87,22 @@ pub struct UpdateTaskAttempt { } impl TaskAttempt { + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + TaskAttempt, + r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, executor_config, created_at, updated_at + FROM task_attempts + WHERE id = $1"#, + id + ) + .fetch_optional(pool) + .await + } + pub async fn find_by_task_id(pool: &PgPool, task_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( TaskAttempt, - r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at + r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, executor_config, created_at, updated_at FROM task_attempts WHERE task_id = $1 ORDER BY created_at DESC"#, @@ -119,17 +136,24 @@ impl TaskAttempt { let branch_name = format!("attempt-{}", attempt_id); repo.worktree(&branch_name, worktree_path, None)?; + // Serialize executor config to JSON + let executor_config_json = data.executor_config.as_ref() + .map(|config| serde_json::to_value(config)) + .transpose() + .map_err(|e| TaskAttemptError::Database(sqlx::Error::decode(e)))?; + // Insert the record into the database let task_attempt = sqlx::query_as!( TaskAttempt, - r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#, + r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, executor_config) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, task_id, worktree_path, base_commit, merge_commit, executor_config, created_at, updated_at"#, attempt_id, data.task_id, data.worktree_path, data.base_commit, - data.merge_commit + data.merge_commit, + executor_config_json ) .fetch_one(pool) .await?; @@ -150,4 +174,15 @@ impl TaskAttempt { .await?; Ok(result.is_some()) } + + /// Get the executor for this task attempt, defaulting to Echo if none is specified + pub fn get_executor(&self) -> Box { + if let Some(config_json) = &self.executor_config { + if let Ok(config) = serde_json::from_value::(config_json.clone()) { + return config.create_executor(); + } + } + // Default to echo executor + ExecutorConfig::Echo.create_executor() + } } diff --git a/frontend/src/components/tasks/TaskDetailsDialog.tsx b/frontend/src/components/tasks/TaskDetailsDialog.tsx index 9da8a356..04a0fdd4 100644 --- a/frontend/src/components/tasks/TaskDetailsDialog.tsx +++ b/frontend/src/components/tasks/TaskDetailsDialog.tsx @@ -9,7 +9,7 @@ import { import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { makeAuthenticatedRequest } from '@/lib/auth' -import type { TaskStatus, TaskAttempt, TaskAttemptActivity } from 'shared/types' +import type { TaskStatus, TaskAttempt, TaskAttemptActivity, ExecutorConfig } from 'shared/types' interface Task { id: string @@ -123,6 +123,7 @@ export function TaskDetailsDialog({ isOpen, onOpenChange, task, projectId, onErr worktree_path: worktreePath, base_commit: null, merge_commit: null, + executor_config: { type: "echo" } as ExecutorConfig, }), } ) diff --git a/shared/types.ts b/shared/types.ts index 141136a8..6427eb77 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -3,6 +3,8 @@ export type ApiResponse = { success: boolean, data: T | null, message: string | null, }; +export type ExecutorConfig = { "type": "echo" }; + export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, }; export type Project = { id: string, name: string, git_repo_path: string, owner_id: string, created_at: Date, updated_at: Date, }; @@ -21,7 +23,7 @@ export type TaskAttemptStatus = "init" | "inprogress" | "paused"; export type TaskAttempt = { id: string, task_id: string, worktree_path: string, base_commit: string | null, merge_commit: string | null, created_at: string, updated_at: string, }; -export type CreateTaskAttempt = { task_id: string, worktree_path: string, base_commit: string | null, merge_commit: string | null, }; +export type CreateTaskAttempt = { task_id: string, worktree_path: string, base_commit: string | null, merge_commit: string | null, executor_config: ExecutorConfig | null, }; export type UpdateTaskAttempt = { worktree_path: string | null, base_commit: string | null, merge_commit: string | null, };