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", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,14 +24,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -42,9 +47,10 @@
false, false,
false, false,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "b62fa26fe7cdbee672504dbf63d3dbe19fca02a4a4f97d7df7143f340540efa0" "hash": "056991f6ec992103f9de72475138ddfa8d5c9d42546fe36116a61f4db94611c3"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,14 +24,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -42,9 +47,10 @@
false, false,
false, false,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "420c9eec0dd98062947b090bc695b67c2bcaba9862c06b701a9ba3d8a5b02abf" "hash": "08f2cb03665a16640d6690f29920521bae3479e3d2602e724d2c93e6fc85d8ee"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,14 +24,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -42,9 +47,10 @@
false, false,
false, false,
true, true,
true,
false, false,
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", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,27 +24,33 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Right": 4 "Right": 5
}, },
"nullable": [ "nullable": [
true, true,
false, false,
false, false,
true, true,
true,
false, false,
false false
] ]
}, },
"hash": "b3bead952fd42b79bed0908db603726935c0e830ea74ff30064bac71185442fc" "hash": "42c0c81bb893af019b5b91b48c3cb65557f770894e21e654303047d4150cca93"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,14 +24,19 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -42,9 +47,10 @@
false, false,
false, false,
true, true,
true,
false, false,
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", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,27 +24,33 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "created_at!: DateTime<Utc>", "name": "dev_script",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "updated_at!: DateTime<Utc>", "name": "created_at!: DateTime<Utc>",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Right": 4 "Right": 5
}, },
"nullable": [ "nullable": [
true, true,
false, false,
false, false,
true, true,
true,
false, false,
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::WorktreeDiff::decl(),
vibe_kanban::models::task_attempt::BranchStatus::decl(), vibe_kanban::models::task_attempt::BranchStatus::decl(),
vibe_kanban::models::execution_process::ExecutionProcess::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::ExecutionProcessStatus::decl(),
vibe_kanban::models::execution_process::ExecutionProcessType::decl(), vibe_kanban::models::execution_process::ExecutionProcessType::decl(),
vibe_kanban::models::execution_process::CreateExecutionProcess::decl(), vibe_kanban::models::execution_process::CreateExecutionProcess::decl(),

View File

@@ -218,26 +218,19 @@ 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 // Add a small delay to ensure completed processes are properly handled first
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let running_process_ids = let running_processes = match ExecutionProcess::find_running(&app_state.db_pool).await {
match TaskAttemptActivity::find_processes_with_latest_running_status(&app_state.db_pool)
.await
{
Ok(processes) => processes, Ok(processes) => processes,
Err(e) => { Err(e) => {
tracing::error!("Failed to query running attempts: {}", e); tracing::error!("Failed to query running execution processes: {}", e);
continue; continue;
} }
}; };
for process_id in running_process_ids { for process in running_processes {
// 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 // Additional check: if the process was recently updated, skip it
// This prevents race conditions with recent completions // This prevents race conditions with recent completions
let now = chrono::Utc::now(); let now = chrono::Utc::now();
@@ -246,29 +239,46 @@ pub async fn execution_monitor(app_state: AppState) {
// Process was updated within last 10 seconds, likely just completed // Process was updated within last 10 seconds, likely just completed
tracing::debug!( tracing::debug!(
"Skipping recently updated process {} (updated {} seconds ago)", "Skipping recently updated process {} (updated {} seconds ago)",
process_id, process.id,
time_since_update.num_seconds() time_since_update.num_seconds()
); );
continue; 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 // Check if this process is not actually running in the app state
if !app_state.has_running_execution(task_attempt_id).await { if !app_state
// This is truly an orphaned task attempt - mark it as failed .has_running_execution(process.task_attempt_id)
.await
{
// 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
);
// Update the execution process status first
if let Err(e) = ExecutionProcess::update_completion(
&app_state.db_pool,
process.id,
ExecutionProcessStatus::Failed,
None, // No exit code for orphaned processes
)
.await
{
tracing::error!(
"Failed to update orphaned execution process {} status: {}",
process.id,
e
);
continue;
}
// Create task attempt activity for non-dev server processes
if process.process_type != ExecutionProcessType::DevServer {
let activity_id = Uuid::new_v4(); let activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity { let create_activity = CreateTaskAttemptActivity {
execution_process_id: process_id, execution_process_id: process.id,
status: Some(TaskAttemptStatus::ExecutorFailed), status: Some(TaskAttemptStatus::ExecutorFailed),
note: Some("Execution lost (server restart or crash)".to_string()), note: Some("Execution lost (server restart or crash)".to_string()),
}; };
@@ -285,17 +295,23 @@ pub async fn execution_monitor(app_state: AppState) {
"Failed to create failed activity for orphaned process: {}", "Failed to create failed activity for orphaned process: {}",
e e
); );
} else { continue;
tracing::info!("Marked orphaned execution process {} as failed", process_id); }
}
// Get task attempt and task to access task_id and project_id for status update 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)) = 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)) = if let Ok(Some(task)) =
Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await 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( if let Err(e) = Task::update_status(
&app_state.db_pool, &app_state.db_pool,
task.id, task.id,
@@ -518,11 +534,11 @@ async fn handle_coding_agent_completion(
/// Handle dev server completion (future functionality) /// Handle dev server completion (future functionality)
async fn handle_dev_server_completion( async fn handle_dev_server_completion(
_app_state: &AppState, app_state: &AppState,
task_attempt_id: Uuid, task_attempt_id: Uuid,
_execution_process_id: Uuid, execution_process_id: Uuid,
_execution_process: ExecutionProcess, _execution_process: ExecutionProcess,
_success: bool, success: bool,
exit_code: Option<i64>, exit_code: Option<i64>,
) { ) {
let exit_text = if let Some(code) = exit_code { let exit_text = if let Some(code) = exit_code {
@@ -537,6 +553,24 @@ async fn handle_dev_server_completion(
exit_text exit_text
); );
// Dev servers might restart automatically or have different completion semantics // Update execution process status instead of creating activity
// For now, just log the completion 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)] #[derive(Debug, Clone)]
pub enum ExecutorType { pub enum ExecutorType {
SetupScript(String), SetupScript(String),
DevServer(String),
CodingAgent(ExecutorConfig), CodingAgent(ExecutorConfig),
FollowUpCodingAgent { FollowUpCodingAgent {
config: ExecutorConfig, 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 amp;
pub mod claude; pub mod claude;
pub mod dev_server;
pub mod echo; pub mod echo;
pub mod setup_script; pub mod setup_script;
pub use amp::{AmpExecutor, AmpFollowupExecutor}; pub use amp::{AmpExecutor, AmpFollowupExecutor};
pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor}; pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor};
pub use dev_server::DevServerExecutor;
pub use echo::EchoExecutor; pub use echo::EchoExecutor;
pub use setup_script::SetupScriptExecutor; pub use setup_script::SetupScriptExecutor;

View File

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

View File

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

View File

@@ -86,6 +86,24 @@ pub struct UpdateExecutionProcess {
pub completed_at: Option<DateTime<Utc>>, 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 { impl ExecutionProcess {
/// Find execution process by ID /// Find execution process by ID
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
@@ -147,6 +165,36 @@ impl ExecutionProcess {
.await .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 /// Find running execution processes
pub async fn find_running(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> { pub async fn find_running(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
@@ -175,6 +223,42 @@ impl ExecutionProcess {
.await .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 /// Create a new execution process
pub async fn create( pub async fn create(
pool: &SqlitePool, pool: &SqlitePool,

View File

@@ -11,6 +11,7 @@ pub struct Project {
pub name: String, pub name: String,
pub git_repo_path: String, pub git_repo_path: String,
pub setup_script: Option<String>, pub setup_script: Option<String>,
pub dev_script: Option<String>,
#[ts(type = "Date")] #[ts(type = "Date")]
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@@ -25,6 +26,7 @@ pub struct CreateProject {
pub git_repo_path: String, pub git_repo_path: String,
pub use_existing_repo: bool, pub use_existing_repo: bool,
pub setup_script: Option<String>, pub setup_script: Option<String>,
pub dev_script: Option<String>,
} }
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
@@ -33,6 +35,7 @@ pub struct UpdateProject {
pub name: Option<String>, pub name: Option<String>,
pub git_repo_path: Option<String>, pub git_repo_path: Option<String>,
pub setup_script: Option<String>, pub setup_script: Option<String>,
pub dev_script: Option<String>,
} }
#[derive(Debug, Serialize, TS)] #[derive(Debug, Serialize, TS)]
@@ -55,7 +58,7 @@ impl Project {
pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> { pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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) .fetch_all(pool)
.await .await
@@ -64,7 +67,7 @@ impl Project {
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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 id
) )
.fetch_optional(pool) .fetch_optional(pool)
@@ -77,7 +80,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> { ) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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 git_repo_path
) )
.fetch_optional(pool) .fetch_optional(pool)
@@ -91,7 +94,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> { ) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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, git_repo_path,
exclude_id exclude_id
) )
@@ -106,11 +109,12 @@ impl Project {
) -> Result<Self, sqlx::Error> { ) -> Result<Self, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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, project_id,
data.name, data.name,
data.git_repo_path, data.git_repo_path,
data.setup_script data.setup_script,
data.dev_script
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
@@ -122,14 +126,16 @@ impl Project {
name: String, name: String,
git_repo_path: String, git_repo_path: String,
setup_script: Option<String>, setup_script: Option<String>,
dev_script: Option<String>,
) -> Result<Self, sqlx::Error> { ) -> Result<Self, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, 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, id,
name, name,
git_repo_path, git_repo_path,
setup_script setup_script,
dev_script
) )
.fetch_one(pool) .fetch_one(pool)
.await .await

View File

@@ -18,6 +18,7 @@ pub enum TaskAttemptError {
Git(GitError), Git(GitError),
TaskNotFound, TaskNotFound,
ProjectNotFound, ProjectNotFound,
ValidationError(String),
} }
impl std::fmt::Display for TaskAttemptError { 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::Git(e) => write!(f, "Git error: {}", e),
TaskAttemptError::TaskNotFound => write!(f, "Task not found"), TaskAttemptError::TaskNotFound => write!(f, "Task not found"),
TaskAttemptError::ProjectNotFound => write!(f, "Project not found"), TaskAttemptError::ProjectNotFound => write!(f, "Project not found"),
TaskAttemptError::ValidationError(e) => write!(f, "Validation error: {}", e),
} }
} }
} }
@@ -486,6 +488,49 @@ impl TaskAttempt {
.await .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 /// Start a follow-up execution using the same executor type as the first process
pub async fn start_followup_execution( pub async fn start_followup_execution(
pool: &SqlitePool, pool: &SqlitePool,
@@ -600,9 +645,14 @@ impl TaskAttempt {
Self::create_executor_session_record(pool, attempt_id, task_id, process_id).await?; Self::create_executor_session_record(pool, attempt_id, task_id, process_id).await?;
} }
// Create activity record // 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) Self::create_activity_record(pool, process_id, activity_status.clone(), &activity_note)
.await?; .await?;
}
tracing::info!("Starting {} for task attempt {}", activity_note, attempt_id); 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()), Some(serde_json::to_string(&["-c", "setup_script"]).unwrap()),
None, // Setup scripts don't have an executor type 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) => { crate::executor::ExecutorType::CodingAgent(config) => {
let executor_type_str = match config { let executor_type_str = match config {
crate::executor::ExecutorConfig::Echo => "echo", crate::executor::ExecutorConfig::Echo => "echo",
@@ -748,7 +803,7 @@ impl TaskAttempt {
process_id: Uuid, process_id: Uuid,
worktree_path: &str, worktree_path: &str,
) -> Result<tokio::process::Child, TaskAttemptError> { ) -> Result<tokio::process::Child, TaskAttemptError> {
use crate::executors::SetupScriptExecutor; use crate::executors::{DevServerExecutor, SetupScriptExecutor};
let result = match executor_type { let result = match executor_type {
crate::executor::ExecutorType::SetupScript(script) => { crate::executor::ExecutorType::SetupScript(script) => {
@@ -759,6 +814,14 @@ impl TaskAttempt {
.execute_streaming(pool, task_id, attempt_id, process_id, worktree_path) .execute_streaming(pool, task_id, attempt_id, process_id, worktree_path)
.await .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) => { crate::executor::ExecutorType::CodingAgent(config) => {
let executor = config.create_executor(); let executor = config.create_executor();
executor executor

View File

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

View File

@@ -205,8 +205,9 @@ pub async fn update_project(
.git_repo_path .git_repo_path
.unwrap_or(existing_project.git_repo_path.clone()); .unwrap_or(existing_project.git_repo_path.clone());
let setup_script = payload.setup_script.or(existing_project.setup_script); 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 { Ok(project) => Ok(ResponseJson(ApiResponse {
success: true, success: true,
data: Some(project), data: Some(project),

View File

@@ -11,7 +11,7 @@ use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{ use crate::models::{
execution_process::ExecutionProcess, execution_process::{ExecutionProcess, ExecutionProcessSummary},
task::Task, task::Task,
task_attempt::{ task_attempt::{
BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus,
@@ -431,7 +431,7 @@ pub async fn rebase_task_attempt(
pub async fn get_task_attempt_execution_processes( pub async fn get_task_attempt_execution_processes(
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<SqlitePool>, 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 // Verify task attempt exists and belongs to the correct task
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
Ok(false) => return Err(StatusCode::NOT_FOUND), Ok(false) => return Err(StatusCode::NOT_FOUND),
@@ -442,7 +442,7 @@ pub async fn get_task_attempt_execution_processes(
Ok(true) => {} 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 { Ok(processes) => Ok(ResponseJson(ApiResponse {
success: true, success: true,
data: Some(processes), data: Some(processes),
@@ -549,7 +549,11 @@ pub async fn stop_all_execution_processes(
tracing::error!("Failed to update execution process status: {}", e); tracing::error!("Failed to update execution process status: {}", e);
errors.push(format!("Failed to update process {} status", process.id)); errors.push(format!("Failed to update process {} status", process.id));
} else { } else {
// Create a new activity record to mark as stopped // 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 activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity { let create_activity = CreateTaskAttemptActivity {
execution_process_id: process.id, execution_process_id: process.id,
@@ -576,6 +580,7 @@ pub async fn stop_all_execution_processes(
} }
} }
} }
}
Ok(false) => { Ok(false) => {
// Process was not running, which is fine // Process was not running, which is fine
} }
@@ -673,7 +678,11 @@ pub async fn stop_execution_process(
return Err(StatusCode::INTERNAL_SERVER_ERROR); return Err(StatusCode::INTERNAL_SERVER_ERROR);
} }
// Create a new activity record to mark as stopped // 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 activity_id = Uuid::new_v4();
let create_activity = CreateTaskAttemptActivity { let create_activity = CreateTaskAttemptActivity {
execution_process_id: process_id, execution_process_id: process_id,
@@ -695,6 +704,7 @@ pub async fn stop_execution_process(
tracing::error!("Failed to create stopped activity: {}", e); tracing::error!("Failed to create stopped activity: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR); return Err(StatusCode::INTERNAL_SERVER_ERROR);
} }
}
Ok(ResponseJson(ApiResponse { Ok(ResponseJson(ApiResponse {
success: true, success: true,
@@ -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 { pub fn task_attempts_router() -> Router {
use axum::routing::post; 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", "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/follow-up",
post(create_followup_attempt), 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 [name, setName] = useState(project?.name || "");
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || ""); const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
const [setupScript, setSetupScript] = useState(project?.setup_script ?? ""); const [setupScript, setSetupScript] = useState(project?.setup_script ?? "");
const [devScript, setDevScript] = useState(project?.dev_script ?? "");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showFolderPicker, setShowFolderPicker] = useState(false); const [showFolderPicker, setShowFolderPicker] = useState(false);
@@ -47,10 +48,12 @@ export function ProjectForm({
setName(project.name || ""); setName(project.name || "");
setGitRepoPath(project.git_repo_path || ""); setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? ""); setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
} else { } else {
setName(""); setName("");
setGitRepoPath(""); setGitRepoPath("");
setSetupScript(""); setSetupScript("");
setDevScript("");
} }
}, [project]); }, [project]);
@@ -90,6 +93,7 @@ export function ProjectForm({
name, name,
git_repo_path: finalGitRepoPath, git_repo_path: finalGitRepoPath,
setup_script: setupScript.trim() || null, setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
}; };
const response = await makeRequest( const response = await makeRequest(
`/api/projects/${project.id}`, `/api/projects/${project.id}`,
@@ -113,6 +117,7 @@ export function ProjectForm({
git_repo_path: finalGitRepoPath, git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing", use_existing_repo: repoMode === "existing",
setup_script: setupScript.trim() || null, setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
}; };
const response = await makeRequest("/api/projects", { const response = await makeRequest("/api/projects", {
method: "POST", method: "POST",
@@ -147,10 +152,12 @@ export function ProjectForm({
setName(project.name || ""); setName(project.name || "");
setGitRepoPath(project.git_repo_path || ""); setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? ""); setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
} else { } else {
setName(""); setName("");
setGitRepoPath(""); setGitRepoPath("");
setSetupScript(""); setSetupScript("");
setDevScript("");
} }
setParentPath(""); setParentPath("");
setFolderName(""); setFolderName("");
@@ -316,6 +323,22 @@ export function ProjectForm({
</p> </p>
</div> </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 && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <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 { Badge } from "@/components/ui/badge";
import { FileText, MessageSquare } from "lucide-react"; import { FileText, MessageSquare } from "lucide-react";
import { ConversationViewer } from "./ConversationViewer"; import { ConversationViewer } from "./ConversationViewer";
import type { ExecutionProcess } from "shared/types"; import type { ExecutionProcess, ExecutionProcessStatus } from "shared/types";
interface ExecutionOutputViewerProps { interface ExecutionOutputViewerProps {
executionProcess: ExecutionProcess; executionProcess: ExecutionProcess;
executor?: string; 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({ export function ExecutionOutputViewer({
executionProcess, executionProcess,
executor, executor,
@@ -93,17 +110,34 @@ export function ExecutionOutputViewer({
); );
} }
const statusDisplay = getExecutionProcessStatusDisplay(executionProcess.status);
return ( return (
<Card className=""> <Card className="">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="space-y-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 */} {/* View mode toggle for executors with valid JSONL */}
{isValidJsonl && hasStdout && ( {isValidJsonl && hasStdout && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{executor} output
</Badge>
{jsonlFormat && ( {jsonlFormat && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{jsonlFormat} format {jsonlFormat} format

View File

@@ -14,12 +14,19 @@ import {
StopCircle, StopCircle,
Send, Send,
AlertCircle, AlertCircle,
Play,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Chip } from "@/components/ui/chip"; import { Chip } from "@/components/ui/chip";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ExecutionOutputViewer } from "./ExecutionOutputViewer"; import { ExecutionOutputViewer } from "./ExecutionOutputViewer";
import { EditorSelectionDialog } from "./EditorSelectionDialog"; import { EditorSelectionDialog } from "./EditorSelectionDialog";
@@ -44,11 +51,14 @@ import type {
ApiResponse, ApiResponse,
TaskWithAttemptStatus, TaskWithAttemptStatus,
ExecutionProcess, ExecutionProcess,
ExecutionProcessSummary,
EditorType, EditorType,
Project,
} from "shared/types"; } from "shared/types";
interface TaskDetailsPanelProps { interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null; task: TaskWithAttemptStatus | null;
project: Project | null;
projectId: string; projectId: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -125,6 +135,7 @@ const getAttemptStatusDisplay = (
export function TaskDetailsPanel({ export function TaskDetailsPanel({
task, task,
project,
projectId, projectId,
isOpen, isOpen,
onClose, onClose,
@@ -135,12 +146,16 @@ export function TaskDetailsPanel({
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>( const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(
null null
); );
const [attemptActivities, setAttemptActivities] = useState< // Combined attempt data state
TaskAttemptActivity[] const [attemptData, setAttemptData] = useState<{
>([]); activities: TaskAttemptActivity[];
const [executionProcesses, setExecutionProcesses] = useState< processes: ExecutionProcessSummary[];
Record<string, ExecutionProcess> runningProcessDetails: Record<string, ExecutionProcess>;
>({}); }>({
activities: [],
processes: [],
runningProcessDetails: {},
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [selectedExecutor, setSelectedExecutor] = useState<string>("claude"); const [selectedExecutor, setSelectedExecutor] = useState<string>("claude");
@@ -152,26 +167,38 @@ export function TaskDetailsPanel({
const [followUpMessage, setFollowUpMessage] = useState(""); const [followUpMessage, setFollowUpMessage] = useState("");
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null); 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 // Auto-scroll state
const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const { config } = useConfig(); 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 // Handle ESC key locally to prevent global navigation
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onClose(); onClose();
} }
}; };
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase document.addEventListener("keydown", handleKeyDown, true); // Use capture phase
return () => document.removeEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Available executors // Available executors
@@ -182,16 +209,15 @@ export function TaskDetailsPanel({
]; ];
// Check if any execution process is currently running // Check if any execution process is currently running
// We need to check the latest activity for each execution process
const isAttemptRunning = useMemo(() => { const isAttemptRunning = useMemo(() => {
if (!selectedAttempt || attemptActivities.length === 0 || isStopping) { if (!selectedAttempt || attemptData.activities.length === 0 || isStopping) {
return false; return false;
} }
// Group activities by execution_process_id and get the latest one for each // Group activities by execution_process_id and get the latest one for each
const latestActivitiesByProcess = new Map<string, TaskAttemptActivity>(); const latestActivitiesByProcess = new Map<string, TaskAttemptActivity>();
attemptActivities.forEach((activity) => { attemptData.activities.forEach((activity) => {
const existing = latestActivitiesByProcess.get( const existing = latestActivitiesByProcess.get(
activity.execution_process_id activity.execution_process_id
); );
@@ -209,21 +235,31 @@ export function TaskDetailsPanel({
activity.status === "setuprunning" || activity.status === "setuprunning" ||
activity.status === "executorrunning" activity.status === "executorrunning"
); );
}, [selectedAttempt, attemptActivities, isStopping]); }, [selectedAttempt, attemptData.activities, isStopping]);
// Check if follow-up should be enabled // Check if follow-up should be enabled
const canSendFollowUp = useMemo(() => { const canSendFollowUp = useMemo(() => {
if (!selectedAttempt || attemptActivities.length === 0 || isAttemptRunning || isSendingFollowUp) { if (
!selectedAttempt ||
attemptData.activities.length === 0 ||
isAttemptRunning ||
isSendingFollowUp
) {
return false; return false;
} }
// Need at least one completed coding agent execution // Need at least one completed coding agent execution
const codingAgentActivities = attemptActivities.filter( const codingAgentActivities = attemptData.activities.filter(
(activity) => activity.status === "executorcomplete" (activity) => activity.status === "executorcomplete"
); );
return codingAgentActivities.length > 0; return codingAgentActivities.length > 0;
}, [selectedAttempt, attemptActivities, isAttemptRunning, isSendingFollowUp]); }, [
selectedAttempt,
attemptData.activities,
isAttemptRunning,
isSendingFollowUp,
]);
// Polling for updates when attempt is running // Polling for updates when attempt is running
useEffect(() => { useEffect(() => {
@@ -231,13 +267,52 @@ export function TaskDetailsPanel({
const interval = setInterval(() => { const interval = setInterval(() => {
if (selectedAttempt) { if (selectedAttempt) {
fetchAttemptActivities(selectedAttempt.id, true); fetchAttemptData(selectedAttempt.id, true);
} }
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isAttemptRunning, task?.id, selectedAttempt?.id]); }, [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 // Set default executor from config
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@@ -254,14 +329,16 @@ export function TaskDetailsPanel({
// Auto-scroll to bottom when activities or execution processes change // Auto-scroll to bottom when activities or execution processes change
useEffect(() => { useEffect(() => {
if (shouldAutoScroll && scrollContainerRef.current) { 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 // Handle scroll events to detect manual scrolling
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const { scrollTop, scrollHeight, clientHeight } =
scrollContainerRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance
if (isAtBottom && !shouldAutoScroll) { if (isAtBottom && !shouldAutoScroll) {
@@ -294,12 +371,15 @@ export function TaskDetailsPanel({
: latest : latest
); );
setSelectedAttempt(latestAttempt); setSelectedAttempt(latestAttempt);
fetchAttemptActivities(latestAttempt.id); fetchAttemptData(latestAttempt.id);
} else { } else {
// Clear state when no attempts exist // Clear state when no attempts exist
setSelectedAttempt(null); setSelectedAttempt(null);
setAttemptActivities([]); setAttemptData({
setExecutionProcesses({}); activities: [],
processes: [],
runningProcessDetails: {},
});
} }
} }
} }
@@ -310,59 +390,74 @@ export function TaskDetailsPanel({
} }
}; };
const fetchAttemptActivities = async ( const fetchAttemptData = async (
attemptId: string, attemptId: string,
_isBackgroundUpdate = false _isBackgroundUpdate = false
) => { ) => {
if (!task) return; if (!task) return;
try { try {
const response = await makeRequest( const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities` `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
); ),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
if (response.ok) { if (activitiesResponse.ok && processesResponse.ok) {
const result: ApiResponse<TaskAttemptActivity[]> = const activitiesResult: ApiResponse<TaskAttemptActivity[]> =
await response.json(); await activitiesResponse.json();
if (result.success && result.data) { const processesResult: ApiResponse<ExecutionProcessSummary[]> =
setAttemptActivities(result.data); await processesResponse.json();
// Fetch execution processes for running activities if (
const runningActivities = result.data.filter( activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
// Find running activities that need detailed execution info
const runningActivities = activitiesResult.data.filter(
(activity) => (activity) =>
activity.status === "setuprunning" || activity.status === "setuprunning" ||
activity.status === "executorrunning" activity.status === "executorrunning"
); );
// Fetch detailed execution info for running processes
const runningProcessDetails: Record<string, ExecutionProcess> = {};
for (const activity of runningActivities) { for (const activity of runningActivities) {
fetchExecutionProcess(activity.execution_process_id);
}
}
}
} catch (err) {
console.error("Failed to fetch attempt activities:", err);
}
};
const fetchExecutionProcess = async (processId: string) => {
if (!task) return;
try { try {
const response = await makeRequest( const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${processId}` `/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
); );
if (detailResponse.ok) {
if (response.ok) { const detailResult: ApiResponse<ExecutionProcess> =
const result: ApiResponse<ExecutionProcess> = await response.json(); await detailResponse.json();
if (result.success && result.data) { if (detailResult.success && detailResult.data) {
setExecutionProcesses((prev) => ({ runningProcessDetails[activity.execution_process_id] =
...prev, detailResult.data;
[processId]: result.data!,
}));
} }
} }
} catch (err) { } catch (err) {
console.error("Failed to fetch execution process:", 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 data:", err);
} }
}; };
@@ -370,7 +465,7 @@ export function TaskDetailsPanel({
const attempt = taskAttempts.find((a) => a.id === attemptId); const attempt = taskAttempts.find((a) => a.id === attemptId);
if (attempt) { if (attempt) {
setSelectedAttempt(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) => { const createNewAttempt = async (executor?: string) => {
if (!task) return; if (!task) return;
@@ -443,13 +602,11 @@ export function TaskDetailsPanel({
); );
if (response.ok) { if (response.ok) {
// Clear cached execution processes since they should be stopped
setExecutionProcesses({});
// Refresh activities to show updated status // Refresh activities to show updated status
await fetchAttemptActivities(selectedAttempt.id); await fetchAttemptData(selectedAttempt.id);
// Wait a bit for the backend to finish updating // Wait a bit for the backend to finish updating
setTimeout(() => { setTimeout(() => {
fetchAttemptActivities(selectedAttempt.id); fetchAttemptData(selectedAttempt.id);
}, 1000); }, 1000);
} }
} catch (err) { } catch (err) {
@@ -494,13 +651,21 @@ export function TaskDetailsPanel({
// Clear the message // Clear the message
setFollowUpMessage(""); setFollowUpMessage("");
// Refresh activities to show the new follow-up execution // Refresh activities to show the new follow-up execution
fetchAttemptActivities(selectedAttempt.id); fetchAttemptData(selectedAttempt.id);
} else { } else {
const errorText = await response.text(); 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) { } 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 { } finally {
setIsSendingFollowUp(false); setIsSendingFollowUp(false);
} }
@@ -618,9 +783,6 @@ export function TaskDetailsPanel({
selectedAttempt.created_at selectedAttempt.created_at
).toLocaleTimeString()} ).toLocaleTimeString()}
</span> </span>
<span className="text-xs text-muted-foreground font-mono">
Worktree: {selectedAttempt.worktree_path}
</span>
</div> </div>
)} )}
<div className="flex gap-1"> <div className="flex gap-1">
@@ -721,6 +883,83 @@ export function TaskDetailsPanel({
{isStopping ? "Stopping..." : "Stop"} {isStopping ? "Stopping..." : "Stop"}
</Button> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -761,7 +1000,7 @@ export function TaskDetailsPanel({
<Label className="text-sm font-medium mb-3 block"> <Label className="text-sm font-medium mb-3 block">
Activity History Activity History
</Label> </Label>
{attemptActivities.length === 0 ? ( {attemptData.activities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
No activities found No activities found
</div> </div>
@@ -790,7 +1029,7 @@ export function TaskDetailsPanel({
</div> </div>
</div> </div>
)} )}
{attemptActivities.slice().map((activity) => ( {attemptData.activities.slice().map((activity) => (
<div key={activity.id}> <div key={activity.id}>
{/* Compact activity message */} {/* Compact activity message */}
<div className="flex items-center gap-3 my-4 rounded-md"> <div className="flex items-center gap-3 my-4 rounded-md">
@@ -825,7 +1064,7 @@ export function TaskDetailsPanel({
{/* Show stdio output for running processes */} {/* Show stdio output for running processes */}
{(activity.status === "setuprunning" || {(activity.status === "setuprunning" ||
activity.status === "executorrunning") && activity.status === "executorrunning") &&
executionProcesses[ attemptData.runningProcessDetails[
activity.execution_process_id activity.execution_process_id
] && ( ] && (
<div className="mt-2"> <div className="mt-2">
@@ -840,7 +1079,7 @@ export function TaskDetailsPanel({
> >
<ExecutionOutputViewer <ExecutionOutputViewer
executionProcess={ executionProcess={
executionProcesses[ attemptData.runningProcessDetails[
activity.execution_process_id activity.execution_process_id
] ]
} }
@@ -908,9 +1147,13 @@ export function TaskDetailsPanel({
if (followUpError) setFollowUpError(null); if (followUpError) setFollowUpError(null);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (canSendFollowUp && followUpMessage.trim() && !isSendingFollowUp) { if (
canSendFollowUp &&
followUpMessage.trim() &&
!isSendingFollowUp
) {
handleSendFollowUp(); handleSendFollowUp();
} }
} }
@@ -920,7 +1163,11 @@ export function TaskDetailsPanel({
/> />
<Button <Button
onClick={handleSendFollowUp} onClick={handleSendFollowUp}
disabled={!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp} disabled={
!canSendFollowUp ||
!followUpMessage.trim() ||
isSendingFollowUp
}
className="self-end" className="self-end"
> >
{isSendingFollowUp ? ( {isSendingFollowUp ? (

View File

@@ -415,6 +415,7 @@ export function ProjectTasks() {
{isPanelOpen && ( {isPanelOpen && (
<TaskDetailsPanel <TaskDetailsPanel
task={selectedTask} task={selectedTask}
project={project}
projectId={projectId!} projectId={projectId!}
isOpen={isPanelOpen} isOpen={isPanelOpen}
onClose={handleClosePanel} 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 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, }; 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 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 ExecutionProcessStatus = "running" | "completed" | "failed" | "killed";
export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver"; export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver";