From dc447304a91cba886fe425e2e0441ea4884cb676 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 19:06:32 -0700 Subject: [PATCH] fix(FN-7591): allow moving tasks out of custom workflow columns (Coding (Ideas)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a card out of a non-legacy workflow column — e.g. Coding (Ideas) "ideas" → "todo" — was rejected with "Invalid transition: 'ideas' → 'todo'. Valid targets: none". Workflow columns graduated to always-on but moveTaskInternal's compat-flag legacy branch (the default path, since no experimental flag is emitted) validated every move against the legacy VALID_TRANSITIONS table, which is keyed only by the built-in column ids. Default-workflow moves survived by coincidence (its ids ARE the legacy ids); a task in a custom column had no key so every move was rejected. The legacy branch now resolves a non-legacy source column's targets from the task's own workflow adjacency (resolveAllowedColumns), while keeping the legacy bare-Error contract intact for legacy columns (transition-parity / characterization suites unchanged). Adds a regression test covering the ideas -> todo -> in-progress -> in-review chain and non-adjacent rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/coding-ideas-custom-column-move.md | 7 +++ .../src/__tests__/coding-ideas-move.test.ts | 59 +++++++++++++++++++ packages/core/src/store.ts | 21 +++++-- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 .changeset/coding-ideas-custom-column-move.md create mode 100644 packages/core/src/__tests__/coding-ideas-move.test.ts diff --git a/.changeset/coding-ideas-custom-column-move.md b/.changeset/coding-ideas-custom-column-move.md new file mode 100644 index 0000000000..b0fcc920fb --- /dev/null +++ b/.changeset/coding-ideas-custom-column-move.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix "Invalid transition" error when moving cards out of a custom workflow column like Coding (Ideas) → Ideas. +category: fix +dev: moveTaskInternal's compat-flag legacy path validated moves against the legacy VALID_TRANSITIONS table, which is keyed only by the built-in column ids; a task in a non-legacy workflow column (e.g. "ideas") had no key and every move was rejected. The legacy branch now resolves a non-legacy source column's targets from the task's own workflow adjacency (resolveAllowedColumns) while preserving the legacy bare-Error contract for legacy columns. diff --git a/packages/core/src/__tests__/coding-ideas-move.test.ts b/packages/core/src/__tests__/coding-ideas-move.test.ts new file mode 100644 index 0000000000..d8232b41a6 --- /dev/null +++ b/packages/core/src/__tests__/coding-ideas-move.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/* +FNXC:WorkflowColumns 2026-07-05-19:10: +Regression for the disappearing move on custom workflow columns. Workflow columns graduated to +always-on (no experimental flag emitted), but moveTaskInternal still gated its workflow path on the +retired strict compatibility flag, so it fell back to the legacy VALID_TRANSITIONS table — which is +keyed only by the legacy column ids. A task in a non-legacy column (Coding (Ideas) → "ideas") could +not move: "Invalid transition: 'ideas' → 'todo'. Valid targets: none". + +Surface enumeration (invariant: a move is validated by the task's WORKFLOW adjacency, not the legacy +table, on a default project with NO experimental flag set): + - Custom intake column forward move: ideas → todo is allowed. + - Full custom-column chain onward: todo → in-progress → in-review all succeed (→ done is gated by the + workflow's own merge trait, which is orthogonal to transition adjacency and excluded here). + - Non-adjacent move still rejects with the workflow's targets (ideas → in-progress). + - Holds for both user- and engine-sourced moves. + - Default workflow (legacy column ids) is unchanged (parity), verified in move-task-characterization. +*/ +describe("Coding (Ideas) custom-column moves (workflow-columns graduation)", () => { + const harness = createTaskStoreTestHarness(); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + + it("moves an ideas-workflow task from the ideas intake column to todo", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + expect(task.column).toBe("ideas"); + + const moved = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(moved.column).toBe("todo"); + }); + + it("advances an ideas task along the Coding (Ideas) custom-column chain", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + const inReview = await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + expect(inReview.column).toBe("in-review"); + }); + + it("still rejects a non-adjacent move out of the ideas column", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + await expect( + store.moveTask(task.id, "in-progress", { moveSource: "user" }), + ).rejects.toThrow(/Invalid transition: 'ideas' → 'in-progress'/); + }); + + it("allows the ideas → todo move from an engine source too", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" }); + const moved = await store.moveTask(task.id, "todo", { moveSource: "engine" }); + expect(moved.column).toBe("todo"); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 12e638163b..2958360fb8 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7676,11 +7676,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} !sourceIsLegacy && (COLUMNS as readonly string[]).includes(toColumn); if (!isEvacuation) { - // Legacy flag-OFF branch (useWorkflow === false): both columns are - // guaranteed legacy ids here — a non-legacy `toColumn` returns `?? []` - // and rejects below, and flag-OFF tasks never hold custom column ids. - // The `as Column` is provably safe within this branch (#1403). - const validTargets = VALID_TRANSITIONS[task.column as Column] ?? []; + /* + FNXC:WorkflowColumns 2026-07-05-19:30: + Workflow columns graduated to always-on (no experimental flag emitted), so this "flag-OFF" + branch is the DEFAULT move path for nearly every project — the strict compat flag reads false + because nothing sets it. Legacy columns (triage/todo/in-progress/in-review/done/archived) are + validated verbatim by VALID_TRANSITIONS, preserving the legacy bare-Error contract. But a task + can legitimately sit in a NON-legacy workflow column now (e.g. Coding (Ideas) → "ideas"), which + VALID_TRANSITIONS cannot key — the old code returned `?? []` and rejected EVERY move out of it + ("Invalid transition: 'ideas' → 'todo'. Valid targets: none"). Resolve a non-legacy source + 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. + */ + const validTargets = sourceIsLegacy + ? (VALID_TRANSITIONS[task.column as Column] ?? []) + : resolveAllowedColumns(this.resolveTaskWorkflowIrSync(id), task.column); if (!validTargets.includes(toColumn as Column)) { throw new Error( `Invalid transition: '${task.column}' → '${toColumn}'. ` +