Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/server/src/bin/generate_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ fn generate_types_content() -> String {
server::routes::tasks::ShareTaskResponse::decl(),
server::routes::tasks::CreateAndStartTaskRequest::decl(),
server::routes::task_attempts::pr::CreatePrApiRequest::decl(),
server::routes::tasks::BulkDeleteTasksRequest::decl(),
server::routes::tasks::BulkDeleteTasksResponse::decl(),
server::routes::images::ImageResponse::decl(),
server::routes::images::ImageMetadata::decl(),
server::routes::task_attempts::CreateTaskAttemptBody::decl(),
Expand Down
168 changes: 167 additions & 1 deletion crates/server/src/routes/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use db::models::{
image::TaskImage,
project::{Project, ProjectError},
repo::Repo,
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask},
workspace::{CreateWorkspace, Workspace},
workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo},
};
Expand Down Expand Up @@ -424,6 +424,171 @@ pub async fn delete_task(
Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(()))))
}

#[derive(Debug, Serialize, Deserialize, TS)]
pub struct BulkDeleteTasksRequest {
pub project_id: Uuid,
pub status: TaskStatus,
}

#[derive(Debug, Serialize, Deserialize, TS)]
pub struct BulkDeleteTasksResponse {
pub deleted_count: u64,
}

pub async fn bulk_delete_tasks(
State(deployment): State<DeploymentImpl>,
Json(payload): Json<BulkDeleteTasksRequest>,
) -> Result<(StatusCode, ResponseJson<ApiResponse<BulkDeleteTasksResponse>>), ApiError> {
let pool = &deployment.db().pool;

// Fetch all tasks with the specified status for the project
let all_tasks =
Task::find_by_project_id_with_attempt_status(pool, payload.project_id).await?;

let tasks_to_delete: Vec<_> = all_tasks
.into_iter()
.filter(|t| t.status == payload.status)
.collect();

if tasks_to_delete.is_empty() {
return Ok((
StatusCode::OK,
ResponseJson(ApiResponse::success(BulkDeleteTasksResponse {
deleted_count: 0,
})),
));
}

// Check for any running processes
for task in &tasks_to_delete {
if deployment
.container()
.has_running_processes(task.id)
.await?
{
return Err(ApiError::Conflict(format!(
"Task '{}' has running execution processes. Please wait for them to complete or stop them first.",
task.title
)));
}
}

// Gather all data BEFORE starting the transaction to minimize lock time
let mut all_workspace_dirs: Vec<PathBuf> = Vec::new();
let mut all_repositories: Vec<Repo> = Vec::new();
let mut task_attempts: Vec<(Uuid, Vec<Workspace>)> = Vec::new();
let mut shared_task_ids: Vec<Uuid> = Vec::new();

for task in &tasks_to_delete {
// Gather task attempts data
let attempts = Workspace::fetch_all(pool, Some(task.id))
.await
.map_err(|e| {
tracing::error!("Failed to fetch task attempts for task {}: {}", task.id, e);
ApiError::Workspace(e)
})?;

let repositories = WorkspaceRepo::find_unique_repos_for_task(pool, task.id).await?;
all_repositories.extend(repositories);

// Collect workspace directories
let workspace_dirs: Vec<PathBuf> = attempts
.iter()
.filter_map(|attempt| attempt.container_ref.as_ref().map(PathBuf::from))
.collect();
all_workspace_dirs.extend(workspace_dirs);

// Collect shared task IDs for deletion
if let Some(shared_task_id) = task.shared_task_id {
shared_task_ids.push(shared_task_id);
}

task_attempts.push((task.id, attempts));
}

// Handle shared task deletions (external API calls, not DB writes)
if let Ok(publisher) = deployment.share_publisher() {
for shared_task_id in &shared_task_ids {
if let Err(e) = publisher.delete_shared_task(*shared_task_id).await {
tracing::warn!("Failed to delete shared task {}: {}", shared_task_id, e);
}
}
}

// Now do all DB writes in a single transaction
let mut deleted_count = 0u64;
let mut tx = pool.begin().await?;

for (task_id, attempts) in &task_attempts {
// Nullify parent_workspace_id for child tasks
for attempt in attempts {
Task::nullify_children_by_workspace_id(&mut *tx, attempt.id).await?;
}

// Delete the task
let rows_affected = Task::delete(&mut *tx, *task_id).await?;
deleted_count += rows_affected;
}

tx.commit().await?;

deployment
.track_if_analytics_allowed(
"tasks_bulk_deleted",
serde_json::json!({
"project_id": payload.project_id.to_string(),
"status": payload.status.to_string(),
"deleted_count": deleted_count,
}),
)
.await;

// Background cleanup
let project_id = payload.project_id;
let pool = pool.clone();
tokio::spawn(async move {
tracing::info!(
"Starting background cleanup for {} deleted tasks in project {}",
deleted_count,
project_id
);

for workspace_dir in &all_workspace_dirs {
if let Err(e) =
WorkspaceManager::cleanup_workspace(workspace_dir, &all_repositories).await
{
tracing::error!(
"Background workspace cleanup failed at {}: {}",
workspace_dir.display(),
e
);
}
}

match Repo::delete_orphaned(&pool).await {
Ok(count) if count > 0 => {
tracing::info!("Deleted {} orphaned repo records", count);
}
Err(e) => {
tracing::error!("Failed to delete orphaned repos: {}", e);
}
_ => {}
}

tracing::info!(
"Background cleanup completed for bulk delete in project {}",
project_id
);
});

Ok((
StatusCode::ACCEPTED,
ResponseJson(ApiResponse::success(BulkDeleteTasksResponse {
deleted_count,
})),
))
}

