From 927efb147706bb9d6d7138c4a0512d495373ce0a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 08:45:52 -0700 Subject: [PATCH] fix(workflow): allow Coding (Ideas) cards to move back from Todo to Ideas A legacy source column (todo/in-progress/...) validated moves only against the closed VALID_TRANSITIONS map, which cannot know about a workflow-declared column, so Todo -> Ideas was rejected even though the board drag pre-check and context menu both offered it. Legacy sources now union VALID_TRANSITIONS with the task's workflow-resolved adjacency, resolved lazily only when the legacy table alone would reject. builtin:coding adjacency is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/coding-ideas-todo-back-to-ideas.md | 7 +++ .../src/__tests__/coding-ideas-move.test.ts | 53 +++++++++++++++++++ packages/core/src/task-store/moves.ts | 34 +++++++++--- 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 .changeset/coding-ideas-todo-back-to-ideas.md diff --git a/.changeset/coding-ideas-todo-back-to-ideas.md b/.changeset/coding-ideas-todo-back-to-ideas.md new file mode 100644 index 0000000000..7a8dd4c96e --- /dev/null +++ b/.changeset/coding-ideas-todo-back-to-ideas.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Coding (Ideas) boards can now move cards back from Todo to Ideas. +category: fix +dev: Legacy source columns in the flag-OFF `moveTaskInternal` path now union `VALID_TRANSITIONS` with the task's workflow-resolved adjacency (`resolveAllowedColumns`), resolved lazily only when the legacy table alone would reject. builtin:coding adjacency is unchanged. diff --git a/packages/core/src/__tests__/coding-ideas-move.test.ts b/packages/core/src/__tests__/coding-ideas-move.test.ts index 1c2c8dabd5..984dbdcbd1 100644 --- a/packages/core/src/__tests__/coding-ideas-move.test.ts +++ b/packages/core/src/__tests__/coding-ideas-move.test.ts @@ -68,6 +68,59 @@ pgDescribe("Coding (Ideas) custom-column moves (workflow-columns graduation)", ( expect(moved.column).toBe("todo"); }); + /* + FNXC:CodingIdeasWorkflow 2026-07-25-10:05: + Regression for the one-way Ideas intake. The reverse move (Todo → Ideas) was rejected with + "Invalid transition: 'todo' → 'ideas'. Valid targets: in-progress, triage, archived" because `todo` + is a LEGACY column id, so validation used the closed VALID_TRANSITIONS map — which cannot know + about a workflow-declared "ideas" column — instead of the task's own workflow adjacency. Legacy + source columns now UNION both, so an operator can demote a card back to Ideas. + + Surface enumeration (invariant: a legacy source column honors the task's workflow adjacency in + addition to the legacy table): + - Board drag / context menu / task detail / List view / CLI + tools all funnel through + store.moveTask, so the store-level assertions below cover every move surface. + - Both move sources (user and engine). + - Round-trip: the demoted card can be promoted again. + - Default (legacy-column) workflow parity: the union never widens builtin:coding, and a column + the workflow does not declare still rejects. + */ + it("moves an ideas-workflow task back from todo to the ideas intake column", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + + const demoted = await store.moveTask(task.id, "ideas", { moveSource: "user" }); + expect(demoted.column).toBe("ideas"); + + // Round-trip: still promotable after the demotion. + expect((await store.moveTask(task.id, "todo", { moveSource: "user" })).column).toBe("todo"); + }); + + it("allows the todo → ideas move from an engine source too", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + + const demoted = await store.moveTask(task.id, "ideas", { moveSource: "engine" }); + expect(demoted.column).toBe("ideas"); + }); + + it("keeps the default workflow's legacy adjacency unchanged", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "legacy", workflowId: "builtin:coding" }); + + // A column the default workflow never declares is still rejected... + await expect( + store.moveTask(task.id, "ideas", { moveSource: "user" }), + ).rejects.toThrow(/Invalid transition: '.*' → 'ideas'/); + // ...and so is a legacy-but-non-adjacent target, with the verbatim legacy target list. + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await expect( + store.moveTask(task.id, "in-review", { moveSource: "user" }), + ).rejects.toThrow("Invalid transition: 'todo' → 'in-review'. Valid targets: in-progress, triage, archived"); + }); + it("cancels an active task continuation when a user sends implementation back to todo", async () => { const store = harness.store(); const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index 4542672d12..e9b152bfdc 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -655,15 +655,37 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum column's targets from the task's own workflow adjacency instead, still throwing the same legacy-style bare Error (not TransitionRejectionError) so the flag-OFF characterization contract holds for legacy columns. Ported from main's FN-7591 fix into the extracted moves.ts. + + FNXC:CodingIdeasWorkflow 2026-07-25-10:05: + A LEGACY source column must also honor the task's own workflow adjacency, not only + VALID_TRANSITIONS. Symptom: in Coding (Ideas) an operator could move Ideas → Todo but the + reverse drag/menu action ("Move to Ideas") failed with "Invalid transition: 'todo' → 'ideas'. + Valid targets: in-progress, triage, archived" — `todo` IS a legacy column, so the legacy branch + ran and VALID_TRANSITIONS (a closed six-id map that cannot know about "ideas") had the final + say. Both the Board drag pre-check and the context menu already offered the move, so the + rejection surfaced only after the optimistic move snapped back. + Fix: UNION the legacy targets with the workflow-resolved adjacency for legacy sources. This + only ever RELAXES (never narrows) the legacy set, so builtin:coding stays byte-identical — its + resolved adjacency reproduces VALID_TRANSITIONS verbatim — while any workflow that inserts its + own column beside a legacy one (Ideas ↔ Todo here) becomes freely movable in both directions. + The workflow IR is resolved lazily, only when VALID_TRANSITIONS alone would reject, so the + happy-path move cost is unchanged. */ - const validTargets = sourceIsLegacy + const legacyTargets: readonly string[] = sourceIsLegacy ? (VALID_TRANSITIONS[task.column as Column] ?? []) - : resolveAllowedColumns(await resolveTaskWorkflowIrForMove(store, id), task.column); - if (!validTargets.includes(toColumn as Column)) { - throw new Error( - `Invalid transition: '${task.column}' → '${toColumn}'. ` + - `Valid targets: ${validTargets.join(", ") || "none"}`, + : []; + if (!legacyTargets.includes(toColumn)) { + const workflowTargets = resolveAllowedColumns( + await resolveTaskWorkflowIrForMove(store, id), + task.column, ); + const validTargets = [...new Set([...legacyTargets, ...workflowTargets])]; + if (!validTargets.includes(toColumn)) { + throw new Error( + `Invalid transition: '${task.column}' → '${toColumn}'. ` + + `Valid targets: ${validTargets.join(", ") || "none"}`, + ); + } } }