fix(FN-7591): allow moving tasks out of custom workflow columns (Coding (Ideas))

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 19:06:32 -07:00
parent c2eb89b8d4
commit dc447304a9
3 changed files with 82 additions and 5 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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}'. ` +