feat(FN-990): add task spec rebuild endpoint and UI

- Add POST /tasks/:id/spec/rebuild API endpoint to regenerate task specifications
- Add rebuildTaskSpec client function in dashboard API layer
- Add respecify button to task detail modal for triggering spec rebuilds
- Add comprehensive tests for the spec/rebuild endpoint covering success, error, and edge cases
This commit is contained in:
gsxdsm
2026-04-06 18:56:39 -07:00
parent 0f4cfecebe
commit 2e902a1ffe
4 changed files with 161 additions and 1 deletions

View File

@@ -334,6 +334,12 @@ export function requestSpecRevision(id: string, feedback: string, projectId?: st
});
}
export function rebuildTaskSpec(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/spec/rebuild`, projectId), {
method: "POST",
});
}
export function refineTask(id: string, feedback: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/refine`, projectId), {
method: "POST",

View File

@@ -4,7 +4,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings, AgentLogEntry } from "@fusion/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, approvePlan, rejectPlan, refineTask, fetchWorkflowResults } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults } from "../api";
import type { WorkflowStepResult } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
@@ -524,6 +524,17 @@ export function TaskDetailModal({
}
}, [task.id, onClose, addToast]);
const handleRespecify = useCallback(async () => {
if (!confirm("Rebuild the specification for this task? The task will move to triage for re-specification.")) return;
try {
await rebuildTaskSpec(task.id, projectId);
onClose();
addToast(`Respecifying ${task.id}...`, "info");
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, projectId, onClose, addToast]);
const handleOpenRefineModal = useCallback(() => {
setShowRefineModal(true);
setRefineFeedback("");
@@ -1245,6 +1256,11 @@ export function TaskDetailModal({
Refine
</button>
)}
{task.column !== "triage" && (
<button className="btn btn-sm" onClick={handleRespecify}>
Respecify
</button>
)}
{(task.status === "failed" || task.status === "stuck-killed") && onRetryTask && (
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
Retry

View File

@@ -3884,6 +3884,108 @@ describe("POST /tasks/:id/spec/revise", () => {
});
// --- Spec Rebuild route tests ---
describe("POST /tasks/:id/spec/rebuild", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("rebuilds spec and moves task from todo to triage", async () => {
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Specification rebuild requested by user"
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
});
it("rebuilds spec and moves task from in-progress to triage", async () => {
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).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", "triage");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
});
it("rebuilds spec and moves task from done to triage", async () => {
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).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", "triage");
});
it("returns 400 when task is already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot rebuild spec");
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 400 when task is in in-review (cannot transition to triage)", async () => {
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(400);
expect(res.body.error).toContain("in-review");
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 404 when task not found", async () => {
const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT";
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/spec/rebuild");
expect(res.status).toBe(404);
});
});
// --- Plan Approval route tests ---
describe("POST /tasks/:id/approve-plan", () => {

View File

@@ -2506,6 +2506,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Rebuild task spec without feedback
router.post("/tasks/:id/spec/rebuild", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
// Get current task state
const task = await scopedStore.getTask(req.params.id);
// Check if task can transition to triage
const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage");
if (!canTransition) {
res.status(400).json({
error: `Cannot rebuild spec for tasks in '${task.column}' column. ` +
`Move task to a valid column first.`,
});
return;
}
// Log the rebuild request
await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
// Move to triage for re-specification
const updated = await scopedStore.moveTask(task.id, "triage");
// Update status to indicate needs re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
res.json(updated);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404
: err.message?.includes("Invalid transition") ? 400
: 500;
res.status(status).json({ error: err.message });
}
});
// Update task
router.patch("/tasks/:id", async (req, res) => {
try {