From 1c800eb35b48b28a82a64e8b2b3ce666323f2c42 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:53:12 +0900 Subject: feat(wake): probe 4 times per marked hour (:00 :15 :30 :45), coalesce same-tick fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/api/tests/routes.test.ts | 113 +++++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 26 deletions(-) (limited to 'packages/api/tests') 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; + schedule: Record>; 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 { + 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 }; - expect(addBody.schedule["9"]).toBe(future); + const addBody = (await addRes.json()) as { + schedule: Record>; + }; + 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 }; + const removeBody = (await removeRes.json()) as { + schedule: Record>; + }; 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 = { + "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 }; + const body = (await delRes.json()) as { schedule: Record }; 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>; + }; + expect(body.schedule["5"]?.["0"]).toBe(base2); + expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000); + await toggle({ hour: 5 }); + }); }); -- cgit v1.2.3