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");
|
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 () => {
|
it("cancels an active task continuation when a user sends implementation back to todo", async () => {
|
||||||
const store = harness.store();
|
const store = harness.store();
|
||||||
const task = await store.createTask({ description: "idea", workflowId: "builtin:coding-ideas" });
|
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
|
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
|
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.
|
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] ?? [])
|
? (VALID_TRANSITIONS[task.column as Column] ?? [])
|
||||||
: resolveAllowedColumns(await resolveTaskWorkflowIrForMove(store, id), task.column);
|
: [];
|
||||||
if (!validTargets.includes(toColumn as Column)) {
|
if (!legacyTargets.includes(toColumn)) {
|
||||||
throw new Error(
|
const workflowTargets = resolveAllowedColumns(
|
||||||
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
|
await resolveTaskWorkflowIrForMove(store, id),
|
||||||
`Valid targets: ${validTargets.join(", ") || "none"}`,
|
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