From 32c689dfc5cef1233e3e99dd39137fd3d0904051 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 4 Dec 2025 17:57:43 +0000 Subject: [PATCH] Run setup or cleanup scripts (vibe-kanban) (#1428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * I've successfully implemented the two new API endpoints for running setup and cleanup scripts on task attempts. Here's a summary of the changes: ## Summary ### Files Modified 1. **`crates/server/src/routes/task_attempts.rs`** - Added `RunScriptError` enum type for error handling when no script is configured - Added `run_setup_script` handler - POST endpoint to run a project's setup script - Added `run_cleanup_script` handler - POST endpoint to run a project's cleanup script - Registered both routes: `/run-setup-script` and `/run-cleanup-script` 2. **`crates/server/src/bin/generate_types.rs`** - Added `RunScriptError` to the list of types to export to TypeScript 3. **`shared/types.ts`** (auto-generated) - Now includes the `RunScriptError` TypeScript type ### New Endpoints - **`POST /api/task-attempts/{id}/run-setup-script`** - Runs the project's setup script in the task attempt's worktree - **`POST /api/task-attempts/{id}/run-cleanup-script`** - Runs the project's cleanup script in the task attempt's worktree Both endpoints: - Return `ExecutionProcess` on success with the created execution process - Return `RunScriptError::NoScriptConfigured` if the project doesn't have the respective script configured - Track analytics events (`setup_script_executed` / `cleanup_script_executed`) - Use the existing `ScriptContext::SetupScript` / `ScriptContext::CleanupScript` and `ExecutionProcessRunReason::SetupScript` / `ExecutionProcessRunReason::CleanupScript` patterns * Done! I've added the running process check to both `run_setup_script` and `run_cleanup_script` endpoints. Here's a summary of the changes: ## Summary ### Files Modified 1. **`crates/db/src/models/execution_process.rs`** - Added `has_running_non_dev_server_processes` method that checks if any running processes (excluding dev servers) exist for a task attempt 2. **`crates/server/src/routes/task_attempts.rs`** - Added `ProcessAlreadyRunning` variant to `RunScriptError` enum - Added check at the start of both `run_setup_script` and `run_cleanup_script` handlers to return an error if non-dev-server processes are already running 3. **`shared/types.ts`** (auto-generated) - `RunScriptError` now includes both `no_script_configured` and `process_already_running` variants ### Behavior Both endpoints now: 1. First check if any non-dev-server processes (coding agents, setup/cleanup scripts) are running on the task attempt 2. If running processes exist, return `RunScriptError::ProcessAlreadyRunning` 3. Otherwise proceed with script execution as before * Cleanup script changes for task attempt cdb9cbd9-a6e7-4936-8e75-6dd837c634f3 * Done! I've implemented the frontend changes for the setup/cleanup script dropdown. Here's a summary: ## Summary of Frontend Changes ### Files Modified 1. **`frontend/src/lib/api.ts`** - Added `RunScriptError` to imports from shared types - Added `runSetupScript` and `runCleanupScript` methods to `attemptsApi` 2. **`frontend/src/components/tasks/TaskFollowUpSection.tsx`** - Added imports for `Terminal` icon, `DropdownMenu`, and `Tooltip` components - Added `attemptsApi` import - Updated `useProject()` to also get `project` data - Added computed values: `hasSetupScript`, `hasCleanupScript`, `hasAnyScript` - Added handlers: `handleRunSetupScript`, `handleRunCleanupScript` - Added dropdown button with Terminal icon after the Attach button - Dropdown only shows if project has at least one script configured - Menu items are disabled with tooltips when scripts are not configured - Button is disabled when `isAttemptRunning` is true 3. **`frontend/src/i18n/locales/en/tasks.json`** - Added translation strings for the dropdown menu items and tooltips ### Behavior - **Dropdown visibility**: Only shown if project has `setup_script` or `cleanup_script` configured - **Button disabled state**: When `isAttemptRunning` is true (tooltip explains why) - **Menu item disabled states**: - "Run setup script" disabled if no `setup_script` (tooltip explains) - "Run cleanup script" disabled if no `cleanup_script` (tooltip explains) - **After clicking**: Script starts executing and `isAttemptRunning` becomes true automatically, preventing further interactions * Done! I've added translations for all languages: - **Spanish (es)**: - "Ejecutar script de configuración" / "Ejecutar script de limpieza" - Tooltips in Spanish - **Japanese (ja)**: - "セットアップスクリプトを実行" / "クリーンアップスクリプトを実行" - Tooltips in Japanese - **Korean (ko)**: - "설정 스크립트 실행" / "정리 스크립트 실행" - Tooltips in Korean * Done! I've removed all the fallback strings from the `t()` calls. All 5 translation calls now use just the key: - `{t('followUp.scriptsDisabledWhileRunning')}` - `{t('followUp.runSetupScript')}` - `{t('followUp.noSetupScript')}` - `{t('followUp.runCleanupScript')}` - `{t('followUp.noCleanupScript')}` --- crates/db/src/models/execution_process.rs | 18 +++ crates/server/src/bin/generate_types.rs | 1 + crates/server/src/routes/task_attempts.rs | 152 ++++++++++++++++++ .../components/tasks/TaskFollowUpSection.tsx | 107 +++++++++++- frontend/src/i18n/locales/en/tasks.json | 7 +- frontend/src/i18n/locales/es/tasks.json | 7 +- frontend/src/i18n/locales/ja/tasks.json | 7 +- frontend/src/i18n/locales/ko/tasks.json | 7 +- frontend/src/lib/api.ts | 29 ++++ shared/types.ts | 2 + 10 files changed, 331 insertions(+), 6 deletions(-) diff --git a/crates/db/src/models/execution_process.rs b/crates/db/src/models/execution_process.rs index 84c1479c..2f5cd86d 100644 --- a/crates/db/src/models/execution_process.rs +++ b/crates/db/src/models/execution_process.rs @@ -301,6 +301,24 @@ impl ExecutionProcess { .await } + /// Check if there are running processes (excluding dev servers) for a task attempt + pub async fn has_running_non_dev_server_processes( + pool: &SqlitePool, + task_attempt_id: Uuid, + ) -> Result { + let count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM execution_processes + WHERE task_attempt_id = ? + AND status = 'running' + AND run_reason != 'devserver'"#, + ) + .bind(task_attempt_id) + .fetch_one(pool) + .await?; + Ok(count > 0) + } + /// Find latest session_id by task attempt (simple scalar query) pub async fn find_latest_session_id_by_task_attempt( pool: &SqlitePool, diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index e3648dac..aeb7648f 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -115,6 +115,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::PushError::decl(), server::routes::task_attempts::CreatePrError::decl(), server::routes::task_attempts::BranchStatus::decl(), + server::routes::task_attempts::RunScriptError::decl(), services::services::filesystem::DirectoryEntry::decl(), services::services::filesystem::DirectoryListResponse::decl(), services::services::config::Config::decl(), diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 04904651..196d3f78 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -1507,6 +1507,156 @@ pub async fn attach_existing_pr( } } +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +pub enum RunScriptError { + NoScriptConfigured, + ProcessAlreadyRunning, +} + +#[axum::debug_handler] +pub async fn run_setup_script( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + // Check if any non-dev-server processes are already running + if ExecutionProcess::has_running_non_dev_server_processes( + &deployment.db().pool, + task_attempt.id, + ) + .await? + { + return Ok(ResponseJson(ApiResponse::error_with_data( + RunScriptError::ProcessAlreadyRunning, + ))); + } + + // Ensure worktree exists + let _ = ensure_worktree_path(&deployment, &task_attempt).await?; + + // Get parent task and project + let task = task_attempt + .parent_task(&deployment.db().pool) + .await? + .ok_or(SqlxError::RowNotFound)?; + + let project = task + .parent_project(&deployment.db().pool) + .await? + .ok_or(SqlxError::RowNotFound)?; + + // Check if setup script is configured + let Some(setup_script) = project.setup_script else { + return Ok(ResponseJson(ApiResponse::error_with_data( + RunScriptError::NoScriptConfigured, + ))); + }; + + // Create and execute the setup script action + let executor_action = ExecutorAction::new( + ExecutorActionType::ScriptRequest(ScriptRequest { + script: setup_script, + language: ScriptRequestLanguage::Bash, + context: ScriptContext::SetupScript, + }), + None, + ); + + let execution_process = deployment + .container() + .start_execution( + &task_attempt, + &executor_action, + &ExecutionProcessRunReason::SetupScript, + ) + .await?; + + deployment + .track_if_analytics_allowed( + "setup_script_executed", + serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": project.id.to_string(), + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(execution_process))) +} + +#[axum::debug_handler] +pub async fn run_cleanup_script( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + // Check if any non-dev-server processes are already running + if ExecutionProcess::has_running_non_dev_server_processes( + &deployment.db().pool, + task_attempt.id, + ) + .await? + { + return Ok(ResponseJson(ApiResponse::error_with_data( + RunScriptError::ProcessAlreadyRunning, + ))); + } + + // Ensure worktree exists + let _ = ensure_worktree_path(&deployment, &task_attempt).await?; + + // Get parent task and project + let task = task_attempt + .parent_task(&deployment.db().pool) + .await? + .ok_or(SqlxError::RowNotFound)?; + + let project = task + .parent_project(&deployment.db().pool) + .await? + .ok_or(SqlxError::RowNotFound)?; + + // Check if cleanup script is configured + let Some(cleanup_script) = project.cleanup_script else { + return Ok(ResponseJson(ApiResponse::error_with_data( + RunScriptError::NoScriptConfigured, + ))); + }; + + // Create and execute the cleanup script action + let executor_action = ExecutorAction::new( + ExecutorActionType::ScriptRequest(ScriptRequest { + script: cleanup_script, + language: ScriptRequestLanguage::Bash, + context: ScriptContext::CleanupScript, + }), + None, + ); + + let execution_process = deployment + .container() + .start_execution( + &task_attempt, + &executor_action, + &ExecutionProcessRunReason::CleanupScript, + ) + .await?; + + deployment + .track_if_analytics_allowed( + "cleanup_script_executed", + serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": project.id.to_string(), + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(execution_process))) +} + #[axum::debug_handler] pub async fn gh_cli_setup_handler( Extension(task_attempt): Extension, @@ -1552,6 +1702,8 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/gh-cli-setup", post(gh_cli_setup_handler)) .route("/commit-compare", get(compare_commit_to_head)) .route("/start-dev-server", post(start_dev_server)) + .route("/run-setup-script", post(run_setup_script)) + .route("/run-cleanup-script", post(run_cleanup_script)) .route("/branch-status", get(get_task_attempt_branch_status)) .route("/diff/ws", get(stream_task_attempt_diff_ws)) .route("/merge", post(merge_task_attempt)) diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index 5ea50471..2afba08b 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -6,9 +6,22 @@ import { Clock, X, Paperclip, + Terminal, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; // import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { ScratchType, type TaskWithAttemptStatus } from 'shared/types'; @@ -42,7 +55,7 @@ import { useTranslation } from 'react-i18next'; import { useScratch } from '@/hooks/useScratch'; import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; import { useQueueStatus } from '@/hooks/useQueueStatus'; -import { imagesApi } from '@/lib/api'; +import { imagesApi, attemptsApi } from '@/lib/api'; interface TaskFollowUpSectionProps { task: TaskWithAttemptStatus; @@ -54,7 +67,7 @@ export function TaskFollowUpSection({ selectedAttemptId, }: TaskFollowUpSectionProps) { const { t } = useTranslation('tasks'); - const { projectId } = useProject(); + const { projectId, project } = useProject(); const { isAttemptRunning, stopExecution, isStopping, processes } = useAttemptExecution(selectedAttemptId, task.id); @@ -352,6 +365,29 @@ export function TaskFollowUpSection({ ]); const isEditable = !isRetryActive && !hasPendingApproval; + // Script availability + const hasSetupScript = Boolean(project?.setup_script); + const hasCleanupScript = Boolean(project?.cleanup_script); + const hasAnyScript = hasSetupScript || hasCleanupScript; + + const handleRunSetupScript = useCallback(async () => { + if (!selectedAttemptId || isAttemptRunning || !hasSetupScript) return; + try { + await attemptsApi.runSetupScript(selectedAttemptId); + } catch (error) { + console.error('Failed to run setup script:', error); + } + }, [selectedAttemptId, isAttemptRunning, hasSetupScript]); + + const handleRunCleanupScript = useCallback(async () => { + if (!selectedAttemptId || isAttemptRunning || !hasCleanupScript) return; + try { + await attemptsApi.runCleanupScript(selectedAttemptId); + } catch (error) { + console.error('Failed to run cleanup script:', error); + } + }, [selectedAttemptId, isAttemptRunning, hasCleanupScript]); + // Handler to queue the current message for execution after agent finishes const handleQueueMessage = useCallback(async () => { if ( @@ -685,6 +721,73 @@ export function TaskFollowUpSection({ + {/* Scripts dropdown - only show if project has any scripts */} + {hasAnyScript && ( + + + + + + + + + {isAttemptRunning && ( + + {t('followUp.scriptsDisabledWhileRunning')} + + )} + + + + + + + + + {t('followUp.runSetupScript')} + + + + {!hasSetupScript && ( + + {t('followUp.noSetupScript')} + + )} + + + + + + + + {t('followUp.runCleanupScript')} + + + + {!hasCleanupScript && ( + + {t('followUp.noCleanupScript')} + + )} + + + + + )} + {isAttemptRunning ? (
{/* Queue/Cancel Queue button when running */} diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 4627f725..130588f5 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -153,7 +153,12 @@ "queueForNextTurn": "Queue for next turn", "queue": "Queue", "cancelQueue": "Cancel Queue", - "queuedMessage": "Message queued - will execute when current run finishes" + "queuedMessage": "Message queued - will execute when current run finishes", + "runSetupScript": "Run setup script", + "runCleanupScript": "Run cleanup script", + "noSetupScript": "No setup script configured for this project", + "noCleanupScript": "No cleanup script configured for this project", + "scriptsDisabledWhileRunning": "Cannot run scripts while a process is running" }, "todos": { "title_one": "Todos ({{count}})", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 7f448013..13362322 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -183,7 +183,12 @@ "unqueuing": "Unqueuing…", "queue": "Encolar", "cancelQueue": "Cancelar cola", - "queuedMessage": "Mensaje en cola - se ejecutará cuando finalice la ejecución actual" + "queuedMessage": "Mensaje en cola - se ejecutará cuando finalice la ejecución actual", + "runSetupScript": "Ejecutar script de configuración", + "runCleanupScript": "Ejecutar script de limpieza", + "noSetupScript": "No hay script de configuración configurado para este proyecto", + "noCleanupScript": "No hay script de limpieza configurado para este proyecto", + "scriptsDisabledWhileRunning": "No se pueden ejecutar scripts mientras un proceso está en ejecución" }, "git": { "branch": { diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 62de801c..c7fe01f8 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -183,7 +183,12 @@ "unqueuing": "Unqueuing…", "queue": "キューに追加", "cancelQueue": "キューをキャンセル", - "queuedMessage": "メッセージがキューに追加されました - 現在の実行が完了すると実行されます" + "queuedMessage": "メッセージがキューに追加されました - 現在の実行が完了すると実行されます", + "runSetupScript": "セットアップスクリプトを実行", + "runCleanupScript": "クリーンアップスクリプトを実行", + "noSetupScript": "このプロジェクトにセットアップスクリプトが設定されていません", + "noCleanupScript": "このプロジェクトにクリーンアップスクリプトが設定されていません", + "scriptsDisabledWhileRunning": "プロセス実行中はスクリプトを実行できません" }, "git": { "branch": { diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index c3962ee9..bbefa4fa 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -183,7 +183,12 @@ "unqueuing": "Unqueuing…", "queue": "대기열에 추가", "cancelQueue": "대기열 취소", - "queuedMessage": "메시지가 대기열에 추가됨 - 현재 실행이 완료되면 실행됩니다" + "queuedMessage": "메시지가 대기열에 추가됨 - 현재 실행이 완료되면 실행됩니다", + "runSetupScript": "설정 스크립트 실행", + "runCleanupScript": "정리 스크립트 실행", + "noSetupScript": "이 프로젝트에 설정 스크립트가 구성되어 있지 않습니다", + "noCleanupScript": "이 프로젝트에 정리 스크립트가 구성되어 있지 않습니다", + "scriptsDisabledWhileRunning": "프로세스가 실행 중일 때는 스크립트를 실행할 수 없습니다" }, "git": { "branch": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c32bf53e..c0610422 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,7 @@ import { RunAgentSetupRequest, RunAgentSetupResponse, GhCliSetupError, + RunScriptError, StatusResponse, ListOrganizationsResponse, OrganizationMemberWithProfile, @@ -605,6 +606,34 @@ export const attemptsApi = { ); return handleApiResponse(response); }, + + runSetupScript: async ( + attemptId: string + ): Promise> => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/run-setup-script`, + { + method: 'POST', + } + ); + return handleApiResponseAsResult( + response + ); + }, + + runCleanupScript: async ( + attemptId: string + ): Promise> => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/run-cleanup-script`, + { + method: 'POST', + } + ); + return handleApiResponseAsResult( + response + ); + }, }; // Extra helpers diff --git a/shared/types.ts b/shared/types.ts index 44952000..9125cb55 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -256,6 +256,8 @@ conflict_op: ConflictOp | null, */ conflicted_files: Array, }; +export type RunScriptError = { "type": "no_script_configured" } | { "type": "process_already_running" }; + export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, last_modified: bigint | null, }; export type DirectoryListResponse = { entries: Array, current_path: string, };