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, };