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

This commit is contained in:
Fusion
2026-04-17 15:10:32 -07:00
committed by gsxdsm
parent dde182286a
commit 8d6ad619e8
2 changed files with 195 additions and 4 deletions

View File

@@ -844,7 +844,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const tmpPath = join(dir, "task.json.tmp");
this.suppressWatcher(taskJsonPath);
await mkdir(dir, { recursive: true }); // Ensure directory exists
await writeFile(tmpPath, JSON.stringify(task, null, 2));
await writeFile(tmpPath, JSON.stringify(task));
await rename(tmpPath, taskJsonPath);
}
@@ -892,7 +892,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const tmpPath = join(dir, "task.json.tmp");
this.suppressWatcher(taskJsonPath);
await mkdir(dir, { recursive: true });
await writeFile(tmpPath, JSON.stringify(task, null, 2));
await writeFile(tmpPath, JSON.stringify(task));
await rename(tmpPath, taskJsonPath);
}
@@ -2416,7 +2416,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
entry.runContext = runContext;
}
task.log.push(entry);
task.log = compactTaskActivityLog(task.log);
if (task.log.length > TASK_ACTIVITY_LOG_ENTRY_LIMIT) {
task.log.splice(0, task.log.length - TASK_ACTIVITY_LOG_ENTRY_LIMIT);
}
task.updatedAt = new Date().toISOString();
// When runContext is provided, record audit event atomically with task mutation

View File

@@ -9,6 +9,13 @@ import {
fetchAgentTasks,
archiveTask,
unarchiveTask,
deleteTask,
moveTask,
mergeTask,
retryTask,
duplicateTask,
pauseTask,
unpauseTask,
fetchAuthStatus,
loginProvider,
logoutProvider,
@@ -54,7 +61,7 @@ import {
type ExecutorStats,
type ExecutorState,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
const FAKE_DETAIL: TaskDetail = {
id: "FN-001",
@@ -1503,6 +1510,188 @@ describe("Git Management API", () => {
});
});
describe("deleteTask", () => {
it("sends DELETE to task endpoint", async () => {
const deletedTask: Task = { ...FAKE_DETAIL, column: "done" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, deletedTask));
const response = await deleteTask("FN-001");
expect(response).toEqual(deletedTask);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "DELETE",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }, 404));
await expect(deleteTask("FN-001")).rejects.toThrow("Not found");
});
});
describe("moveTask", () => {
it("sends POST to move endpoint with column body", async () => {
const movedTask: Task = { ...FAKE_DETAIL, column: "todo" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, movedTask));
const response = await moveTask("FN-001", "todo");
expect(response.column).toBe("todo");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/move", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ column: "todo" }),
});
});
it("sends POST with done column", async () => {
const movedTask: Task = { ...FAKE_DETAIL, column: "done" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, movedTask));
const response = await moveTask("FN-001", "done");
expect(response.column).toBe("done");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/move", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ column: "done" }),
});
});
it("forwards projectId as query param", async () => {
const movedTask: Task = { ...FAKE_DETAIL, column: "todo" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, movedTask));
await moveTask("FN-001", "todo", "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/move?projectId=proj_123", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ column: "todo" }),
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Cannot move to same column" }, 400));
await expect(moveTask("FN-001", "todo")).rejects.toThrow("Cannot move to same column");
});
});
describe("mergeTask", () => {
it("sends POST to merge endpoint and returns MergeResult", async () => {
const mergeResult: MergeResult = {
source: "FN-001",
target: "FN-002",
details: {
mergedFields: ["description", "steps"],
sourceDeleted: true,
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mergeResult));
const response = await mergeTask("FN-001");
expect(response).toEqual(mergeResult);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/merge", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Cannot merge with itself" }, 400));
await expect(mergeTask("FN-001")).rejects.toThrow("Cannot merge with itself");
});
});
describe("retryTask", () => {
it("sends POST to retry endpoint", async () => {
const retriedTask: Task = { ...FAKE_DETAIL, column: "todo" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, retriedTask));
const response = await retryTask("FN-001");
expect(response).toEqual(retriedTask);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/retry", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in error state" }, 400));
await expect(retryTask("FN-001")).rejects.toThrow("Task not in error state");
});
});
describe("duplicateTask", () => {
it("sends POST to duplicate endpoint", async () => {
const duplicatedTask: Task = { ...FAKE_DETAIL, id: "FN-002" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, duplicatedTask));
const response = await duplicateTask("FN-001");
expect(response.id).toBe("FN-002");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/duplicate", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Duplicate failed" }, 500));
await expect(duplicateTask("FN-001")).rejects.toThrow("Duplicate failed");
});
});
describe("pauseTask", () => {
it("sends POST to pause endpoint", async () => {
const pausedTask: Task = { ...FAKE_DETAIL, column: "in-progress" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, pausedTask));
const response = await pauseTask("FN-001");
expect(response).toEqual(pausedTask);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/pause", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Cannot pause completed task" }, 400));
await expect(pauseTask("FN-001")).rejects.toThrow("Cannot pause completed task");
});
});
describe("unpauseTask", () => {
it("sends POST to unpause endpoint", async () => {
const unpausedTask: Task = { ...FAKE_DETAIL, column: "in-progress" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, unpausedTask));
const response = await unpauseTask("FN-001");
expect(response).toEqual(unpausedTask);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/unpause", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not paused" }, 400));
await expect(unpauseTask("FN-001")).rejects.toThrow("Task not paused");
});
});
describe("workspace file APIs", () => {
it("fetchWorkspaces requests the workspace list", async () => {
const payload = {