diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:53:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:53:12 +0900 |
| commit | 1c800eb35b48b28a82a64e8b2b3ce666323f2c42 (patch) | |
| tree | c26044a38f0099fbbe98680a02b73bc2417f1eb0 /packages/api/tests | |
| parent | c351719ec81a1b87565abc0656756f4aa9e473d0 (diff) | |
| download | dispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.tar.gz dispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.zip | |
feat(wake): probe 4 times per marked hour (:00 :15 :30 :45), coalesce same-tick fires
Marking an hour on the Claude Wake Schedule panel now schedules FOUR probes
within that hour instead of one. Rate-window edges are unforgiving — a
single probe at :15 can miss the actual reset moment by up to 14 minutes;
hitting :00 / :15 / :30 / :45 puts us within ~7 minutes of any reset that
happens during that hour.
When multiple slots come due in the same 30s scheduler tick (or recover
together at boot), they coalesce into a SINGLE upstream wake call — no
point hitting Anthropic 4× in the same window.
DB schema
- wake_schedule is now (hour, slot_minute, next_wake_at) PK (hour,
slot_minute). Destructive migration: detect old single-row-per-hour
schema by absence of the slot_minute column and DROP TABLE. No other
table is touched. Per user direction: no back-compat for old rows.
API
- POST /models/wake-schedule/toggle add: { hour, timestamps: { '0': ms,
'15': ms, '30': ms, '45': ms } } — all 4 slots required, all must be
future Unix ms. Delete shape unchanged ({ hour }).
- GET /models/wake-schedule shape:
schedule: { '9': { '0': ts, '15': ts, '30': ts, '45': ts }, ... }
probeSlotMinutes: [0, 15, 30, 45]
resetOffsetHours, lastWake, pendingRetry (unchanged from prior commit)
Frontend
- Computes 4 timestamps client-side (next occurrence of HH:MM in local TZ)
and sends them in one request.
- markedHours summary now says 'Probes :00 :15 :30 :45 → reset by ~Xh later'.
- Same in-flight tracking / current-hour ring / status row as before.
Tests
- wake-scheduler.test.ts unchanged (pure helpers still correct; added
PROBE_SLOT_MINUTES + isProbeSlotMinute exports).
- routes.test.ts rewritten for the new payload shape: 12 wake-schedule
tests covering snapshot shape, add/remove (full 4-slot round-trip),
validation (range, integer, past-slot, missing slot, non-object,
missing timestamps), independent multi-hour scheduling, and
re-toggle replacement. 417 tests total (was 414).
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/routes.test.ts | 113 |
1 files changed, 87 insertions, 26 deletions
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index e3dff3d..090131c 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -402,8 +402,9 @@ describe("Wake schedule routes", () => { const res = await app.request("/models/wake-schedule"); expect(res.status).toBe(200); return (await res.json()) as { - schedule: Record<string, number>; + schedule: Record<string, Record<string, number>>; resetOffsetHours: number; + probeSlotMinutes: number[]; lastWake: unknown; pendingRetry: unknown; }; @@ -417,75 +418,135 @@ describe("Wake schedule routes", () => { }); } - it("GET returns the full snapshot shape with resetOffsetHours, lastWake, pendingRetry", async () => { + /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */ + function buildTimestamps(base: number): Record<string, number> { + return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 }; + } + + it("GET returns the full snapshot shape with probeSlotMinutes, resetOffsetHours, lastWake, pendingRetry", async () => { const snap = await getSchedule(); expect(snap.schedule).toBeDefined(); - // CLAUDE_RESET_OFFSET_HOURS is currently 5; keep this loose in case the - // product changes the constant, but verify it's a positive integer. expect(Number.isInteger(snap.resetOffsetHours)).toBe(true); expect(snap.resetOffsetHours).toBeGreaterThan(0); + expect(snap.probeSlotMinutes).toEqual([0, 15, 30, 45]); expect(snap.lastWake).toBeNull(); expect(snap.pendingRetry).toBeNull(); }); - it("POST toggle adds and removes a wake hour", async () => { - const future = Date.now() + 60 * 60 * 1000; // 1 h ahead + it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => { + const base = Date.now() + 60 * 60 * 1000; // 1 h ahead + const timestamps = buildTimestamps(base); - const addRes = await toggle({ hour: 9, timestamp: future }); + const addRes = await toggle({ hour: 9, timestamps }); expect(addRes.status).toBe(200); - const addBody = (await addRes.json()) as { schedule: Record<string, number> }; - expect(addBody.schedule["9"]).toBe(future); + const addBody = (await addRes.json()) as { + schedule: Record<string, Record<string, number>>; + }; + expect(addBody.schedule["9"]).toEqual({ + "0": base, + "15": base + 60_000, + "30": base + 120_000, + "45": base + 180_000, + }); const removeRes = await toggle({ hour: 9 }); expect(removeRes.status).toBe(200); - const removeBody = (await removeRes.json()) as { schedule: Record<string, number> }; + const removeBody = (await removeRes.json()) as { + schedule: Record<string, Record<string, number>>; + }; expect(removeBody.schedule["9"]).toBeUndefined(); }); it("POST toggle rejects out-of-range hour", async () => { - const res = await toggle({ hour: 24, timestamp: Date.now() + 60_000 }); + const res = await toggle({ + hour: 24, + timestamps: buildTimestamps(Date.now() + 60_000), + }); expect(res.status).toBe(400); }); it("POST toggle rejects negative hour", async () => { - const res = await toggle({ hour: -1, timestamp: Date.now() + 60_000 }); + const res = await toggle({ + hour: -1, + timestamps: buildTimestamps(Date.now() + 60_000), + }); expect(res.status).toBe(400); }); it("POST toggle rejects non-integer hour", async () => { - const res = await toggle({ hour: 4.5, timestamp: Date.now() + 60_000 }); + const res = await toggle({ + hour: 4.5, + timestamps: buildTimestamps(Date.now() + 60_000), + }); expect(res.status).toBe(400); }); - it("POST toggle rejects past timestamp on add", async () => { - const res = await toggle({ hour: 7, timestamp: Date.now() - 1000 }); + it("POST toggle rejects past timestamp in any slot", async () => { + const future = Date.now() + 60_000; + const badTimestamps: Record<string, number> = { + "0": future, + "15": Date.now() - 1000, // past + "30": future + 60_000, + "45": future + 120_000, + }; + const res = await toggle({ hour: 7, timestamps: badTimestamps }); expect(res.status).toBe(400); }); - it("POST toggle rejects missing timestamp on add", async () => { + it("POST toggle rejects missing timestamps on add", async () => { const res = await toggle({ hour: 8 }); expect(res.status).toBe(400); }); - it("POST toggle: a delete does NOT require a timestamp", async () => { - const future = Date.now() + 60 * 60 * 1000; - const addRes = await toggle({ hour: 11, timestamp: future }); + it("POST toggle rejects missing slot in timestamps object", async () => { + const res = await toggle({ + hour: 8, + timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 }, + }); + expect(res.status).toBe(400); + }); + + it("POST toggle rejects non-object timestamps", async () => { + const res = await toggle({ hour: 8, timestamps: 12345 }); + expect(res.status).toBe(400); + }); + + it("POST toggle: a delete does NOT require timestamps", async () => { + const base = Date.now() + 60 * 60 * 1000; + const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) }); expect(addRes.status).toBe(200); const delRes = await toggle({ hour: 11 }); expect(delRes.status).toBe(200); - const body = (await delRes.json()) as { schedule: Record<string, number> }; + const body = (await delRes.json()) as { schedule: Record<string, unknown> }; expect(body.schedule["11"]).toBeUndefined(); }); - it("snapshot reflects multiple scheduled hours independently", async () => { - const future = Date.now() + 2 * 60 * 60 * 1000; - await toggle({ hour: 14, timestamp: future }); - await toggle({ hour: 19, timestamp: future + 60_000 }); + it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => { + const base1 = Date.now() + 2 * 60 * 60 * 1000; + const base2 = base1 + 60 * 60 * 1000; + await toggle({ hour: 14, timestamps: buildTimestamps(base1) }); + await toggle({ hour: 19, timestamps: buildTimestamps(base2) }); const snap = await getSchedule(); - expect(snap.schedule["14"]).toBe(future); - expect(snap.schedule["19"]).toBe(future + 60_000); + expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]); + expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]); + expect(snap.schedule["14"]?.["0"]).toBe(base1); + expect(snap.schedule["19"]?.["0"]).toBe(base2); // Cleanup so later tests start clean. await toggle({ hour: 14 }); await toggle({ hour: 19 }); }); + + it("re-toggling the same hour replaces all 4 slot timestamps", async () => { + const base1 = Date.now() + 60 * 60 * 1000; + const base2 = base1 + 30 * 60 * 1000; + await toggle({ hour: 5, timestamps: buildTimestamps(base1) }); + await toggle({ hour: 5 }); // remove + const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) }); + const body = (await addRes.json()) as { + schedule: Record<string, Record<string, number>>; + }; + expect(body.schedule["5"]?.["0"]).toBe(base2); + expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000); + await toggle({ hour: 5 }); + }); }); |
