a4a6bce577
* fix(task): cascade-archive child tasks on workflow delete Tasks pointing at a deleted workflow_id silently became unreachable because the FK on tasks.workflow_id was dropped to support ephemeral quick-chat tasks, so SQLite cannot CASCADE. Service.DeleteWorkflow now archives every active child task before removing the workflow, re-using the existing ArchiveTask path (events, worktree cleanup, agent stop). Frontend confirm copy and button label updated to reflect the new behavior. Closes #1279 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): make workflow-delete cascade race-safe Address review feedback (Greptile, Claude, cubic): ArchiveTask now returns a typed ErrTaskAlreadyArchived sentinel so DeleteWorkflow can swallow the TOCTOU race where a task is archived between ListTasks and ArchiveTask. Without the sentinel, that race would propagate as an opaque error and leave the workflow row intact even though every task in the snapshot was already in the desired state. Tests: - TestService_DeleteWorkflow_SkipsConcurrentlyArchivedTask covers the sentinel-skip path through the cascade. - TestService_DeleteWorkflow_PartialArchiveErrorPreservesWorkflow covers the fail-fast contract on a real (non-sentinel) error: workflow row survives, previously-archived tasks stay archived, retry is safe. - TestService_ArchiveTask_ReturnsAlreadyArchivedSentinel locks in the sentinel-error contract DeleteWorkflow relies on. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
import { Button } from "@kandev/ui/button";
|
|
import { Label } from "@kandev/ui/label";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@kandev/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import type { Workflow, WorkflowStep } from "@/lib/types/http";
|
|
|
|
type WorkflowDeleteDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
workflowTaskCount: number | null;
|
|
otherWorkflows: Workflow[];
|
|
targetWorkflowId: string;
|
|
setTargetWorkflowId: (id: string) => void;
|
|
targetWorkflowSteps: WorkflowStep[];
|
|
targetStepId: string;
|
|
setTargetStepId: (id: string) => void;
|
|
migrateLoading: boolean;
|
|
deleteLoading: boolean;
|
|
onDelete: () => Promise<void>;
|
|
onMigrateAndDelete: () => Promise<void>;
|
|
};
|
|
|
|
export function WorkflowDeleteDialog({
|
|
open,
|
|
onOpenChange,
|
|
workflowTaskCount,
|
|
otherWorkflows,
|
|
targetWorkflowId,
|
|
setTargetWorkflowId,
|
|
targetWorkflowSteps,
|
|
targetStepId,
|
|
setTargetStepId,
|
|
migrateLoading,
|
|
deleteLoading,
|
|
onDelete,
|
|
onMigrateAndDelete,
|
|
}: WorkflowDeleteDialogProps) {
|
|
const hasTasks = workflowTaskCount !== null && workflowTaskCount > 0;
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete workflow</DialogTitle>
|
|
<DialogDescription>
|
|
{hasTasks
|
|
? `This workflow has ${workflowTaskCount} task${workflowTaskCount === 1 ? "" : "s"}. Choose where to migrate them, or delete the workflow and archive the tasks.`
|
|
: "This will permanently delete the workflow and all its steps."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{hasTasks && otherWorkflows.length > 0 && (
|
|
<div className="space-y-3 py-2">
|
|
<div className="space-y-2">
|
|
<Label>Target Workflow</Label>
|
|
<Select value={targetWorkflowId} onValueChange={setTargetWorkflowId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select workflow" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{otherWorkflows.map((w) => (
|
|
<SelectItem key={w.id} value={w.id}>
|
|
{w.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{targetWorkflowSteps.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label>Target Step</Label>
|
|
<Select value={targetStepId} onValueChange={setTargetStepId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select step" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{targetWorkflowSteps.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="cursor-pointer"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{hasTasks && otherWorkflows.length > 0 && (
|
|
<Button
|
|
type="button"
|
|
onClick={onMigrateAndDelete}
|
|
disabled={!targetWorkflowId || !targetStepId || migrateLoading || deleteLoading}
|
|
className="cursor-pointer"
|
|
>
|
|
{migrateLoading ? "Migrating..." : "Migrate & Delete"}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={onDelete}
|
|
disabled={deleteLoading || migrateLoading}
|
|
className="cursor-pointer"
|
|
>
|
|
{hasTasks ? "Delete & Archive Tasks" : "Delete Workflow"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
type StepDeleteDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
stepTaskCount: number | null;
|
|
stepsForMigration: WorkflowStep[];
|
|
targetStep: string;
|
|
setTargetStep: (id: string) => void;
|
|
loading: boolean;
|
|
onMigrateAndDelete: () => Promise<void>;
|
|
onDeleteAndTasks: () => Promise<void>;
|
|
};
|
|
|
|
export function StepDeleteDialog({
|
|
open,
|
|
onOpenChange,
|
|
stepTaskCount,
|
|
stepsForMigration,
|
|
targetStep,
|
|
setTargetStep,
|
|
loading,
|
|
onMigrateAndDelete,
|
|
onDeleteAndTasks,
|
|
}: StepDeleteDialogProps) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete step</DialogTitle>
|
|
<DialogDescription>
|
|
This step has {stepTaskCount} task{stepTaskCount === 1 ? "" : "s"}.
|
|
{stepsForMigration.length > 0
|
|
? " Choose where to migrate them, or delete the step and its tasks."
|
|
: " Deleting this step will affect these tasks."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{stepsForMigration.length > 0 && (
|
|
<div className="space-y-2 py-2">
|
|
<Label>Target Step</Label>
|
|
<Select value={targetStep} onValueChange={setTargetStep}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select step" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{stepsForMigration.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="cursor-pointer"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{stepsForMigration.length > 0 && (
|
|
<Button
|
|
type="button"
|
|
onClick={onMigrateAndDelete}
|
|
disabled={!targetStep || loading}
|
|
className="cursor-pointer"
|
|
>
|
|
{loading ? "Migrating..." : "Migrate & Delete Step"}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={onDeleteAndTasks}
|
|
disabled={loading}
|
|
className="cursor-pointer"
|
|
>
|
|
Delete Step & Tasks
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|