Skip to content

Commit de36ad0

Browse files
Validate task updates against merged state
Co-authored-by: DanielWalnut <hetaoBackend@users.noreply.github.com>
1 parent ebe2c8e commit de36ad0

2 files changed

Lines changed: 106 additions & 43 deletions

File tree

backend/src/api.ts

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,37 +2247,15 @@ async function handlePut(
22472247
origin,
22482248
);
22492249
}
2250-
const prompt = asString(body["prompt"] ?? task["prompt"]);
2251-
if (!prompt.trim())
2250+
const validated = validateTaskInput(ctx, { ...task, ...body });
2251+
if (validated.response) {
22522252
return jsonResponse(
2253-
{ error: "prompt cannot be empty", field: "prompt" },
2254-
400,
2253+
validated.response[0],
2254+
validated.response[1] ?? 400,
22552255
origin,
22562256
);
2257-
const workingDir = asString(body["working_dir"] ?? task["working_dir"]);
2258-
const dirError = ensureWorkingDir(
2259-
workingDir,
2260-
`working_dir does not exist: ${workingDir}`,
2261-
);
2262-
if (dirError) return jsonResponse(dirError, 400, origin);
2263-
const scheduleType = asString(
2264-
body["schedule_type"] ?? task["schedule_type"],
2265-
);
2266-
const cronExpr = asString(body["cron_expr"] ?? task["cron_expr"]);
2267-
if (scheduleType === "cron") {
2268-
if (!cronExpr.trim())
2269-
return jsonResponse(
2270-
{ error: "cron_expr required for cron schedule", field: "cron_expr" },
2271-
400,
2272-
origin,
2273-
);
2274-
if (!cronValid(cronExpr))
2275-
return jsonResponse(
2276-
{ error: `invalid cron expression: ${cronExpr}`, field: "cron_expr" },
2277-
400,
2278-
origin,
2279-
);
22802257
}
2258+
const fields = validated.task!;
22812259

22822260
const updates: Row = {};
22832261
for (const field of [
@@ -2303,9 +2281,12 @@ async function handlePut(
23032281
parseJsonList(body["image_paths"]),
23042282
);
23052283

2306-
const newScheduleType = asString(
2307-
updates["schedule_type"] ?? task["schedule_type"],
2308-
);
2284+
if ("prompt" in body) updates["prompt"] = fields.prompt;
2285+
if ("working_dir" in body) updates["working_dir"] = fields.workingDir;
2286+
if ("schedule_type" in body) updates["schedule_type"] = fields.scheduleType;
2287+
if ("agent" in body) updates["agent"] = fields.agent;
2288+
2289+
const newScheduleType = fields.scheduleType;
23092290
if (newScheduleType === "immediate") {
23102291
Object.assign(updates, {
23112292
status: "pending",
@@ -2318,28 +2299,19 @@ async function handlePut(
23182299
status: "pending",
23192300
next_run_at: null,
23202301
cron_expr: null,
2302+
delay_seconds: fields.delaySeconds,
23212303
});
23222304
} else if (newScheduleType === "scheduled_at") {
2323-
const nextRunAt = body["next_run_at"] ?? task["next_run_at"];
2324-
if (!nextRunAt)
2325-
return jsonResponse(
2326-
{
2327-
error: "next_run_at required for scheduled_at",
2328-
field: "next_run_at",
2329-
},
2330-
400,
2331-
origin,
2332-
);
23332305
Object.assign(updates, {
2334-
next_run_at: nextRunAt,
2306+
next_run_at: fields.nextRunAt,
23352307
status: "scheduled",
23362308
cron_expr: null,
23372309
delay_seconds: null,
23382310
});
23392311
} else if (newScheduleType === "cron") {
2340-
const newCron = asString(updates["cron_expr"] ?? task["cron_expr"]);
23412312
Object.assign(updates, {
2342-
next_run_at: cronNextIso(newCron),
2313+
cron_expr: fields.cronExpr,
2314+
next_run_at: cronNextIso(fields.cronExpr!),
23432315
status: "scheduled",
23442316
delay_seconds: null,
23452317
});

backend/tests/api-handler.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,97 @@ describe("api handler", () => {
12921292
expect(db.get_all_tasks()).toHaveLength(0);
12931293
});
12941294

1295+
test("PUT /api/tasks validates the merged final task without partial updates", async () => {
1296+
async function createTask(
1297+
payload: Record<string, unknown>,
1298+
): Promise<number> {
1299+
const created = await json(
1300+
new Request("http://127.0.0.1:9712/api/tasks", {
1301+
method: "POST",
1302+
headers: { "Content-Type": "application/json" },
1303+
body: JSON.stringify({ prompt: "original", ...payload }),
1304+
}),
1305+
);
1306+
return Number(created.id);
1307+
}
1308+
1309+
const delayedId = await createTask({
1310+
title: "Delayed",
1311+
schedule_type: "delayed",
1312+
delay_seconds: 120,
1313+
agent: "codex",
1314+
});
1315+
const partial = await handleApiRequest(
1316+
ctx,
1317+
new Request(`http://127.0.0.1:9712/api/tasks/${delayedId}`, {
1318+
method: "PUT",
1319+
headers: { "Content-Type": "application/json" },
1320+
body: JSON.stringify({ title: "Renamed" }),
1321+
}),
1322+
);
1323+
expect(partial.status).toBe(200);
1324+
expect(db.get_task(delayedId)).toMatchObject({
1325+
title: "Renamed",
1326+
schedule_type: "delayed",
1327+
delay_seconds: 120,
1328+
agent: "codex",
1329+
});
1330+
1331+
for (const [body, field] of [
1332+
[{ title: "Must not change", schedule_type: "invalid" }, "schedule_type"],
1333+
[{ title: "Must not change", agent: "invalid" }, "agent"],
1334+
[{ title: "Must not change", schedule_type: "cron" }, "cron_expr"],
1335+
[
1336+
{ title: "Must not change", schedule_type: "scheduled_at" },
1337+
"next_run_at",
1338+
],
1339+
] as const) {
1340+
const before = db.get_task(delayedId);
1341+
const res = await handleApiRequest(
1342+
ctx,
1343+
new Request(`http://127.0.0.1:9712/api/tasks/${delayedId}`, {
1344+
method: "PUT",
1345+
headers: { "Content-Type": "application/json" },
1346+
body: JSON.stringify(body),
1347+
}),
1348+
);
1349+
expect(res.status).toBe(400);
1350+
expect(await res.json()).toMatchObject({ field });
1351+
expect(db.get_task(delayedId)).toEqual(before);
1352+
}
1353+
1354+
const immediateId = await createTask({ title: "Immediate" });
1355+
const missingDelay = await handleApiRequest(
1356+
ctx,
1357+
new Request(`http://127.0.0.1:9712/api/tasks/${immediateId}`, {
1358+
method: "PUT",
1359+
headers: { "Content-Type": "application/json" },
1360+
body: JSON.stringify({
1361+
title: "Must not change",
1362+
schedule_type: "delayed",
1363+
}),
1364+
}),
1365+
);
1366+
expect(missingDelay.status).toBe(400);
1367+
expect(await missingDelay.json()).toMatchObject({ field: "delay_seconds" });
1368+
expect(db.get_task(immediateId)!["title"]).toBe("Immediate");
1369+
1370+
const badDir = await handleApiRequest(
1371+
ctx,
1372+
new Request(`http://127.0.0.1:9712/api/tasks/${immediateId}`, {
1373+
method: "PUT",
1374+
headers: { "Content-Type": "application/json" },
1375+
body: JSON.stringify({
1376+
title: "Must not change",
1377+
working_dir: path.join(tmpDir, "missing-put-dir"),
1378+
}),
1379+
}),
1380+
);
1381+
expect(badDir.status).toBe(400);
1382+
expect(await badDir.json()).toMatchObject({ field: "working_dir" });
1383+
expect(db.get_task(immediateId)!["title"]).toBe("Immediate");
1384+
});
1385+
12951386
test("task mutation routes edit, respond, resume, cancel, retry, and remove dependencies", async () => {
12961387
const upstream = await json(
12971388
new Request("http://127.0.0.1:9712/api/tasks", {

0 commit comments

Comments
 (0)