fix(dashboard): let Start work on a copied Ideas workflow, not just the built-in one

Start resolved its create-time Planning lane from the literal builtin:coding-ideas id, so a duplicated Ideas workflow fell through to a promotion that skipped the Planning hold lane and targeted the WIP lane. Column adjacency permits intake -> hold | archived only, so that move was rejected and the card stayed parked in Ideas.

Resolve the lane from traits (first declared hold column immediately after a manual intake, mirroring resolveWorkflowIntakeFacts), and promote exactly one legal forward step when no atomic lane can be proven.
This commit is contained in:
Fusion Agent
2026-08-26 19:24:58 +00:00
parent cdef6ad7e8
commit c501ec9617
5 changed files with 197 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Start in the task composer doing nothing on a duplicated Ideas workflow.
category: fix
dev: `resolveQuickAddStartInitialColumn` no longer keys on the literal `builtin:coding-ideas` id — a manual-intake workflow now resolves its create-time Planning lane from traits (first declared `hold` column immediately after the intake), mirroring `resolveWorkflowIntakeFacts`'s unplanned-Start classification in `packages/core/src/task-store/task-creation.ts`. `resolveQuickAddStartTargetColumn` promotes exactly one legal forward step (hold lanes included) instead of skipping holds into the WIP lane, which column adjacency always rejected (`intake -> hold | archived`). Covers both Start surfaces: QuickEntryBox and NewTaskModal.

View File

@@ -1785,6 +1785,43 @@ describe("NewTaskModal", () => {
expect(props.addToast).toHaveBeenCalledWith("Queued FN-START for planning", "success");
});
/*
FNXC:QuickAddStart 2026-08-26-19:19:
Second Start surface for the duplicated-Ideas-workflow fix: the New Task dialog shares
`quickAddStart`'s resolution with the board composer, so it must also create in the duplicate's
own Planning lane instead of promoting into the WIP lane (a rejected transition that left the
card parked in Ideas).
*/
it("atomically creates a duplicated Ideas workflow's Start in its own Planning lane", async () => {
await mockStartWorkflows("WF-014", "Coding ideas V2");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "WF-014",
name: "Coding ideas V2",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
}],
taskWorkflowIds: {},
} as BoardWorkflowsPayload);
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-CLONE"), column: "todo", workflowId: "WF-014" });
const onMoveTask = vi.fn();
renderNewTaskModal({ onCreateTask, onMoveTask });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("WF-014");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start this idea" } });
fireEvent.click(await screen.findByTestId("task-form-inline-start"));
await waitFor(() => expect(onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-014", column: "todo" })));
expect(onMoveTask).not.toHaveBeenCalled();
});
it("exposes the same eligible Start action in the desktop floating host", async () => {
mockViewportMode = "desktop";
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");

View File

@@ -5080,6 +5080,36 @@ describe("QuickEntryBox", () => {
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-hold", "working"));
});
/*
FNXC:QuickAddStart 2026-08-26-19:19:
Reported symptom: on a DUPLICATED Ideas workflow ("Coding ideas V2") the composer's Start button
created the card but never started it. Start keyed its atomic destination on the literal
`builtin:coding-ideas` id, so a copy fell through to a promotion that skipped the Planning hold
lane and moved into the WIP lane — a transition the server always rejects. Assert the composer
surface creates in the duplicate's own Planning lane and issues no move at all.
*/
it("creates a duplicated Ideas workflow's Start in its own Planning lane", async () => {
const duplicatedIdeasWorkflow = {
id: "WF-014",
name: "Coding ideas V2",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-clone", column: "todo", workflowId: duplicatedIdeasWorkflow.id });
const onMoveTask = vi.fn().mockResolvedValue({});
renderQuickEntryBox({ onCreate, onMoveTask, workflowId: duplicatedIdeasWorkflow.id, workflowOptions: [duplicatedIdeasWorkflow] });
enterDescription();
clickStart();
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: duplicatedIdeasWorkflow.id, column: "todo" })));
expect(onMoveTask).not.toHaveBeenCalled();
});
it("disables Start until a description is entered and never hides it mid-typing", () => {
renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });

