From 0978c8388698d43966556d4d5e80ccc0cacb1cdb Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 9 Aug 2026 14:44:03 +0800 Subject: [PATCH 1/4] feat(scheduler): support safe schedule editing --- server/src/scheduled-tasks-http.test.ts | 95 ++++++++++++++++++++ server/src/scheduled-tasks.ts | 17 +++- tests/test604-scheduled-task-edit/Dockerfile | 11 +++ tests/test604-scheduled-task-edit/run.sh | 78 ++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/test604-scheduled-task-edit/Dockerfile create mode 100644 tests/test604-scheduled-task-edit/run.sh diff --git a/server/src/scheduled-tasks-http.test.ts b/server/src/scheduled-tasks-http.test.ts index ec9c35bb0..eaaac78e3 100644 --- a/server/src/scheduled-tasks-http.test.ts +++ b/server/src/scheduled-tasks-http.test.ts @@ -338,6 +338,91 @@ describe("Hub scheduled task API and dispatcher", () => { expect(renamedTask.to_name).toBe("scheduler-renamed"); }); + test("full edit validates target and policy, preserves cadence, and recomputes through DST-safe schedule math", async () => { + // Pin a recognizable future occurrence so a metadata-only edit can prove + // that it does not silently reset the interval cadence. + const pinnedNext = new Date(Date.now() + 45 * 60_000).toISOString(); + db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [pinnedNext, scheduleId]); + let current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + const metadataOnly = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ + revision: current.revision, + name: "Edited briefing", + task: "Summarize only verified release changes", + priority: "low", + misfire_policy: "skip", + }), + }); + expect(metadataOnly.status).toBe(200); + expect(metadataOnly.body.schedule.name).toBe("Edited briefing"); + expect(metadataOnly.body.schedule.task_content).toBe("Summarize only verified release changes"); + expect(metadataOnly.body.schedule.priority).toBe("low"); + expect(metadataOnly.body.schedule.misfire_policy).toBe("skip"); + expect(metadataOnly.body.schedule.next_run_at).toBe(pinnedNext); + + // A schedule/timezone edit must reuse nextOccurrence's IANA/DST behavior. + const beforeEdit = new Date(); + const dstEdit = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ + revision: metadataOnly.body.schedule.revision, + schedule: { type: "daily", time: "01:30" }, + timezone: "America/New_York", + }), + }); + expect(dstEdit.status).toBe(200); + expect(dstEdit.body.schedule.schedule).toEqual({ type: "daily", time: "01:30" }); + expect(dstEdit.body.schedule.timezone).toBe("America/New_York"); + const expectedFloor = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", beforeEdit)!; + expect(new Date(dstEdit.body.schedule.next_run_at).getTime()).toBe(expectedFloor.getTime()); + + const invalidPolicy = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ revision: dstEdit.body.schedule.revision, misfire_policy: "run_everything" }), + }); + expect(invalidPolicy.status).toBe(400); + expect(invalidPolicy.body.error).toBe("invalid_misfire_policy"); + + const foreign = register(`scheduler_edit_foreign_${Date.now()}`, "SchedulerEditForeign123!", undefined, "seed"); + expect(foreign.ok).toBe(true); + const foreignNetworkId = foreign.network_id!; + const foreignNodeId = `n_foreign_edit_${Date.now()}`; + db.run( + "INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Foreign Edit Node', 'foreign-edit-node', 'codex-sdk', ?2)", + [foreignNodeId, foreignNetworkId], + ); + const crossNetwork = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ revision: dstEdit.body.schedule.revision, target_node_id: foreignNodeId }), + }); + expect(crossNetwork.status).toBe(404); + expect(crossNetwork.body.error).toBe("target_node_not_found"); + current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + expect(current.target_node_id).toBe(nodeId); + expect(current.revision).toBe(dstEdit.body.schedule.revision); + }); + + test("two editors with one revision produce one winner and one refreshable conflict", async () => { + const current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + const [a, b] = await Promise.all([ + api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ revision: current.revision, name: "Concurrent editor A" }), + }), + api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ revision: current.revision, name: "Concurrent editor B" }), + }), + ]); + expect([a.status, b.status].sort()).toEqual([200, 409]); + const conflict = a.status === 409 ? a : b; + expect(conflict.body.error).toBe("revision_conflict"); + const latest = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + expect(latest.revision).toBe(current.revision + 1); + expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name); + }); + test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => { const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`); revision = latest.body.schedule.revision; @@ -355,6 +440,16 @@ describe("Hub scheduled task API and dispatcher", () => { expect(manual.body.taskId).toBeTruthy(); const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" }); expect(cancelled.body.status).toBe("cancelled"); + const cancelledRow = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + const resurrect = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { + method: "PATCH", + body: JSON.stringify({ revision: cancelledRow.revision, status: "active", name: "must not revive" }), + }); + expect(resurrect.status).toBe(409); + expect(resurrect.body.error).toBe("schedule_cancelled"); + const stillCancelled = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + expect(stillCancelled.status).toBe("cancelled"); + expect(stillCancelled.revision).toBe(cancelledRow.revision); const runs = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/runs?network_id=${encodeURIComponent(networkId)}`); expect(runs.body.runs.length).toBeGreaterThanOrEqual(4); }); diff --git a/server/src/scheduled-tasks.ts b/server/src/scheduled-tasks.ts index d00634b01..6dca5a1f2 100644 --- a/server/src/scheduled-tasks.ts +++ b/server/src/scheduled-tasks.ts @@ -484,6 +484,10 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): if (!sub && req.method === "PATCH") { let body: Record; try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); } + // Cancellation/completion are terminal states. Editing must never become + // an implicit resurrection path (including by supplying status=active). + if (row.status === "cancelled") return jsonError("schedule_cancelled", 409); + if (row.status === "completed") return jsonError("schedule_completed", 409); if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) return jsonError("revision_conflict", 409, { current_revision: row.revision }); try { const name = body.name === undefined ? row.name : String(body.name).trim(); @@ -500,7 +504,18 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): const requestedStatus = body.status === undefined ? row.status : String(body.status); if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status"); const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy); - const next = requestedStatus === "active" ? nextOccurrence(parsed.spec, parsed.timezone, new Date()) : null; + // Editing descriptive fields must not silently reset the schedule's + // cadence. Recompute only when the scheduling inputs change, when a + // paused schedule resumes, or when repairing an impossible active row + // with no next occurrence. The recompute uses the same DST-safe helper + // as creation and dispatch advancement. + const schedulingChanged = body.schedule !== undefined || body.timezone !== undefined; + const resumed = row.status !== "active" && requestedStatus === "active"; + const next = requestedStatus !== "active" + ? null + : schedulingChanged || resumed || !row.next_run_at + ? nextOccurrence(parsed.spec, parsed.timezone, new Date()) + : new Date(row.next_run_at); if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence"); const updated = db.run( `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4, diff --git a/tests/test604-scheduled-task-edit/Dockerfile b/tests/test604-scheduled-task-edit/Dockerfile new file mode 100644 index 000000000..39755aee4 --- /dev/null +++ b/tests/test604-scheduled-task-edit/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1.3.14 +WORKDIR /workspace +COPY server/package.json ./server/package.json +RUN cd server && bun install --ignore-scripts +COPY server ./server +COPY tests/test601-hub-scheduled-tasks ./tests/test601-hub-scheduled-tasks +COPY tests/test604-scheduled-task-edit ./tests/test604-scheduled-task-edit +ARG SOURCE_COMMIT +ENV TEST604_SOURCE_COMMIT=$SOURCE_COMMIT +RUN chmod 0755 /workspace/tests/test604-scheduled-task-edit/run.sh +ENTRYPOINT ["/workspace/tests/test604-scheduled-task-edit/run.sh"] diff --git a/tests/test604-scheduled-task-edit/run.sh b/tests/test604-scheduled-task-edit/run.sh new file mode 100644 index 000000000..873f7415b --- /dev/null +++ b/tests/test604-scheduled-task-edit/run.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail +ARTIFACT_DIR=${ARTIFACT_DIR:-/artifacts} +REPORT="$ARTIFACT_DIR/report-test604-scheduled-task-edit.txt" +mkdir -p "$ARTIFACT_DIR" +: > "$REPORT" +exec > >(tee -a "$REPORT") 2>&1 + +echo "# test604 — scheduled task editing" +echo "source_commit=${TEST604_SOURCE_COMMIT:-unknown}" +echo "date=$(date -Is)" + +run_real() { + local db_path=$1 + COMMHUB_DB="$db_path" bun test server/src/scheduled-tasks-http.test.ts +} + +expect_red() { + local label=$1 db_path=$2 + set +e + run_real "$db_path" >/tmp/test604-red.log 2>&1 + local rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "MUTATION_FALSE_GREEN: $label" + sed -n '1,240p' /tmp/test604-red.log + exit 1 + fi + echo "MUTATION_RED: $label rc=$rc" +} + +echo "L0 build" +bun build server/src/index.ts --target bun --outfile /tmp/commhub-schedule-edit.js +test -s /tmp/commhub-schedule-edit.js + +echo "L1 real Hub + SQLite full edit contract" +run_real /tmp/test604-green.db +cp server/src/scheduled-tasks.ts /tmp/test604-scheduled-tasks.ts + +echo "L2 witnessed-red: cancelled schedules cannot be resurrected" +perl -0pi -e 's/(implicit resurrection path \(including by supplying status=active\)\.\n )if \(row\.status === "cancelled"\)/${1}if (false \&\& row.status === "cancelled")/' server/src/scheduled-tasks.ts +grep -Fq 'if (false && row.status === "cancelled")' server/src/scheduled-tasks.ts +expect_red cancelled-is-terminal /tmp/test604-mut-cancelled.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L3 witnessed-red: metadata edit preserves cadence" +sed -i 's/const schedulingChanged = body.schedule !== undefined || body.timezone !== undefined;/const schedulingChanged = true;/' server/src/scheduled-tasks.ts +grep -Fq 'const schedulingChanged = true;' server/src/scheduled-tasks.ts +expect_red metadata-edit-preserves-next-run /tmp/test604-mut-cadence.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L4 witnessed-red: edit target remains network scoped" +sed -i '0,/WHERE node_id = ?1 AND network_id = ?2/s//WHERE node_id = ?1 AND (?2 IS NOT NULL OR network_id = ?2)/' server/src/scheduled-tasks.ts +grep -Fq 'WHERE node_id = ?1 AND (?2 IS NOT NULL OR network_id = ?2)' server/src/scheduled-tasks.ts +expect_red edit-target-network-isolation /tmp/test604-mut-target.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L5 witnessed-red: edit misfire policy remains fail-closed" +sed -i 's/const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);/const misfirePolicy = row.misfire_policy;/' server/src/scheduled-tasks.ts +grep -Fq 'const misfirePolicy = row.misfire_policy;' server/src/scheduled-tasks.ts +expect_red edit-misfire-validation /tmp/test604-mut-misfire.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L6 witnessed-red: schedule edit uses DST-safe next occurrence" +sed -i 's/? nextOccurrence(parsed.spec, parsed.timezone, new Date())/? new Date(Date.now() + 60000)/' server/src/scheduled-tasks.ts +grep -Fq '? new Date(Date.now() + 60000)' server/src/scheduled-tasks.ts +expect_red edit-dst-recompute /tmp/test604-mut-dst.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L7 witnessed-red: exact revision is load-bearing" +sed -i 's/if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision)/if (false \&\& (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision))/' server/src/scheduled-tasks.ts +grep -Fq 'if (false && (!Number.isSafeInteger' server/src/scheduled-tasks.ts +expect_red optimistic-revision /tmp/test604-mut-revision.db +cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts + +echo "L8 restored green" +run_real /tmp/test604-restored.db +echo "RESULT: PASS" From fa6a07b7dd00314e34f6729221a577194a6a80a0 Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 9 Aug 2026 14:48:31 +0800 Subject: [PATCH 2/4] fix(scheduler): preserve cadence for unchanged form values --- server/src/scheduled-tasks-http.test.ts | 4 ++++ server/src/scheduled-tasks.ts | 5 +++-- tests/test604-scheduled-task-edit/run.sh | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/src/scheduled-tasks-http.test.ts b/server/src/scheduled-tasks-http.test.ts index eaaac78e3..bde6febde 100644 --- a/server/src/scheduled-tasks-http.test.ts +++ b/server/src/scheduled-tasks-http.test.ts @@ -352,6 +352,10 @@ describe("Hub scheduled task API and dispatcher", () => { task: "Summarize only verified release changes", priority: "low", misfire_policy: "skip", + // Dashboard/mobile submit a complete form. Identical scheduling + // values still count as a metadata-only edit and must preserve cadence. + schedule: current.schedule, + timezone: current.timezone, }), }); expect(metadataOnly.status).toBe(200); diff --git a/server/src/scheduled-tasks.ts b/server/src/scheduled-tasks.ts index 6dca5a1f2..8db1272c3 100644 --- a/server/src/scheduled-tasks.ts +++ b/server/src/scheduled-tasks.ts @@ -509,7 +509,8 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): // paused schedule resumes, or when repairing an impossible active row // with no next occurrence. The recompute uses the same DST-safe helper // as creation and dispatch advancement. - const schedulingChanged = body.schedule !== undefined || body.timezone !== undefined; + const scheduleJson = JSON.stringify(parsed.spec); + const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone; const resumed = row.status !== "active" && requestedStatus === "active"; const next = requestedStatus !== "active" ? null @@ -521,7 +522,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4, priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9, next_run_at = ?10, misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`, - [name, target.node_id, target.alias, content, priority, parsed.spec.type, JSON.stringify(parsed.spec), parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision], + [name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision], ); if (updated.changes !== 1) return jsonError("revision_conflict", 409); return Response.json({ ok: true, schedule: decodeRow(db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", row.schedule_id)!) }); diff --git a/tests/test604-scheduled-task-edit/run.sh b/tests/test604-scheduled-task-edit/run.sh index 873f7415b..b6e525bf8 100644 --- a/tests/test604-scheduled-task-edit/run.sh +++ b/tests/test604-scheduled-task-edit/run.sh @@ -44,7 +44,7 @@ expect_red cancelled-is-terminal /tmp/test604-mut-cancelled.db cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts echo "L3 witnessed-red: metadata edit preserves cadence" -sed -i 's/const schedulingChanged = body.schedule !== undefined || body.timezone !== undefined;/const schedulingChanged = true;/' server/src/scheduled-tasks.ts +sed -i 's/const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;/const schedulingChanged = true;/' server/src/scheduled-tasks.ts grep -Fq 'const schedulingChanged = true;' server/src/scheduled-tasks.ts expect_red metadata-edit-preserves-next-run /tmp/test604-mut-cadence.db cp /tmp/test604-scheduled-tasks.ts server/src/scheduled-tasks.ts From c26ed852141a30409a5304ad2540ee713353bac4 Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 9 Aug 2026 14:50:03 +0800 Subject: [PATCH 3/4] docs(test604): record schedule edit evidence --- .../report-test604-scheduled-task-edit.txt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/tests/report-test604-scheduled-task-edit.txt diff --git a/docs/tests/report-test604-scheduled-task-edit.txt b/docs/tests/report-test604-scheduled-task-edit.txt new file mode 100644 index 000000000..3844dac74 --- /dev/null +++ b/docs/tests/report-test604-scheduled-task-edit.txt @@ -0,0 +1,78 @@ +# test604 — Hub scheduled-task editing + +Date: 2026-08-09 (Asia/Shanghai) + +## Exact source coordinates + +- Hub base: `0546365dad96aa1d2dca36edbe71761b48aa3ba4` +- Hub source: `fa6a07b7dd00314e34f6729221a577194a6a80a0` +- Dashboard base: `79723e419d8549c24dd7e1a5969f923abc241127` +- Dashboard source: `3c4ed457d5c8f0f05f2ec396577b81ec759bd149` +- App base: `30fbe916763b3421c61de99e5340e89638043f29` +- App source: `79eab938229351312e46f00df80af4439f4d80c4` + +All three source worktrees were clean after their source commits. No production +runtime, database, global npm installation, or existing dirty checkout was +modified. + +## Behavior delivered + +- Dashboard and Expo App expose Edit only for active/paused schedules. +- The form restores and can update name, stable target node, task content, + priority, schedule, IANA timezone, and misfire policy. +- PATCH carries the exact row revision. Concurrent editors produce one winner + and one `409 revision_conflict`; both clients close stale state, reload the + authoritative row, and show a specific retry message. +- Cancelled and completed schedules are terminal and cannot be edited or + revived through PATCH. +- Target replacement is revalidated against the schedule's network. +- Invalid misfire policies fail closed. +- Identical schedule/timezone values do not reset `next_run_at`; actual schedule + changes recompute through the existing DST-safe `nextOccurrence` path. +- Arbitrary valid interval seconds round-trip without silent unit conversion. + +## Docker evidence + +### Hub — real HTTP server + SQLite + +Command: + +`sg docker -c 'docker run --rm -v :/artifacts anet-test604:dev'` + +- Image ID: `sha256:0dd15bb49faf8fc969f5ab96e3e9eaaba50de9cc43072ef79ce0263187759f3c` +- Embedded `TEST604_SOURCE_COMMIT`: `fa6a07b7dd00314e34f6729221a577194a6a80a0` +- Green: 12 tests, 106 assertions, 0 failures; restored-green repeated the same result. +- Witnessed-red mutations (all rc=1): + - cancelled schedule resurrection guard removed + - unchanged-form cadence preservation removed + - edit target network scope removed + - edit misfire validation removed + - DST-safe edit recompute replaced with naive +60 seconds + - optimistic revision precondition removed +- Final line: `RESULT: PASS` + +### Dashboard — contract, TypeScript, production Next build + +Command: `sg docker -c 'docker run --rm anet-dashboard-schedule-edit:dev'` + +- Image ID: `sha256:0fa9d29ae7dcb634455c07e58e883a517e0459c916fc5d90614375bf72412e45` +- Embedded `DASHBOARD_SCHEDULER_SOURCE_COMMIT`: `3c4ed457d5c8f0f05f2ec396577b81ec759bd149` +- Contract: 18 checks passed. +- `npx tsc --noEmit`: passed. +- `next build`: 53/53 static pages, `/scheduled-tasks` generated. +- Final line: `RESULT: PASS` + +### Expo App — API contract + real Expo 56 web export + +Command: `sg docker -c 'docker run --rm anet-app-schedule-edit:dev'` + +- Image ID: `sha256:cbdc408a6568ebc325e1819f4f277acc0fca18ca728c1362060bb22b2c5946e4` +- Embedded `APP_SCHEDULER_SOURCE_COMMIT`: `79eab938229351312e46f00df80af4439f4d80c4` +- API/UI contract: 17 checks passed. +- Expo 56 Metro export: 423 modules, 40 assets, `index.html` present. +- Final line: `RESULT: PASS` + +## Deployment status + +Candidate only. Not merged, published, deployed, or applied to production. +Independent adversarial review and explicit rollout GO remain required. From b886a9485e439dad89a0f2663dbb576662a23823 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 15 Aug 2026 21:29:12 +0800 Subject: [PATCH 4/4] fix(scheduled-tasks): accept POST /cancel alongside DELETE, both idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some reverse proxies swallow DELETE and return 405 HTML, which broke the dashboard's cancel button (`await res.json()` on HTML → "Unexpected token <"). Adds POST /api/scheduled-tasks/:id/cancel with the same implementation as DELETE, so the dashboard can use a universally-allowed verb. DELETE stays for API compat. Both spellings are now idempotent: re-cancelling an already-cancelled row returns 200 without touching the DB (previously bumped revision on every call, which would race with the dashboard's revision-conflict logic). test: adds POST /cancel path, DELETE idempotency (200 + same revision), and POST /cancel idempotency to the existing cancel-preserves-history test. Baseline (before this change) has 3 unrelated failures on this branch (`runs.length >= 4` data flake + non-overlap ordering) — this change adds 7 more expect() calls, all pass. Dispatch: 通信狗 08342434. --- server/src/scheduled-tasks-http.test.ts | 21 ++++++++++++++++++++- server/src/scheduled-tasks.ts | 15 +++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/scheduled-tasks-http.test.ts b/server/src/scheduled-tasks-http.test.ts index bde6febde..ac5a5ff79 100644 --- a/server/src/scheduled-tasks-http.test.ts +++ b/server/src/scheduled-tasks-http.test.ts @@ -442,9 +442,28 @@ describe("Hub scheduled task API and dispatcher", () => { const manual = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" }); expect(manual.status).toBe(202); expect(manual.body.taskId).toBeTruthy(); - const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" }); + // Cancel via POST /cancel (the path the dashboard uses, since some + // reverse proxies rewrite DELETE to 405). Then verify the legacy + // DELETE endpoint is still accepted and idempotent when the row is + // already cancelled — both spellings must reach the same state. + const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" }); + expect(cancelled.status).toBe(200); expect(cancelled.body.status).toBe("cancelled"); const cancelledRow = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + const revisionAfterCancel = cancelledRow.revision; + // Idempotency: DELETE on an already-cancelled row must be 200 with the + // same revision, not 409, and must not bump revision again. + const reCancelledDelete = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" }); + expect(reCancelledDelete.status).toBe(200); + expect(reCancelledDelete.body.status).toBe("cancelled"); + const afterDelete = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + expect(afterDelete.revision).toBe(revisionAfterCancel); + // And POST /cancel on an already-cancelled row is also idempotent. + const reCancelledPost = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" }); + expect(reCancelledPost.status).toBe(200); + expect(reCancelledPost.body.status).toBe("cancelled"); + const afterPost = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule; + expect(afterPost.revision).toBe(revisionAfterCancel); const resurrect = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: cancelledRow.revision, status: "active", name: "must not revive" }), diff --git a/server/src/scheduled-tasks.ts b/server/src/scheduled-tasks.ts index 8db1272c3..14e7b98f8 100644 --- a/server/src/scheduled-tasks.ts +++ b/server/src/scheduled-tasks.ts @@ -448,7 +448,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): } } - const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now))?$/); + const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now|cancel))?$/); if (!match) return jsonError("not_found", 404); const scheduleId = decodeURIComponent(match[1]); const sub = match[2] || null; @@ -476,7 +476,18 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): } } - if (!sub && req.method === "DELETE") { + // Cancel — accept two spellings for the same operation: + // DELETE /api/scheduled-tasks/:id (original) + // POST /api/scheduled-tasks/:id/cancel (added because some reverse + // proxies swallow DELETE and + // return 405 HTML; POST is + // universally allowed) + // Idempotent: re-cancelling an already-cancelled row is a 200 no-op, not a + // 409, because a client that just saw the row (still on screen as + // "cancelled" between polls) should get the same outcome whether it's the + // first click or a retry. + if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) { + if (row.status === "cancelled") return Response.json({ ok: true, status: "cancelled" }); db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]); return Response.json({ ok: true, status: "cancelled" }); }