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:
7
.changeset/coding-ideas-todo-back-to-ideas.md
Normal file
7
.changeset/coding-ideas-todo-back-to-ideas.md
Normal 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.
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user