Add concept of executors
This commit is contained in:
@@ -27,6 +27,7 @@ jsonwebtoken = "9.2"
|
|||||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
git2 = "0.18"
|
git2 = "0.18"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
||||||
|
|||||||
2
backend/migrations/007_add_executor_config.sql
Normal file
2
backend/migrations/007_add_executor_config.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add executor_config column to task_attempts table
|
||||||
|
ALTER TABLE task_attempts ADD COLUMN executor_config JSONB;
|
||||||
@@ -69,8 +69,11 @@ export {}
|
|||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
export {}"#,
|
export {}"#,
|
||||||
bloop_backend::models::ApiResponse::<()>::decl(),
|
bloop_backend::models::ApiResponse::<()>::decl(),
|
||||||
|
bloop_backend::executor::ExecutorConfig::decl(),
|
||||||
bloop_backend::models::project::CreateProject::decl(),
|
bloop_backend::models::project::CreateProject::decl(),
|
||||||
bloop_backend::models::project::Project::decl(),
|
bloop_backend::models::project::Project::decl(),
|
||||||
bloop_backend::models::project::UpdateProject::decl(),
|
bloop_backend::models::project::UpdateProject::decl(),
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use chrono::{DateTime, Utc};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::process::Command;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
||||||
task_attempt::TaskAttemptStatus
|
task_attempt::{TaskAttempt, TaskAttemptStatus}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -86,13 +86,25 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the process
|
// Get the task attempt to access the executor
|
||||||
let child = match Command::new("echo")
|
let task_attempt = match TaskAttempt::find_by_id(&app_state.db_pool, attempt_id).await {
|
||||||
.arg("hello world")
|
Ok(Some(attempt)) => attempt,
|
||||||
.spawn() {
|
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,
|
Ok(child) => child,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to spawn echo command: {}", e);
|
tracing::error!("Failed to spawn command for task attempt {}: {}", attempt_id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
72
backend/src/executor.rs
Normal file
72
backend/src/executor.rs
Normal file
@@ -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<sqlx::Error> 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<Child, ExecutorError>;
|
||||||
|
|
||||||
|
/// 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<dyn Executor> {
|
||||||
|
match self {
|
||||||
|
ExecutorConfig::Echo => Box::new(EchoExecutor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn executor_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ExecutorConfig::Echo => "echo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
40
backend/src/executors/echo.rs
Normal file
40
backend/src/executors/echo.rs
Normal file
@@ -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<Child, ExecutorError> {
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/src/executors/mod.rs
Normal file
3
backend/src/executors/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod echo;
|
||||||
|
|
||||||
|
pub use echo::EchoExecutor;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod executors;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use tower_http::cors::CorsLayer;
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod execution_monitor;
|
mod execution_monitor;
|
||||||
|
mod executor;
|
||||||
|
mod executors;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use super::task::Task;
|
use super::task::Task;
|
||||||
use super::project::Project;
|
use super::project::Project;
|
||||||
|
use crate::executor::ExecutorConfig;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum TaskAttemptError {
|
pub enum TaskAttemptError {
|
||||||
@@ -60,6 +61,9 @@ pub struct TaskAttempt {
|
|||||||
pub worktree_path: String,
|
pub worktree_path: String,
|
||||||
pub base_commit: Option<String>,
|
pub base_commit: Option<String>,
|
||||||
pub merge_commit: Option<String>,
|
pub merge_commit: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(skip)]
|
||||||
|
pub executor_config: Option<serde_json::Value>, // JSON field for ExecutorConfig
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -71,6 +75,7 @@ pub struct CreateTaskAttempt {
|
|||||||
pub worktree_path: String,
|
pub worktree_path: String,
|
||||||
pub base_commit: Option<String>,
|
pub base_commit: Option<String>,
|
||||||
pub merge_commit: Option<String>,
|
pub merge_commit: Option<String>,
|
||||||
|
pub executor_config: Option<ExecutorConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
@@ -82,10 +87,22 @@ pub struct UpdateTaskAttempt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TaskAttempt {
|
impl TaskAttempt {
|
||||||
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, 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<Vec<Self>, sqlx::Error> {
|
pub async fn find_by_task_id(pool: &PgPool, task_id: Uuid) -> Result<Vec<Self>, sqlx::Error> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
TaskAttempt,
|
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
|
FROM task_attempts
|
||||||
WHERE task_id = $1
|
WHERE task_id = $1
|
||||||
ORDER BY created_at DESC"#,
|
ORDER BY created_at DESC"#,
|
||||||
@@ -119,17 +136,24 @@ impl TaskAttempt {
|
|||||||
let branch_name = format!("attempt-{}", attempt_id);
|
let branch_name = format!("attempt-{}", attempt_id);
|
||||||
repo.worktree(&branch_name, worktree_path, None)?;
|
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
|
// Insert the record into the database
|
||||||
let task_attempt = sqlx::query_as!(
|
let task_attempt = sqlx::query_as!(
|
||||||
TaskAttempt,
|
TaskAttempt,
|
||||||
r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit)
|
r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, executor_config)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#,
|
RETURNING id, task_id, worktree_path, base_commit, merge_commit, executor_config, created_at, updated_at"#,
|
||||||
attempt_id,
|
attempt_id,
|
||||||
data.task_id,
|
data.task_id,
|
||||||
data.worktree_path,
|
data.worktree_path,
|
||||||
data.base_commit,
|
data.base_commit,
|
||||||
data.merge_commit
|
data.merge_commit,
|
||||||
|
executor_config_json
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -150,4 +174,15 @@ impl TaskAttempt {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result.is_some())
|
Ok(result.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the executor for this task attempt, defaulting to Echo if none is specified
|
||||||
|
pub fn get_executor(&self) -> Box<dyn crate::executor::Executor> {
|
||||||
|
if let Some(config_json) = &self.executor_config {
|
||||||
|
if let Ok(config) = serde_json::from_value::<ExecutorConfig>(config_json.clone()) {
|
||||||
|
return config.create_executor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to echo executor
|
||||||
|
ExecutorConfig::Echo.create_executor()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
import type { TaskStatus, TaskAttempt, TaskAttemptActivity } from 'shared/types'
|
import type { TaskStatus, TaskAttempt, TaskAttemptActivity, ExecutorConfig } from 'shared/types'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -123,6 +123,7 @@ export function TaskDetailsDialog({ isOpen, onOpenChange, task, projectId, onErr
|
|||||||
worktree_path: worktreePath,
|
worktree_path: worktreePath,
|
||||||
base_commit: null,
|
base_commit: null,
|
||||||
merge_commit: null,
|
merge_commit: null,
|
||||||
|
executor_config: { type: "echo" } as ExecutorConfig,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
|
export type ApiResponse<T> = { 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 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, };
|
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 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, };
|
export type UpdateTaskAttempt = { worktree_path: string | null, base_commit: string | null, merge_commit: string | null, };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user