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:
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user