feat: duplicate workflow drafts (#2539)

This commit is contained in:
Carlos Florêncio
2026-08-12 11:12:56 +01:00
committed by GitHub
parent a757b546bf
commit a63cbf3d85
31 changed files with 1608 additions and 145 deletions
@@ -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
}
@@ -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) {
+75
View File
@@ -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<typeof createWorkflowStepAction>[0]);
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
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<typeof updateWorkflowStepAction>[1]);
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init.body as string)).toMatchObject({ stage_type: "review" });
});
});
+6
View File
@@ -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<WorkflowStep> {
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<BackendWorkflowStep>(`${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<WorkflowStep> {
@@ -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<BackendWorkflowStep>(
`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`,
{
@@ -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<Workflow[]>) => {
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 }),
]);
});
});
@@ -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<React.SetStateAction<Workflow[]>>;
};
@@ -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,
};
@@ -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<Parameters<typeof useWorkflowDuplication>[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<void>;
let secondRequest: Promise<void>;
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();
});
});
@@ -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<typeof useToast>["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,
};
}
@@ -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");
});
});
@@ -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<Workflow, "name">[],
): 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<T>(value: T, stepIds: ReadonlyMap<string, string>): 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<string, string>,
): 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 };
}
@@ -183,6 +183,7 @@ function useWorkflowActions({
const finalizedWorkflowIdsRef = useRef(new Map<string, string>());
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}
@@ -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<WorkflowStep> {
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,
@@ -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<void>;
handleMigrateAndDeleteWorkflow: () => Promise<void>;
};
exportOpen: boolean;
setExportOpen: (open: boolean) => void;
exportYaml: string;
stepDel: StepDeleteState;
stepToDeleteName: string;
stepsForStepMigration: WorkflowStep[];
stepDeleteHandlers: {
handleMigrateAndDeleteStep: () => Promise<void>;
handleDeleteStepAndTasks: () => Promise<void>;
};
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 (
<>
<WorkflowDeleteDialog
open={wfDel.deleteOpen}
onOpenChange={wfDel.setDeleteOpen}
workflowTaskCount={wfDel.workflowTaskCount}
otherWorkflows={otherWorkflows}
targetWorkflowId={wfDel.targetWorkflowId}
setTargetWorkflowId={wfDel.setTargetWorkflowId}
targetWorkflowSteps={wfDel.targetWorkflowSteps}
targetStepId={wfDel.targetStepId}
setTargetStepId={wfDel.setTargetStepId}
migrateLoading={wfDel.migrateLoading}
deleteLoading={deleteWorkflowLoading}
onDelete={wfDeleteHandlers.handleDeleteWorkflow}
onMigrateAndDelete={wfDeleteHandlers.handleMigrateAndDeleteWorkflow}
hasUnsavedChanges={hasUnsavedChanges}
/>
<WorkflowExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
title={t("workflows:exportWorkflowTitle")}
content={exportYaml}
/>
<StepDeleteDialog
open={stepDel.stepDeleteOpen}
onOpenChange={stepDel.setStepDeleteOpen}
stepName={stepToDeleteName}
stepTaskCount={stepDel.stepTaskCount}
stepsForMigration={stepsForStepMigration}
targetStep={stepDel.targetStepForMigration}
setTargetStep={stepDel.setTargetStepForMigration}
loading={stepDel.stepMigrateLoading}
pending={stepDel.stepDeletePending}
onMigrateAndDelete={stepDeleteHandlers.handleMigrateAndDeleteStep}
onDeleteAndTasks={stepDeleteHandlers.handleDeleteStepAndTasks}
hasUnsavedChanges={hasUnsavedChanges}
/>
<WorkflowCycleGuardDialog
proposal={mutationGuard.proposal}
onCancel={mutationGuard.cancelProposal}
onConfirm={mutationGuard.confirmProposal}
/>
</>
);
}
@@ -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<ComponentProps<typeof WorkflowCardHeaderActions>> = {},
) => {
const defaultProps: ComponentProps<typeof WorkflowCardHeaderActions> = {
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(
<TooltipProvider>
<WorkflowCardHeaderActions {...defaultProps} {...overrides} />
</TooltipProvider>,
);
};
describe("WorkflowCardHeaderActions", () => {
it("surfaces failures when deleting a temporary workflow", async () => {
const failure = new Error("cleanup failed");
const toast = vi.fn();
render(
<TooltipProvider>
<WorkflowCardHeaderActions
workflowId="temp-workflow-1"
setExportYaml={vi.fn()}
setExportOpen={vi.fn()}
toast={toast}
onDeleteClick={vi.fn().mockRejectedValue(failure)}
deleteDisabled={false}
exportDisabled
readOnly={false}
/>
</TooltipProvider>,
);
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...");
});
});
@@ -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<typeof useToast>["toast"];
onDeleteClick: () => Promise<void>;
onDuplicateClick: () => Promise<void>;
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 (
<div className="flex flex-wrap justify-end gap-2">
<Tooltip>
@@ -53,6 +63,30 @@ export function WorkflowCardHeaderActions({
</TooltipTrigger>
{exportDisabled && <TooltipContent>{t("workflows:saveBeforeExporting")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={duplicateDisabled ? 0 : -1}
className="inline-flex"
aria-label={duplicateTooltip}
>
<Button
type="button"
variant="outline"
onClick={() => void onDuplicateClick()}
disabled={duplicateDisabled}
className="cursor-pointer"
data-testid="duplicate-workflow-button"
>
<IconCopy className="h-4 w-4 mr-2" />
{duplicateLoading
? t("workflows:duplicatingWorkflow")
: t("workflows:duplicateWorkflow")}
</Button>
</span>
</TooltipTrigger>
{duplicateTooltip && <TooltipContent>{duplicateTooltip}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={readOnly ? 0 : undefined} className="inline-flex">
+19 -120
View File
@@ -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<unknown>;
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<number | null>(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<string | null>(null);
@@ -195,28 +167,6 @@ function useStepDeleteState(): StepDeleteState {
};
}
type WorkflowCardDialogsProps = {
wfDel: WorkflowDeleteState;
otherWorkflows: Workflow[];
deleteWorkflowLoading: boolean;
wfDeleteHandlers: {
handleDeleteWorkflow: () => Promise<void>;
handleMigrateAndDeleteWorkflow: () => Promise<void>;
};
exportOpen: boolean;
setExportOpen: (open: boolean) => void;
exportYaml: string;
stepDel: StepDeleteState;
stepToDeleteName: string;
stepsForStepMigration: WorkflowStep[];
stepDeleteHandlers: {
handleMigrateAndDeleteStep: () => Promise<void>;
handleDeleteStepAndTasks: () => Promise<void>;
};
hasUnsavedChanges: boolean;
mutationGuard: ReturnType<typeof useWorkflowMutationGuard>;
};
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 (
<>
<WorkflowDeleteDialog
open={wfDel.deleteOpen}
onOpenChange={wfDel.setDeleteOpen}
workflowTaskCount={wfDel.workflowTaskCount}
otherWorkflows={otherWorkflows}
targetWorkflowId={wfDel.targetWorkflowId}
setTargetWorkflowId={wfDel.setTargetWorkflowId}
targetWorkflowSteps={wfDel.targetWorkflowSteps}
targetStepId={wfDel.targetStepId}
setTargetStepId={wfDel.setTargetStepId}
migrateLoading={wfDel.migrateLoading}
deleteLoading={deleteWorkflowLoading}
onDelete={wfDeleteHandlers.handleDeleteWorkflow}
onMigrateAndDelete={wfDeleteHandlers.handleMigrateAndDeleteWorkflow}
hasUnsavedChanges={hasUnsavedChanges}
/>
<WorkflowExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
title={t("workflows:exportWorkflowTitle")}
content={exportYaml}
/>
<StepDeleteDialog
open={stepDel.stepDeleteOpen}
onOpenChange={stepDel.setStepDeleteOpen}
stepName={stepToDeleteName}
stepTaskCount={stepDel.stepTaskCount}
stepsForMigration={stepsForStepMigration}
targetStep={stepDel.targetStepForMigration}
setTargetStep={stepDel.setTargetStepForMigration}
loading={stepDel.stepMigrateLoading}
pending={stepDel.stepDeletePending}
onMigrateAndDelete={stepDeleteHandlers.handleMigrateAndDeleteStep}
onDeleteAndTasks={stepDeleteHandlers.handleDeleteStepAndTasks}
hasUnsavedChanges={hasUnsavedChanges}
/>
<WorkflowCycleGuardDialog
proposal={mutationGuard.proposal}
onCancel={mutationGuard.cancelProposal}
onConfirm={mutationGuard.confirmProposal}
/>
</>
);
}
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 (
<SettingsCard
@@ -569,6 +464,7 @@ export function WorkflowCard(props: WorkflowCardProps) {
if (workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)) await s.removeDraftWorkflow();
else await s.wfDeleteHandlers.handleDeleteWorkflowClick();
}}
onDuplicateClick={duplicate.handleDuplicateWorkflow}
deleteDisabled={
s.mutationGuard.isMutationPending ||
s.deleteWorkflowRequest.isLoading ||
@@ -578,6 +474,9 @@ export function WorkflowCard(props: WorkflowCardProps) {
}
readOnly={s.readOnly}
exportDisabled={workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)}
duplicateDisabled={duplicate.duplicateDisabled}
duplicateDisabledReason={duplicate.duplicateDisabledReason}
duplicateLoading={duplicate.duplicateLoading}
/>
</div>
</CardContent>
+1
View File
@@ -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<void> {
await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates });
+36 -2
View File
@@ -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<Locator> {
async findWorkflowCard(
name: string,
{ waitForName = false }: { waitForName?: boolean } = {},
): Promise<Locator> {
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<void> {
await this.activate(this.duplicateWorkflowButton(card), touch);
}
/** The step delete confirmation dialog. */
get stepDeleteDialog(): Locator {
return this.page.getByRole("dialog").filter({
@@ -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);
});
});
@@ -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<WorkflowDuplicationSeed> {
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,
};
}
@@ -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();
});
});
+6
View File
@@ -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",
@@ -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": "Ŵōŕķƒĺōŵ Śţēƥś",
+120
View File
@@ -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 `<base> (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.
@@ -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`.
@@ -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.
@@ -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.
+23
View File
@@ -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 `<name> (copy)`.
If that name exists, Kandev uses the lowest available number. For example, the next name can be `<name> (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**
+1
View File
@@ -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
+71
View File
@@ -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 `<base> (copy)`. If that name already exists, including as another unsaved draft, Kandev uses the lowest available numbered name: `<base> (copy 2)`, `<base> (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).
+18
View File
@@ -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: [