feat: duplicate workflow drafts (#2539)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": "Ŵōŕķƒĺōŵ Śţēƥś",
|
||||
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user