Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/tests/report-test604-scheduled-task-edit.txt
Original file line number Diff line number Diff line change
@@ -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 <artifact-dir>:/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.
Comment on lines +43 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the source tree actually exercised

The claimed Hub source fa6a07b contains seven fewer assertions than the reported 106—the seven /cancel assertions appear only later in this reviewed change—so an image embedding that SHA cannot both have been built from a clean fa6a07b tree and produce this result. This makes the required Docker evidence non-reproducible; rebuild from a clean reviewed source and record its actual commit.

AGENTS.md reference: AGENTS.md:L16-L16

Useful? React with 👍 / 👎.

- 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.
120 changes: 119 additions & 1 deletion server/src/scheduled-tasks-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,95 @@ 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",
// 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);
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;
Expand All @@ -353,8 +442,37 @@ 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" }),
});
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);
});
Expand Down
35 changes: 31 additions & 4 deletions server/src/scheduled-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -476,14 +476,29 @@ 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]);
Comment on lines +490 to 491

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the cancellation idempotency check atomic

When duplicate cancellation requests are routed concurrently to different Hub processes, both can read the row as active before either update commits, and this unconditional update then increments revision twice. The new status precheck therefore guarantees idempotency only for sequential retries; make the update conditional on the persisted status (or perform the check and update transactionally) so concurrent retries cannot mutate the row more than once.

Useful? React with 👍 / 👎.

return Response.json({ ok: true, status: "cancelled" });
}

if (!sub && req.method === "PATCH") {
let body: Record<string, unknown>;
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();
Expand All @@ -500,13 +515,25 @@ 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 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
: 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,
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<ScheduledRow>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", row.schedule_id)!) });
Expand Down
11 changes: 11 additions & 0 deletions tests/test604-scheduled-task-edit/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading