Files
kandev/apps/web/components/settings/workflow-card-actions.ts
T
2026-09-10 16:32:41 +01:00

655 lines
22 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import {
normalizeWorkflowProfileSessionStartPolicy,
normalizeWorkflowProfileSessionEndPolicy,
type Workflow,
type WorkflowStep,
} from "@/lib/types/http";
import { generateUUID } from "@/lib/utils";
import { t } from "@/lib/i18n";
import { useToast } from "@/components/toast-provider";
import { useSerializedMutationQueue } from "./use-serialized-mutation-queue";
import type { WorkflowMutationGuardController } from "./workflow-mutation-guard";
import { applyWorkflowStepUpdates } from "./workflow-step-mutations";
import { areStepDraftsEqual, stepUpdatePayload } from "./workflow-step-equality";
export { areStepDraftsEqual } from "./workflow-step-equality";
import {
createWorkflowAction,
createWorkflowStepAction,
updateWorkflowAction,
updateWorkflowStepAction,
deleteWorkflowStepAction,
reorderWorkflowStepsAction,
listWorkflowStepsAction,
getStepTaskCount,
getWorkflowTaskCount,
bulkMoveTasks,
} from "@/app/actions/workspaces";
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
function fallbackErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : t("common:requestFailed");
}
type WorkflowStepActionsParams = {
workflow: Workflow;
isNewWorkflow?: boolean;
/**
* Workflows synced from GitHub (`workflow.source === "github"`) are
* read-only in the UI; the backend also rejects step mutations with a 409.
* Gating here is defense-in-depth in case a disabled control is somehow
* still triggered.
*/
readOnly?: boolean;
workflowSteps: WorkflowStep[];
setWorkflowSteps: (updater: ((prev: WorkflowStep[]) => WorkflowStep[]) | WorkflowStep[]) => void;
refreshWorkflowSteps?: () => Promise<void>;
setStepToDelete: (id: string | null) => void;
setStepTaskCount: (count: number | null) => void;
setTargetStepForMigration: (id: string) => void;
setStepDeleteOpen: (open: boolean) => void;
toast: ReturnType<typeof useToast>["toast"];
mutationGuard?: WorkflowMutationGuardController;
};
type OpenStepDeleteDialogParams = {
stepId: string;
taskCount: number;
workflowSteps: WorkflowStep[];
setStepToDelete: (id: string | null) => void;
setStepTaskCount: (count: number | null) => void;
setTargetStepForMigration: (id: string) => void;
setStepDeleteOpen: (open: boolean) => void;
};
function openStepDeleteDialog({
stepId,
taskCount,
workflowSteps,
setStepToDelete,
setStepTaskCount,
setTargetStepForMigration,
setStepDeleteOpen,
}: OpenStepDeleteDialogParams) {
setStepToDelete(stepId);
setStepTaskCount(taskCount);
const otherSteps = workflowSteps.filter((s) => s.id !== stepId);
setTargetStepForMigration(otherSteps.length > 0 ? otherSteps[0].id : "");
setStepDeleteOpen(true);
}
// Seeded default for a brand-new step. Both fields are PERSISTED verbatim —
// `name` becomes the step's stored name and `color` its Tailwind class — so
// neither is translated; translating the name would write a localized string
// into the database.
// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS.
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
return {
id: `temp-step-${generateUUID()}`,
workflow_id: workflow.id,
...NEW_STEP_DEFAULTS,
position,
allow_manual_move: true,
complete_task_on_enter: false,
created_at: "",
updated_at: "",
};
}
function useWorkflowStepMutationQueue(toast: WorkflowStepActionsParams["toast"]) {
return useSerializedMutationQueue((errorTitle, error) => {
toast({ title: errorTitle, description: fallbackErrorMessage(error), variant: "error" });
});
}
function createRemoveWorkflowStepHandler(
params: WorkflowStepActionsParams,
guardMutation: WorkflowMutationGuardController["guardMutation"],
) {
const {
workflow,
readOnly = false,
workflowSteps,
setWorkflowSteps,
setStepToDelete,
setStepTaskCount,
setTargetStepForMigration,
setStepDeleteOpen,
toast,
} = params;
const isNewWorkflow = params.isNewWorkflow ?? workflow.id.startsWith(TEMP_WORKFLOW_PREFIX);
return async (stepId: string) => {
if (readOnly) return;
const dependentStep = workflowSteps.find(
(step) => step.session_target?.kind === "step" && step.session_target.step_id === stepId,
);
if (dependentStep) {
toast({
title: t("workflows:sessionTargetDependentMustBeRepaired"),
description: t("workflows:sessionTargetDependentRepairDescription", {
stepName: dependentStep.name,
}),
variant: "error",
});
return;
}
const proposedSteps = workflowSteps
.filter((step) => step.id !== stepId)
.map((step, position) => ({ ...step, position }));
if (isNewWorkflow) {
setWorkflowSteps(proposedSteps);
return;
}
await guardMutation({
proposedSteps,
operation: async () => {
if (stepId.startsWith("temp-")) {
setWorkflowSteps(proposedSteps);
return;
}
let taskCount: number;
try {
({ task_count: taskCount } = await getStepTaskCount(stepId));
} catch (error) {
toast({
title: t("workflows:failedToCheckWorkflowStepTasks"),
description: fallbackErrorMessage(error),
variant: "error",
});
return;
}
openStepDeleteDialog({
stepId,
taskCount,
workflowSteps,
setStepToDelete,
setStepTaskCount,
setTargetStepForMigration,
setStepDeleteOpen,
});
},
});
};
}
export function useWorkflowStepActions(params: WorkflowStepActionsParams) {
const { workflow, workflowSteps, setWorkflowSteps, toast, mutationGuard } = params;
const isNewWorkflow = params.isNewWorkflow ?? workflow.id.startsWith(TEMP_WORKFLOW_PREFIX);
const readOnly = params.readOnly ?? false;
const mutationQueue = useWorkflowStepMutationQueue(toast);
const runMutation = mutationQueue.run;
const guardMutation: WorkflowMutationGuardController["guardMutation"] =
mutationGuard?.guardMutation ?? (async ({ operation }) => operation());
const handleUpdateWorkflowStep = async (stepId: string, updates: Partial<WorkflowStep>) => {
if (readOnly) return;
const proposedSteps = applyWorkflowStepUpdates(workflowSteps, stepId, updates);
if (isNewWorkflow) {
setWorkflowSteps(() => proposedSteps);
return;
}
await guardMutation({
proposedSteps,
operation: async () => setWorkflowSteps(() => proposedSteps),
});
};
const handleAddWorkflowStep = async () => {
if (readOnly) return;
const draftStep = createDraftStep(workflow, workflowSteps.length);
const proposedSteps = [...workflowSteps, draftStep];
if (isNewWorkflow) {
setWorkflowSteps((previous) => [...previous, draftStep]);
return;
}
await guardMutation({
proposedSteps,
operation: async () => setWorkflowSteps((previous) => [...previous, draftStep]),
});
};
const handleRemoveWorkflowStep = createRemoveWorkflowStepHandler(params, guardMutation);
const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => {
if (readOnly) return;
const proposedSteps = reorderedSteps.map((step, position) => ({ ...step, position }));
if (isNewWorkflow) {
setWorkflowSteps(proposedSteps);
return;
}
await guardMutation({
proposedSteps,
operation: async () => setWorkflowSteps(proposedSteps),
});
};
return {
handleUpdateWorkflowStep,
handleAddWorkflowStep,
handleRemoveWorkflowStep,
handleReorderWorkflowSteps,
status: mutationQueue.status,
retry: mutationQueue.retry,
runMutation,
};
}
export type WorkflowDraftSaveProgress = {
workflow?: Workflow;
stepIds: Map<string, string>;
templateStepsLoaded?: boolean;
};
export function createWorkflowDraftSaveProgress(): WorkflowDraftSaveProgress {
return { stepIds: new Map() };
}
type PersistWorkflowDraftParams = {
workflow: Workflow;
draftSteps: WorkflowStep[];
savedSteps: WorkflowStep[];
progress: WorkflowDraftSaveProgress;
};
export async function persistWorkflowDraft({
workflow,
draftSteps,
savedSteps,
progress,
}: PersistWorkflowDraftParams): Promise<{ workflow: Workflow; steps: WorkflowStep[] }> {
const isNewWorkflow = workflow.id.startsWith(TEMP_WORKFLOW_PREFIX);
const persistedWorkflow = await ensurePersistedWorkflow(workflow, progress);
const updatedWorkflow = await updateWorkflowAction(persistedWorkflow.id, {
name: workflow.name.trim(),
description: workflow.description ?? "",
prompt: workflow.prompt ?? "",
agent_profile_id: workflow.agent_profile_id ?? "",
});
progress.workflow = updatedWorkflow;
await reconcileTemplateSteps({ workflow, draftSteps, updatedWorkflow, progress, isNewWorkflow });
await createMissingSteps(updatedWorkflow.id, draftSteps, progress.stepIds);
const remappedSteps = remapWorkflowDraftSteps(draftSteps, updatedWorkflow.id, progress.stepIds);
await updateChangedSteps(remappedSteps, savedSteps);
if (remappedSteps.length > 0) {
await reorderWorkflowStepsAction(
updatedWorkflow.id,
remappedSteps.map((step) => step.id),
);
}
return { workflow: updatedWorkflow, steps: remappedSteps };
}
async function ensurePersistedWorkflow(
workflow: Workflow,
progress: WorkflowDraftSaveProgress,
): Promise<Workflow> {
if (progress.workflow) return progress.workflow;
const persisted = workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)
? await createWorkflowAction({
workspace_id: workflow.workspace_id,
name: workflow.name.trim(),
description: workflow.description ?? undefined,
prompt: workflow.prompt ?? undefined,
workflow_template_id: workflow.workflow_template_id ?? undefined,
})
: workflow;
progress.workflow = persisted;
return persisted;
}
async function reconcileTemplateSteps({
workflow,
draftSteps,
updatedWorkflow,
progress,
isNewWorkflow,
}: {
workflow: Workflow;
draftSteps: WorkflowStep[];
updatedWorkflow: Workflow;
progress: WorkflowDraftSaveProgress;
isNewWorkflow: boolean;
}) {
if (!isNewWorkflow || !workflow.workflow_template_id || progress.templateStepsLoaded) return;
const templateSteps = (await listWorkflowStepsAction(updatedWorkflow.id)).steps ?? [];
mapTemplateStepIds(workflow.id, draftSteps, templateSteps, progress.stepIds);
const keptServerIds = new Set(progress.stepIds.values());
for (const serverStep of templateSteps) {
if (!keptServerIds.has(serverStep.id)) await deleteWorkflowStepAction(serverStep.id);
}
progress.templateStepsLoaded = true;
}
async function createMissingSteps(
workflowId: string,
draftSteps: WorkflowStep[],
mappings: Map<string, string>,
) {
for (const step of draftSteps) {
if (!step.id.startsWith("temp-") || mappings.has(step.id)) continue;
const created = await createWorkflowStepAction({
workflow_id: workflowId,
name: step.name,
position: step.position,
color: step.color,
stage_type: step.stage_type ?? "custom",
complete_task_on_enter: step.complete_task_on_enter ?? false,
cancel_triggers_turn_complete: step.cancel_triggers_turn_complete ?? false,
agent_profile_id: step.agent_profile_id,
profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy(
step.profile_session_start_policy,
),
profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy(
step.profile_session_end_policy,
),
session_target: undefined,
auto_advance_requires_signal: step.auto_advance_requires_signal ?? false,
});
mappings.set(step.id, created.id);
}
}
async function updateChangedSteps(remapped: WorkflowStep[], savedSteps: WorkflowStep[]) {
const savedById = new Map(savedSteps.map((step) => [step.id, step]));
const changed = remapped.filter((step) => {
const saved = savedById.get(step.id);
return !saved || !areStepDraftsEqual(step, saved);
});
const targetChanged = (step: WorkflowStep, saved: WorkflowStep) =>
JSON.stringify(step.session_target ?? null) !== JSON.stringify(saved.session_target ?? null);
const targetNeedsSourceProfileFirst = (step: WorkflowStep) => {
const target = step.session_target;
if (target?.kind !== "step") return false;
const sourceDraft = remapped.find((candidate) => candidate.id === target.step_id);
const sourceSaved = sourceDraft ? savedById.get(sourceDraft.id) : undefined;
return sourceSaved?.agent_profile_id === "" && sourceDraft?.agent_profile_id !== "";
};
// Existing targets must be detached before their source profile is removed.
// A replacement target also clears the mutually-exclusive direct profile in
// the same request, so the controller never observes an invalid half-state.
for (const step of changed) {
const saved = savedById.get(step.id);
if (!saved || !targetChanged(step, saved) || saved.session_target == null) continue;
const attachAfterSourceProfile = targetNeedsSourceProfileFirst(step);
await updateWorkflowStepAction(step.id, {
session_target: attachAfterSourceProfile ? null : (step.session_target ?? null),
...(!attachAfterSourceProfile && step.session_target != null ? { agent_profile_id: "" } : {}),
});
}
// A newly attached target can refer to a source whose direct profile is also
// being enabled in this save. Persist all other changes first, then attach
// those targets once the source profile is valid on the server.
const targetAdditions = changed.filter((step) => {
const saved = savedById.get(step.id);
return (
(saved?.session_target == null && step.session_target != null) ||
targetNeedsSourceProfileFirst(step)
);
});
const targetAdditionIDs = new Set(targetAdditions.map((step) => step.id));
for (const step of changed) {
if (!targetAdditionIDs.has(step.id)) {
await updateWorkflowStepAction(step.id, stepUpdatePayload(step));
}
}
for (const step of targetAdditions) {
await updateWorkflowStepAction(step.id, stepUpdatePayload(step));
}
}
function mapTemplateStepIds(
clientWorkflowId: string,
draftSteps: WorkflowStep[],
serverSteps: WorkflowStep[],
mappings: Map<string, string>,
) {
const serverByPosition = new Map(serverSteps.map((step) => [step.position, step.id]));
const prefix = `temp-template-step-${clientWorkflowId}-`;
for (const step of draftSteps) {
if (!step.id.startsWith(prefix)) continue;
const originalPosition = Number(step.id.slice(prefix.length));
const serverId = serverByPosition.get(originalPosition);
if (serverId) mappings.set(step.id, serverId);
}
}
function remapDraftStep(
step: WorkflowStep,
persistedWorkflowId: string,
position: number,
mappings: Map<string, string>,
): WorkflowStep {
const remapId = (id: string | null | undefined) => (id ? (mappings.get(id) ?? id) : id);
return {
...step,
id: mappings.get(step.id) ?? step.id,
workflow_id: persistedWorkflowId as WorkflowStep["workflow_id"],
position,
pull_from_step_id: remapId(step.pull_from_step_id),
session_target:
step.session_target?.kind === "step"
? { kind: "step", step_id: remapId(step.session_target.step_id)! }
: (step.session_target ?? null),
events: remapStepReferences(step.events, mappings),
};
}
export function remapWorkflowDraftSteps(
steps: WorkflowStep[],
persistedWorkflowId: string,
mappings: Map<string, string>,
): WorkflowStep[] {
return steps.map((step, position) =>
remapDraftStep(step, persistedWorkflowId, position, mappings),
);
}
function remapStepReferences<T>(value: T, mappings: Map<string, string>): T {
if (Array.isArray(value)) return value.map((item) => remapStepReferences(item, mappings)) as T;
if (!value || typeof value !== "object") return value;
const mapped = Object.entries(value).map(([key, item]) => [
key,
key === "step_id" && typeof item === "string"
? (mappings.get(item) ?? item)
: remapStepReferences(item, mappings),
]);
return Object.fromEntries(mapped) as T;
}
type WorkflowDeleteHandlersParams = {
workflow: Workflow;
/** See `WorkflowStepActionsParams.readOnly` — defense-in-depth for synced workflows. */
readOnly?: boolean;
otherWorkflows: Workflow[];
wfDel: {
setDeleteOpen: (v: boolean) => void;
setWorkflowTaskCount: (v: number | null) => void;
setWorkflowDeleteLoading: (v: boolean) => void;
setTargetWorkflowId: (v: string) => void;
setTargetWorkflowSteps: (v: WorkflowStep[]) => void;
setTargetStepId: (v: string) => void;
targetWorkflowId: string;
targetStepId: string;
setMigrateLoading: (v: boolean) => void;
};
deleteWorkflowRun: () => Promise<unknown>;
toast: ReturnType<typeof useToast>["toast"];
};
export function useWorkflowDeleteHandlers({
workflow,
readOnly = false,
otherWorkflows,
wfDel,
deleteWorkflowRun,
toast,
}: WorkflowDeleteHandlersParams) {
useEffect(() => {
if (!wfDel.targetWorkflowId) {
wfDel.setTargetWorkflowSteps([]);
wfDel.setTargetStepId("");
return;
}
let cancelled = false;
listWorkflowStepsAction(wfDel.targetWorkflowId)
.then((res) => {
if (!cancelled) {
const steps = res.steps ?? [];
wfDel.setTargetWorkflowSteps(steps);
wfDel.setTargetStepId(steps.length > 0 ? steps[0].id : "");
}
})
.catch(() => {
if (!cancelled) wfDel.setTargetWorkflowSteps([]);
});
return () => {
cancelled = true;
};
}, [wfDel.targetWorkflowId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleDeleteWorkflowClick = async () => {
if (readOnly) return;
wfDel.setWorkflowDeleteLoading(true);
try {
const { task_count } = await getWorkflowTaskCount(workflow.id);
wfDel.setWorkflowTaskCount(task_count);
if (task_count > 0 && otherWorkflows.length > 0)
wfDel.setTargetWorkflowId(otherWorkflows[0].id);
wfDel.setDeleteOpen(true);
} catch (error) {
toast({
title: t("workflows:failedToCheckWorkflowTasks"),
description: fallbackErrorMessage(error),
variant: "error",
});
} finally {
wfDel.setWorkflowDeleteLoading(false);
}
};
const handleDeleteWorkflow = async () => {
// A background sync can flip the workflow read-only while the delete
// dialog is already open; re-check at confirm time.
if (readOnly) return;
try {
await deleteWorkflowRun();
wfDel.setDeleteOpen(false);
} catch (error) {
toast({
title: t("workflows:failedToDeleteWorkflow"),
description: fallbackErrorMessage(error),
variant: "error",
});
}
};
const handleMigrateAndDeleteWorkflow = async () => {
if (readOnly) return;
if (!wfDel.targetWorkflowId || !wfDel.targetStepId) return;
wfDel.setMigrateLoading(true);
try {
await bulkMoveTasks({
source_workflow_id: workflow.id,
target_workflow_id: wfDel.targetWorkflowId,
target_step_id: wfDel.targetStepId,
});
await deleteWorkflowRun();
wfDel.setDeleteOpen(false);
} catch (error) {
toast({
title: t("workflows:failedToMigrateTasks"),
description: fallbackErrorMessage(error),
variant: "error",
});
} finally {
wfDel.setMigrateLoading(false);
}
};
return { handleDeleteWorkflowClick, handleDeleteWorkflow, handleMigrateAndDeleteWorkflow };
}
type StepDeleteHandlersParams = {
workflow: Workflow;
stepDel: {
stepToDelete: string | null;
targetStepForMigration: string;
setStepMigrateLoading: (v: boolean) => void;
setStepDeletePending: (v: boolean) => void;
setStepDeleteOpen: (v: boolean) => void;
setStepToDelete: (v: string | null) => void;
};
refreshWorkflowSteps: () => Promise<void>;
runMutation: (operation: () => Promise<void>, errorTitle: string) => Promise<boolean>;
};
export function useStepDeleteHandlers({
workflow,
stepDel,
refreshWorkflowSteps,
runMutation,
}: StepDeleteHandlersParams) {
const deletePendingRef = useRef(false);
const runStepDelete = async (operation: () => Promise<void>, errorTitle: string) => {
if (deletePendingRef.current) return;
let mutationCompleted = false;
deletePendingRef.current = true;
stepDel.setStepDeletePending(true);
try {
const saved = await runMutation(async () => {
stepDel.setStepMigrateLoading(true);
try {
if (!mutationCompleted) {
await operation();
mutationCompleted = true;
}
await refreshWorkflowSteps();
stepDel.setStepToDelete(null);
stepDel.setStepDeleteOpen(false);
} finally {
stepDel.setStepMigrateLoading(false);
}
}, errorTitle);
if (!saved) {
stepDel.setStepToDelete(null);
stepDel.setStepDeleteOpen(false);
}
} finally {
deletePendingRef.current = false;
stepDel.setStepDeletePending(false);
}
};
const handleMigrateAndDeleteStep = async () => {
if (!stepDel.stepToDelete || !stepDel.targetStepForMigration) return;
await runStepDelete(async () => {
await bulkMoveTasks({
source_workflow_id: workflow.id,
source_step_id: stepDel.stepToDelete!,
target_workflow_id: workflow.id,
target_step_id: stepDel.targetStepForMigration,
});
await deleteWorkflowStepAction(stepDel.stepToDelete!);
}, t("workflows:failedToMigrateTasks"));
};
const handleDeleteStepAndTasks = async () => {
if (!stepDel.stepToDelete) return;
await runStepDelete(
() => deleteWorkflowStepAction(stepDel.stepToDelete!),
t("workflows:failedToDeleteStep"),
);
};
return { handleMigrateAndDeleteStep, handleDeleteStepAndTasks };
}