feat(FN-2246): add executionMode support to task APIs and storage

- Add ExecutionMode type contracts and executionMode field to core task interfaces
- Persist executionMode through SQLite schema mappings and TaskStore read/write paths
- Validate executionMode in dashboard route handlers and API request handling
- Expand core and dashboard test coverage for executionMode persistence and route behavior
This commit is contained in:
Fusion
2026-04-22 10:34:11 -07:00
committed by gsxdsm
parent 46b80323e4
commit 086dbe80bd
14 changed files with 325 additions and 28 deletions

View File

@@ -185,6 +185,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
thinkingLevel,
summarize,
reviewLevel,
executionMode,
} = input;
return api<Task>(withProjectId("/tasks", projectId), {
@@ -207,11 +208,12 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
thinkingLevel,
summarize,
reviewLevel,
executionMode,
}),
});
}
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; reviewLevel?: number | null }, projectId?: string): Promise<Task> {
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; reviewLevel?: number | null; executionMode?: "standard" | "fast" | null }, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -824,6 +824,81 @@ describe("POST /tasks", () => {
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards executionMode when provided with 'fast'", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
executionMode: "fast",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Fast task",
executionMode: "fast",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Fast task",
executionMode: "fast",
}),
expect.any(Object),
);
});
it("forwards executionMode when provided with 'standard'", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
executionMode: "standard",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Standard task",
executionMode: "standard",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Standard task",
executionMode: "standard",
}),
expect.any(Object),
);
});
it("returns 400 for invalid executionMode value", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Bad execution mode",
executionMode: "turbo",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("executionMode must be one of");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards planningModelProvider and planningModelId when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
@@ -2798,6 +2873,77 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
});
it("forwards executionMode to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
executionMode: "fast",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
executionMode: "fast",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
executionMode: "fast",
});
});
it("accepts null to clear executionMode via PATCH", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
executionMode: undefined,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
executionMode: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
executionMode: null,
});
});
it("returns 400 for invalid executionMode value via PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
executionMode: "turbo",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("executionMode must be one of");
});
it("omission does not overwrite executionMode via PATCH", async () => {
// When executionMode is not in the request body, it should not be passed to updateTask
const existingTask = {
...FAKE_TASK_DETAIL,
executionMode: "fast",
};
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(existingTask);
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
title: "Updated Title", // Only update title, not executionMode
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
// Verify executionMode was NOT included in the update
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: "Updated Title",
});
// The call should NOT include executionMode
const updateArg = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateArg).not.toHaveProperty("executionMode");
});
});

View File

@@ -3806,6 +3806,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
planningModelId,
thinkingLevel,
reviewLevel,
executionMode,
} = req.body;
if (!description || typeof description !== "string") {
throw badRequest("description is required");
@@ -3834,6 +3835,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Validate executionMode if provided (must be "standard" or "fast")
const validExecutionModes = ["standard", "fast"];
if (executionMode !== undefined && executionMode !== null && !validExecutionModes.includes(executionMode)) {
throw badRequest(`executionMode must be one of: ${validExecutionModes.join(", ")}`);
}
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
@@ -3900,6 +3907,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
thinkingLevel: thinkingLevel || undefined,
summarize,
reviewLevel: reviewLevel ?? undefined,
executionMode: executionMode || undefined,
},
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
);
@@ -5095,7 +5103,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel } = req.body;
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -5129,6 +5137,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Validate executionMode if provided (must be "standard" or "fast")
const validExecutionModes = ["standard", "fast"];
if (executionMode !== undefined && executionMode !== null && !validExecutionModes.includes(executionMode)) {
throw new Error(`executionMode must be one of: ${validExecutionModes.join(", ")}`);
}
if (enabledWorkflowSteps !== undefined) {
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
throw new Error("enabledWorkflowSteps must be an array of strings");
@@ -5150,6 +5164,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (hasBodyField("thinkingLevel")) updates.thinkingLevel = thinkingLevel === null ? null : thinkingLevel;
if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId;
if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel;
if (hasBodyField("executionMode")) updates.executionMode = executionMode === null ? null : executionMode;
const task = await scopedStore.updateTask(req.params.id, updates);
res.json(task);
@@ -5157,7 +5172,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") ? 400 : 500;
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") || (err instanceof Error ? err.message : String(err)).includes("executionMode must be one of") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});