View File

@@ -92,16 +92,94 @@ describe("quick add Start workflow guards", () => {
expect(resolveQuickAddStartWorkflowTarget(noTarget)).toBeNull();
});
it("only chooses a later visible working destination", () => {
/*
FNXC:QuickAddStart 2026-08-26-19:19:
Was "only chooses a later visible working destination", which asserted that the promotion skipped
`hold` lanes to reach the first working column. Column adjacency never permitted that jump, so the
move it described was rejected server-side. The promotion is one legal forward step.
*/
it("promotes exactly one legal forward step, hold lanes included", () => {
const valid = validateQuickAddStartWorkflow(workflow({ columns: [
{ id: "ideas", name: "Ideas", flags: { hold: true } },
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "review", name: "Review", flags: { hold: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "todo", name: "Todo", flags: {} },
] }));
expect(valid).not.toBeNull();
expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("todo");
expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("review");
expect(resolveQuickAddStartTargetColumn(valid!, "review")).toBeNull();
expect(resolveQuickAddStartTargetColumn(valid!, "todo")).toBeNull();
expect(resolveQuickAddStartTargetColumn(valid!, "unknown")).toBeNull();
});
/*
FNXC:QuickAddStart 2026-08-26-19:19:
Regression: a duplicated Ideas workflow ("Coding ideas V2") reported as "Start does not start the
task". Start resolved its destination from the literal `builtin:coding-ideas` id, so a copy fell
through to a promotion that skipped the Planning hold lane and targeted the WIP lane — a move
`intake -> wip` that column adjacency always rejects. Surfaces: both Start callers share these
helpers (QuickEntryBox composer and NewTaskModal), so the invariant is asserted here once for the
built-in, its duplicate, and the metadata shapes that must fail closed.
*/
describe("duplicated Ideas workflows", () => {
const clone = (overrides: Record<string, unknown> = {}) => validateQuickAddStartWorkflow(workflow({
id: "WF-014",
name: "Coding ideas V2",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In review", flags: { mergeBlocker: true, humanReview: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true, hiddenFromBoard: true } },
],
...overrides,
}));
it("creates in its own Planning lane instead of jumping into the WIP lane", () => {
const duplicate = clone();
expect(duplicate).not.toBeNull();
expect(workflowSupportsQuickAddStart(duplicate)).toBe(true);
expect(resolveQuickAddStartInitialColumn(duplicate!)).toBe("todo");
expect(resolveQuickAddStartWorkflowTarget(duplicate)).toBe("todo");
// The rejected move that made Start a no-op must be unreachable from the intake lane.
expect(resolveQuickAddStartTargetColumn(duplicate!, "ideas")).not.toBe("in-progress");
});
it("fails closed to the promotion path when the planning lane is unprovable", () => {
// The server classifies a Start create by the FIRST DECLARED hold column, so an intake that
// also holds, or a hidden earlier hold lane, means our visible candidate is the wrong column.
const intakeAlsoHolds = clone({ columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, hold: true, manualIntake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "done", name: "Done", flags: { complete: true } },
] });
expect(resolveQuickAddStartInitialColumn(intakeAlsoHolds!)).toBeNull();
expect(resolveQuickAddStartWorkflowTarget(intakeAlsoHolds)).toBe("todo");
const hiddenEarlierHold = clone({ columns: [
{ id: "parked", name: "Parked", flags: { hold: true, hiddenFromBoard: true } },
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "done", name: "Done", flags: { complete: true } },
] });
expect(resolveQuickAddStartInitialColumn(hiddenEarlierHold!)).toBeNull();
const noPlanningLane = clone({ columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
] });
expect(resolveQuickAddStartInitialColumn(noPlanningLane!)).toBeNull();
expect(resolveQuickAddStartWorkflowTarget(noPlanningLane)).toBe("in-progress");
const autoTriagingIntake = clone({ columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true } },
{ id: "todo", name: "Planning", flags: { hold: true } },
{ id: "done", name: "Done", flags: { complete: true } },
] });
expect(resolveQuickAddStartInitialColumn(autoTriagingIntake!)).toBeNull();
expect(resolveQuickAddStartWorkflowTarget(autoTriagingIntake)).toBeNull();
});
});
});

