feat(FN-3450): add mesh task create replication across nodes
Implements distributed mesh task creation by adding replicated create primitives to the core store, wiring new API routes (`register-mesh-routes.ts`) that replicate task creation across clustered nodes while preserving remote-targeting metadata, and updating the dashboard's task creation flow accord Fusion-Task-Id: FN-3450
This commit is contained in:
@@ -28,6 +28,7 @@ const mockReserveDistributedTaskId = vi.fn();
|
||||
const mockCommitDistributedTaskIdReservation = vi.fn();
|
||||
const mockAbortDistributedTaskIdReservation = vi.fn();
|
||||
const mockGetDistributedTaskIdState = vi.fn();
|
||||
const mockApplyReplicatedTaskCreate = vi.fn();
|
||||
|
||||
// Mock GlobalSettingsStore
|
||||
const mockGetSettings = vi.fn().mockResolvedValue({});
|
||||
@@ -99,6 +100,10 @@ class MockStore extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
async applyReplicatedTaskCreate(payload: unknown): Promise<{ task: Task; applied: boolean }> {
|
||||
return mockApplyReplicatedTaskCreate(payload);
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
@@ -726,3 +731,97 @@ describe("/api/mesh/task-ids routes", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/mesh/tasks/create", () => {
|
||||
let app: ReturnType<typeof createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockClose.mockResolvedValue(undefined);
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockApplyReplicatedTaskCreate.mockResolvedValue({
|
||||
task: {
|
||||
id: "FN-001",
|
||||
description: "replicated",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
},
|
||||
applied: true,
|
||||
});
|
||||
app = createServer(new MockStore() as unknown as TaskStore);
|
||||
});
|
||||
|
||||
it("applies replicated task create payload", async () => {
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockApplyReplicatedTaskCreate).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("returns 200 when replicated task create is an idempotent replay", async () => {
|
||||
mockApplyReplicatedTaskCreate.mockResolvedValue({
|
||||
task: {
|
||||
id: "FN-001",
|
||||
description: "replicated",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthorized replicated create", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ id: "node_remote_1", apiKey: "secret" }));
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer wrong",
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralGetLocalNode = vi.fn().mockResolvedValue({ id: "node-local" });
|
||||
const mockCentralListNodes = vi.fn().mockResolvedValue([]);
|
||||
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
|
||||
mockPerformUpdateCheck: vi.fn(),
|
||||
mockClearUpdateCheckCache: vi.fn(),
|
||||
@@ -104,6 +106,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
close: mockCentralClose,
|
||||
listProjects: mockCentralListProjects,
|
||||
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
|
||||
getLocalNode: mockCentralGetLocalNode,
|
||||
listNodes: mockCentralListNodes,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -175,6 +179,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
createTaskWithReservedId: undefined,
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
@@ -205,6 +210,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||
reserveDistributedTaskId: vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7001" }),
|
||||
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
@@ -670,6 +680,98 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses distributed allocator flow when reserved-id create is available", async () => {
|
||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-7001",
|
||||
column: "triage",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
nodeId: "node-target",
|
||||
});
|
||||
const storeWithReservedCreate = createMockStore({
|
||||
createTaskWithReservedId,
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7001\n\nBig initiative\n" }),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Big initiative", nodeId: "node-target" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(createTaskWithReservedId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: "Big initiative", nodeId: "node-target" }),
|
||||
expect.objectContaining({ taskId: "FN-7001" }),
|
||||
);
|
||||
expect((storeWithReservedCreate.getDistributedTaskIdAllocator as ReturnType<typeof vi.fn>).mock.results[0]?.value.commitDistributedTaskIdReservation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when nodeId is not a string", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Task", nodeId: 123 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("nodeId must be a string");
|
||||
});
|
||||
|
||||
it("aborts reservation and deletes local task on replication failure", async () => {
|
||||
const reserveDistributedTaskId = vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7002" });
|
||||
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
||||
const abortDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-7002",
|
||||
column: "triage",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
});
|
||||
const deleteTask = vi.fn().mockResolvedValue(undefined);
|
||||
const storeWithReservedCreate = createMockStore({
|
||||
createTaskWithReservedId,
|
||||
deleteTask,
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7002\n\nBig initiative\n" }),
|
||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||
reserveDistributedTaskId,
|
||||
commitDistributedTaskIdReservation,
|
||||
abortDistributedTaskIdReservation,
|
||||
}),
|
||||
});
|
||||
mockCentralListNodes.mockResolvedValue([{ id: "node-remote", type: "remote", url: "https://remote.example.com", apiKey: "secret" }]);
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Big initiative" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
|
||||
expect(deleteTask).toHaveBeenCalledWith("FN-7002");
|
||||
expect(commitDistributedTaskIdReservation).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
mockCentralListNodes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("forwards branch and baseBranch on create", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
Reference in New Issue
Block a user