diff --git a/.changeset/fn-8148-respecify-fix.md b/.changeset/fn-8148-respecify-fix.md new file mode 100644 index 0000000000..8d00d76ff7 --- /dev/null +++ b/.changeset/fn-8148-respecify-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Respecify replan tasks across workflow board layouts. +category: fix +dev: Resolves workflow planner lanes with recovery rehome and hides the unsupported archived action. diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index a1176171d4..81cc703359 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -264,7 +264,15 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine }); } - actions.push({ id: "respecify", label: t("taskDetail.respecify.btn", "Respecify"), onSelect: options.onRespecify }); + /* + FNXC:TaskContextMenu 2026-07-16-12:00: + Archived is an unsupported Respecify source: the rebuild route rejects it rather than + resurrecting intentionally archived work into a planner lane. Check both the semantic + workflow trait and legacy id so every menu host omits this dead affordance. + */ + if (task.column !== "archived" && currentColumnFlags?.archived !== true) { + actions.push({ id: "respecify", label: t("taskDetail.respecify.btn", "Respecify"), onSelect: options.onRespecify }); + } if (canRetryTask && hasRetryHandler) { actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry }); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index d8bdb977f6..61b69f9b57 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -45,7 +45,7 @@ describe("TaskContextMenu shared task action model", () => { ]); expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true, onOpenRefine: vi.fn() })).toEqual(["refine", "respecify", "delete"]); expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]); - expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]); + expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["delete"]); }); it("exposes Plan only for pre-execution hold columns with a host callback", () => { @@ -199,7 +199,7 @@ describe("TaskContextMenu shared task action model", () => { workflowMoveColumns, hasResetHandler: true, }); - expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]); + expect(archivedModel.actions.map((action) => action.id)).toEqual(["delete"]); expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset"); }); @@ -254,7 +254,7 @@ describe("TaskContextMenu shared task action model", () => { onDelete, }); - expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]); + expect(archivedModel.actions.map((action) => action.id)).toEqual(["delete"]); expect(archivedModel.actions.map((action) => action.id)).not.toContain("pause"); expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset"); archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.(); diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index 1b5e97d0b4..a0078beace 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -2271,7 +2271,7 @@ describe("POST /tasks/:id/spec/rebuild", () => { "FN-001", "Specification rebuild requested by user" ); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true }); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); @@ -2291,7 +2291,7 @@ describe("POST /tasks/:id/spec/rebuild", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); expect(res.status).toBe(200); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true }); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); }); @@ -2307,7 +2307,7 @@ describe("POST /tasks/:id/spec/rebuild", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); expect(res.status).toBe(200); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true }); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); }); @@ -2337,6 +2337,7 @@ describe("POST /tasks/:id/spec/rebuild", () => { expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); + expect(res.body.status).toBe("needs-replan"); } finally { rmSync(tempRoot, { recursive: true, force: true }); } @@ -2352,11 +2353,105 @@ describe("POST /tasks/:id/spec/rebuild", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); expect(res.status).toBe(200); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true }); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); }); + function selectWorkflow(columns: Array<{ id: string; traits?: Array<{ trait: string }> }>) { + const workflowIr = { + version: 2, + columns: columns.map((column) => ({ name: column.id, traits: [], ...column })), + nodes: [], + edges: [], + }; + (store as unknown as { getTaskWorkflowSelection: ReturnType }).getTaskWorkflowSelection = vi.fn() + .mockReturnValue({ workflowId: "WF-rebuild-test" }); + (store as unknown as { getWorkflowDefinition: ReturnType }).getWorkflowDefinition = vi.fn() + .mockResolvedValue({ id: "WF-rebuild-test", ir: workflowIr }); + } + + it("rebuilds a no-triage/no-todo workflow through legacy triage recovery rehome", async () => { + const customTask = { ...FAKE_TASK_DETAIL, column: "publish" as any }; + const movedTask = { ...customTask, column: "triage" as any }; + const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-legacy-rehome-")); + const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001"); + mkdirSync(taskDir, { recursive: true }); + writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n"); + selectWorkflow([{ id: "publish" }]); + (store.getTask as ReturnType).mockResolvedValue(customTask); + (store.moveTask as ReturnType).mockResolvedValue(movedTask); + (store.updateTask as ReturnType).mockResolvedValue(movedTask); + (store.getRootDir as ReturnType).mockReturnValue(tempRoot); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); + + expect(res.status).toBe(200); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true }); + expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); + expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("returns the updated task when a plan-in-place workflow is already in todo", async () => { + const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as any }; + const updatedTask = { ...todoTask, status: "needs-replan" as const }; + selectWorkflow([{ id: "todo" }, { id: "in-review" }]); + (store.getTask as ReturnType) + .mockResolvedValueOnce(todoTask) + .mockResolvedValueOnce(updatedTask); + (store.updateTask as ReturnType).mockResolvedValue(updatedTask); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); + + expect(res.status).toBe(200); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); + expect(res.body.status).toBe("needs-replan"); + }); + + it("rebuilds a plan-in-place workflow into todo", async () => { + const customTask = { ...FAKE_TASK_DETAIL, column: "in-review" as any }; + const movedTask = { ...customTask, column: "todo" as any }; + selectWorkflow([{ id: "todo" }, { id: "in-review" }]); + (store.getTask as ReturnType).mockResolvedValue(customTask); + (store.moveTask as ReturnType).mockResolvedValue(movedTask); + (store.updateTask as ReturnType).mockResolvedValue(movedTask); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); + + expect(res.status).toBe(200); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { moveSource: "user", recoveryRehome: true }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); + }); + + it("rejects legacy and semantic archived tasks before rebuilding", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-archived-")); + const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001"); + mkdirSync(taskDir, { recursive: true }); + writeFileSync(join(taskDir, "PROMPT.md"), "# retained spec\n"); + const archivedTask = { ...FAKE_TASK_DETAIL, column: "cold-storage" as any }; + selectWorkflow([{ id: "cold-storage", traits: [{ trait: "archived" }] }]); + (store.getTask as ReturnType).mockResolvedValue(archivedTask); + (store.getRootDir as ReturnType).mockReturnValue(tempRoot); + + try { + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("not available for archived tasks"); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(true); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("returns 404 when task not found", async () => { const error = new Error("Task not found") as Error & { code?: string }; error.code = "ENOENT"; diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e3c2f6d2e3..a185f46d10 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -45,6 +45,7 @@ import { isWorkflowColumnsEnabled, resolveWorkflowIrForTask, workflowHasColumn, + resolveColumnFlags, TransitionRejectionError, getPlannerInterventionTimeline, isBuiltinWorkflowId, @@ -4260,44 +4261,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Get current task state const task = await scopedStore.getTask(req.params.id); - // If task is already in triage, skip the transition check and moveTask. - // Just reset for replanning in place. - if (task.column === "triage") { - // Log the rebuild request - await scopedStore.logEntry(task.id, "Specification rebuild requested by user"); - clearRebuiltSpecWorkflowPins(scopedStore, task.id); - - // Remove the existing spec so rebuilds produce a fresh PROMPT.md instead - // of asking triage to revise whatever was already on disk. - const { rm } = await import("node:fs/promises"); - const { join } = await import("node:path"); - const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"); - await rm(promptPath, { force: true }); - - // Update status to indicate needs replanning - await scopedStore.updateTask(task.id, { status: "needs-replan" }); - - const updated = await scopedStore.getTask(task.id); - res.json(updated); - return; + const workflowIr = await resolveWorkflowIrForTask(scopedStore, task.id); + const currentColumn = "columns" in workflowIr + ? workflowIr.columns.find((column) => column.id === task.column) + : undefined; + const isArchived = task.column === "archived" || (currentColumn != null && resolveColumnFlags(currentColumn).archived); + if (isArchived) { + throw badRequest("Respecify is not available for archived tasks; unarchive first."); } - // Check if task can transition to triage - // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy - // closed union. A non-legacy custom column id has no legacy transition row, - // so it correctly resolves to "cannot transition" here. - const canTransition = - isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); - if (!canTransition) { - throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`); - } + /* + FNXC:WorkflowReplan 2026-07-16-12:00: + Respecify must park work in a planner lane belonging to the task's own workflow: + triage when declared, otherwise plan-in-place todo, then legacy triage for workflows + with neither. The legacy fallback is intentionally recovery-rehomed: plain moves reject + an undeclared triage target as unknown-column (and reject non-adjacent sources), which + previously stranded no-triage workflows before their needs-replan status was written. + Archived cards are rejected above rather than resurrected into a planner lane. + */ + const replanColumn = workflowHasColumn(workflowIr, "triage") + ? "triage" + : workflowHasColumn(workflowIr, "todo") + ? "todo" + : "triage"; - // Log the rebuild request await scopedStore.logEntry(task.id, "Specification rebuild requested by user"); clearRebuiltSpecWorkflowPins(scopedStore, task.id); - // Move to triage for replanning - const updated = await scopedStore.moveTask(task.id, "triage"); + if (task.column !== replanColumn) { + await scopedStore.moveTask(task.id, replanColumn, { + moveSource: "user", + recoveryRehome: true, + }); + } // Remove the existing spec so rebuilds produce a fresh PROMPT.md instead // of asking triage to revise whatever was already on disk. @@ -4309,6 +4305,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Update status to indicate needs replanning await scopedStore.updateTask(task.id, { status: "needs-replan" }); + /* + FNXC:WorkflowReplan 2026-07-16-12:00: + Respecify responses must re-read the persisted task after setting needs-replan so + planner-lane-in-place requests, including legacy triage, never return stale status. + */ + const updated = await scopedStore.getTask(task.id); res.json(updated); } catch (err: unknown) { if (err instanceof ApiError) {