#[derive(Debug, Serialize, Deserialize, TS)]
pub struct ShareTaskResponse {
pub shared_task_id: Uuid,
Expand Down Expand Up @@ -471,6 +636,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.route("/", get(get_tasks).post(create_task))
.route("/stream/ws", get(stream_tasks_ws))
.route("/create-and-start", post(create_task_and_start))
.route("/bulk-delete", post(bulk_delete_tasks))
.nest("/{task_id}", task_id_router);

// mount under /projects/:project_id/tasks
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/components/dialogs/tasks/BulkDeleteTasksDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Alert } from '@/components/ui/alert';
import { tasksApi } from '@/lib/api';
import type { TaskStatus } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { statusLabels } from '@/utils/statusLabels';

export interface BulkDeleteTasksDialogProps {
projectId: string;
status: TaskStatus;
count: number;
}

const BulkDeleteTasksDialogImpl =
NiceModal.create<BulkDeleteTasksDialogProps>(
({ projectId, status, count }) => {
const modal = useModal();
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);

const statusLabel = statusLabels[status];

const handleConfirmDelete = async () => {
setIsDeleting(true);
setError(null);

try {
await tasksApi.bulkDelete({ project_id: projectId, status });
modal.resolve();
modal.hide();
} catch (err: unknown) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to delete tasks';
setError(errorMessage);
} finally {
setIsDeleting(false);
}
};

const handleCancelDelete = () => {
modal.reject();
modal.hide();
};

return (
<Dialog
open={modal.visible}
onOpenChange={(open) => !open && handleCancelDelete()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear {statusLabel} Tasks</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<span className="font-semibold">
{count} {count === 1 ? 'task' : 'tasks'}
</span>{' '}
from {statusLabel}?
</DialogDescription>
</DialogHeader>

<Alert variant="destructive" className="mb-4">
<strong>Warning:</strong> This action will permanently delete all{' '}
{statusLabel.toLowerCase()} tasks and cannot be undone.
</Alert>

{error && (
<Alert variant="destructive" className="mb-4">
{error}
</Alert>
)}

<DialogFooter>
<Button
variant="outline"
onClick={handleCancelDelete}
disabled={isDeleting}
autoFocus
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : `Delete ${count} Tasks`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);

export const BulkDeleteTasksDialog = defineModal<
BulkDeleteTasksDialogProps,
void
>(BulkDeleteTasksDialogImpl);
33 changes: 32 additions & 1 deletion frontend/src/components/tasks/TaskKanbanBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/hooks';
import {
type DragEndEvent,
Expand All @@ -12,6 +13,9 @@ import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
import { statusBoardColors, statusLabels } from '@/utils/statusLabels';
import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
import { SharedTaskCard } from './SharedTaskCard';
import { BulkDeleteTasksDialog } from '@/components/dialogs/tasks/BulkDeleteTasksDialog';
import { taskKeys } from '@/hooks/useTask';
import { taskRelationshipsKeys } from '@/hooks/useTaskRelationships';

export type KanbanColumnItem =
| {
Expand Down Expand Up @@ -48,17 +52,44 @@ function TaskKanbanBoard({
projectId,
}: TaskKanbanBoardProps) {
const { userId } = useAuth();
const queryClient = useQueryClient();

const handleClearColumn = useCallback(
async (status: TaskStatus) => {
const taskCount =
columns[status]?.filter((item) => item.type === 'task').length ?? 0;
if (taskCount === 0) return;

try {
await BulkDeleteTasksDialog.show({
projectId,
status,
count: taskCount,
});
// Dialog resolved successfully (API already called), invalidate queries
queryClient.invalidateQueries({ queryKey: taskKeys.all });
queryClient.invalidateQueries({ queryKey: taskRelationshipsKeys.all });
} catch {
// User cancelled
}
},
[columns, projectId, queryClient]
);

return (
<KanbanProvider onDragEnd={onDragEnd}>
{Object.entries(columns).map(([status, items]) => {
const statusKey = status as TaskStatus;
const hasOwnTasks = items.some((item) => item.type === 'task');
return (
<KanbanBoard key={status} id={statusKey}>
<KanbanHeader
name={statusLabels[statusKey]}
color={statusBoardColors[statusKey]}
onAddTask={onCreateTask}
onClearColumn={
hasOwnTasks ? () => handleClearColumn(statusKey) : undefined
}
/>
<KanbanCards>
{items.map((item, index) => {
Expand Down
Loading