View File

@@ -45,16 +45,51 @@ export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflo
* Custom hold-workflow Start promotion uses the returned task's actual column and moves forward only to
* a later working column. Missing data, holds, complete lanes, or no later destination are
* successful create-only outcomes; Quick Add never guesses `todo` or moves backwards.
*
* FNXC:QuickAddStart 2026-08-26-19:19:
* The forward step is now the IMMEDIATELY following visible column, and a `hold` lane is a legal
* destination rather than something to skip. Skipping holds produced a move the server always
* refuses: column adjacency permits `intake -> hold | archived` only (ROLE_TRANSITIONS in
* packages/core/src/workflows/workflow-transitions.ts), and neighbour-derived adjacency for
* genuinely custom shapes permits the next declared column only. Jumping over a Planning hold lane
* into the WIP lane therefore returned 409 "Invalid transition: 'ideas' -> 'in-progress'", so Start
* created a card that never started. One legal forward step is the only promotion Quick Add can
* prove; anything further is the operator's move to make.
*/
export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWorkflow, createdColumn: unknown): string | null {
if (typeof createdColumn !== "string" || !createdColumn.trim()) return null;
const columns = visibleColumns(workflow);
const createdIndex = columns.findIndex((column) => column.id === createdColumn);
if (createdIndex < 0) return null;
for (const column of columns.slice(createdIndex + 1)) {
if (!column.flags.intake && !column.flags.hold && !column.flags.complete) return column.id;
}
return null;
const next = columns[createdIndex + 1];
if (!next || next.flags.intake || next.flags.complete) return null;
return next.id;
}
/*
FNXC:QuickAddStart 2026-08-26-19:19:
A DUPLICATED or hand-authored Ideas workflow ("Coding ideas V2") must start exactly like the
built-in one. The atomic create-in-Planning path below keys on the literal `builtin:coding-ideas`
id, so every copy fell through to the promotion path and its card stayed parked in Ideas.
The destination is derived from the SAME traits the server uses, not from a name: a create lands in
the planning lane pre-planned only when `resolveWorkflowIntakeFacts` classifies it as an unplanned
Start create, and that check is `task.column === columnsWithFlag(ir, "hold")[0]` on a manual-intake
workflow (packages/core/src/task-store/task-creation.ts). Submitting any OTHER column earns
`generateSpecifiedPrompt` instead of the bootstrap seed, and triage only admits seed prompts — the
card would sit in Planning looking planned, forever (FN-8587). So the candidate must be the first
DECLARED hold column (hidden lanes included, exactly as the server scans them) and must sit
immediately after the manual intake. Anything else fails closed to the one-step promotion path,
which is legal under column adjacency.
*/
function resolveManualIntakePlanningColumn(workflow: ValidatedQuickAddWorkflow): string | null {
const columns = visibleColumns(workflow);
const intake = columns[0];
if (!intake || intake.flags.manualIntake !== true) return null;
const planning = columns[1];
if (!planning || planning.flags.hold !== true || planning.flags.intake || planning.flags.complete) return null;
if (workflow.columns.find((column) => column.flags.hold === true)?.id !== planning.id) return null;
return planning.id;
}
/**
@@ -80,7 +115,9 @@ export function resolveQuickAddStartWorkflowTarget(workflow: ValidatedQuickAddWo
}
export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null {
if (workflow.id !== "builtin:coding-ideas") return null;
/* FNXC:QuickAddStart 2026-08-26-19:19: every other workflow — including a duplicate of this one —
resolves its planning lane from traits instead of returning null. */
if (workflow.id !== "builtin:coding-ideas") return resolveManualIntakePlanningColumn(workflow);
const columns = visibleColumns(workflow);
/*
DELIBERATE-LITERAL — these are ONE NAMED BUILTIN's own declared ids, not a lifecycle guard.