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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 08:45:52 -07:00
parent 3597d06380
commit 927efb1477
3 changed files with 88 additions and 6 deletions

View File

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

View File

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

View File

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