feat(FN-2008): merge fusion/fn-2008

This commit is contained in:
gsxdsm
2026-04-17 14:55:28 -07:00
parent f17f666df1
commit 24c4c008ad
3 changed files with 107 additions and 28 deletions

View File

@@ -1758,15 +1758,13 @@ export function TaskDetailModal({
)}
{/* Respecify */}
{task.column !== "triage" && (
<button
className="detail-actions-menu-item"
role="menuitem"
onClick={() => handleActionsMenuItemClick(handleRespecify)}
>
Respecify
</button>
)}
<button
className="detail-actions-menu-item"
role="menuitem"
onClick={() => handleActionsMenuItemClick(handleRespecify)}
>
Respecify
</button>
{/* Retry */}
{canRetryTask && onRetryTask && (

View File

@@ -6075,21 +6075,41 @@ describe("POST /tasks/:id/spec/revise", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
});
it("returns 400 when task is already in triage", async () => {
it("allows spec revision for task already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-respecify" as const };
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-revise-triage-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(triageTask)
.mockResolvedValueOnce(updatedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot request spec revision");
expect(store.moveTask).not.toHaveBeenCalled();
try {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"AI spec revision requested",
"Some feedback"
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("returns 400 when task is in in-review", async () => {
@@ -6297,15 +6317,34 @@ describe("POST /tasks/:id/spec/rebuild", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
});
it("returns 400 when task is already in triage", async () => {
it("allows rebuild for task already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-respecify" as const };
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-triage-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(triageTask)
.mockResolvedValueOnce(updatedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot rebuild spec");
expect(store.moveTask).not.toHaveBeenCalled();
try {
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).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("returns 400 when task is in in-review (cannot transition to triage)", async () => {

View File

@@ -4533,6 +4533,27 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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 re-specification in place.
if (task.column === "triage") {
// Log the revision request
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
// Remove the existing spec so re-specification starts from the task
// description and feedback rather than revising stale PROMPT.md content.
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 re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
const updated = await scopedStore.getTask(task.id);
res.json(updated);
return;
}
// Check if task can transition to triage
const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage");
if (!canTransition) {
@@ -4544,7 +4565,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Log the revision request
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
// Move to triage for re-specification (only valid for todo/in-progress)
// Move to triage for re-specification
const updated = await scopedStore.moveTask(task.id, "triage");
// Remove the existing spec so re-specification starts from the task
@@ -4578,6 +4599,27 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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 re-specification in place.
if (task.column === "triage") {
// Log the rebuild request
await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
// 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 re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
const updated = await scopedStore.getTask(task.id);
res.json(updated);
return;
}
// Check if task can transition to triage
const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage");
if (!canTransition) {