diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index 658f3e4086..2f9d69c44d 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -103,6 +103,7 @@ type CreateStepRequest struct { Name string `json:"name"` Position int `json:"position"` Color string `json:"color"` + StageType *models.StageType `json:"stage_type,omitempty"` Prompt string `json:"prompt,omitempty"` Events *models.StepEvents `json:"events,omitempty"` AllowManualMove bool `json:"allow_manual_move"` @@ -130,6 +131,9 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge if req.Events != nil { step.Events = *req.Events } + if req.StageType != nil { + step.StageType = *req.StageType + } if req.IsStartStep != nil { step.IsStartStep = *req.IsStartStep } @@ -169,6 +173,7 @@ type UpdateStepRequest struct { Name *string `json:"name,omitempty"` Position *int `json:"position,omitempty"` Color *string `json:"color,omitempty"` + StageType *models.StageType `json:"stage_type,omitempty"` Prompt *string `json:"prompt,omitempty"` Events *models.StepEvents `json:"events,omitempty"` AllowManualMove *bool `json:"allow_manual_move,omitempty"` @@ -206,6 +211,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.Events != nil { step.Events = *req.Events } + if req.StageType != nil { + step.StageType = *req.StageType + } if req.AllowManualMove != nil { step.AllowManualMove = *req.AllowManualMove } diff --git a/apps/backend/internal/workflow/handlers/step_events_test.go b/apps/backend/internal/workflow/handlers/step_events_test.go index 057e87a8dd..144237fe39 100644 --- a/apps/backend/internal/workflow/handlers/step_events_test.go +++ b/apps/backend/internal/workflow/handlers/step_events_test.go @@ -177,8 +177,9 @@ func TestHTTPUpdateStepPublishesUpdatedEvent(t *testing.T) { rec := recordStepEvents(t, eventBus) resp := doJSON(t, router, http.MethodPut, "/api/v1/workflow/steps/"+step.ID, map[string]interface{}{ - "name": "Triage", - "wip_limit": 3, + "name": "Triage", + "stage_type": "review", + "wip_limit": 3, }) if resp.Code != http.StatusOK { t.Fatalf("update status = %d, body = %s", resp.Code, resp.Body.String()) @@ -191,6 +192,9 @@ func TestHTTPUpdateStepPublishesUpdatedEvent(t *testing.T) { if wip, ok := got.step["wip_limit"].(int); !ok || wip != 3 { t.Errorf("wip_limit = %#v, want 3", got.step["wip_limit"]) } + if stageType, ok := got.step["stage_type"].(string); !ok || stageType != "review" { + t.Errorf("stage_type = %#v, want review", got.step["stage_type"]) + } } func TestHTTPDeleteStepPublishesDeletedEvent(t *testing.T) { diff --git a/apps/web/app/actions/workspaces.test.ts b/apps/web/app/actions/workspaces.test.ts index 937847baef..b9de71c5e3 100644 --- a/apps/web/app/actions/workspaces.test.ts +++ b/apps/web/app/actions/workspaces.test.ts @@ -265,3 +265,78 @@ describe("workflow step cancellation fields", () => { }); }); }); + +describe("workflow step stage types", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + id: "step-1", + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + stage_type: "review", + created_at: "", + updated_at: "", + }), + ), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("preserves stage types returned from workflow step APIs", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + steps: [ + { + id: "step-1", + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + stage_type: "review", + created_at: "", + updated_at: "", + }, + ], + }), + ), + ); + + const result = await listWorkflowStepsAction("wf-1"); + + expect(result.steps[0].stage_type).toBe("review"); + }); + + it("sends stage types when creating a workflow step", async () => { + await createWorkflowStepAction({ + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + stage_type: "review", + } as Parameters[0]); + + const fetchMock = fetch as unknown as ReturnType; + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ stage_type: "review" }); + }); + + it("sends stage types when updating a workflow step", async () => { + await updateWorkflowStepAction("step-1", { + stage_type: "review", + } as Parameters[1]); + + const fetchMock = fetch as unknown as ReturnType; + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ stage_type: "review" }); + }); +}); diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 35692fba0b..bacf6912ee 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -400,6 +400,7 @@ type BackendWorkflowStep = { cancel_triggers_turn_complete?: boolean; wip_limit?: number; pull_from_step_id?: string | null; + stage_type?: WorkflowStep["stage_type"]; created_at: string; updated_at: string; }; @@ -421,6 +422,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ cancel_triggers_turn_complete: step.cancel_triggers_turn_complete, wip_limit: step.wip_limit ?? 0, pull_from_step_id: step.pull_from_step_id ?? null, + stage_type: step.stage_type, created_at: step.created_at, updated_at: step.updated_at, }); @@ -462,6 +464,7 @@ export async function createWorkflowStepAction(payload: { allow_manual_move?: boolean; wip_limit?: number; pull_from_step_id?: string | null; + stage_type?: WorkflowStep["stage_type"]; cancel_triggers_turn_complete?: boolean; }): Promise { const body = { @@ -475,6 +478,7 @@ export async function createWorkflowStepAction(payload: { is_start_step: payload.is_start_step ?? false, wip_limit: payload.wip_limit ?? 0, pull_from_step_id: payload.pull_from_step_id ?? "", + stage_type: payload.stage_type, cancel_triggers_turn_complete: payload.cancel_triggers_turn_complete ?? false, }; const response = await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps`, { @@ -503,6 +507,7 @@ export async function updateWorkflowStepAction( | "cancel_triggers_turn_complete" | "wip_limit" | "pull_from_step_id" + | "stage_type" > >, ): Promise { @@ -525,6 +530,7 @@ export async function updateWorkflowStepAction( body.cancel_triggers_turn_complete = payload.cancel_triggers_turn_complete; if (payload.wip_limit !== undefined) body.wip_limit = payload.wip_limit; if (payload.pull_from_step_id !== undefined) body.pull_from_step_id = payload.pull_from_step_id; + body.stage_type = payload.stage_type; const response = await fetchJson( `${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { diff --git a/apps/web/app/settings/workspace/use-workflow-creation.test.ts b/apps/web/app/settings/workspace/use-workflow-creation.test.ts index c30690e010..fbedc302fa 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.test.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Workflow, WorkflowTemplate, Workspace } from "@/lib/types/http"; +import type { Workflow, WorkflowStep, WorkflowTemplate, Workspace } from "@/lib/types/http"; import { createDraftWorkflowSteps, useWorkflowCreation } from "./use-workflow-creation"; const workspace = { id: "workspace-1", name: "Workspace" } as Workspace; @@ -18,13 +18,21 @@ const template = { ], } as WorkflowTemplate; -function renderCreationHook(workflowTemplates: WorkflowTemplate[] = []) { - let workflows: Workflow[] = []; +function renderCreationHook( + workflowTemplates: WorkflowTemplate[] = [], + initialWorkflows: Workflow[] = [], +) { + let workflows: Workflow[] = initialWorkflows; const setWorkflowItems = vi.fn((update: React.SetStateAction) => { workflows = typeof update === "function" ? update(workflows) : update; }); const view = renderHook(() => - useWorkflowCreation({ workspace, workflowTemplates, setWorkflowItems }), + useWorkflowCreation({ + workspace, + workflowItems: workflows, + workflowTemplates, + setWorkflowItems, + }), ); return { ...view, setWorkflowItems, getWorkflows: () => workflows }; } @@ -113,3 +121,48 @@ describe("useWorkflowCreation", () => { }); }); }); + +describe("useWorkflowCreation duplication", () => { + it("inserts a duplicated workflow after its source and keeps its steps local", () => { + const source = { + id: "workflow-source", + workspace_id: workspace.id, + name: "Source Workflow", + description: "Source description", + created_at: "saved", + updated_at: "saved", + } as Workflow; + const sourceSteps = [ + { + id: "step-source", + workflow_id: source.id, + name: "Source Step", + position: 0, + color: "bg-blue-500", + created_at: "saved", + updated_at: "saved", + }, + ] as WorkflowStep[]; + const { result, getWorkflows } = renderCreationHook([], [source]); + const duplicate = ( + result.current as typeof result.current & { + handleDuplicateWorkflow: (workflow: Workflow, steps: WorkflowStep[]) => void; + } + ).handleDuplicateWorkflow; + + expect(duplicate).toEqual(expect.any(Function)); + if (typeof duplicate !== "function") return; + + act(() => duplicate(source, sourceSteps)); + + expect(getWorkflows().map((workflow) => workflow.name)).toEqual([ + "Source Workflow", + "Source Workflow (copy)", + ]); + const copied = getWorkflows()[1]; + expect(copied.id).toMatch(/^temp-workflow-/); + expect(result.current.initialStepsByWorkflowId.get(copied.id)).toEqual([ + expect.objectContaining({ name: "Source Step", workflow_id: copied.id }), + ]); + }); +}); diff --git a/apps/web/app/settings/workspace/use-workflow-creation.ts b/apps/web/app/settings/workspace/use-workflow-creation.ts index bab300d3d3..f7fbae5f34 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.ts @@ -11,6 +11,7 @@ import { type WorkflowTemplate, type Workspace, } from "@/lib/types/http"; +import { createWorkflowDuplication } from "./workflow-duplication"; // Seeded step definitions used whenever no template supplies default steps — // the Custom option, and any template whose `default_steps` is absent or empty. @@ -26,6 +27,7 @@ export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [ type WorkflowCreationArgs = { workspace: Workspace | null; + workflowItems: Workflow[]; workflowTemplates: WorkflowTemplate[]; setWorkflowItems: React.Dispatch>; }; @@ -93,6 +95,7 @@ export function createDraftWorkflowSteps( export function useWorkflowCreation({ workspace, + workflowItems, workflowTemplates, setWorkflowItems, }: WorkflowCreationArgs) { @@ -135,6 +138,16 @@ export function useWorkflowCreation({ setIsAddWorkflowDialogOpen(false); }; + const handleDuplicateWorkflow = (source: Workflow, sourceSteps: WorkflowStep[]) => { + const { workflow, steps } = createWorkflowDuplication(source, workflowItems, sourceSteps); + setInitialStepsByWorkflowId((previous) => new Map(previous).set(workflow.id, steps)); + setWorkflowItems((previous) => { + const sourceIndex = previous.findIndex((item) => item.id === source.id); + if (sourceIndex === -1) return [...previous, workflow]; + return [...previous.slice(0, sourceIndex + 1), workflow, ...previous.slice(sourceIndex + 1)]; + }); + }; + const forgetInitialSteps = (workflowId: string) => { setInitialStepsByWorkflowId((previous) => { const next = new Map(previous); @@ -167,6 +180,7 @@ export function useWorkflowCreation({ initialStepsByWorkflowId, handleOpenAddWorkflowDialog, handleCreateWorkflow, + handleDuplicateWorkflow, forgetInitialSteps, remapInitialSteps, }; diff --git a/apps/web/app/settings/workspace/use-workflow-duplication.test.ts b/apps/web/app/settings/workspace/use-workflow-duplication.test.ts new file mode 100644 index 0000000000..e6a8f20f60 --- /dev/null +++ b/apps/web/app/settings/workspace/use-workflow-duplication.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listWorkflowStepsAction } from "@/app/actions/workspaces"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { useWorkflowDuplication } from "./use-workflow-duplication"; + +vi.mock("@/app/actions/workspaces", () => ({ + listWorkflowStepsAction: vi.fn(), +})); + +const workflow = { + id: "workflow-1", + workspace_id: "workspace-1", + name: "Workflow", + created_at: "saved", + updated_at: "saved", +} as Workflow; + +const steps = [ + { + id: "step-1", + workflow_id: workflow.id, + name: "Step", + position: 0, + created_at: "saved", + updated_at: "saved", + }, +] as WorkflowStep[]; + +function renderDuplicationHook( + overrides: Partial[0]> = {}, +) { + const onDuplicateWorkflow = vi.fn(); + const toast = vi.fn(); + const view = renderHook(() => + useWorkflowDuplication({ + workflow, + hasUnsavedChanges: false, + mutationPending: false, + onDuplicateWorkflow, + toast, + ...overrides, + }), + ); + return { ...view, onDuplicateWorkflow, toast }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listWorkflowStepsAction).mockResolvedValue({ steps, total: steps.length }); +}); + +describe("useWorkflowDuplication", () => { + it("loads saved steps and delegates them to the draft creator", async () => { + const { result, onDuplicateWorkflow } = renderDuplicationHook(); + + await act(async () => { + await result.current.handleDuplicateWorkflow(); + }); + + expect(listWorkflowStepsAction).toHaveBeenCalledWith(workflow.id); + expect(onDuplicateWorkflow).toHaveBeenCalledWith(steps); + expect(result.current.duplicateLoading).toBe(false); + }); + + it("blocks dirty sources and explains that they must be saved first", () => { + const { result } = renderDuplicationHook({ hasUnsavedChanges: true }); + + expect(result.current.duplicateDisabled).toBe(true); + expect(result.current.duplicateDisabledReason).toBe("Save the workflow before duplicating it."); + expect(listWorkflowStepsAction).not.toHaveBeenCalled(); + }); + + it("keeps sync-managed sources copyable", () => { + const { result } = renderDuplicationHook({ + workflow: { ...workflow, source: "github" } as Workflow, + }); + + expect(result.current.duplicateDisabled).toBe(false); + }); + + it("shows a load failure without creating a draft", async () => { + const failure = new Error("step load failed"); + vi.mocked(listWorkflowStepsAction).mockRejectedValueOnce(failure); + const { result, onDuplicateWorkflow, toast } = renderDuplicationHook(); + + await act(async () => { + await result.current.handleDuplicateWorkflow(); + }); + + expect(onDuplicateWorkflow).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith({ + title: "Failed to duplicate workflow", + description: "step load failed", + variant: "error", + }); + expect(result.current.duplicateLoading).toBe(false); + }); + + it("ignores a second activation while the saved steps are loading", async () => { + let resolveSteps!: (value: { steps: WorkflowStep[]; total: number }) => void; + vi.mocked(listWorkflowStepsAction).mockReturnValueOnce( + new Promise((resolve) => { + resolveSteps = resolve; + }), + ); + const { result, onDuplicateWorkflow } = renderDuplicationHook(); + + let firstRequest: Promise; + let secondRequest: Promise; + await act(async () => { + firstRequest = result.current.handleDuplicateWorkflow(); + secondRequest = result.current.handleDuplicateWorkflow(); + await Promise.resolve(); + }); + + expect(listWorkflowStepsAction).toHaveBeenCalledOnce(); + resolveSteps({ steps, total: steps.length }); + await act(async () => { + await Promise.all([firstRequest!, secondRequest!]); + }); + + expect(onDuplicateWorkflow).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/app/settings/workspace/use-workflow-duplication.ts b/apps/web/app/settings/workspace/use-workflow-duplication.ts new file mode 100644 index 0000000000..3443416af5 --- /dev/null +++ b/apps/web/app/settings/workspace/use-workflow-duplication.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listWorkflowStepsAction } from "@/app/actions/workspaces"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { useToast } from "@/components/toast-provider"; + +type UseWorkflowDuplicationArgs = { + workflow: Workflow; + hasUnsavedChanges: boolean; + mutationPending: boolean; + isImproveWorkspace?: boolean; + onDuplicateWorkflow: (steps: WorkflowStep[]) => void; + toast: ReturnType["toast"]; +}; + +export function useWorkflowDuplication({ + workflow, + hasUnsavedChanges, + mutationPending, + isImproveWorkspace, + onDuplicateWorkflow, + toast, +}: UseWorkflowDuplicationArgs) { + const { t } = useTranslation(); + const [duplicateLoading, setDuplicateLoading] = useState(false); + const duplicateLoadingRef = useRef(false); + let duplicateDisabledReason: string | undefined; + + if (isImproveWorkspace) { + duplicateDisabledReason = t("workflows:duplicateUnavailableInImproveKandev"); + } else if (workflow.id.startsWith("temp-workflow-") || hasUnsavedChanges) { + duplicateDisabledReason = t("workflows:saveBeforeDuplicating"); + } else if (mutationPending) { + duplicateDisabledReason = t("workflows:workflowMutationInProgress"); + } + + const duplicateDisabled = duplicateLoading || duplicateDisabledReason !== undefined; + + const handleDuplicateWorkflow = async () => { + if (duplicateLoadingRef.current || duplicateDisabled) return; + duplicateLoadingRef.current = true; + setDuplicateLoading(true); + try { + const result = await listWorkflowStepsAction(workflow.id); + onDuplicateWorkflow(result.steps ?? []); + } catch (error) { + toast({ + title: t("workflows:failedToDuplicateWorkflow"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); + } finally { + duplicateLoadingRef.current = false; + setDuplicateLoading(false); + } + }; + + return { + duplicateDisabled, + duplicateDisabledReason, + duplicateLoading, + handleDuplicateWorkflow, + }; +} diff --git a/apps/web/app/settings/workspace/workflow-duplication.test.ts b/apps/web/app/settings/workspace/workflow-duplication.test.ts new file mode 100644 index 0000000000..ca136ceda0 --- /dev/null +++ b/apps/web/app/settings/workspace/workflow-duplication.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + agentProfileId, + workflowId, + workspaceId, + type Workflow, + type WorkflowStep, +} from "@/lib/types/http"; +import { createWorkflowDuplication, getWorkflowCopyName } from "./workflow-duplication"; + +const workspace = workspaceId("workspace-1"); +const REVIEW_STEP_ID = "step-review"; +const DONE_STEP_ID = "step-done"; +const REVIEW_COPY_NAME = "Review (copy)"; +const REVIEW_COPY_TWO_NAME = "Review (copy 2)"; + +function sourceWorkflow(name = "Review"): Workflow { + return { + id: workflowId("workflow-source"), + workspace_id: workspace, + name, + description: "Review configuration", + prompt: "Review the task", + agent_profile_id: agentProfileId("agent-source"), + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }; +} + +function sourceSteps(): WorkflowStep[] { + return [ + { + id: REVIEW_STEP_ID, + workflow_id: workflowId("workflow-source"), + name: "Review", + position: 0, + color: "bg-purple-500", + prompt: "Review this task", + stage_type: "review", + events: { + on_turn_complete: [ + { type: "move_to_step", config: { step_id: DONE_STEP_ID, requires_approval: true } }, + ], + }, + allow_manual_move: false, + is_start_step: true, + show_in_command_panel: true, + auto_archive_after_hours: 24, + agent_profile_id: "step-agent", + auto_advance_requires_signal: true, + cancel_triggers_turn_complete: true, + wip_limit: 2, + pull_from_step_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + { + id: DONE_STEP_ID, + workflow_id: workflowId("workflow-source"), + name: "Done", + position: 1, + color: "bg-green-500", + events: { + on_children_completed: [{ type: "move_to_step", config: { step_id: REVIEW_STEP_ID } }], + }, + allow_manual_move: true, + is_start_step: false, + show_in_command_panel: false, + auto_archive_after_hours: 0, + agent_profile_id: "", + auto_advance_requires_signal: false, + cancel_triggers_turn_complete: false, + wip_limit: 0, + pull_from_step_id: REVIEW_STEP_ID, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + ]; +} + +let uuidCounter = 0; + +beforeEach(() => { + uuidCounter = 0; + vi.spyOn(crypto, "randomUUID").mockImplementation(() => { + return `00000000-0000-4000-8000-${String(uuidCounter++).padStart(12, "0")}`; + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe("getWorkflowCopyName", () => { + it.each([ + { + name: "Review", + existing: ["Review"], + expected: REVIEW_COPY_NAME, + }, + { + name: "Review", + existing: ["Review", REVIEW_COPY_NAME], + expected: REVIEW_COPY_TWO_NAME, + }, + { + name: REVIEW_COPY_TWO_NAME, + existing: ["Review", REVIEW_COPY_NAME, REVIEW_COPY_TWO_NAME], + expected: "Review (copy 3)", + }, + { + name: "Review", + existing: ["Review", REVIEW_COPY_NAME, "Review (copy 3)"], + expected: REVIEW_COPY_TWO_NAME, + }, + ])("chooses the lowest available suffix for $name", ({ name, existing, expected }) => { + expect( + getWorkflowCopyName( + name, + existing.map((item) => ({ name: item })), + ), + ).toBe(expected); + }); +}); + +describe("createWorkflowDuplication", () => { + it("copies editable metadata and remaps every internal step reference", () => { + const source = sourceWorkflow(); + const steps = sourceSteps(); + const result = createWorkflowDuplication(source, [source], steps); + + expect(result.workflow).toMatchObject({ + workspace_id: source.workspace_id, + name: REVIEW_COPY_NAME, + description: source.description, + prompt: source.prompt, + agent_profile_id: source.agent_profile_id, + created_at: "", + updated_at: "", + }); + expect(result.workflow.id).toMatch(/^temp-workflow-/); + expect(result.workflow).not.toHaveProperty("workflow_template_id"); + expect(result.workflow).not.toHaveProperty("source"); + expect(result.workflow).not.toHaveProperty("source_path"); + + const [copiedReview, copiedDone] = result.steps; + expect(copiedReview).toMatchObject({ + name: "Review", + position: 0, + color: "bg-purple-500", + prompt: "Review this task", + stage_type: "review", + allow_manual_move: false, + is_start_step: true, + show_in_command_panel: true, + auto_archive_after_hours: 24, + agent_profile_id: "step-agent", + auto_advance_requires_signal: true, + cancel_triggers_turn_complete: true, + wip_limit: 2, + pull_from_step_id: null, + created_at: "", + updated_at: "", + workflow_id: result.workflow.id, + }); + expect(copiedDone.pull_from_step_id).toBe(copiedReview.id); + expect(copiedReview.events?.on_turn_complete).toEqual([ + { + type: "move_to_step", + config: { step_id: copiedDone.id, requires_approval: true }, + }, + ]); + expect(copiedDone.events?.on_children_completed).toEqual([ + { type: "move_to_step", config: { step_id: copiedReview.id } }, + ]); + expect(result.steps.map((step) => step.id)).not.toContain(REVIEW_STEP_ID); + expect(result.steps.map((step) => step.id)).not.toContain(DONE_STEP_ID); + }); + + it("returns a deeply independent graph", () => { + const source = sourceWorkflow(); + const steps = sourceSteps(); + const result = createWorkflowDuplication(source, [source], steps); + + result.workflow.description = "Changed copy"; + ( + result.steps[0].events!.on_turn_complete![0] as { config: { step_id: string } } + ).config.step_id = "changed"; + result.steps[1].name = "Changed step"; + + expect(source.description).toBe("Review configuration"); + expect( + (steps[0].events?.on_turn_complete?.[0] as { config: { step_id: string } }).config.step_id, + ).toBe(DONE_STEP_ID); + expect(steps[1].name).toBe("Done"); + }); +}); diff --git a/apps/web/app/settings/workspace/workflow-duplication.ts b/apps/web/app/settings/workspace/workflow-duplication.ts new file mode 100644 index 0000000000..8b264732ab --- /dev/null +++ b/apps/web/app/settings/workspace/workflow-duplication.ts @@ -0,0 +1,99 @@ +import { generateUUID } from "@/lib/utils"; +import { workflowId as toWorkflowId, type Workflow, type WorkflowStep } from "@/lib/types/http"; + +const COPY_SUFFIX_PATTERN = /^(.*) \(copy(?: (\d+))?\)$/; + +export function getWorkflowCopyName( + sourceName: string, + existingWorkflows: readonly Pick[], +): string { + const baseName = sourceName.match(COPY_SUFFIX_PATTERN)?.[1] ?? sourceName; + const existingNames = new Set(existingWorkflows.map((workflow) => workflow.name)); + + for (let suffix = 1; ; suffix += 1) { + const candidate = `${baseName} (copy${suffix === 1 ? "" : ` ${suffix}`})`; + if (!existingNames.has(candidate)) return candidate; + } +} + +function remapStepReferences(value: T, stepIds: ReadonlyMap): T { + if (Array.isArray(value)) return value.map((item) => remapStepReferences(item, stepIds)) as T; + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === "step_id" && typeof item === "string" + ? (stepIds.get(item) ?? item) + : remapStepReferences(item, stepIds), + ]), + ) as T; +} + +function temporaryWorkflowId(): string { + return `temp-workflow-${generateUUID()}`; +} + +function temporaryStepId(): string { + return `temp-step-${generateUUID()}`; +} + +function cloneWorkflow(source: Workflow, workflows: readonly Workflow[], id: string): Workflow { + return { + id: toWorkflowId(id), + workspace_id: source.workspace_id, + name: getWorkflowCopyName(source.name, workflows), + description: source.description, + prompt: source.prompt, + agent_profile_id: source.agent_profile_id, + created_at: "", + updated_at: "", + }; +} + +function cloneWorkflowStep( + source: WorkflowStep, + workflowId: WorkflowStep["workflow_id"], + stepId: string, + stepIds: ReadonlyMap, +): WorkflowStep { + const remapStepId = (id: string | null | undefined) => + id == null ? id : (stepIds.get(id) ?? id); + + return { + id: stepId, + workflow_id: workflowId, + name: source.name, + position: source.position, + color: source.color, + prompt: source.prompt, + events: remapStepReferences(source.events, stepIds), + allow_manual_move: source.allow_manual_move, + is_start_step: source.is_start_step, + show_in_command_panel: source.show_in_command_panel, + auto_archive_after_hours: source.auto_archive_after_hours, + agent_profile_id: source.agent_profile_id, + auto_advance_requires_signal: source.auto_advance_requires_signal, + cancel_triggers_turn_complete: source.cancel_triggers_turn_complete, + wip_limit: source.wip_limit, + pull_from_step_id: remapStepId(source.pull_from_step_id), + stage_type: source.stage_type, + created_at: "", + updated_at: "", + }; +} + +export function createWorkflowDuplication( + source: Workflow, + workflows: readonly Workflow[], + sourceSteps: readonly WorkflowStep[], +): { workflow: Workflow; steps: WorkflowStep[] } { + const clientWorkflowId = temporaryWorkflowId(); + const stepIds = new Map(sourceSteps.map((step) => [step.id, temporaryStepId()])); + const workflow = cloneWorkflow(source, workflows, clientWorkflowId); + const steps = sourceSteps.map((step) => + cloneWorkflowStep(step, workflow.id, stepIds.get(step.id)!, stepIds), + ); + + return { workflow, steps }; +} diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 28937ae61e..b891df1624 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -183,6 +183,7 @@ function useWorkflowActions({ const finalizedWorkflowIdsRef = useRef(new Map()); const creation = useWorkflowCreation({ workspace, + workflowItems, workflowTemplates, setWorkflowItems, }); @@ -294,6 +295,7 @@ type WorkflowListProps = { u: { name?: string; description?: string; agent_profile_id?: string }, ) => void; onDelete: (id: string) => void; + onDuplicate: (workflow: Workflow, steps: WorkflowStep[]) => void; onWorkflowSaved: (params: WorkflowSavedParams) => void; onDiscard: (id: string) => void; onReorder: (items: Workflow[]) => void; @@ -352,6 +354,7 @@ function WorkflowList({ isImproveWorkspace, onUpdate, onDelete, + onDuplicate, onWorkflowSaved, onDiscard, onReorder, @@ -403,6 +406,7 @@ function WorkflowList({ onDeleteWorkflow={async () => { await onDelete(workflow.id); }} + onDuplicateWorkflow={(steps) => onDuplicate(workflow, steps)} onWorkflowSaved={onWorkflowSaved} onDiscardWorkflow={() => onDiscard(workflow.id)} /> @@ -470,6 +474,7 @@ export function WorkspaceWorkflowsClient({ isImproveWorkspace={isImproveWorkspace} onUpdate={page.handleUpdateWorkflow} onDelete={page.handleDeleteWorkflow} + onDuplicate={page.handleDuplicateWorkflow} onWorkflowSaved={page.handleWorkflowSaved} onDiscard={page.handleDiscardWorkflow} onReorder={page.handleReorderWorkflows} diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index 062c4971d2..cf4ac89eb3 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -316,6 +316,7 @@ async function createMissingSteps( name: step.name, position: step.position, color: step.color, + stage_type: step.stage_type ?? "custom", cancel_triggers_turn_complete: step.cancel_triggers_turn_complete ?? false, }); mappings.set(step.id, created.id); @@ -392,6 +393,7 @@ function stepUpdatePayload(step: WorkflowStep): Partial { name: step.name, position: step.position, color: step.color, + stage_type: step.stage_type ?? "custom", prompt: step.prompt ?? "", events: step.events ?? {}, allow_manual_move: step.allow_manual_move ?? true, diff --git a/apps/web/components/settings/workflow-card-dialogs-content.tsx b/apps/web/components/settings/workflow-card-dialogs-content.tsx new file mode 100644 index 0000000000..19968aa430 --- /dev/null +++ b/apps/web/components/settings/workflow-card-dialogs-content.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog"; +import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic"; +import { WorkflowDeleteDialog, StepDeleteDialog } from "./workflow-card-dialogs"; +import type { WorkflowMutationGuardController } from "./workflow-mutation-guard"; + +export type WorkflowDeleteState = { + deleteOpen: boolean; + setDeleteOpen: (v: boolean) => void; + workflowTaskCount: number | null; + setWorkflowTaskCount: (v: number | null) => void; + workflowDeleteLoading: boolean; + setWorkflowDeleteLoading: (v: boolean) => void; + targetWorkflowId: string; + setTargetWorkflowId: (v: string) => void; + targetWorkflowSteps: WorkflowStep[]; + setTargetWorkflowSteps: (v: WorkflowStep[]) => void; + targetStepId: string; + setTargetStepId: (v: string) => void; + migrateLoading: boolean; + setMigrateLoading: (v: boolean) => void; +}; + +export type StepDeleteState = { + stepDeleteOpen: boolean; + setStepDeleteOpen: (v: boolean) => void; + stepToDelete: string | null; + setStepToDelete: (v: string | null) => void; + stepTaskCount: number | null; + setStepTaskCount: (v: number | null) => void; + targetStepForMigration: string; + setTargetStepForMigration: (v: string) => void; + stepMigrateLoading: boolean; + setStepMigrateLoading: (v: boolean) => void; + stepDeletePending: boolean; + setStepDeletePending: (v: boolean) => void; +}; + +type WorkflowCardDialogsProps = { + wfDel: WorkflowDeleteState; + otherWorkflows: Workflow[]; + deleteWorkflowLoading: boolean; + wfDeleteHandlers: { + handleDeleteWorkflow: () => Promise; + handleMigrateAndDeleteWorkflow: () => Promise; + }; + exportOpen: boolean; + setExportOpen: (open: boolean) => void; + exportYaml: string; + stepDel: StepDeleteState; + stepToDeleteName: string; + stepsForStepMigration: WorkflowStep[]; + stepDeleteHandlers: { + handleMigrateAndDeleteStep: () => Promise; + handleDeleteStepAndTasks: () => Promise; + }; + hasUnsavedChanges: boolean; + mutationGuard: WorkflowMutationGuardController; +}; + +export function WorkflowCardDialogs({ + wfDel, + otherWorkflows, + deleteWorkflowLoading, + wfDeleteHandlers, + exportOpen, + setExportOpen, + exportYaml, + stepDel, + stepToDeleteName, + stepsForStepMigration, + stepDeleteHandlers, + hasUnsavedChanges, + mutationGuard, +}: WorkflowCardDialogsProps) { + const { t } = useTranslation(); + return ( + <> + + + + + + ); +} diff --git a/apps/web/components/settings/workflow-card-header-actions.test.tsx b/apps/web/components/settings/workflow-card-header-actions.test.tsx index ebb5f3ea0d..4834b01884 100644 --- a/apps/web/components/settings/workflow-card-header-actions.test.tsx +++ b/apps/web/components/settings/workflow-card-header-actions.test.tsx @@ -1,27 +1,49 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { WorkflowCardHeaderActions } from "./workflow-card-header-actions"; +afterEach(cleanup); + +const DUPLICATE_BUTTON_TEST_ID = "duplicate-workflow-button"; + +const renderActions = ( + overrides: Partial> = {}, +) => { + const defaultProps: ComponentProps = { + workflowId: "workflow-1", + setExportYaml: vi.fn(), + setExportOpen: vi.fn(), + toast: vi.fn(), + onDeleteClick: vi.fn().mockResolvedValue(undefined), + onDuplicateClick: vi.fn().mockResolvedValue(undefined), + deleteDisabled: false, + exportDisabled: false, + duplicateDisabled: false, + duplicateLoading: false, + readOnly: false, + }; + + return render( + + + , + ); +}; + describe("WorkflowCardHeaderActions", () => { it("surfaces failures when deleting a temporary workflow", async () => { const failure = new Error("cleanup failed"); const toast = vi.fn(); - render( - - - , - ); + renderActions({ + workflowId: "temp-workflow-1", + toast, + onDeleteClick: vi.fn().mockRejectedValue(failure), + exportDisabled: true, + duplicateDisabled: true, + }); fireEvent.click(screen.getByRole("button", { name: "Delete Workflow" })); @@ -33,4 +55,34 @@ describe("WorkflowCardHeaderActions", () => { }), ); }); + + it("shows the duplicate action in the card action row", () => { + renderActions(); + + expect(screen.getByTestId(DUPLICATE_BUTTON_TEST_ID)).toBeTruthy(); + }); + + it("invokes duplication and exposes the save-first disabled reason", () => { + const onDuplicateClick = vi.fn().mockResolvedValue(undefined); + const reason = "Save the workflow before duplicating it."; + + renderActions({ onDuplicateClick }); + + fireEvent.click(screen.getByTestId(DUPLICATE_BUTTON_TEST_ID)); + expect(onDuplicateClick).toHaveBeenCalledOnce(); + + cleanup(); + renderActions({ onDuplicateClick, duplicateDisabled: true, duplicateDisabledReason: reason }); + + const duplicateButton = screen.getByTestId(DUPLICATE_BUTTON_TEST_ID); + expect((duplicateButton as HTMLButtonElement).disabled).toBe(true); + expect(duplicateButton.parentElement?.getAttribute("aria-label")).toBe(reason); + }); + + it("labels the duplicate action while loading", () => { + renderActions({ duplicateDisabled: true, duplicateLoading: true }); + + const duplicateButton = screen.getByTestId(DUPLICATE_BUTTON_TEST_ID); + expect(duplicateButton.parentElement?.getAttribute("aria-label")).toBe("Duplicating..."); + }); }); diff --git a/apps/web/components/settings/workflow-card-header-actions.tsx b/apps/web/components/settings/workflow-card-header-actions.tsx index 4f5c682df8..8a5cc8c714 100644 --- a/apps/web/components/settings/workflow-card-header-actions.tsx +++ b/apps/web/components/settings/workflow-card-header-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { IconDownload, IconTrash } from "@tabler/icons-react"; +import { IconCopy, IconDownload, IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -13,8 +13,12 @@ type WorkflowCardHeaderActionsProps = { setExportOpen: (open: boolean) => void; toast: ReturnType["toast"]; onDeleteClick: () => Promise; + onDuplicateClick: () => Promise; deleteDisabled: boolean; exportDisabled: boolean; + duplicateDisabled: boolean; + duplicateDisabledReason?: string; + duplicateLoading: boolean; readOnly: boolean; }; @@ -24,11 +28,17 @@ export function WorkflowCardHeaderActions({ setExportOpen, toast, onDeleteClick, + onDuplicateClick, deleteDisabled, exportDisabled, + duplicateDisabled, + duplicateDisabledReason, + duplicateLoading, readOnly, }: WorkflowCardHeaderActionsProps) { const { t } = useTranslation(); + const duplicateTooltip = + duplicateDisabledReason ?? (duplicateLoading ? t("workflows:duplicatingWorkflow") : undefined); return (
@@ -53,6 +63,30 @@ export function WorkflowCardHeaderActions({ {exportDisabled && {t("workflows:saveBeforeExporting")}} + + + + + + + {duplicateTooltip && {duplicateTooltip}} + diff --git a/apps/web/components/settings/workflow-card.tsx b/apps/web/components/settings/workflow-card.tsx index 804b9df439..3fca325d9f 100644 --- a/apps/web/components/settings/workflow-card.tsx +++ b/apps/web/components/settings/workflow-card.tsx @@ -12,11 +12,14 @@ import type { WorkflowReplayCycleDiagnostic } from "@/lib/workflows/replay-cycle import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles"; import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; -import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog"; import { WorkflowPipelineEditor } from "@/components/settings/workflow-pipeline-editor"; import { listWorkflowStepsAction } from "@/app/actions/workspaces"; import { HelpTip } from "./workflow-pipeline-editor-helpers"; -import { WorkflowDeleteDialog, StepDeleteDialog } from "./workflow-card-dialogs"; +import { + WorkflowCardDialogs, + type StepDeleteState, + type WorkflowDeleteState, +} from "./workflow-card-dialogs-content"; import { useWorkflowStepActions, useWorkflowDeleteHandlers, @@ -27,10 +30,10 @@ import { SettingsCard } from "./settings-card"; import { isWorkflowFieldDirty } from "./workflow-dirty-state"; import { WorkflowSyncedBadge } from "./workflow-synced-badge"; import { useWorkflowMutationGuard } from "./workflow-mutation-guard"; -import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic"; import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor"; import { WorkflowPromptSection } from "./workflow-prompt-section"; import { WorkflowDescriptionField } from "./workflow-description-field"; +import { useWorkflowDuplication } from "@/app/settings/workspace/use-workflow-duplication"; const TEMP_WORKFLOW_PREFIX = "temp-workflow-"; @@ -50,6 +53,7 @@ type WorkflowCardProps = { agent_profile_id?: string; }) => void; onDeleteWorkflow: () => Promise; + onDuplicateWorkflow: (steps: WorkflowStep[]) => void; onWorkflowSaved: (params: { clientWorkflow: Workflow; submittedWorkflow: Workflow; @@ -114,23 +118,6 @@ function useWorkflowSteps( }; } -type WorkflowDeleteState = { - deleteOpen: boolean; - setDeleteOpen: (v: boolean) => void; - workflowTaskCount: number | null; - setWorkflowTaskCount: (v: number | null) => void; - workflowDeleteLoading: boolean; - setWorkflowDeleteLoading: (v: boolean) => void; - targetWorkflowId: string; - setTargetWorkflowId: (v: string) => void; - targetWorkflowSteps: WorkflowStep[]; - setTargetWorkflowSteps: (v: WorkflowStep[]) => void; - targetStepId: string; - setTargetStepId: (v: string) => void; - migrateLoading: boolean; - setMigrateLoading: (v: boolean) => void; -}; - function useWorkflowDeleteState(): WorkflowDeleteState { const [deleteOpen, setDeleteOpen] = useState(false); const [workflowTaskCount, setWorkflowTaskCount] = useState(null); @@ -157,21 +144,6 @@ function useWorkflowDeleteState(): WorkflowDeleteState { }; } -type StepDeleteState = { - stepDeleteOpen: boolean; - setStepDeleteOpen: (v: boolean) => void; - stepToDelete: string | null; - setStepToDelete: (v: string | null) => void; - stepTaskCount: number | null; - setStepTaskCount: (v: number | null) => void; - targetStepForMigration: string; - setTargetStepForMigration: (v: string) => void; - stepMigrateLoading: boolean; - setStepMigrateLoading: (v: boolean) => void; - stepDeletePending: boolean; - setStepDeletePending: (v: boolean) => void; -}; - function useStepDeleteState(): StepDeleteState { const [stepDeleteOpen, setStepDeleteOpen] = useState(false); const [stepToDelete, setStepToDelete] = useState(null); @@ -195,28 +167,6 @@ function useStepDeleteState(): StepDeleteState { }; } -type WorkflowCardDialogsProps = { - wfDel: WorkflowDeleteState; - otherWorkflows: Workflow[]; - deleteWorkflowLoading: boolean; - wfDeleteHandlers: { - handleDeleteWorkflow: () => Promise; - handleMigrateAndDeleteWorkflow: () => Promise; - }; - exportOpen: boolean; - setExportOpen: (open: boolean) => void; - exportYaml: string; - stepDel: StepDeleteState; - stepToDeleteName: string; - stepsForStepMigration: WorkflowStep[]; - stepDeleteHandlers: { - handleMigrateAndDeleteStep: () => Promise; - handleDeleteStepAndTasks: () => Promise; - }; - hasUnsavedChanges: boolean; - mutationGuard: ReturnType; -}; - type WorkflowCardBodyProps = { workflow: Workflow; savedWorkflow?: Workflow; @@ -377,69 +327,6 @@ function WorkflowCardBody({ ); } -function WorkflowCardDialogs({ - wfDel, - otherWorkflows, - deleteWorkflowLoading, - wfDeleteHandlers, - exportOpen, - setExportOpen, - exportYaml, - stepDel, - stepToDeleteName, - stepsForStepMigration, - stepDeleteHandlers, - hasUnsavedChanges, - mutationGuard, -}: WorkflowCardDialogsProps) { - const { t } = useTranslation(); - return ( - <> - - - - - - ); -} - function useWorkflowCardState(props: WorkflowCardProps) { const { workflow, initialWorkflowSteps, otherWorkflows = [] } = props; const { onDeleteWorkflow } = props; @@ -538,6 +425,14 @@ export function WorkflowCard(props: WorkflowCardProps) { const { workflow, savedWorkflow, otherWorkflows = [], onUpdateWorkflow } = props; const s = useWorkflowCardState(props); const visibleSavedSteps = savedWorkflow ? s.savedWorkflowSteps : []; + const duplicate = useWorkflowDuplication({ + workflow, + hasUnsavedChanges: s.hasUnsavedChanges, + mutationPending: s.mutationGuard.isMutationPending, + isImproveWorkspace: props.isImproveWorkspace, + onDuplicateWorkflow: props.onDuplicateWorkflow, + toast: s.toast, + }); return (
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 5d97adcf2b..bc8b48916f 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1002,6 +1002,7 @@ export class ApiClient { wip_limit?: number; pull_from_step_id?: string | null; cancel_triggers_turn_complete?: boolean; + stage_type?: "work" | "review" | "approval" | "custom"; }, ): Promise { await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates }); diff --git a/apps/web/e2e/pages/workflow-settings-page.ts b/apps/web/e2e/pages/workflow-settings-page.ts index a9f7ba80e3..e6febb5175 100644 --- a/apps/web/e2e/pages/workflow-settings-page.ts +++ b/apps/web/e2e/pages/workflow-settings-page.ts @@ -32,9 +32,33 @@ export class WorkflowSettingsPage { } /** Find a workflow card by the name shown in its input field using its current value. */ - async findWorkflowCard(name: string): Promise { + async findWorkflowCard( + name: string, + { waitForName = false }: { waitForName?: boolean } = {}, + ): Promise { const cards = this.page.locator('[data-testid^="workflow-card-"]'); - await expect(cards.first()).toBeVisible(); + if (waitForName) { + await expect + .poll( + async () => { + const testIds = await cards.evaluateAll((elements) => + elements + .map((element) => element.getAttribute("data-testid")) + .filter((testId): testId is string => Boolean(testId)), + ); + for (const testId of testIds) { + const input = this.page.getByTestId(testId).locator("input").first(); + if ((await input.inputValue({ timeout: 500 }).catch(() => null)) === name) + return true; + } + return false; + }, + { timeout: 5_000 }, + ) + .toBe(true); + } else { + await expect(cards.first()).toBeVisible(); + } const testIds = await cards.evaluateAll((elements) => elements @@ -162,6 +186,16 @@ export class WorkflowSettingsPage { return card.getByTestId("delete-workflow-button"); } + /** The duplicate workflow button within a card. */ + duplicateWorkflowButton(card: Locator): Locator { + return card.getByTestId("duplicate-workflow-button"); + } + + /** Duplicate a saved workflow through its visible card action. */ + async duplicateWorkflow(card: Locator, touch = false): Promise { + await this.activate(this.duplicateWorkflowButton(card), touch); + } + /** The step delete confirmation dialog. */ get stepDeleteDialog(): Locator { return this.page.getByRole("dialog").filter({ diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-duplication.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-duplication.spec.ts new file mode 100644 index 0000000000..2c48a84372 --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-duplication.spec.ts @@ -0,0 +1,71 @@ +import { expect } from "@playwright/test"; +import { test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; +import { seedWorkflowDuplication } from "./workflow-duplication-helpers"; + +test.describe("mobile: workflow duplication", () => { + test("duplicates and saves a workflow through the touch action", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + const seed = await seedWorkflowDuplication( + apiClient, + seedData.workspaceId, + "Mobile Duplication Source", + seedData.agentProfileId, + ); + const before = await apiClient.listWorkflows(seedData.workspaceId); + const settings = new WorkflowSettingsPage(testPage); + + await settings.goto(seedData.workspaceId); + const sourceCard = await settings.findWorkflowCard("Mobile Duplication Source"); + const duplicateButton = settings.duplicateWorkflowButton(sourceCard); + const exportButton = sourceCard.getByRole("button", { name: "Export", exact: true }); + await expect(duplicateButton).toBeVisible(); + await expect(duplicateButton).toBeEnabled(); + const [duplicateBox, exportBox] = await Promise.all([ + duplicateButton.boundingBox(), + exportButton.boundingBox(), + ]); + expect(duplicateBox).not.toBeNull(); + expect(exportBox).not.toBeNull(); + expect(duplicateBox!.height).toBe(exportBox!.height); + + await settings.duplicateWorkflow(sourceCard, true); + const copyCard = await settings.findWorkflowCard("Mobile Duplication Source (copy)", { + waitForName: true, + }); + await expect(copyCard).toBeVisible(); + await expect(settings.floatingSave).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage); + + const beforeSave = await apiClient.listWorkflows(seedData.workspaceId); + expect(beforeSave.workflows).toHaveLength(before.workflows.length); + + await settings.saveChanges(true); + await testPage.reload(); + const reloadedCopy = await settings.findWorkflowCard("Mobile Duplication Source (copy)", { + waitForName: true, + }); + await expect(reloadedCopy).toBeVisible(); + + const afterSave = await apiClient.listWorkflows(seedData.workspaceId); + const copy = afterSave.workflows.find( + (workflow) => workflow.name === "Mobile Duplication Source (copy)", + ); + expect(copy).toBeDefined(); + const copiedSteps = await apiClient.listWorkflowSteps(copy!.id); + expect(copiedSteps.steps).toHaveLength(2); + expect(copiedSteps.steps.map((step) => step.id)).not.toContain(seed.reviewStepId); + expect(copiedSteps.steps.map((step) => step.id)).not.toContain(seed.doneStepId); + + const tasks = await apiClient.listTasks(seedData.workspaceId); + expect(tasks.tasks.find((task) => task.id === seed.taskId)?.workflow_step_id).toBe( + seed.reviewStepId, + ); + await assertNoDocumentHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-duplication-helpers.ts b/apps/web/e2e/tests/workflow/workflow-duplication-helpers.ts new file mode 100644 index 0000000000..5bfd2222d0 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-duplication-helpers.ts @@ -0,0 +1,51 @@ +import { ApiClient } from "../../helpers/api-client"; + +export type WorkflowDuplicationSeed = { + workflowId: string; + reviewStepId: string; + doneStepId: string; + taskId: string; +}; + +export async function seedWorkflowDuplication( + apiClient: ApiClient, + workspaceId: string, + name: string, + agentProfileId?: string, +): Promise { + const workflow = await apiClient.createWorkflow(workspaceId, name); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 0, { + is_start_step: true, + }); + const done = await apiClient.createWorkflowStep(workflow.id, "Done", 1); + + await apiClient.updateWorkflow(workflow.id, { + description: "Copied workflow description", + prompt: "Copied workflow prompt", + ...(agentProfileId ? { agent_profile_id: agentProfileId } : {}), + }); + await apiClient.updateWorkflowStep(review.id, { + prompt: "Review step prompt", + stage_type: "review", + events: { + on_turn_complete: [{ type: "move_to_step", config: { step_id: done.id } }], + }, + wip_limit: 2, + }); + await apiClient.updateWorkflowStep(done.id, { + prompt: "Done step prompt", + pull_from_step_id: review.id, + }); + + const task = await apiClient.seedTask(workspaceId, `${name} source task`, { + workflow_id: workflow.id, + workflow_step_id: review.id, + }); + + return { + workflowId: workflow.id, + reviewStepId: review.id, + doneStepId: done.id, + taskId: task.task_id, + }; +} diff --git a/apps/web/e2e/tests/workflow/workflow-duplication.spec.ts b/apps/web/e2e/tests/workflow/workflow-duplication.spec.ts new file mode 100644 index 0000000000..6204fec1c1 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-duplication.spec.ts @@ -0,0 +1,92 @@ +import { expect } from "@playwright/test"; +import { test } from "../../fixtures/test-base"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; +import { seedWorkflowDuplication } from "./workflow-duplication-helpers"; + +test.describe("workflow duplication", () => { + test("creates a configured copy only after Save changes", async ({ + testPage, + apiClient, + seedData, + }) => { + const seed = await seedWorkflowDuplication( + apiClient, + seedData.workspaceId, + "Duplication Source", + seedData.agentProfileId, + ); + const before = await apiClient.listWorkflows(seedData.workspaceId); + const sourceStepsBefore = await apiClient.listWorkflowSteps(seed.workflowId); + const settings = new WorkflowSettingsPage(testPage); + + await settings.goto(seedData.workspaceId); + const sourceCard = await settings.findWorkflowCard("Duplication Source"); + await expect(settings.duplicateWorkflowButton(sourceCard)).toBeEnabled(); + + await settings.duplicateWorkflow(sourceCard); + const copyCard = await settings.findWorkflowCard("Duplication Source (copy)", { + waitForName: true, + }); + await expect(copyCard).toBeVisible(); + await expect(copyCard.getByTestId("workflow-description-input")).toHaveValue( + "Copied workflow description", + ); + await expect(copyCard.getByTestId("workflow-prompt-input")).toHaveValue( + "Copied workflow prompt", + ); + await expect(copyCard.getByText("Review", { exact: true })).toBeVisible(); + await expect(copyCard.getByText("Done", { exact: true })).toBeVisible(); + + const beforeSave = await apiClient.listWorkflows(seedData.workspaceId); + expect(beforeSave.workflows).toHaveLength(before.workflows.length); + expect( + beforeSave.workflows.some((workflow) => workflow.name === "Duplication Source (copy)"), + ).toBe(false); + + await settings.saveChanges(); + + const afterSave = await apiClient.listWorkflows(seedData.workspaceId); + const copy = afterSave.workflows.find( + (workflow) => workflow.name === "Duplication Source (copy)", + ); + expect(copy).toBeDefined(); + expect(afterSave.workflows).toHaveLength(before.workflows.length + 1); + expect(copy).toMatchObject({ + description: "Copied workflow description", + prompt: "Copied workflow prompt", + agent_profile_id: seedData.agentProfileId, + }); + + const copiedSteps = await apiClient.listWorkflowSteps(copy!.id); + expect(copiedSteps.steps).toHaveLength(sourceStepsBefore.steps.length); + const copiedStepIds = copiedSteps.steps.map((step) => step.id); + for (const sourceStep of sourceStepsBefore.steps) { + expect(copiedStepIds).not.toContain(sourceStep.id); + } + const copiedReview = copiedSteps.steps.find((step) => step.name === "Review"); + const copiedDone = copiedSteps.steps.find((step) => step.name === "Done"); + expect(copiedReview).toBeDefined(); + expect(copiedDone).toBeDefined(); + expect(copiedReview?.stage_type).toBe("review"); + expect(copiedReview?.events?.on_turn_complete).toEqual([ + { type: "move_to_step", config: { step_id: copiedDone!.id } }, + ]); + expect(copiedDone?.pull_from_step_id).toBe(copiedReview!.id); + + const tasks = await apiClient.listTasks(seedData.workspaceId); + const sourceTask = tasks.tasks.find((task) => task.id === seed.taskId); + expect(sourceTask?.workflow_step_id).toBe(seed.reviewStepId); + expect(copiedSteps.steps.map((step) => step.id)).not.toContain(sourceTask?.workflow_step_id); + + await testPage.reload(); + const reloadedCopy = await settings.findWorkflowCard("Duplication Source (copy)", { + waitForName: true, + }); + await expect(reloadedCopy).toBeVisible(); + await expect(reloadedCopy.getByTestId("workflow-description-input")).toHaveValue( + "Copied workflow description", + ); + const reloadedSource = await settings.findWorkflowCard("Duplication Source"); + await expect(reloadedSource).toBeVisible(); + }); +}); diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json index 14fefe18e4..a211a6d7be 100644 --- a/apps/web/src/locales/en/workflows.json +++ b/apps/web/src/locales/en/workflows.json @@ -68,6 +68,9 @@ "disablePlanModeOnExitHelp": "Keep plan mode on for every turn in this step, then turn it off only when the task moves to another step.", "doNothing": "Do nothing", "doNothingWaitForUser": "Do nothing (wait for user)", + "duplicateUnavailableInImproveKandev": "Workflows in this workspace cannot be duplicated.", + "duplicateWorkflow": "Duplicate", + "duplicatingWorkflow": "Duplicating...", "everySeconds_one": "every {{count}}s", "everySeconds_other": "every {{count}}s", "export": "Export", @@ -79,6 +82,7 @@ "failedToCheckWorkflowTasks": "Failed to check workflow tasks", "failedToDeleteStep": "Failed to delete step", "failedToDeleteWorkflow": "Failed to delete workflow", + "failedToDuplicateWorkflow": "Failed to duplicate workflow", "failedToExportWorkflow": "Failed to export workflow", "failedToExportWorkflows": "Failed to export workflows", "failedToImportWorkflows": "Failed to import workflows", @@ -158,6 +162,7 @@ "runCompletionActionsWhenTurnCancelledHelp": "Applies only when a user explicitly cancels a turn. The destination step’s on-enter actions may immediately start another agent turn.", "save": "Save", "saveBeforeExporting": "Save the workflow before exporting.", + "saveBeforeDuplicating": "Save the workflow before duplicating it.", "saveFailed": "Save failed: {{error}}", "saveWorkflowsBeforeOrdering": "Save workflows before ordering them", "saving": "Saving...", @@ -251,6 +256,7 @@ "workflowsReadOnlyImprove": "Workflows in this workspace are managed by Improve Kandev and cannot be changed.", "workflowNameIsRequired": "Workflow name is required", "workflowNamePlaceholder": "My Project Workflow", + "workflowMutationInProgress": "Wait for the current workflow change to finish.", "workflows": "Workflows", "workflowsSectionDescription": "Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself", "workflowSteps": "Workflow Steps", diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json index 0e3ee7ed43..597b6cb423 100644 --- a/apps/web/src/locales/pseudo/workflows.json +++ b/apps/web/src/locales/pseudo/workflows.json @@ -68,6 +68,9 @@ "disablePlanModeOnExitHelp": "Ķēēƥ ƥĺàń ḿōďē ōń ƒōŕ ēvēŕŷ ţũŕń ĩń ţĥĩś śţēƥ, ţĥēń ţũŕń ĩţ ōƒƒ ōńĺŷ ŵĥēń ţĥē ţàśķ ḿōvēś ţō àńōţĥēŕ śţēƥ.", "doNothing": "Ďō ńōţĥĩńĝ", "doNothingWaitForUser": "Ďō ńōţĥĩńĝ (ŵàĩţ ƒōŕ ũśēŕ)", + "duplicateUnavailableInImproveKandev": "Ŵōŕķƒĺōŵś ĩń ţĥĩś ŵōŕķśƥàćē ćàńńōţ ƀē ďũƥĺĩćàţēď.", + "duplicateWorkflow": "Ďũƥĺĩćàţē", + "duplicatingWorkflow": "Ďũƥĺĩćàţĩńĝ...", "everySeconds_one": "ēvēŕŷ {{count}}ś", "everySeconds_other": "ēvēŕŷ {{count}}ś", "export": "Ēxƥōŕţ", @@ -79,6 +82,7 @@ "failedToCheckWorkflowTasks": "Ƒàĩĺēď ţō ćĥēćķ ŵōŕķƒĺōŵ ţàśķś", "failedToDeleteStep": "Ƒàĩĺēď ţō ďēĺēţē śţēƥ", "failedToDeleteWorkflow": "Ƒàĩĺēď ţō ďēĺēţē ŵōŕķƒĺōŵ", + "failedToDuplicateWorkflow": "Ƒàĩĺēď ţō ďũƥĺĩćàţē ŵōŕķƒĺōŵ", "failedToExportWorkflow": "Ƒàĩĺēď ţō ēxƥōŕţ ŵōŕķƒĺōŵ", "failedToExportWorkflows": "Ƒàĩĺēď ţō ēxƥōŕţ ŵōŕķƒĺōŵś", "failedToImportWorkflows": "Ƒàĩĺēď ţō ĩḿƥōŕţ ŵōŕķƒĺōŵś", @@ -158,6 +162,7 @@ "runCompletionActionsWhenTurnCancelledHelp": "Àƥƥĺĩēś ōńĺŷ ŵĥēń à ũśēŕ ēxƥĺĩćĩţĺŷ ćàńćēĺś à ţũŕń. Ţĥē ďēśţĩńàţĩōń śţēƥ’ś ōń-ēńţēŕ àćţĩōńś ḿàŷ ĩḿḿēďĩàţēĺŷ śţàŕţ àńōţĥēŕ àĝēńţ ţũŕń.", "save": "Śàvē", "saveBeforeExporting": "Śàvē ţĥē ŵōŕķƒĺōŵ ƀēƒōŕē ēxƥōŕţĩńĝ.", + "saveBeforeDuplicating": "Śàvē ţĥē ŵōŕķƒĺōŵ ƀēƒōŕē ďũƥĺĩćàţĩńĝ ĩţ.", "saveFailed": "Śàvē ƒàĩĺēď: {{error}}", "saveWorkflowsBeforeOrdering": "Śàvē ŵōŕķƒĺōŵś ƀēƒōŕē ōŕďēŕĩńĝ ţĥēḿ", "saving": "Śàvĩńĝ...", @@ -251,6 +256,7 @@ "workflowsReadOnlyImprove": "Ŵōŕķƒĺōŵś ĩń ţĥĩś ŵōŕķśƥàćē àŕē ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv àńď ćàńńōţ ƀē ćĥàńĝēď.", "workflowNameIsRequired": "Ŵōŕķƒĺōŵ ńàḿē ĩś ŕēqũĩŕēď", "workflowNamePlaceholder": "Ḿŷ Ƥŕōĵēćţ Ŵōŕķƒĺōŵ", + "workflowMutationInProgress": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ ŵōŕķƒĺōŵ ćĥàńĝē ţō ƒĩńĩśĥ.", "workflows": "Ŵōŕķƒĺōŵś", "workflowsSectionDescription": "Ćŕēàţē àũţōńōḿōũś ƥĩƥēĺĩńēś ŵĩţĥ àũţōḿàţēď ţŕàńśĩţĩōńś ōŕ ḿàńũàĺ ŵōŕķƒĺōŵś ŵĥēŕē ŷōũ ḿōvē ţàśķś ŷōũŕśēĺƒ", "workflowSteps": "Ŵōŕķƒĺōŵ Śţēƥś", diff --git a/docs/plans/workflow-duplication/plan.md b/docs/plans/workflow-duplication/plan.md new file mode 100644 index 0000000000..914b9073d9 --- /dev/null +++ b/docs/plans/workflow-duplication/plan.md @@ -0,0 +1,120 @@ +--- +spec: docs/specs/workflow-duplication/spec.md +created: 2026-08-11 +status: complete +--- + +# Implementation Plan: Workflow Duplication + +## Overview + +Workflow duplication will reuse the current draft and save coordinator. It will not add a backend duplication endpoint. + +The frontend will load the saved source steps and build an independent client-only graph. It will insert the graph after the source. The current workflow contributor will persist it on **Save changes**. + +This order preserves the no-write-before-save behavior. It also reuses the current partial-save retry contract. + +## Backend + +No new duplication endpoint, schema, service, or route is required. The existing workflow-step controller and frontend adapter must carry the persisted `stage_type` field through create, update, and list operations so the current save endpoints preserve semantic step configuration. + +The draft-save pipeline creates only a workflow and its steps. As a result, it excludes tasks and history. + +## Frontend + +### Draft construction + +- Add `apps/web/app/settings/workspace/workflow-duplication.ts` with pure helpers that: + - derive the lowest available ` (copy N)` name from persisted workflows and route-local drafts. + - allocate a temporary workflow ID and one temporary ID per source step with `generateUUID()`. + - copy only editable workflow metadata: `description`, `prompt`, and `agent_profile_id`. + - omit `workflow_template_id`, `source`, `source_path`, timestamps, and server identity. + - clone all persisted editable step fields, including `stage_type`. + - remap nested `step_id` values in events and remap `pull_from_step_id`. + - clear timestamps and return an independent object graph. +- Extend `useWorkflowCreation` in `apps/web/app/settings/workspace/use-workflow-creation.ts` with `handleDuplicateWorkflow(source, sourceSteps)`. It stores the copied steps in `initialStepsByWorkflowId` and inserts the draft immediately after the source in `workflowItems`. +- Wire the handler through `useWorkflowActions`, `WorkflowList`, and `WorkspaceWorkflowsClient` in `apps/web/app/settings/workspace/workspace-workflows-client.tsx`. Existing ID remapping and workflow-order save behavior will persist the copy's final position. + +### Duplicate action + +- Extend `WorkflowCard` in `apps/web/components/settings/workflow-card.tsx` with an async duplication callback. On activation, fetch the authoritative saved source steps with `listWorkflowStepsAction`, then hand them to the parent draft constructor. +- Keep request state and disabled/error behavior in `apps/web/app/settings/workspace/use-workflow-duplication.ts`; keep dialog composition separate from the workflow card so the card stays within the frontend size limit. +- Disable the action for temporary workflows, dirty workflow/step drafts, pending workflow mutations, and while duplication is loading. Hide or disable it in the Improve Kandev workspace. Do not disable it solely because `workflow.source` is sync-managed. +- Surface step-load failures through the existing toast provider and create no draft on failure. +- Add a labeled **Duplicate** button with an `IconCopy`, stable `data-testid="duplicate-workflow-button"`, loading state, and save-first tooltip to `apps/web/components/settings/workflow-card-header-actions.tsx`. Place it between Export and Delete. +- Add all new copy to `apps/web/src/locales/en/workflows.json`. Do not hardcode user-facing strings. + +### Mobile design contract + +- **Desktop outcome:** the workflow-card footer offers Export, Duplicate, and Delete. Duplicate opens the full copied card after its source. +- **Mobile entry point:** the same visible labeled Duplicate action remains in the existing wrapping card-action row. It is not hidden behind hover or a desktop-only menu. +- **Nearest shipped exemplar:** use `WorkflowCardHeaderActions` and `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts`. Reuse their single-column composition and document scroll owner. +- **Hierarchy and primary action:** the copied card is the next vertical item, while the existing safe-area-aware floating **Save changes** control remains the persistence action. +- **Presentation rationale:** duplication is a single frequent command with no choices, so a direct button is clearer than a drawer or dialog. The resulting editor is substantive content and remains inline rather than being placed in a temporary overlay. +- **Geometry:** give the new touch action at least a 44px active height on phone/coarse-pointer layouts, keep the existing document as the only page scroll owner, and add no fixed controls or new safe-area requirements. +- **Shared logic:** all viewports share naming, graph cloning, request state, and persistence. Only button size and wrapping are responsive. + +### Public documentation + +- Update the workflow authoring how-to in `docs/public/workflow-tips.md` with the Duplicate flow, save requirement, generated names, and the explicit exclusion of tasks and history. + +## Tests + +- **What:** copied names choose `(copy)`, then the lowest free numeric suffix, including when duplicating a duplicate. + **File:** `apps/web/app/settings/workspace/workflow-duplication.test.ts`. + **How:** table-driven pure-function tests including persisted and unsaved name collisions. +- **What:** workflow and step configuration is deeply copied, source metadata is omitted, and all internal step references target copied IDs. + **File:** `apps/web/app/settings/workspace/workflow-duplication.test.ts`. + **How:** construct a multi-step graph with nested transition actions and pull-from relationships. Mutate the result and prove the source is unchanged. +- **What:** the creation hook inserts the duplicate after its source and registers copied initial steps without persistence. + **File:** `apps/web/app/settings/workspace/use-workflow-creation.test.ts`. + **How:** hook test with mocked state setter and deterministic UUIDs. +- **What:** the action invokes duplication, exposes loading/error-safe disabled states, prevents same-render duplicate requests, and explains why an unsaved source cannot be copied. + **File:** `apps/web/components/settings/workflow-card-header-actions.test.tsx` and, if needed for card request wiring, a focused `workflow-card` test. + **How:** Testing Library interaction and accessibility assertions. +- **What:** the existing workflow-step transport preserves a non-custom stage type through list, create, update, and draft save. + **Files:** `apps/web/app/actions/workspaces.test.ts`, `apps/backend/internal/workflow/handlers/step_events_test.go`, and `apps/web/e2e/tests/workflow/workflow-duplication.spec.ts`. + **How:** assert frontend DTO and request bodies, assert the backend update event payload, and assert a persisted `review` step remains `review` after duplication. + +## E2E Tests + +- **Scenario:** GIVEN a configured workflow with a task, WHEN the user duplicates it, THEN a configured draft appears without a write. After Save and reload, it has remapped steps and no copied task. + **File:** `apps/web/e2e/tests/workflow/workflow-duplication.spec.ts`. + **What to verify:** generated name, pre-save workflow count, copied metadata and step relationships, post-save persistence, source preservation, and absence of tasks in copied steps. +- **Scenario:** GIVEN a 390px touch viewport, WHEN the user duplicates and saves a workflow, THEN the action is touch-reachable and the copied editor stays within the viewport. + **File:** `apps/web/e2e/tests/workflow/mobile-workflow-duplication.spec.ts`. + **What to verify:** `.tap()` activation, at least 44px Duplicate hitbox, visible duplicate card and Save action, persistence after reload, and no document horizontal overflow. +- Extend `apps/web/e2e/pages/workflow-settings-page.ts` with a duplicate-action locator/helper shared by both specs. + +## Verification Results + +Passed: + +- `cd apps && pnpm install --frozen-lockfile` completed successfully. +- `cd apps && pnpm --filter @kandev/web test -- --run components/settings/workflow-card-header-actions.test.tsx app/settings/workspace/workflow-duplication.test.ts app/settings/workspace/use-workflow-creation.test.ts app/settings/workspace/use-workflow-duplication.test.ts` passed 4 files and 20 tests. +- `cd apps/web && pnpm --filter @kandev/web test -- --run app/actions/workspaces.test.ts app/settings/workspace/use-workflow-duplication.test.ts app/settings/workspace/workflow-duplication.test.ts components/settings/workflow-card-actions.test.ts` passed 4 files and 41 tests after the review fixes. +- `cd apps/backend && go test ./internal/workflow/handlers -run 'TestHTTP(UpdateStepPublishesUpdatedEvent|CreateStepPublishesCreatedEvent)' -count=1` passed. +- `cd apps/web && pnpm run typecheck` passed. +- `cd apps && pnpm --filter @kandev/web lint` passed for all changed frontend, E2E, and test files. +- `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet` passed. Existing real-locale parity warnings remain advisory; the pseudo catalog and new-code ratchet are clean. +- `node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs` passed 59 tests and validated 41 published docs pages. +- `git diff --check` passed. +- `cd apps/web && pnpm e2e:run tests/workflow/workflow-duplication.spec.ts` passed 1 desktop test. +- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/workflow/mobile-workflow-duplication.spec.ts` passed 1 mobile test after the shared card finder was made timing-safe. +- The managed E2E runner cleaned `e2e/test-results`, `e2e/blob-report`, `.pr-assets`, and temporary shard logs before each run. No generated E2E artifacts remain in the worktree. + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [Task 01: Build duplication drafts](task-01-build-duplication-drafts.md) + +Wave 2: + +- [x] [Task 02: Expose duplicate action](task-02-expose-duplicate-action.md) + +Wave 3: + +- [x] [Task 03: Prove duplication flows](task-03-prove-duplication-flows.md) + +The tasks are sequential. The action depends on the draft constructor, and E2E depends on the completed UI and persistence wiring. diff --git a/docs/plans/workflow-duplication/task-01-build-duplication-drafts.md b/docs/plans/workflow-duplication/task-01-build-duplication-drafts.md new file mode 100644 index 0000000000..8ca0f43569 --- /dev/null +++ b/docs/plans/workflow-duplication/task-01-build-duplication-drafts.md @@ -0,0 +1,74 @@ +--- +id: "01-build-duplication-drafts" +title: "Build duplication drafts" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/workflow-duplication/spec.md" +--- + +# Task 01: Build Duplication Drafts + +## Acceptance + +- A pure constructor creates an independent temporary workflow and step graph with the next collision-free copy name and no source/template/sync identity. +- Nested transition references and pull-from relationships resolve only to copied step IDs, while every editable workflow setting except the generated workflow `name`, and every editable step setting, remains equal to the source. +- A persisted semantic step type is carried through the existing list, create, and update transport so draft save does not reset it to `custom`. +- `useWorkflowCreation` inserts the draft directly after the source and registers its initial steps without issuing a persistence request. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run app/settings/workspace/workflow-duplication.test.ts app/settings/workspace/use-workflow-creation.test.ts && cd web && pnpm run typecheck +``` + +## Files Likely Touched + +- `apps/web/app/settings/workspace/workflow-duplication.ts` +- `apps/web/app/settings/workspace/workflow-duplication.test.ts` +- `apps/web/app/settings/workspace/use-workflow-creation.ts` +- `apps/web/app/settings/workspace/use-workflow-creation.test.ts` +- `apps/web/app/settings/workspace/workspace-workflows-client.tsx` +- `apps/web/app/actions/workspaces.ts` +- `apps/web/app/actions/workspaces.test.ts` +- `apps/backend/internal/workflow/controller/controller.go` +- `apps/backend/internal/workflow/handlers/step_events_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task defines the draft contract consumed by the UI task. + +## Inputs + +- Spec sections: What, Persistence Guarantees, naming/reference Scenarios. +- Existing temporary workflow creation and identity remapping in `use-workflow-creation.ts` and `workflow-card-actions.ts`. +- Existing route-level workflow ordering and save-contributor flow in `workspace-workflows-client.tsx`. + +## Risks + +- A shallow copy can let editor mutations change the source's nested event objects. +- Missing a nested `step_id` or `pull_from_step_id` remap can make the copied workflow transition back into the source graph or fail validation. +- Carrying `workflow_template_id` or sync provenance can create unwanted server steps or make the copy read-only. + +## Output Contract + +Report the naming rules, copied/omitted fields, reference-remapping behavior, files changed, exact tests run, blockers, risks, and task/plan status updates. + +## Results + +- Added pure copy-name and workflow/step graph helpers with new temporary IDs, + independent nested event objects, and remapped step references. +- Added route-local insertion after the source and initial-step registration in + `useWorkflowCreation`. +- Checks passed: + - `cd apps && pnpm --filter @kandev/web test -- --run app/settings/workspace/workflow-duplication.test.ts app/settings/workspace/use-workflow-creation.test.ts` + (2 files, 11 tests). + - `cd apps && pnpm --filter @kandev/web test -- --run app/actions/workspaces.test.ts app/settings/workspace/use-workflow-duplication.test.ts app/settings/workspace/workflow-duplication.test.ts components/settings/workflow-card-actions.test.ts` + (4 files, 41 tests after review fixes). + - `cd apps/backend && go test ./internal/workflow/handlers -run 'TestHTTP(UpdateStepPublishesUpdatedEvent|CreateStepPublishesCreatedEvent)' -count=1`. + - `cd apps/web && pnpm run typecheck`. diff --git a/docs/plans/workflow-duplication/task-02-expose-duplicate-action.md b/docs/plans/workflow-duplication/task-02-expose-duplicate-action.md new file mode 100644 index 0000000000..9804ab407f --- /dev/null +++ b/docs/plans/workflow-duplication/task-02-expose-duplicate-action.md @@ -0,0 +1,69 @@ +--- +id: "02-expose-duplicate-action" +title: "Expose duplicate action" +status: done +wave: 2 +depends_on: ["01-build-duplication-drafts"] +plan: "plan.md" +spec: "../../specs/workflow-duplication/spec.md" +--- + +# Task 02: Expose Duplicate Action + +## Acceptance + +- Every clean persisted Kanban workflow exposes a localized Duplicate action. A sync-managed source remains copyable into a manual draft. +- A temporary or dirty source shows a localized save-first explanation. A load error shows an error message and creates nothing. +- The direct mobile action has at least a 44px active height, remains inside the wrapping card footer, and public workflow guidance documents the save and exclusion semantics. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/settings/workflow-card-header-actions.test.tsx app/settings/workspace/use-workflow-creation.test.ts && pnpm --filter @kandev/web i18n:check && pnpm --filter @kandev/web i18n:ratchet && cd .. && node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs +``` + +## Files Likely Touched + +- `apps/web/components/settings/workflow-card.tsx` +- `apps/web/components/settings/workflow-card-header-actions.tsx` +- `apps/web/components/settings/workflow-card-header-actions.test.tsx` +- `apps/web/app/settings/workspace/workspace-workflows-client.tsx` +- `apps/web/src/locales/en/workflows.json` +- `docs/public/workflow-tips.md` + +## Dependencies + +Task 01. + +## Parallelism + +Sequential. The UI calls the draft constructor and parent insertion handler from Task 01. + +## Inputs + +- Spec sections: What, Permissions, Failure Modes, mobile Scenario. +- Plan sections: Duplicate action, Mobile design contract, Public documentation. +- Existing `WorkflowCardHeaderActions`, `useRequest`, toast, tooltip, and manual-save contributor patterns. + +## Risks + +- Treat sync read-only as a source property only. Do not mark the new draft read-only. +- Do not copy a dirty in-memory editor state while the contract promises saved configuration. +- Preserve keyboard accessibility and do not make hover the only discovery path. + +## Output Contract + +Report desktop/mobile entry points, disabled/error behavior, translations, public docs impact, files changed, exact checks run, blockers, risks, and task/plan status updates. + +## Results + +- Added the localized Duplicate action between Export and Delete with a 44px phone hitbox, loading state, stable test ID, and disabled explanations for Improve Kandev, unsaved sources, and pending mutations. +- Added authoritative saved-step loading with error toast handling and a ref-backed same-render request gate. Sync-managed sources remain copyable because the new draft is manual and independent. +- Kept the request and disabled-state logic in `use-workflow-duplication.ts` and moved dialog composition into `workflow-card-dialogs-content.tsx` to keep the main workflow card below the configured size limit. +- Added English and pseudo-locale strings and documented the save-first flow, numbered names, copied settings, and task/history exclusions in `docs/public/workflow-tips.md`. +- Checks passed: + - `cd apps && pnpm --filter @kandev/web test -- --run components/settings/workflow-card-header-actions.test.tsx app/settings/workspace/workflow-duplication.test.ts app/settings/workspace/use-workflow-creation.test.ts app/settings/workspace/use-workflow-duplication.test.ts` (4 files, 20 tests including same-render double activation and loading-label coverage). + - `cd apps/web && pnpm run typecheck`. + - `cd apps && pnpm --filter @kandev/web lint` passed. + - `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet`. + - `node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs` passed 59 tests and validated 41 docs pages. diff --git a/docs/plans/workflow-duplication/task-03-prove-duplication-flows.md b/docs/plans/workflow-duplication/task-03-prove-duplication-flows.md new file mode 100644 index 0000000000..f0789e0cda --- /dev/null +++ b/docs/plans/workflow-duplication/task-03-prove-duplication-flows.md @@ -0,0 +1,64 @@ +--- +id: "03-prove-duplication-flows" +title: "Prove duplication flows" +status: done +wave: 3 +depends_on: ["02-expose-duplicate-action"] +plan: "plan.md" +spec: "../../specs/workflow-duplication/spec.md" +--- + +# Task 03: Prove Duplication Flows + +## Acceptance + +- Desktop Playwright proves duplication performs no pre-save write, then persists copied configuration with remapped step relationships and no copied task after Save and reload. +- Mobile Playwright performs the same user outcome by touch, measures the Duplicate hitbox, and proves the copied editor and Save action fit without document horizontal overflow. +- Both tests use disposable workflows/tasks and leave worker-scoped canonical seed data unchanged. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm e2e:run tests/workflow/workflow-duplication.spec.ts) +(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/workflow/mobile-workflow-duplication.spec.ts) +``` + +## Files Likely Touched + +- `apps/web/e2e/pages/workflow-settings-page.ts` +- `apps/web/e2e/tests/workflow/workflow-duplication.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-duplication.spec.ts` + +## Dependencies + +Task 02. + +## Parallelism + +Sequential. E2E requires the complete draft, action, translation, and save wiring. + +## Inputs + +- Every user-visible scenario selected in `plan.md` E2E Tests. +- Existing workflow E2E fixtures, `WorkflowSettingsPage`, `apiClient.listWorkflows`, `listWorkflowSteps`, `createTask`, and managed-runner guidance. +- Mobile parity requirements for `mobile-*.spec.ts`, `.tap()`, 44px hitboxes, and overflow assertions. + +## Risks + +- Confirm the desktop run builds current production assets before using `--no-build` for mobile. +- Assert the pre-save backend workflow count before pressing Save so a local draft cannot masquerade as correct persistence behavior. +- Identify copied steps by the newly persisted workflow ID, not by source names alone, and assert task step IDs remain outside that copied set. + +## Output Contract + +Report desktop/mobile scenarios, discovered test counts, exact managed-runner results, files changed, artifacts and cleanup evidence, blockers, risks, and task/plan status updates. + +## Results + +- Added disposable desktop and mobile Playwright scenarios with a shared API seed helper. The scenarios verify no pre-save workflow write, copied metadata, remapped transition and pull-from references, source-task preservation, post-save reload, touch reachability, and no mobile horizontal overflow. +- Updated `WorkflowSettingsPage.findWorkflowCard` to poll for route-local drafts. This prevents a container timing race after touch duplication from snapshotting the card list before React inserts the draft. +- Checks passed: + - `cd apps/web && pnpm e2e:run tests/workflow/workflow-duplication.spec.ts` (1 desktop test passed). + - `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/workflow/mobile-workflow-duplication.spec.ts` (1 mobile test passed). + - The managed runner cleaned its test-results, blob-report, PR asset, and temporary shard-log locations. No E2E artifacts remain in the worktree. diff --git a/docs/public/workflow-tips.md b/docs/public/workflow-tips.md index b0e23338bd..2f7181a4c2 100644 --- a/docs/public/workflow-tips.md +++ b/docs/public/workflow-tips.md @@ -35,6 +35,29 @@ The template prompts are product behavior, not merely sample text. Review them b Choose this for short implementation work with a simple run-and-review loop. +## Duplicate a workflow + +Use **Duplicate** to create a new workflow from a saved workflow. The copy starts as a local draft. + +1. Save the source workflow before you select **Duplicate**. +2. Select **Duplicate** on the source workflow card. +3. Review and edit the copied workflow and its steps. +4. Select the route-level **Save changes** action to persist the copy. + +Kandev places the draft after the source workflow. The first copy uses ` (copy)`. +If that name exists, Kandev uses the lowest available number. For example, the next name can be ` (copy 2)`. + +The copy includes these settings: + +- workflow description, prompt, and default agent profile +- step prompts, colors, positions, transitions, and start-step state +- command-panel visibility, manual-move policy, and auto-archive policy +- step agent profiles, completion-signal policy, cancellation policy, WIP limits, and pull-from relationships + +The copy does not include tasks, task sessions, execution history, workflow history, template identity, or sync ownership. A copy of a sync-managed workflow becomes an independent manual workflow. The source remains unchanged. + +If you remove or discard the draft, or reload before you save it, Kandev does not create a workflow. + ### Plan & Build **Todo → Plan → Implementation → Done** diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index fc4e09e371..21ec59fa7a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -100,6 +100,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped | | [cancelled-turn-completion](workflow/cancelled-turn-completion/spec.md) | building | | [conditional-session-settings](workflow-session-settings/spec.md) | approved | +| [workflow-duplication](workflow-duplication/spec.md) | draft | ## agents/ — agent governance diff --git a/docs/specs/workflow-duplication/spec.md b/docs/specs/workflow-duplication/spec.md new file mode 100644 index 0000000000..b239dd56dd --- /dev/null +++ b/docs/specs/workflow-duplication/spec.md @@ -0,0 +1,71 @@ +--- +status: draft +created: 2026-08-11 +owner: kandev +--- + +# Workflow Duplication + +## Why + +Workflow authors often need a new pipeline that differs only slightly from an existing one. Rebuilding its metadata, prompts, steps, and transitions is slow and risks omitting configuration that already works. + +## What + +- Every persisted Kanban workflow on an editable workspace's Workflows settings page offers a **Duplicate** action. +- Duplicate reads the source's saved configuration and creates a route-local, unsaved workflow draft directly after the source card. It does not persist a workflow until the user presses the route-level **Save changes** action. +- The draft copies the source description, workflow prompt, default agent profile, and all persisted step configuration. +- Copied step configuration includes prompts, colors, order, transitions, and start-step state. +- It preserves each step's semantic stage type, including `work`, `review`, `approval`, and `custom`. +- It also includes command-panel visibility, manual-move policy, auto-archive policy, and step agent profiles. +- It also includes completion-signal policy, cancellation policy, WIP limits, and pull-from relationships. +- The copy receives new workflow and step identities. Every transition or relationship that referred to a source step refers to the corresponding copied step in the draft. +- The draft does not inherit template identity, workflow-sync ownership, source path, tasks, task sessions, execution history, or workflow history. A copy of a sync-managed workflow is a manual workflow that can be edited independently. +- The generated name uses ` (copy)`. If that name already exists, including as another unsaved draft, Kandev uses the lowest available numbered name: ` (copy 2)`, ` (copy 3)`, and so on. Duplicating a name that already ends in `(copy)` or `(copy N)` reuses its original base. +- Duplicate is unavailable for a new unsaved workflow or a workflow with unsaved metadata or step changes. The UI explains that the source must be saved before it can be duplicated. +- Duplicate is unavailable in the read-only Improve Kandev workspace. It remains available for a sync-managed workflow in a normal editable workspace because creating the independent manual draft does not mutate the source. +- The source workflow remains unchanged. Editing, saving, deleting, or reordering the duplicate after creation has no ongoing effect on the source. +- Discarding or removing the duplicate draft creates no durable workflow. Reloading before Save also discards it, following the existing [Settings Manual Save](../ui/settings-manual-save.md) contract. + +## Permissions + +The user must be able to create workflows in the source workspace. A sync-managed source does not bypass workspace ownership or the Improve Kandev read-only boundary. + +## Failure Modes + +- If Kandev cannot load the source's saved steps, it shows a duplication error and creates no draft. +- If the duplicated draft fails to save, the existing workflow-draft retry and partial-save cleanup behavior applies. Save must not create a second copy when retried. +- A server or WebSocket refresh must not replace the duplicate's unsaved local edits. + +## Persistence Guarantees + +- Duplication itself performs no write. The draft is route-local and does not survive reload, process restart, or explicit discard. +- A successful **Save changes** persists a new workflow and new steps in the same workspace. +- Save also persists the list order. The duplicate remains after its source unless the user moves it first. +- The persisted duplicate has no reference that keeps it synchronized with the source. +- Existing tasks, task sessions, and workflow history remain attached only to the source. + +## Scenarios + +- **GIVEN** a clean persisted workflow, **WHEN** the user chooses Duplicate, **THEN** an editable draft appears after the source and no write occurs. +- **GIVEN** `Review` and `Review (copy)` already exist, **WHEN** the user duplicates `Review`, **THEN** the draft name is `Review (copy 2)`. +- **GIVEN** `Review (copy 2)` is duplicated while `Review (copy)` also exists, **WHEN** the draft is created, **THEN** its name is `Review (copy 3)`. +- **GIVEN** source relationships refer to source steps, **WHEN** the draft is created, **THEN** those relationships refer only to copied steps. +- **GIVEN** a source contains tasks and history, **WHEN** the user saves its duplicate, **THEN** the new workflow contains no copied tasks or history. +- **GIVEN** an unsaved duplicate draft, **WHEN** the user removes it, resets the route, discards while leaving, or reloads, **THEN** no durable workflow is created. +- **GIVEN** an unsaved duplicate, **WHEN** the user saves and reloads, **THEN** the copied workflow and its remapped steps remain visible. +- **GIVEN** a sync-managed workflow, **WHEN** the user duplicates and saves it, **THEN** the source remains read-only and the manual copy is editable. +- **GIVEN** a new or dirty workflow, **WHEN** its actions appear, **THEN** Duplicate is disabled with a save-first explanation. +- **GIVEN** a 390px touch viewport, **WHEN** the user duplicates and saves, **THEN** all required controls fit without document-level horizontal overflow. + +## Out of Scope + +- Copying workflows between workspaces. +- Copying tasks, task sessions, execution history, or workflow history. +- Bulk duplication. +- Keeping a duplicate synchronized with its source or workflow-sync repository. +- Duplicating Office-style workflows from the Kanban workflow settings page. + +## Implementation Plan + +See [the implementation plan](../../plans/workflow-duplication/plan.md). diff --git a/scripts/validate-public-docs.test.mjs b/scripts/validate-public-docs.test.mjs index a87421d8a0..97ee2f7cfd 100644 --- a/scripts/validate-public-docs.test.mjs +++ b/scripts/validate-public-docs.test.mjs @@ -846,6 +846,24 @@ test("accepts source-backed coverage for every settings route and MCP tool", asy await assert.doesNotReject(validateCoverageInventory(fixture)); }); +test("accepts a route-path export between the settings table and renderer", async () => { + const fixture = await createCoverageRepo(); + const routePath = path.join( + fixture.repoRoot, + "apps/web/src/settings-routes.tsx", + ); + const source = await fs.readFile(routePath, "utf8"); + await fs.writeFile( + routePath, + source.replace( + "};\n\nexport function SettingsRoutes", + "};\n\nexport const SETTINGS_ROUTE_PATHS = new Set(Object.keys(SETTINGS_ROUTES));\n\nexport function SettingsRoutes", + ), + ); + + await assert.doesNotReject(validateCoverageInventory(fixture)); +}); + test("rejects a shipped settings route with no documentation owner", async () => { const fixture = await createCoverageRepo({ settingsRoutes: [