Squashed commit of the following:

commit ca21aa40163902dfb20582d6dced8c884b4b0119
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 16:50:43 2025 +0100

    Fixes

commit 75c982209a71704d0df15982b9ac0aca87aa68de
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 16:35:58 2025 +0100

    Improve process killing

commit f58fd3b8a315880cc940d7e59719d23428c72e92
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 16:23:59 2025 +0100

    WIP

commit 7a6cd4772e15a5df0d760fe79776979c3ba206e8
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 12:34:13 2025 +0100

    Fix dev server activity not showing

commit 09eb3095c1850b5f3173b72b6b220811ef68524c
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 12:27:01 2025 +0100

    Add activity for dev server

commit 73db9a20312a8ed15c130760c6aacfa720d102d7
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 12:04:38 2025 +0100

    Lint

commit 0a0ad901773e14f634ded8a68a108efc2fbca0ae
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 24 12:01:37 2025 +0100

    WIP dev server
This commit is contained in:
Louis Knight-Webb
2025-06-24 16:50:58 +01:00
parent 11aecaedc6
commit fd0cdff0e4
27 changed files with 1144 additions and 262 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1",
"describe": {
"columns": [
{
@@ -24,14 +24,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -42,9 +47,10 @@
false,
false,
true,
true,
false,
false
]
},
"hash": "b62fa26fe7cdbee672504dbf63d3dbe19fca02a4a4f97d7df7143f340540efa0"
"hash": "056991f6ec992103f9de72475138ddfa8d5c9d42546fe36116a61f4db94611c3"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects ORDER BY created_at DESC",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects ORDER BY created_at DESC",
"describe": {
"columns": [
{
@@ -24,14 +24,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -42,9 +47,10 @@
false,
false,
true,
true,
false,
false
]
},
"hash": "420c9eec0dd98062947b090bc695b67c2bcaba9862c06b701a9ba3d8a5b02abf"
"hash": "08f2cb03665a16640d6690f29920521bae3479e3d2602e724d2c93e6fc85d8ee"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1 AND id != $2",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1 AND id != $2",
"describe": {
"columns": [
{
@@ -24,14 +24,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -42,9 +47,10 @@
false,
false,
true,
true,
false,
false
]
},
"hash": "205da45211b3aa413684ecd76d065fc59f793da42da075246464ac776016f5ff"
"hash": "1f3dd0f80e984a8472457be40cd96e1a03de71cc6c8adc62ef4873b79449f078"
}

View File

@@ -0,0 +1,104 @@
{
"db_name": "SQLite",
"query": "SELECT \n ep.id as \"id!: Uuid\", \n ep.task_attempt_id as \"task_attempt_id!: Uuid\", \n ep.process_type as \"process_type!: ExecutionProcessType\",\n ep.executor_type,\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.command, \n ep.args, \n ep.working_directory, \n ep.stdout, \n ep.stderr, \n ep.exit_code,\n ep.started_at as \"started_at!: DateTime<Utc>\",\n ep.completed_at as \"completed_at?: DateTime<Utc>\",\n ep.created_at as \"created_at!: DateTime<Utc>\", \n ep.updated_at as \"updated_at!: DateTime<Utc>\"\n FROM execution_processes ep\n JOIN task_attempts ta ON ep.task_attempt_id = ta.id\n JOIN tasks t ON ta.task_id = t.id\n WHERE ep.status = 'running' \n AND ep.process_type = 'devserver'\n AND t.project_id = $1\n ORDER BY ep.created_at ASC",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "process_type!: ExecutionProcessType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "executor_type",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "status!: ExecutionProcessStatus",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "command",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "args",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "working_directory",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "stdout",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "stderr",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "exit_code",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "started_at!: DateTime<Utc>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "completed_at?: DateTime<Utc>",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
false,
false,
true,
false,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "412bacd3477d86369082e90f52240407abce436cb81292d42b2dbe1e5c18eea1"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4 WHERE id = $1 RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"query": "UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4, dev_script = $5 WHERE id = $1 RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
@@ -24,27 +24,33 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 4
"Right": 5
},
"nullable": [
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "b3bead952fd42b79bed0908db603726935c0e830ea74ff30064bac71185442fc"
"hash": "42c0c81bb893af019b5b91b48c3cb65557f770894e21e654303047d4150cca93"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE id = $1",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE id = $1",
"describe": {
"columns": [
{
@@ -24,14 +24,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -42,9 +47,10 @@
false,
false,
true,
true,
false,
false
]
},
"hash": "346d58b8e0628d6a5936675beadc0a43ffa2dca384ed4f4b3a3abfcd09592c07"
"hash": "4fd26525fb4e2f606200695e1b62509409e3763fa6e6c8a905c5f9536b2c9a92"
}

View File

@@ -0,0 +1,92 @@
{
"db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n executor_type,\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n exit_code,\n started_at as \"started_at!: DateTime<Utc>\",\n completed_at as \"completed_at?: DateTime<Utc>\",\n created_at as \"created_at!: DateTime<Utc>\", \n updated_at as \"updated_at!: DateTime<Utc>\"\n FROM execution_processes \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "process_type!: ExecutionProcessType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "executor_type",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "status!: ExecutionProcessStatus",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "command",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "args",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "working_directory",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "exit_code",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "started_at!: DateTime<Utc>",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "completed_at?: DateTime<Utc>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 12,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
false,
false,
true,
false,
true,
false,
true,
false,
false
]
},
"hash": "58408c7a8cdeeda0bef359f1f9bd91299a339dc2b191462fc58c9736a56d5227"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "INSERT INTO projects (id, name, git_repo_path, setup_script) VALUES ($1, $2, $3, $4) RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"query": "INSERT INTO projects (id, name, git_repo_path, setup_script, dev_script) VALUES ($1, $2, $3, $4, $5) RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
@@ -24,27 +24,33 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "dev_script",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 4
"Right": 5
},
"nullable": [
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "64fd750d2f767096f94b28650018dc657ad41c6a0af908215f694100319b4864"
"hash": "5dc5d9e57b9dee5421b414f385a4c99f6014c4d9c0f965ff571ec75945132285"
}

View File

@@ -0,0 +1,4 @@
PRAGMA foreign_keys = ON;
-- Add dev_script column to projects table
ALTER TABLE projects ADD COLUMN dev_script TEXT DEFAULT '';

View File

@@ -103,6 +103,7 @@ fn main() {
vibe_kanban::models::task_attempt::WorktreeDiff::decl(),
vibe_kanban::models::task_attempt::BranchStatus::decl(),
vibe_kanban::models::execution_process::ExecutionProcess::decl(),
vibe_kanban::models::execution_process::ExecutionProcessSummary::decl(),
vibe_kanban::models::execution_process::ExecutionProcessStatus::decl(),
vibe_kanban::models::execution_process::ExecutionProcessType::decl(),
vibe_kanban::models::execution_process::CreateExecutionProcess::decl(),

View File

@@ -218,84 +218,100 @@ pub async fn execution_monitor(app_state: AppState) {
}
}
// Check for orphaned task attempts AFTER handling completions
// Check for orphaned execution processes AFTER handling completions
// Add a small delay to ensure completed processes are properly handled first
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let running_process_ids =
match TaskAttemptActivity::find_processes_with_latest_running_status(&app_state.db_pool)
let running_processes = match ExecutionProcess::find_running(&app_state.db_pool).await {
Ok(processes) => processes,
Err(e) => {
tracing::error!("Failed to query running execution processes: {}", e);
continue;
}
};
for process in running_processes {
// Additional check: if the process was recently updated, skip it
// This prevents race conditions with recent completions
let now = chrono::Utc::now();
let time_since_update = now - process.updated_at;
if time_since_update.num_seconds() < 10 {
// Process was updated within last 10 seconds, likely just completed
tracing::debug!(
"Skipping recently updated process {} (updated {} seconds ago)",
process.id,
time_since_update.num_seconds()
);
continue;
}
// Check if this process is not actually running in the app state
if !app_state
.has_running_execution(process.task_attempt_id)
.await
{
Ok(processes) => processes,
Err(e) => {
tracing::error!("Failed to query running attempts: {}", e);
continue;
}
};
// This is truly an orphaned execution process - mark it as failed
tracing::info!(
"Found orphaned execution process {} for task attempt {}",
process.id,
process.task_attempt_id
);
for process_id in running_process_ids {
// Get the execution process to find the task attempt ID
let task_attempt_id =
match ExecutionProcess::find_by_id(&app_state.db_pool, process_id).await {
Ok(Some(process)) => {
// Additional check: if the process was recently updated, skip it
// This prevents race conditions with recent completions
let now = chrono::Utc::now();
let time_since_update = now - process.updated_at;
if time_since_update.num_seconds() < 10 {
// Process was updated within last 10 seconds, likely just completed
tracing::debug!(
"Skipping recently updated process {} (updated {} seconds ago)",
process_id,
time_since_update.num_seconds()
);
continue;
}
process.task_attempt_id
}
Ok(None) => {
tracing::error!("Execution process {} not found", process_id);
continue;
}
Err(e) => {
tracing::error!("Failed to fetch execution process {}: {}", process_id, e);
continue;
}
};
// Double-check that this task attempt is not currently running and hasn't just completed
if !app_state.has_running_execution(task_attempt_id).await {
// This is truly an orphaned task attempt - mark it as failed
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process_id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some("Execution lost (server restart or crash)".to_string()),
};
if let Err(e) = TaskAttemptActivity::create(
// Update the execution process status first
if let Err(e) = ExecutionProcess::update_completion(
&app_state.db_pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
process.id,
ExecutionProcessStatus::Failed,
None, // No exit code for orphaned processes
)
.await
{
tracing::error!(
"Failed to create failed activity for orphaned process: {}",
"Failed to update orphaned execution process {} status: {}",
process.id,
e
);
} else {
tracing::info!("Marked orphaned execution process {} as failed", process_id);
continue;
}
// Get task attempt and task to access task_id and project_id for status update
// Create task attempt activity for non-dev server processes
if process.process_type != ExecutionProcessType::DevServer {
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process.id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some("Execution lost (server restart or crash)".to_string()),
};
if let Err(e) = TaskAttemptActivity::create(
&app_state.db_pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
)
.await
{
tracing::error!(
"Failed to create failed activity for orphaned process: {}",
e
);
continue;
}
}
tracing::info!("Marked orphaned execution process {} as failed", process.id);
// Update task status to InReview for coding agent and setup script failures
if matches!(
process.process_type,
ExecutionProcessType::CodingAgent | ExecutionProcessType::SetupScript
) {
if let Ok(Some(task_attempt)) =
TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await
TaskAttempt::find_by_id(&app_state.db_pool, process.task_attempt_id).await
{
if let Ok(Some(task)) =
Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await
{
// Update task status to InReview
if let Err(e) = Task::update_status(
&app_state.db_pool,
task.id,
@@ -518,11 +534,11 @@ async fn handle_coding_agent_completion(
/// Handle dev server completion (future functionality)
async fn handle_dev_server_completion(
_app_state: &AppState,
app_state: &AppState,
task_attempt_id: Uuid,
_execution_process_id: Uuid,
execution_process_id: Uuid,
_execution_process: ExecutionProcess,
_success: bool,
success: bool,
exit_code: Option<i64>,
) {
let exit_text = if let Some(code) = exit_code {
@@ -537,6 +553,24 @@ async fn handle_dev_server_completion(
exit_text
);
// Dev servers might restart automatically or have different completion semantics
// For now, just log the completion
// Update execution process status instead of creating activity
let process_status = if success {
ExecutionProcessStatus::Completed
} else {
ExecutionProcessStatus::Failed
};
if let Err(e) = ExecutionProcess::update_completion(
&app_state.db_pool,
execution_process_id,
process_status,
exit_code,
)
.await
{
tracing::error!(
"Failed to update dev server execution process status: {}",
e
);
}
}

View File

@@ -91,6 +91,7 @@ pub trait Executor: Send + Sync {
#[derive(Debug, Clone)]
pub enum ExecutorType {
SetupScript(String),
DevServer(String),
CodingAgent(ExecutorConfig),
FollowUpCodingAgent {
config: ExecutorConfig,

View File

@@ -0,0 +1,44 @@
use async_trait::async_trait;
use tokio::process::{Child, Command};
use uuid::Uuid;
use crate::executor::{Executor, ExecutorError};
use crate::models::project::Project;
use crate::models::task::Task;
/// Executor for running project dev server scripts
pub struct DevServerExecutor {
pub script: String,
}
#[async_trait]
impl Executor for DevServerExecutor {
async fn spawn(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
worktree_path: &str,
) -> Result<Child, ExecutorError> {
// Validate the task and project exist
let task = Task::find_by_id(pool, task_id)
.await?
.ok_or(ExecutorError::TaskNotFound)?;
let _project = Project::find_by_id(pool, task.project_id)
.await?
.ok_or(ExecutorError::TaskNotFound)?; // Reuse TaskNotFound for simplicity
let child = Command::new("bash")
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.arg("-c")
.arg(&self.script)
.current_dir(worktree_path)
.process_group(0)
.spawn()
.map_err(ExecutorError::SpawnFailed)?;
Ok(child)
}
}

View File

@@ -1,9 +1,11 @@
pub mod amp;
pub mod claude;
pub mod dev_server;
pub mod echo;
pub mod setup_script;
pub use amp::{AmpExecutor, AmpFollowupExecutor};
pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor};
pub use dev_server::DevServerExecutor;
pub use echo::EchoExecutor;
pub use setup_script::SetupScriptExecutor;

View File

@@ -35,6 +35,7 @@ impl Executor for SetupScriptExecutor {
.arg("-c")
.arg(&self.script)
.current_dir(worktree_path)
.process_group(0)
.spawn()
.map_err(ExecutorError::SpawnFailed)?;

View File

@@ -83,14 +83,23 @@ async fn serve_file(path: &str) -> impl IntoResponse {
}
}
async fn serve_sound_file(axum::extract::Path(filename): axum::extract::Path<String>) -> impl IntoResponse {
use tokio::fs;
async fn serve_sound_file(
axum::extract::Path(filename): axum::extract::Path<String>,
) -> impl IntoResponse {
use std::path::Path;
use tokio::fs;
// Validate filename contains only expected sound files
let valid_sounds = ["abstract-sound1.mp3", "abstract-sound2.mp3", "abstract-sound3.mp3",
"abstract-sound4.mp3", "cow-mooing.mp3", "phone-vibration.mp3", "rooster.mp3"];
let valid_sounds = [
"abstract-sound1.mp3",
"abstract-sound2.mp3",
"abstract-sound3.mp3",
"abstract-sound4.mp3",
"cow-mooing.mp3",
"phone-vibration.mp3",
"rooster.mp3",
];
if !valid_sounds.contains(&filename.as_str()) {
return Response::builder()
.status(StatusCode::NOT_FOUND)
@@ -99,21 +108,17 @@ async fn serve_sound_file(axum::extract::Path(filename): axum::extract::Path<Str
}
let sound_path = Path::new("backend/sounds").join(&filename);
match fs::read(&sound_path).await {
Ok(content) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, HeaderValue::from_static("audio/mpeg"))
.body(Body::from(content))
.unwrap()
}
Err(_) => {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Sound file not found"))
.unwrap()
}
Ok(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, HeaderValue::from_static("audio/mpeg"))
.body(Body::from(content))
.unwrap(),
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Sound file not found"))
.unwrap(),
}
}

View File

@@ -86,6 +86,24 @@ pub struct UpdateExecutionProcess {
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ExecutionProcessSummary {
pub id: Uuid,
pub task_attempt_id: Uuid,
pub process_type: ExecutionProcessType,
pub executor_type: Option<String>, // "echo", "claude", "amp", etc. - only for CodingAgent processes
pub status: ExecutionProcessStatus,
pub command: String,
pub args: Option<String>, // JSON array of arguments
pub working_directory: String,
pub exit_code: Option<i64>,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl ExecutionProcess {
/// Find execution process by ID
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
@@ -147,6 +165,36 @@ impl ExecutionProcess {
.await
}
/// Find execution process summaries for a task attempt (excluding stdio)
pub async fn find_summaries_by_task_attempt_id(
pool: &SqlitePool,
task_attempt_id: Uuid,
) -> Result<Vec<ExecutionProcessSummary>, sqlx::Error> {
sqlx::query_as!(
ExecutionProcessSummary,
r#"SELECT
id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid",
process_type as "process_type!: ExecutionProcessType",
executor_type,
status as "status!: ExecutionProcessStatus",
command,
args,
working_directory,
exit_code,
started_at as "started_at!: DateTime<Utc>",
completed_at as "completed_at?: DateTime<Utc>",
created_at as "created_at!: DateTime<Utc>",
updated_at as "updated_at!: DateTime<Utc>"
FROM execution_processes
WHERE task_attempt_id = $1
ORDER BY created_at ASC"#,
task_attempt_id
)
.fetch_all(pool)
.await
}
/// Find running execution processes
pub async fn find_running(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
@@ -175,6 +223,42 @@ impl ExecutionProcess {
.await
}
/// Find running dev servers for a specific project
pub async fn find_running_dev_servers_by_project(
pool: &SqlitePool,
project_id: Uuid,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
ExecutionProcess,
r#"SELECT
ep.id as "id!: Uuid",
ep.task_attempt_id as "task_attempt_id!: Uuid",
ep.process_type as "process_type!: ExecutionProcessType",
ep.executor_type,
ep.status as "status!: ExecutionProcessStatus",
ep.command,
ep.args,
ep.working_directory,
ep.stdout,
ep.stderr,
ep.exit_code,
ep.started_at as "started_at!: DateTime<Utc>",
ep.completed_at as "completed_at?: DateTime<Utc>",
ep.created_at as "created_at!: DateTime<Utc>",
ep.updated_at as "updated_at!: DateTime<Utc>"
FROM execution_processes ep
JOIN task_attempts ta ON ep.task_attempt_id = ta.id
JOIN tasks t ON ta.task_id = t.id
WHERE ep.status = 'running'
AND ep.process_type = 'devserver'
AND t.project_id = $1
ORDER BY ep.created_at ASC"#,
project_id
)
.fetch_all(pool)
.await
}
/// Create a new execution process
pub async fn create(
pool: &SqlitePool,

View File

@@ -11,6 +11,7 @@ pub struct Project {
pub name: String,
pub git_repo_path: String,
pub setup_script: Option<String>,
pub dev_script: Option<String>,
#[ts(type = "Date")]
pub created_at: DateTime<Utc>,
@@ -25,6 +26,7 @@ pub struct CreateProject {
pub git_repo_path: String,
pub use_existing_repo: bool,
pub setup_script: Option<String>,
pub dev_script: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
@@ -33,6 +35,7 @@ pub struct UpdateProject {
pub name: Option<String>,
pub git_repo_path: Option<String>,
pub setup_script: Option<String>,
pub dev_script: Option<String>,
}
#[derive(Debug, Serialize, TS)]
@@ -55,7 +58,7 @@ impl Project {
pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects ORDER BY created_at DESC"#
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects ORDER BY created_at DESC"#
)
.fetch_all(pool)
.await
@@ -64,7 +67,7 @@ impl Project {
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE id = $1"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE id = $1"#,
id
)
.fetch_optional(pool)
@@ -77,7 +80,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1"#,
git_repo_path
)
.fetch_optional(pool)
@@ -91,7 +94,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1 AND id != $2"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1 AND id != $2"#,
git_repo_path,
exclude_id
)
@@ -106,11 +109,12 @@ impl Project {
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
r#"INSERT INTO projects (id, name, git_repo_path, setup_script) VALUES ($1, $2, $3, $4) RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
r#"INSERT INTO projects (id, name, git_repo_path, setup_script, dev_script) VALUES ($1, $2, $3, $4, $5) RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
project_id,
data.name,
data.git_repo_path,
data.setup_script
data.setup_script,
data.dev_script
)
.fetch_one(pool)
.await
@@ -122,14 +126,16 @@ impl Project {
name: String,
git_repo_path: String,
setup_script: Option<String>,
dev_script: Option<String>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
r#"UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4 WHERE id = $1 RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
r#"UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4, dev_script = $5 WHERE id = $1 RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
id,
name,
git_repo_path,
setup_script
setup_script,
dev_script
)
.fetch_one(pool)
.await

View File

@@ -18,6 +18,7 @@ pub enum TaskAttemptError {
Git(GitError),
TaskNotFound,
ProjectNotFound,
ValidationError(String),
}
impl std::fmt::Display for TaskAttemptError {
@@ -27,6 +28,7 @@ impl std::fmt::Display for TaskAttemptError {
TaskAttemptError::Git(e) => write!(f, "Git error: {}", e),
TaskAttemptError::TaskNotFound => write!(f, "Task not found"),
TaskAttemptError::ProjectNotFound => write!(f, "Project not found"),
TaskAttemptError::ValidationError(e) => write!(f, "Validation error: {}", e),
}
}
}
@@ -486,6 +488,49 @@ impl TaskAttempt {
.await
}
/// Start a dev server for this task attempt
pub async fn start_dev_server(
pool: &SqlitePool,
app_state: &crate::app_state::AppState,
attempt_id: Uuid,
task_id: Uuid,
project_id: Uuid,
) -> Result<(), TaskAttemptError> {
let task_attempt = TaskAttempt::find_by_id(pool, attempt_id)
.await?
.ok_or(TaskAttemptError::TaskNotFound)?;
// Get the project to access the dev_script
let project = crate::models::project::Project::find_by_id(pool, project_id)
.await?
.ok_or(TaskAttemptError::TaskNotFound)?;
let dev_script = project.dev_script.ok_or_else(|| {
TaskAttemptError::ValidationError(
"No dev script configured for this project".to_string(),
)
})?;
if dev_script.trim().is_empty() {
return Err(TaskAttemptError::ValidationError(
"Dev script is empty".to_string(),
));
}
Self::start_process_execution(
pool,
app_state,
attempt_id,
task_id,
crate::executor::ExecutorType::DevServer(dev_script),
"Starting dev server".to_string(),
TaskAttemptStatus::ExecutorRunning, // Dev servers don't create activities, just use generic status
crate::models::execution_process::ExecutionProcessType::DevServer,
&task_attempt.worktree_path,
)
.await
}
/// Start a follow-up execution using the same executor type as the first process
pub async fn start_followup_execution(
pool: &SqlitePool,
@@ -600,9 +645,14 @@ impl TaskAttempt {
Self::create_executor_session_record(pool, attempt_id, task_id, process_id).await?;
}
// Create activity record
Self::create_activity_record(pool, process_id, activity_status.clone(), &activity_note)
.await?;
// Create activity record (skip for dev servers as they run in parallel)
if !matches!(
process_type,
crate::models::execution_process::ExecutionProcessType::DevServer
) {
Self::create_activity_record(pool, process_id, activity_status.clone(), &activity_note)
.await?;
}
tracing::info!("Starting {} for task attempt {}", activity_note, attempt_id);
@@ -646,6 +696,11 @@ impl TaskAttempt {
Some(serde_json::to_string(&["-c", "setup_script"]).unwrap()),
None, // Setup scripts don't have an executor type
),
crate::executor::ExecutorType::DevServer(_) => (
"bash".to_string(),
Some(serde_json::to_string(&["-c", "dev_server"]).unwrap()),
None, // Dev servers don't have an executor type
),
crate::executor::ExecutorType::CodingAgent(config) => {
let executor_type_str = match config {
crate::executor::ExecutorConfig::Echo => "echo",
@@ -748,7 +803,7 @@ impl TaskAttempt {
process_id: Uuid,
worktree_path: &str,
) -> Result<tokio::process::Child, TaskAttemptError> {
use crate::executors::SetupScriptExecutor;
use crate::executors::{DevServerExecutor, SetupScriptExecutor};
let result = match executor_type {
crate::executor::ExecutorType::SetupScript(script) => {
@@ -759,6 +814,14 @@ impl TaskAttempt {
.execute_streaming(pool, task_id, attempt_id, process_id, worktree_path)
.await
}
crate::executor::ExecutorType::DevServer(script) => {
let executor = DevServerExecutor {
script: script.clone(),
};
executor
.execute_streaming(pool, task_id, attempt_id, process_id, worktree_path)
.await
}
crate::executor::ExecutorType::CodingAgent(config) => {
let executor = config.create_executor();
executor

View File

@@ -7,7 +7,10 @@ use axum::{
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::models::{config::{Config, EditorConstants, SoundConstants}, ApiResponse};
use crate::models::{
config::{Config, EditorConstants, SoundConstants},
ApiResponse,
};
use crate::utils;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

View File

@@ -205,8 +205,9 @@ pub async fn update_project(
.git_repo_path
.unwrap_or(existing_project.git_repo_path.clone());
let setup_script = payload.setup_script.or(existing_project.setup_script);
let dev_script = payload.dev_script.or(existing_project.dev_script);
match Project::update(&pool, id, name, git_repo_path, setup_script).await {
match Project::update(&pool, id, name, git_repo_path, setup_script, dev_script).await {
Ok(project) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(project),

View File

@@ -11,7 +11,7 @@ use tokio::sync::RwLock;
use uuid::Uuid;
use crate::models::{
execution_process::ExecutionProcess,
execution_process::{ExecutionProcess, ExecutionProcessSummary},
task::Task,
task_attempt::{
BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus,
@@ -431,7 +431,7 @@ pub async fn rebase_task_attempt(
pub async fn get_task_attempt_execution_processes(
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<SqlitePool>,
) -> Result<ResponseJson<ApiResponse<Vec<ExecutionProcess>>>, StatusCode> {
) -> Result<ResponseJson<ApiResponse<Vec<ExecutionProcessSummary>>>, StatusCode> {
// Verify task attempt exists and belongs to the correct task
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
Ok(false) => return Err(StatusCode::NOT_FOUND),
@@ -442,7 +442,7 @@ pub async fn get_task_attempt_execution_processes(
Ok(true) => {}
}
match ExecutionProcess::find_by_task_attempt_id(&pool, attempt_id).await {
match ExecutionProcess::find_summaries_by_task_attempt_id(&pool, attempt_id).await {
Ok(processes) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(processes),
@@ -549,30 +549,35 @@ pub async fn stop_all_execution_processes(
tracing::error!("Failed to update execution process status: {}", e);
errors.push(format!("Failed to update process {} status", process.id));
} else {
// Create a new activity record to mark as stopped
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process.id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some(format!(
"Execution process {:?} ({}) stopped by user",
process.process_type, process.id
)),
};
// Create activity record for stopped processes (skip dev servers)
if !matches!(
process.process_type,
crate::models::execution_process::ExecutionProcessType::DevServer
) {
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process.id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some(format!(
"Execution process {:?} ({}) stopped by user",
process.process_type, process.id
)),
};
if let Err(e) = TaskAttemptActivity::create(
&pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
)
.await
{
tracing::error!("Failed to create stopped activity: {}", e);
errors.push(format!(
"Failed to create activity for process {}",
process.id
));
if let Err(e) = TaskAttemptActivity::create(
&pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
)
.await
{
tracing::error!("Failed to create stopped activity: {}", e);
errors.push(format!(
"Failed to create activity for process {}",
process.id
));
}
}
}
}
@@ -673,27 +678,32 @@ pub async fn stop_execution_process(
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
// Create a new activity record to mark as stopped
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process_id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some(format!(
"Execution process {:?} ({}) stopped by user",
process.process_type, process_id
)),
};
// Create activity record for stopped processes (skip dev servers)
if !matches!(
process.process_type,
crate::models::execution_process::ExecutionProcessType::DevServer
) {
let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity {
execution_process_id: process_id,
status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some(format!(
"Execution process {:?} ({}) stopped by user",
process.process_type, process_id
)),
};
if let Err(e) = TaskAttemptActivity::create(
&pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
)
.await
{
tracing::error!("Failed to create stopped activity: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
if let Err(e) = TaskAttemptActivity::create(
&pool,
&create_activity,
activity_id,
TaskAttemptStatus::ExecutorFailed,
)
.await
{
tracing::error!("Failed to create stopped activity: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
Ok(ResponseJson(ApiResponse {
@@ -793,6 +803,86 @@ pub async fn create_followup_attempt(
}
}
pub async fn start_dev_server(
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<SqlitePool>,
Extension(app_state): Extension<crate::app_state::AppState>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
// Verify task attempt exists and belongs to the correct task
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
Ok(false) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task attempt existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(true) => {}
}
// Stop any existing dev servers for this project
let existing_dev_servers =
match ExecutionProcess::find_running_dev_servers_by_project(&pool, project_id).await {
Ok(servers) => servers,
Err(e) => {
tracing::error!(
"Failed to find running dev servers for project {}: {}",
project_id,
e
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
for dev_server in existing_dev_servers {
tracing::info!(
"Stopping existing dev server {} for project {}",
dev_server.id,
project_id
);
// Stop the running process
if let Err(e) = app_state.stop_running_execution_by_id(dev_server.id).await {
tracing::error!("Failed to stop dev server {}: {}", dev_server.id, e);
} else {
// Update the execution process status in the database
if let Err(e) = ExecutionProcess::update_completion(
&pool,
dev_server.id,
crate::models::execution_process::ExecutionProcessStatus::Killed,
None,
)
.await
{
tracing::error!(
"Failed to update dev server {} status: {}",
dev_server.id,
e
);
}
}
}
// Start dev server execution
match TaskAttempt::start_dev_server(&pool, &app_state, attempt_id, task_id, project_id).await {
Ok(_) => Ok(ResponseJson(ApiResponse {
success: true,
data: None,
message: Some("Dev server started successfully".to_string()),
})),
Err(e) => {
tracing::error!(
"Failed to start dev server for task attempt {}: {}",
attempt_id,
e
);
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(e.to_string()),
}))
}
}
}
pub fn task_attempts_router() -> Router {
use axum::routing::post;
@@ -850,4 +940,8 @@ pub fn task_attempts_router() -> Router {
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/follow-up",
post(create_followup_attempt),
)
.route(
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/start-dev-server",
post(start_dev_server),
)
}

View File

@@ -32,6 +32,7 @@ export function ProjectForm({
const [name, setName] = useState(project?.name || "");
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
const [setupScript, setSetupScript] = useState(project?.setup_script ?? "");
const [devScript, setDevScript] = useState(project?.dev_script ?? "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showFolderPicker, setShowFolderPicker] = useState(false);
@@ -47,10 +48,12 @@ export function ProjectForm({
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
setDevScript("");
}
}, [project]);
@@ -90,6 +93,7 @@ export function ProjectForm({
name,
git_repo_path: finalGitRepoPath,
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest(
`/api/projects/${project.id}`,
@@ -113,6 +117,7 @@ export function ProjectForm({
git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing",
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest("/api/projects", {
method: "POST",
@@ -147,10 +152,12 @@ export function ProjectForm({
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
setDevScript("");
}
setParentPath("");
setFolderName("");
@@ -316,6 +323,22 @@ export function ProjectForm({
</p>
</div>
<div className="space-y-2">
<Label htmlFor="dev-script">Dev Server Script (Optional)</Label>
<textarea
id="dev-script"
value={devScript}
onChange={(e) => setDevScript(e.target.value)}
placeholder="#!/bin/bash&#10;npm run dev&#10;# Add dev server start command here..."
rows={4}
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-sm text-muted-foreground">
This script can be run from task attempts to start a development server.
Use it to quickly start your project's dev server for testing changes.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />

View File

@@ -4,13 +4,30 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileText, MessageSquare } from "lucide-react";
import { ConversationViewer } from "./ConversationViewer";
import type { ExecutionProcess } from "shared/types";
import type { ExecutionProcess, ExecutionProcessStatus } from "shared/types";
interface ExecutionOutputViewerProps {
executionProcess: ExecutionProcess;
executor?: string;
}
const getExecutionProcessStatusDisplay = (
status: ExecutionProcessStatus
): { label: string; color: string } => {
switch (status) {
case "running":
return { label: "Running", color: "bg-blue-500" };
case "completed":
return { label: "Completed", color: "bg-green-500" };
case "failed":
return { label: "Failed", color: "bg-red-500" };
case "killed":
return { label: "Stopped", color: "bg-gray-500" };
default:
return { label: "Unknown", color: "bg-gray-400" };
}
};
export function ExecutionOutputViewer({
executionProcess,
executor,
@@ -93,17 +110,34 @@ export function ExecutionOutputViewer({
);
}
const statusDisplay = getExecutionProcessStatusDisplay(executionProcess.status);
return (
<Card className="">
<CardContent className="p-3">
<div className="space-y-3">
{/* Execution process header with status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs capitalize">
{executionProcess.process_type.replace(/([A-Z])/g, ' $1').toLowerCase()}
</Badge>
<div className="flex items-center gap-1">
<div className={`h-2 w-2 rounded-full ${statusDisplay.color}`} />
<span className="text-xs text-muted-foreground">{statusDisplay.label}</span>
</div>
{executor && (
<Badge variant="secondary" className="text-xs">
{executor}
</Badge>
)}
</div>
</div>
{/* View mode toggle for executors with valid JSONL */}
{isValidJsonl && hasStdout && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{executor} output
</Badge>
{jsonlFormat && (
<Badge variant="secondary" className="text-xs">
{jsonlFormat} format

View File

@@ -14,12 +14,19 @@ import {
StopCircle,
Send,
AlertCircle,
Play,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@/components/ui/label";
import { Chip } from "@/components/ui/chip";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ExecutionOutputViewer } from "./ExecutionOutputViewer";
import { EditorSelectionDialog } from "./EditorSelectionDialog";
@@ -44,11 +51,14 @@ import type {
ApiResponse,
TaskWithAttemptStatus,
ExecutionProcess,
ExecutionProcessSummary,
EditorType,
Project,
} from "shared/types";
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
project: Project | null;
projectId: string;
isOpen: boolean;
onClose: () => void;
@@ -125,6 +135,7 @@ const getAttemptStatusDisplay = (
export function TaskDetailsPanel({
task,
project,
projectId,
isOpen,
onClose,
@@ -135,12 +146,16 @@ export function TaskDetailsPanel({
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(
null
);
const [attemptActivities, setAttemptActivities] = useState<
TaskAttemptActivity[]
>([]);
const [executionProcesses, setExecutionProcesses] = useState<
Record<string, ExecutionProcess>
>({});
// Combined attempt data state
const [attemptData, setAttemptData] = useState<{
activities: TaskAttemptActivity[];
processes: ExecutionProcessSummary[];
runningProcessDetails: Record<string, ExecutionProcess>;
}>({
activities: [],
processes: [],
runningProcessDetails: {},
});
const [loading, setLoading] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [selectedExecutor, setSelectedExecutor] = useState<string>("claude");
@@ -152,26 +167,38 @@ export function TaskDetailsPanel({
const [followUpMessage, setFollowUpMessage] = useState("");
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
// Auto-scroll state
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { config } = useConfig();
// Find running dev server in current project (across all task attempts)
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
(process) =>
process.process_type === "devserver" && process.status === "running"
);
}, [attemptData.processes]);
// Handle ESC key locally to prevent global navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase
return () => document.removeEventListener('keydown', handleKeyDown, true);
document.addEventListener("keydown", handleKeyDown, true); // Use capture phase
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, onClose]);
// Available executors
@@ -182,16 +209,15 @@ export function TaskDetailsPanel({
];
// Check if any execution process is currently running
// We need to check the latest activity for each execution process
const isAttemptRunning = useMemo(() => {
if (!selectedAttempt || attemptActivities.length === 0 || isStopping) {
if (!selectedAttempt || attemptData.activities.length === 0 || isStopping) {
return false;
}
// Group activities by execution_process_id and get the latest one for each
const latestActivitiesByProcess = new Map<string, TaskAttemptActivity>();
attemptActivities.forEach((activity) => {
attemptData.activities.forEach((activity) => {
const existing = latestActivitiesByProcess.get(
activity.execution_process_id
);
@@ -209,21 +235,31 @@ export function TaskDetailsPanel({
activity.status === "setuprunning" ||
activity.status === "executorrunning"
);
}, [selectedAttempt, attemptActivities, isStopping]);
}, [selectedAttempt, attemptData.activities, isStopping]);
// Check if follow-up should be enabled
const canSendFollowUp = useMemo(() => {
if (!selectedAttempt || attemptActivities.length === 0 || isAttemptRunning || isSendingFollowUp) {
if (
!selectedAttempt ||
attemptData.activities.length === 0 ||
isAttemptRunning ||
isSendingFollowUp
) {
return false;
}
// Need at least one completed coding agent execution
const codingAgentActivities = attemptActivities.filter(
const codingAgentActivities = attemptData.activities.filter(
(activity) => activity.status === "executorcomplete"
);
return codingAgentActivities.length > 0;
}, [selectedAttempt, attemptActivities, isAttemptRunning, isSendingFollowUp]);
}, [
selectedAttempt,
attemptData.activities,
isAttemptRunning,
isSendingFollowUp,
]);
// Polling for updates when attempt is running
useEffect(() => {
@@ -231,13 +267,52 @@ export function TaskDetailsPanel({
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptActivities(selectedAttempt.id, true);
fetchAttemptData(selectedAttempt.id, true);
}
}, 2000);
return () => clearInterval(interval);
}, [isAttemptRunning, task?.id, selectedAttempt?.id]);
// Fetch dev server details when hovering
const fetchDevServerDetails = async () => {
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
} catch (err) {
console.error("Failed to fetch dev server details:", err);
}
};
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
// Fetch immediately
fetchDevServerDetails();
// Then poll every 2 seconds
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [
isHoveringDevServer,
runningDevServer?.id,
task?.id,
selectedAttempt?.id,
]);
// Set default executor from config
useEffect(() => {
if (config) {
@@ -254,16 +329,18 @@ export function TaskDetailsPanel({
// Auto-scroll to bottom when activities or execution processes change
useEffect(() => {
if (shouldAutoScroll && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [attemptActivities, executionProcesses, shouldAutoScroll]);
}, [attemptData.activities, attemptData.processes, shouldAutoScroll]);
// Handle scroll events to detect manual scrolling
const handleScroll = useCallback(() => {
if (scrollContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
const { scrollTop, scrollHeight, clientHeight } =
scrollContainerRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance
if (isAtBottom && !shouldAutoScroll) {
setShouldAutoScroll(true);
} else if (!isAtBottom && shouldAutoScroll) {
@@ -294,12 +371,15 @@ export function TaskDetailsPanel({
: latest
);
setSelectedAttempt(latestAttempt);
fetchAttemptActivities(latestAttempt.id);
fetchAttemptData(latestAttempt.id);
} else {
// Clear state when no attempts exist
setSelectedAttempt(null);
setAttemptActivities([]);
setExecutionProcesses({});
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
}
}
}
@@ -310,59 +390,74 @@ export function TaskDetailsPanel({
}
};
const fetchAttemptActivities = async (
const fetchAttemptData = async (
attemptId: string,
_isBackgroundUpdate = false
) => {
if (!task) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
);
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
if (response.ok) {
const result: ApiResponse<TaskAttemptActivity[]> =
await response.json();
if (result.success && result.data) {
setAttemptActivities(result.data);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivity[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
// Fetch execution processes for running activities
const runningActivities = result.data.filter(
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
// Find running activities that need detailed execution info
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === "setuprunning" ||
activity.status === "executorrunning"
);
// Fetch detailed execution info for running processes
const runningProcessDetails: Record<string, ExecutionProcess> = {};
for (const activity of runningActivities) {
fetchExecutionProcess(activity.execution_process_id);
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
}
// Update all attempt data at once
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
}
}
} catch (err) {
console.error("Failed to fetch attempt activities:", err);
}
};
const fetchExecutionProcess = async (processId: string) => {
if (!task) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${processId}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setExecutionProcesses((prev) => ({
...prev,
[processId]: result.data!,
}));
}
}
} catch (err) {
console.error("Failed to fetch execution process:", err);
console.error("Failed to fetch attempt data:", err);
}
};
@@ -370,7 +465,7 @@ export function TaskDetailsPanel({
const attempt = taskAttempts.find((a) => a.id === attemptId);
if (attempt) {
setSelectedAttempt(attempt);
fetchAttemptActivities(attempt.id);
fetchAttemptData(attempt.id);
}
};
@@ -401,6 +496,70 @@ export function TaskDetailsPanel({
}
};
const startDevServer = async () => {
if (!task || !selectedAttempt || !project?.dev_script) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error("Failed to start dev server");
}
const data: ApiResponse<null> = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to start dev server");
}
// Refresh activities to show the new dev server process
fetchAttemptData(selectedAttempt.id);
} catch (err) {
console.error("Failed to start dev server:", err);
} finally {
setIsStartingDevServer(false);
}
};
const stopDevServer = async () => {
if (!task || !selectedAttempt || !runningDevServer) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error("Failed to stop dev server");
}
// Refresh activities to show the stopped dev server
fetchAttemptData(selectedAttempt.id);
} catch (err) {
console.error("Failed to stop dev server:", err);
} finally {
setIsStartingDevServer(false);
}
};
const createNewAttempt = async (executor?: string) => {
if (!task) return;
@@ -443,13 +602,11 @@ export function TaskDetailsPanel({
);
if (response.ok) {
// Clear cached execution processes since they should be stopped
setExecutionProcesses({});
// Refresh activities to show updated status
await fetchAttemptActivities(selectedAttempt.id);
await fetchAttemptData(selectedAttempt.id);
// Wait a bit for the backend to finish updating
setTimeout(() => {
fetchAttemptActivities(selectedAttempt.id);
fetchAttemptData(selectedAttempt.id);
}, 1000);
}
} catch (err) {
@@ -494,13 +651,21 @@ export function TaskDetailsPanel({
// Clear the message
setFollowUpMessage("");
// Refresh activities to show the new follow-up execution
fetchAttemptActivities(selectedAttempt.id);
fetchAttemptData(selectedAttempt.id);
} else {
const errorText = await response.text();
setFollowUpError(`Failed to start follow-up execution: ${errorText || response.statusText}`);
setFollowUpError(
`Failed to start follow-up execution: ${
errorText || response.statusText
}`
);
}
} catch (err) {
setFollowUpError(`Failed to send follow-up: ${err instanceof Error ? err.message : 'Unknown error'}`);
setFollowUpError(
`Failed to send follow-up: ${
err instanceof Error ? err.message : "Unknown error"
}`
);
} finally {
setIsSendingFollowUp(false);
}
@@ -618,9 +783,6 @@ export function TaskDetailsPanel({
selectedAttempt.created_at
).toLocaleTimeString()}
</span>
<span className="text-xs text-muted-foreground font-mono">
Worktree: {selectedAttempt.worktree_path}
</span>
</div>
)}
<div className="flex gap-1">
@@ -721,6 +883,83 @@ export function TaskDetailsPanel({
{isStopping ? "Stopping..." : "Stop"}
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={
!project?.dev_script ? "cursor-not-allowed" : ""
}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<Button
variant={
runningDevServer ? "destructive" : "outline"
}
size="sm"
onClick={
runningDevServer
? stopDevServer
: startDevServer
}
disabled={
isStartingDevServer || !project?.dev_script
}
>
{runningDevServer ? (
<StopCircle className="h-4 w-4 mr-1" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
{isStartingDevServer
? runningDevServer
? "Stopping..."
: "Starting..."
: runningDevServer
? "Stop Dev Server"
: "Start Dev Server"}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className={runningDevServer ? "max-w-2xl p-4" : ""}
side="top"
align="center"
avoidCollisions={true}
>
{!project?.dev_script ? (
<p>
Configure a dev server command in project
settings
</p>
) : runningDevServer && devServerDetails ? (
<div className="space-y-2">
<p className="text-sm font-medium">
Dev Server Logs (Last 10 lines):
</p>
<pre className="text-xs bg-muted p-2 rounded max-h-64 overflow-y-auto whitespace-pre-wrap">
{(() => {
const stdout =
devServerDetails.stdout || "";
const stderr =
devServerDetails.stderr || "";
const allOutput =
stdout + (stderr ? "\n" + stderr : "");
const lines = allOutput
.split("\n")
.filter((line) => line.trim());
const lastLines = lines.slice(-10);
return lastLines.length > 0
? lastLines.join("\n")
: "No output yet...";
})()}
</pre>
</div>
) : null}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
@@ -743,7 +982,7 @@ export function TaskDetailsPanel({
</div>
{/* Content */}
<div
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-6 space-y-6"
@@ -761,7 +1000,7 @@ export function TaskDetailsPanel({
<Label className="text-sm font-medium mb-3 block">
Activity History
</Label>
{attemptActivities.length === 0 ? (
{attemptData.activities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No activities found
</div>
@@ -790,7 +1029,7 @@ export function TaskDetailsPanel({
</div>
</div>
)}
{attemptActivities.slice().map((activity) => (
{attemptData.activities.slice().map((activity) => (
<div key={activity.id}>
{/* Compact activity message */}
<div className="flex items-center gap-3 my-4 rounded-md">
@@ -825,22 +1064,22 @@ export function TaskDetailsPanel({
{/* Show stdio output for running processes */}
{(activity.status === "setuprunning" ||
activity.status === "executorrunning") &&
executionProcesses[
attemptData.runningProcessDetails[
activity.execution_process_id
] && (
<div className="mt-2">
<div
className={`transition-all duration-200 ${
expandedOutputs.has(
activity.execution_process_id
)
? ""
: "max-h-64 overflow-hidden flex flex-col justify-end"
}`}
className={`transition-all duration-200 ${
expandedOutputs.has(
activity.execution_process_id
)
? ""
: "max-h-64 overflow-hidden flex flex-col justify-end"
}`}
>
<ExecutionOutputViewer
executionProcess={
executionProcesses[
attemptData.runningProcessDetails[
activity.execution_process_id
]
}
@@ -908,9 +1147,13 @@ export function TaskDetailsPanel({
if (followUpError) setFollowUpError(null);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
if (canSendFollowUp && followUpMessage.trim() && !isSendingFollowUp) {
if (
canSendFollowUp &&
followUpMessage.trim() &&
!isSendingFollowUp
) {
handleSendFollowUp();
}
}
@@ -920,7 +1163,11 @@ export function TaskDetailsPanel({
/>
<Button
onClick={handleSendFollowUp}
disabled={!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp}
disabled={
!canSendFollowUp ||
!followUpMessage.trim() ||
isSendingFollowUp
}
className="self-end"
>
{isSendingFollowUp ? (

View File

@@ -415,6 +415,7 @@ export function ProjectTasks() {
{isPanelOpen && (
<TaskDetailsPanel
task={selectedTask}
project={project}
projectId={projectId!}
isOpen={isPanelOpen}
onClose={handleClosePanel}

View File

@@ -24,11 +24,11 @@ export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, dev_script: string | null, };
export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, created_at: Date, updated_at: Date, };
export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, created_at: Date, updated_at: Date, };
export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, };
export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, dev_script: string | null, };
export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, };
@@ -74,6 +74,8 @@ export type BranchStatus = { is_behind: boolean, commits_behind: number, commits
export type ExecutionProcess = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, stdout: string | null, stderr: string | null, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };
export type ExecutionProcessSummary = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };
export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed";
export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver";