From 082a70db06e8bde9f46662de950f6f58910062dc Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 14:33:51 +0800 Subject: [PATCH] docs: consolidate windowed-fanout spec/plan with ETA + paused/resume Folds in three rounds of requirement evolution: * Pause/resume on window close (was stop-and-report-partial). * ETA preview pill at compose / edit time so the operator sees whether their chosen window will fit before scheduling. * Interactive paused-run banner with Resume / Cancel buttons on the detail page; pause notification deep-links to it. Helper relocations: * windowEndAt() moves to packages/shared so both bot fire-reminder and the web ETA pill can import the same calculator. Plan grows from 8 to 10 tasks: adds Task 9 (run-eta + RunEtaPill, TDD) and Task 10 (resume/cancel actions + PausedRunBanner). Acceptance gains two paused-flow smoke tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-10-windowed-fanout.md | 871 ++++++++++++++++-- .../2026-05-10-windowed-fanout-design.md | 99 +- 2 files changed, 877 insertions(+), 93 deletions(-) diff --git a/docs/superpowers/plans/2026-05-10-windowed-fanout.md b/docs/superpowers/plans/2026-05-10-windowed-fanout.md index da200ee..24fd338 100644 --- a/docs/superpowers/plans/2026-05-10-windowed-fanout.md +++ b/docs/superpowers/plans/2026-05-10-windowed-fanout.md @@ -21,16 +21,19 @@ | `apps/bot/src/scheduler/per-key-mutex.test.ts` (new) | unit tests | | `apps/bot/src/scheduler/rate-limiter.ts` (new) | per-account token bucket | | `apps/bot/src/scheduler/rate-limiter.test.ts` (new) | fake-clock unit tests | -| `apps/bot/src/scheduler/delivery-window.ts` (new) | pure window-end calculator | -| `apps/bot/src/scheduler/delivery-window.test.ts` (new) | unit tests | +| `packages/shared/src/delivery-window.ts` (new) | pure window-end calculator (shared bot+web) | +| `packages/shared/src/delivery-window.test.ts` (new) | unit tests | | `apps/bot/src/scheduler/media-upload-cache.ts` (new) | `prepareWAMessageMedia` results, keyed by mediaId | | `apps/bot/src/scheduler/media-upload-cache.test.ts` (new) | mock-socket unit tests | -| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above | +| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above; accepts optional `runId` for resume | | `apps/bot/src/scheduler/reminder-jobs.ts` | pass `teamSize` config | -| `apps/web/src/actions/reminders.ts` | accept the two new fields on create/update | +| `apps/web/src/actions/reminders.ts` | accept the two new fields; add `resumeReminderRunAction`, `cancelReminderRunAction` | | `apps/web/src/components/reminder-wizard/when-form-client.tsx` | "Delivery hours" inputs | | `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same | -| `apps/web/src/lib/notifications.ts` | partial-status notification body extension | +| `apps/web/src/lib/run-eta.ts` (new) | pure ETA calculator | +| `apps/web/src/components/reminder-wizard/run-eta-pill.tsx` (new) | green/amber ETA pill | +| `apps/web/src/components/reminder-detail/paused-run-banner.tsx` (new) | Resume / Cancel run banner on detail page | +| `apps/web/src/lib/notifications.ts` | paused + partial notification body extension | --- @@ -657,11 +660,14 @@ WhatsApp account." ## Task 5: Delivery window helper (TDD) -**Files:** -- Create: `apps/bot/src/scheduler/delivery-window.test.ts` -- Create: `apps/bot/src/scheduler/delivery-window.ts` +The helper lives in `packages/shared` (NOT the bot) because both bundles need it: bot's fire-reminder loop gates on it, and web's ETA pill (Task 9) compares ETA against it to flip the green/amber state. -- [ ] **Step 1: Write the failing test file at `apps/bot/src/scheduler/delivery-window.test.ts`** +**Files:** +- Create: `packages/shared/src/delivery-window.test.ts` +- Create: `packages/shared/src/delivery-window.ts` +- Modify: `packages/shared/src/index.ts` (re-export `windowEndAt`) + +- [ ] **Step 1: Write the failing test file at `packages/shared/src/delivery-window.test.ts`** ```ts import { describe, it, expect } from "vitest"; @@ -720,12 +726,12 @@ describe("windowEndAt", () => { - [ ] **Step 2: Run the failing test** ```bash -NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run delivery-window +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run delivery-window ``` Expected: FAIL with "Cannot find module './delivery-window.js'". -- [ ] **Step 3: Implement `apps/bot/src/scheduler/delivery-window.ts`** +- [ ] **Step 3: Implement `packages/shared/src/delivery-window.ts`** ```ts import { DateTime } from "luxon"; @@ -766,19 +772,27 @@ export function windowEndAt( } ``` -- [ ] **Step 4: Run the tests** +- [ ] **Step 4: Re-export from `packages/shared/src/index.ts`** + +Append: + +```ts +export { windowEndAt } from "./delivery-window.js"; +``` + +- [ ] **Step 5: Run the tests** ```bash -NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run delivery-window +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run delivery-window ``` Expected: PASS, 6 tests. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash -git add apps/bot/src/scheduler/delivery-window.ts apps/bot/src/scheduler/delivery-window.test.ts -git commit -m "feat(bot): pure delivery-window end calculator +git add packages/shared/src/delivery-window.ts packages/shared/src/delivery-window.test.ts packages/shared/src/index.ts +git commit -m "feat(shared): pure delivery-window end calculator windowEndAt(timezone, endHour, fireAt) returns the end-of-window for the day fireAt is on. If fireAt is already past, the result is a @@ -1052,7 +1066,7 @@ import { scheduleReminderFire } from "./reminder-jobs.js"; import { pgNotifyWeb } from "../ipc/notify.js"; import { accountMutex } from "./per-key-mutex.js"; import { accountRateLimiter } from "./rate-limiter.js"; -import { windowEndAt } from "./delivery-window.js"; +import { windowEndAt } from "@cmbot/shared"; import { MediaUploadCache } from "./media-upload-cache.js"; export type FireReminderPayload = { @@ -1155,7 +1169,10 @@ async function fireReminderInner( const mediaById = new Map(mediaRows.map((m) => [m.id, m])); // Pre-create run_targets rows so progress is observable mid-run. - if (reminder.targets.length > 0) { + // On a RESUME, the rows already exist — only the original fire path + // inserts them. The resume path skips this; the loop below filters + // to only the still-pending rows. + if (!resumeRunId && reminder.targets.length > 0) { await db.insert(reminderRunTargets).values( reminder.targets.map((t) => ({ runId, @@ -1166,6 +1183,37 @@ async function fireReminderInner( ); } + // On a RESUME, only the still-pending targets need attention. On + // a fresh fire, every target is pending. Either way we read the + // current run_target rows from the DB to be the source of truth + // about what's left to do. + const pendingRows = await db.query.reminderRunTargets.findMany({ + where: (t, { eq, and: drizzleAnd }) => + drizzleAnd(eq(t.runId, runId), eq(t.status, "pending")), + }); + const pendingGroupIds = new Set(pendingRows.map((r) => r.groupId)); + const targetsToProcess = reminder.targets.filter((t) => + pendingGroupIds.has(t.groupId), + ); + + // Already-sent count from prior run (so the final tally adds to total). + const priorSentCount = resumeRunId + ? ( + await db.query.reminderRunTargets.findMany({ + where: (t, { eq, and: drizzleAnd }) => + drizzleAnd(eq(t.runId, runId), eq(t.status, "sent")), + }) + ).length + : 0; + const priorFailedCount = resumeRunId + ? ( + await db.query.reminderRunTargets.findMany({ + where: (t, { eq, and: drizzleAnd }) => + drizzleAnd(eq(t.runId, runId), eq(t.status, "failed")), + }) + ).length + : 0; + // Window-end timestamp. If the reminder fires AFTER today's end-hour // (e.g. cron miss-fired late) this is in the past — every iteration // will trip the gate and the run resolves as failed. @@ -1211,22 +1259,14 @@ async function fireReminderInner( const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY); await Promise.all( - reminder.targets.map((target) => + targetsToProcess.map((target) => groupConcurrency(async () => { - // Window-end gate — even if we already started other groups, - // any not-yet-started one stops here. + // Window-end gate. CRITICAL: leave the row as `pending` (NOT + // `skipped`) so the run can be resumed later. The run as a + // whole flips to `paused` after this loop. if (Date.now() >= windowEnd.getTime()) { windowClosed = true; - await db - .update(reminderRunTargets) - .set({ status: "skipped", error: "delivery window closed" }) - .where( - and( - eq(reminderRunTargets.runId, runId), - eq(reminderRunTargets.groupId, target.groupId), - ), - ); - skippedCount++; + // Don't touch the row — it's already `pending`. Just count. return; } @@ -1303,21 +1343,45 @@ async function fireReminderInner( ), ); + // Final status. The four shapes: + // - paused : window closed with at least one row STILL pending. + // Resumable. Sent rows stay sent, pending stays pending. + // - success : every target sent (no failures, no pending). + // - partial : every target was attempted; some sent, some failed + // or skipped. NOT resumable; failures are real. + // - failed : zero sent. Either every send errored, or the window + // was already closed when the run began (nothing + // attempted, but no pending-with-progress to resume). const total = reminder.targets.length; - let status: "success" | "partial" | "failed"; + const totalSent = priorSentCount + sentCount; + const totalFailed = priorFailedCount + failedCount; + // Re-read pending count from the DB so the count reflects whatever + // the loop left behind (any window-skipped rows are still pending). + const remainingPending = ( + await db.query.reminderRunTargets.findMany({ + where: (t, { eq, and: drizzleAnd }) => + drizzleAnd(eq(t.runId, runId), eq(t.status, "pending")), + }) + ).length; + + let status: "success" | "partial" | "failed" | "paused"; let errorSummary: string | null = null; - if (sentCount === total) { + if (windowClosed && remainingPending > 0 && totalSent > 0) { + status = "paused"; + errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${totalSent} of ${total} groups delivered, ${remainingPending} still pending. Resume from the Activity tab. If this happens repeatedly, consider offloading to another paired account, or shrinking the message body / media size to fit more groups in your daily window.`; + } else if (windowClosed && totalSent === 0) { + // Window was closed before any send happened. Not paused — there's + // nothing meaningful to resume. Counts as a hard failure. + status = "failed"; + errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`; + } else if (totalSent === total) { status = "success"; - } else if (sentCount > 0) { + } else if (totalSent > 0) { status = "partial"; - errorSummary = windowClosed - ? `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${sentCount} of ${total} groups delivered. This account is at capacity for this fan-out — consider sending the remainder from another paired account.` - : `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`; + errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`; } else { status = "failed"; - errorSummary = windowClosed - ? `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.` - : `All ${total} sends failed.`; + errorSummary = `All ${total} sends failed.`; } await db @@ -1332,29 +1396,39 @@ async function fireReminderInner( status, }); - // One-off reminders end after firing. Recurring reminders re-arm. - if (reminder.scheduleKind === "one_off") { - await db - .update(reminders) - .set({ status: "ended", updatedAt: new Date() }) - .where(eq(reminders.id, reminder.id)); - } else if (reminder.scheduleKind === "recurring" && reminder.rrule) { - const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date()); - await db - .update(reminders) - .set({ lastFiredAt: new Date(), updatedAt: new Date() }) - .where(eq(reminders.id, reminder.id)); - if (next) { - try { - await scheduleReminderFire(getBoss(), reminder.id, next); - logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence"); - } catch (err) { - logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence"); + // Lifecycle bookkeeping. Skip when the run is paused — the reminder + // shouldn't end or re-arm while a resume is still possible. + const runIsTerminal = status !== "paused"; + + if (runIsTerminal) { + if (reminder.scheduleKind === "one_off") { + await db + .update(reminders) + .set({ status: "ended", updatedAt: new Date() }) + .where(eq(reminders.id, reminder.id)); + } else if (reminder.scheduleKind === "recurring" && reminder.rrule) { + const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date()); + await db + .update(reminders) + .set({ lastFiredAt: new Date(), updatedAt: new Date() }) + .where(eq(reminders.id, reminder.id)); + if (next) { + try { + await scheduleReminderFire(getBoss(), reminder.id, next); + logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence"); + } catch (err) { + logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence"); + } + } else { + logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending"); + await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id)); } - } else { - logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending"); - await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id)); } + } else { + logger.info( + { reminderId: reminder.id, runId }, + "fire-reminder: paused — leaving reminder lifecycle unchanged for resume", + ); } await writeAuditLog(db, { @@ -1606,7 +1680,8 @@ In the JSX, just before the existing `` block, add a new "Deli

The bot stops sending after the end hour. Long fan-outs that don't - finish in this window get reported as partial. + finish in this window are paused — you can resume them from the + Activity tab.

``` @@ -1691,9 +1766,9 @@ initialDeliveryStartHour?: number; initialDeliveryEndHour?: number; ``` -- [ ] **Step 6: Extend the partial-status notification body in `apps/web/src/lib/notifications.ts`** +- [ ] **Step 6: Extend the notification body for paused + partial in `apps/web/src/lib/notifications.ts`** -Find `reminderFiredToNotification`. The existing function takes `{ reminderId, runId, status }`. Update the body to mention the delivered/total when status is `partial`: +Find `reminderFiredToNotification`. The existing function takes `{ reminderId, runId, status }`. Extend the event shape to carry `sent`/`total` and handle `paused` as a first-class status: ```ts export function reminderFiredToNotification(event: { @@ -1708,16 +1783,22 @@ export function reminderFiredToNotification(event: { const headline = event.status === "success" ? "Reminder sent" - : event.status === "partial" - ? "Reminder partly sent" - : "Reminder failed"; + : event.status === "paused" + ? "Reminder paused" + : event.status === "partial" + ? "Reminder partly sent" + : "Reminder failed"; let body = event.status === "success" ? "All groups received the message." - : event.status === "partial" - ? "Some groups received the message; others failed. See activity." - : "No groups received the message. See activity."; - if (event.status === "partial" && event.sent !== undefined && event.total !== undefined) { + : event.status === "paused" + ? "Delivery window closed before all groups got the message." + : event.status === "partial" + ? "Some groups received the message; others failed. See activity." + : "No groups received the message. See activity."; + if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) { + body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`; + } else if (event.status === "partial" && event.sent !== undefined && event.total !== undefined) { body = `${event.sent} of ${event.total} groups delivered. See activity for details.`; } return { @@ -1729,11 +1810,37 @@ export function reminderFiredToNotification(event: { } ``` +Also update the SSE event emitter (search for `reminder.fired` in `apps/bot/src/scheduler/fire-reminder.ts`) to include `sent` and `total` on the published event payload — Task 7 already wires this in the new fire-reminder. Confirm the web-side receiver passes those fields through to `reminderFiredToNotification`. + - [ ] **Step 7: Add tests for the extended notification body** Append to `apps/web/src/lib/notifications.test.ts` (inside the existing `describe("reminderFiredToNotification mapping", () => {...})` block): ```ts +it("paused with sent/total renders 'Tap to resume or cancel'", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-1", + runId: "run-1", + status: "paused", + sent: 412, + total: 1000, + }); + expect(args?.title).toBe("Reminder paused"); + expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel."); +}); + +it("paused without sent/total falls back to a generic paused body", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-1", + runId: "run-1", + status: "paused", + }); + expect(args?.title).toBe("Reminder paused"); + expect(args?.body).toMatch(/Delivery window closed/); +}); + it("partial with sent/total renders 'X of Y groups delivered' instead of the generic body", () => { const args = reminderFiredToNotification({ type: "reminder.fired", @@ -1793,16 +1900,616 @@ inputs for the delivery window hours (default 6/18). Server actions accept them on the create/update Zod schemas, validate 0 <= start < end <= 24, and persist to the new reminders columns. -Notification body for partial-status reminders now reads -'412 of 1000 groups delivered. See activity for details.' when the -SSE event carries sent/total counts." +Notification body for paused-status now reads +'412 of 1000 groups delivered. Tap to resume or cancel.'; partial +status uses the same delivered/total wording when sent/total are +present." +``` + +--- + +## Task 9: Run-ETA helper + ETA pill in wizard review (TDD) + +**Files:** +- Create: `apps/web/src/lib/run-eta.ts` +- Create: `apps/web/src/lib/run-eta.test.ts` +- Create: `apps/web/src/components/reminder-wizard/run-eta-pill.tsx` +- Create: `apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx` +- Modify: `apps/web/src/components/reminder-wizard/review-submit-client.tsx` +- Modify: `apps/web/src/components/reminder-edit/edit-when-form.tsx` +- Modify: `apps/web/src/components/reminder-edit/edit-groups-form.tsx` + +- [ ] **Step 1: Write the failing run-eta test** + +Create `apps/web/src/lib/run-eta.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { estimateRunDuration, ASSUMED_RATE_PER_MINUTE } from "./run-eta"; + +describe("estimateRunDuration", () => { + it("uses target count / rate plus a 15% buffer, ceiling-rounded to whole minutes", () => { + const r = estimateRunDuration({ + targetCount: 1000, + ratePerMinute: 40, + fireAt: new Date("2026-05-13T09:00:00.000+08:00"), + }); + // 1000 / 40 = 25 min; +15% = 28.75 → ceil = 29 + expect(r.durationMinutes).toBe(29); + expect(r.estimatedFinishAt.toISOString()).toBe( + new Date("2026-05-13T09:29:00.000+08:00").toISOString(), + ); + }); + + it("returns a 1-minute floor for very small runs", () => { + const r = estimateRunDuration({ + targetCount: 1, + ratePerMinute: 40, + fireAt: new Date("2026-05-13T09:00:00.000+08:00"), + }); + expect(r.durationMinutes).toBe(1); + }); + + it("returns 0 minutes and finishAt = fireAt when targetCount is 0", () => { + const fireAt = new Date("2026-05-13T09:00:00.000+08:00"); + const r = estimateRunDuration({ targetCount: 0, ratePerMinute: 40, fireAt }); + expect(r.durationMinutes).toBe(0); + expect(r.estimatedFinishAt.toISOString()).toBe(fireAt.toISOString()); + }); + + it("throws when ratePerMinute is 0 or negative", () => { + expect(() => + estimateRunDuration({ + targetCount: 100, + ratePerMinute: 0, + fireAt: new Date(), + }), + ).toThrow(); + expect(() => + estimateRunDuration({ + targetCount: 100, + ratePerMinute: -1, + fireAt: new Date(), + }), + ).toThrow(); + }); + + it("exports the configured default rate constant", () => { + expect(typeof ASSUMED_RATE_PER_MINUTE).toBe("number"); + expect(ASSUMED_RATE_PER_MINUTE).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta +``` + +Expected: FAIL — `Cannot find module './run-eta'`. + +- [ ] **Step 3: Write the helper to pass** + +Create `apps/web/src/lib/run-eta.ts`: + +```ts +/** + * Default per-account send rate, mirroring `BOT_MAX_SEND_PER_MINUTE` + * in the bot env. The web bundle hardcodes this — operators who tune + * the bot env are expected to redeploy web with the matching value. + */ +export const ASSUMED_RATE_PER_MINUTE = 40; + +const ETA_BUFFER = 1.15; + +export function estimateRunDuration(opts: { + targetCount: number; + ratePerMinute?: number; + fireAt: Date; +}): { durationMinutes: number; estimatedFinishAt: Date } { + const rate = opts.ratePerMinute ?? ASSUMED_RATE_PER_MINUTE; + if (rate <= 0) throw new Error("ratePerMinute must be > 0"); + if (opts.targetCount <= 0) { + return { durationMinutes: 0, estimatedFinishAt: new Date(opts.fireAt) }; + } + const raw = (opts.targetCount / rate) * ETA_BUFFER; + const durationMinutes = Math.max(1, Math.ceil(raw)); + const estimatedFinishAt = new Date( + opts.fireAt.getTime() + durationMinutes * 60_000, + ); + return { durationMinutes, estimatedFinishAt }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta +``` + +Expected: PASS (5/5). + +- [ ] **Step 5: Write the failing pill component test** + +Create `apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx`: + +```tsx +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { RunEtaPill } from "./run-eta-pill"; + +describe("RunEtaPill", () => { + it("renders green 'Fits in window' when estimatedFinishAt <= windowEndAt", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Fits in window/); + expect(html).toMatch(/min/); + expect(html).not.toMatch(/Likely to pause/); + }); + + it("renders amber 'Likely to pause' when ETA exceeds window", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Likely to pause/); + expect(html).toMatch(/Widen the window/); + }); + + it("renders nothing for zero targets", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); +}); +``` + +- [ ] **Step 6: Implement the pill** + +Create `apps/web/src/components/reminder-wizard/run-eta-pill.tsx`: + +```tsx +import { ClockIcon, AlertTriangleIcon } from "lucide-react"; +import { estimateRunDuration } from "@/lib/run-eta"; + +interface RunEtaPillProps { + targetCount: number; + fireAt: Date; + windowEndAt: Date; + timezone: string; +} + +/** + * Visible at the wizard's review step and on the per-section edit + * pages that affect ETA (groups, when). Advisory only — does NOT + * block submission. The operator can still schedule a run that + * pauses; the pause-and-resume flow covers that case. + */ +export function RunEtaPill({ + targetCount, + fireAt, + windowEndAt, + timezone, +}: RunEtaPillProps) { + if (targetCount <= 0) return null; + + const { durationMinutes, estimatedFinishAt } = estimateRunDuration({ + targetCount, + fireAt, + }); + const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime(); + + const finishLocal = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: timezone, + }).format(estimatedFinishAt); + + if (fits) { + return ( +
+ + + ~{durationMinutes} min · finishes ~{finishLocal} · Fits in window + +
+ ); + } + return ( +
+ +
+
+ ~{durationMinutes} min · finishes ~{finishLocal} · Likely to pause +
+
+ Widen the window or split into smaller runs. +
+
+
+ ); +} +``` + +- [ ] **Step 7: Run pill tests** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta-pill +``` + +Expected: 3/3 PASS. + +- [ ] **Step 8: Wire the pill into review-submit-client** + +In `apps/web/src/components/reminder-wizard/review-submit-client.tsx`, add the pill above the Schedule button. The component already has access to `groupIds`, `scheduledAt`, `timezone`, and now `deliveryEndHour` (from Task 8). Compute the windowEndAt inline: + +```tsx +import { RunEtaPill } from "./run-eta-pill"; +import { windowEndAt as computeWindowEndAt } from "@cmbot/shared"; // see note below + +// inside the component, just before the action button: +{groupIds && scheduledAt && (() => { + const ids = groupIds.split(",").filter(Boolean); + const fireAt = new Date(scheduledAt); + const wEnd = computeWindowEndAt(timezone, deliveryEndHour ?? 18, fireAt); + return ( + + ); +})()} +``` + +`computeWindowEndAt` is the helper Task 5 created in `packages/shared/src/delivery-window.ts` — both bundles import it from `@cmbot/shared`. + +- [ ] **Step 9: Wire the pill into edit-groups-form and edit-when-form** + +Both forms know the reminder's `targetCount`, `fireAt`, `timezone`, and `deliveryWindowEndHour`. Add the pill above their Save button: + +```tsx + +``` + +- [ ] **Step 10: Run all web tests** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run +``` + +Expected: all green. Web ≈304 (added 8 ETA tests + 3 pill tests, partially offset by no test removals). Shared adds the windowEndAt tests that moved from bot. + +- [ ] **Step 11: Commit** + +```bash +git add apps/web packages/shared apps/bot +git commit -m "feat(web): ETA preview pill in wizard + edit-groups + edit-when + +estimateRunDuration() computes a per-run ETA from BOT_MAX_SEND_PER_MINUTE +(hardcoded as ASSUMED_RATE_PER_MINUTE in the web bundle) plus a 15% +buffer. The RunEtaPill component shows a green 'Fits in window' or +amber 'Likely to pause' badge with a one-line suggestion. windowEndAt +moves from apps/bot/src/scheduler/delivery-window.ts to +packages/shared/src/delivery-window.ts so both bundles can import it." +``` + +--- + +## Task 10: Resume + cancel actions and PausedRunBanner + +**Files:** +- Modify: `apps/web/src/actions/reminders.ts` (add `resumeReminderRunAction`, `cancelReminderRunAction`) +- Create: `apps/web/src/components/reminder-detail/paused-run-banner.tsx` +- Create: `apps/web/src/components/reminder-detail/paused-run-banner.test.tsx` +- Modify: `apps/web/src/app/reminders/[id]/page.tsx` (mount the banner) +- Modify: `apps/web/src/app/activity/page.tsx` (add Paused filter + Resume button per row) + +- [ ] **Step 1: Write the failing banner test** + +Create `apps/web/src/components/reminder-detail/paused-run-banner.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PausedRunBanner } from "./paused-run-banner"; + +const resumeMock = vi.fn(); +const cancelMock = vi.fn(); +vi.mock("@/actions/reminders", () => ({ + resumeReminderRunAction: (...args: unknown[]) => resumeMock(...args), + cancelReminderRunAction: (...args: unknown[]) => cancelMock(...args), +})); + +describe("PausedRunBanner", () => { + beforeEach(() => { + resumeMock.mockReset(); + cancelMock.mockReset(); + }); + + it("renders 'Resume' and 'Cancel run' buttons when latest run is paused", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Reminder paused/); + expect(html).toMatch(/412 of 1000/); + expect(html).toMatch(/Resume/); + expect(html).toMatch(/Cancel run/); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run paused-run-banner +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Add resume + cancel actions to `apps/web/src/actions/reminders.ts`** + +Append (near the other run-related actions): + +```ts +const runIdSchema = z.object({ runId: z.string().uuid() }); + +export async function resumeReminderRunAction(input: { runId: string }) { + const op = await getSeededOperator(); + const parsed = runIdSchema.safeParse(input); + if (!parsed.success) { + return { ok: false as const, error: "Invalid runId" }; + } + + const run = await db.query.reminderRuns.findFirst({ + where: (r, { eq }) => eq(r.id, parsed.data.runId), + with: { reminder: { columns: { operatorId: true, id: true } } }, + }); + if (!run || run.reminder.operatorId !== op.id) { + return { ok: false as const, error: "Run not found" }; + } + if (run.status !== "paused") { + return { ok: false as const, error: `Cannot resume a ${run.status} run` }; + } + + await getBoss().send("reminder.fire", { + reminderId: run.reminder.id, + runId: run.id, + }); + await writeAudit(op.id, "reminder.run.resumed", { runId: run.id }); + + revalidatePath(`/reminders/${run.reminder.id}`); + revalidatePath(`/activity`); + return { ok: true as const }; +} + +export async function cancelReminderRunAction(input: { runId: string }) { + const op = await getSeededOperator(); + const parsed = runIdSchema.safeParse(input); + if (!parsed.success) { + return { ok: false as const, error: "Invalid runId" }; + } + + const run = await db.query.reminderRuns.findFirst({ + where: (r, { eq }) => eq(r.id, parsed.data.runId), + with: { reminder: { columns: { operatorId: true, id: true } } }, + }); + if (!run || run.reminder.operatorId !== op.id) { + return { ok: false as const, error: "Run not found" }; + } + if (run.status !== "paused") { + return { ok: false as const, error: `Cannot cancel a ${run.status} run` }; + } + + await db.transaction(async (tx) => { + await tx + .update(reminderRunTargets) + .set({ status: "skipped", error: "canceled by operator" }) + .where( + and( + eq(reminderRunTargets.runId, run.id), + eq(reminderRunTargets.status, "pending"), + ), + ); + await tx + .update(reminderRuns) + .set({ + status: "partial", + endedAt: new Date(), + errorSummary: "Canceled by operator before all groups received the message.", + }) + .where(eq(reminderRuns.id, run.id)); + }); + await writeAudit(op.id, "reminder.run.canceled", { runId: run.id }); + + revalidatePath(`/reminders/${run.reminder.id}`); + revalidatePath(`/activity`); + return { ok: true as const }; +} +``` + +(Imports needed at top of file: `revalidatePath` from `next/cache`, `getBoss` from `@/lib/boss`, `writeAudit` from `@/lib/audit`, `reminderRuns` and `reminderRunTargets` from `@cmbot/db`, `and`/`eq` from `drizzle-orm`.) + +- [ ] **Step 4: Implement PausedRunBanner** + +Create `apps/web/src/components/reminder-detail/paused-run-banner.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { AlertCircleIcon, PlayIcon, XIcon, Loader2Icon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + resumeReminderRunAction, + cancelReminderRunAction, +} from "@/actions/reminders"; + +interface PausedRunBannerProps { + runId: string; + sent: number; + total: number; + windowEndHour: number; + timezone: string; +} + +export function PausedRunBanner({ + runId, + sent, + total, + windowEndHour, + timezone, +}: PausedRunBannerProps) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const onResume = () => + startTransition(async () => { + setError(null); + const r = await resumeReminderRunAction({ runId }); + if (!r.ok) setError(r.error); + }); + + const onCancel = () => + startTransition(async () => { + setError(null); + const r = await cancelReminderRunAction({ runId }); + if (!r.ok) setError(r.error); + }); + + return ( +
+
+ +
+

Reminder paused

+

+ {sent} of {total} groups delivered. The delivery window + closed at {windowEndHour}:00 ({timezone}). Resume to send + the remaining {total - sent} groups, or cancel the run. +

+
+
+ {error && ( +
{error}
+ )} +
+ + +
+
+ ); +} +``` + +- [ ] **Step 5: Run banner test** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run paused-run-banner +``` + +Expected: PASS. + +- [ ] **Step 6: Mount the banner on the reminder detail page** + +In `apps/web/src/app/reminders/[id]/page.tsx`, after loading `reminder` and `runs`, find the latest paused run and conditionally render the banner above the section list: + +```tsx +const latestPausedRun = runs.find((r) => r.status === "paused"); + +// in JSX, near the top of the page content: +{latestPausedRun && ( + +)} +``` + +(`sentCount` and `totalCount` need to be derived in the query — adjust `getReminderWithRuns` to count run-target rows by status. If not present, add a count step there.) + +- [ ] **Step 7: Add Paused filter + Resume button on Activity page** + +In `apps/web/src/app/activity/page.tsx`, extend the status filter pills to include `"paused"` (amber). For each row whose status is `paused`, render a small Resume button inline (use the same `resumeReminderRunAction`). + +Re-use a small client component `ResumeRunButton` that wraps the action call (mirrors the existing inline buttons elsewhere). Place it in `apps/web/src/components/activity/resume-run-button.tsx`. + +- [ ] **Step 8: Run all web tests** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run +``` + +Expected: all green. + +- [ ] **Step 9: Restart web container so SSR picks up the new client components** + +```bash +NO_SUDO=1 docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml restart web +``` + +- [ ] **Step 10: Commit** + +```bash +git add apps/web +git commit -m "feat(web): paused-run banner with Resume / Cancel buttons + +resumeReminderRunAction re-enqueues the existing run via pg-boss with +runId in the payload (Task 7's fire-reminder accepts that). Cancel +action flips remaining pending targets to skipped and resolves the +run to partial. Activity tab gets a Paused filter and inline Resume +button on each paused row." ``` --- ## Acceptance check (manual) -After all 8 tasks land: +After all 10 tasks land: - [ ] **Smoke 1.** Create a reminder for 1 group with default delivery hours and a 30-second future fire time. Verify it lands; verify the run's `error_summary` is null and status `success`. @@ -1818,6 +2525,18 @@ After all 8 tasks land: - [ ] **Smoke 4.** Create a reminder with a 5-MB JPEG and 3 groups. Fire it. Bot logs should show only ONE `prepareWAMessageMedia` / upload entry (look for the upload size in the Baileys debug log) followed by 3 relayMessage events. +- [ ] **Smoke 5.** Wizard ETA pill: pick 5 groups + default 6/18 hours. Pill should be green ("Fits in window"). Pick 5000 groups (clone an existing one): pill should flip amber ("Likely to pause") with the "Widen the window" hint. + +- [ ] **Smoke 6.** Trigger a paused run on purpose: set `BOT_MAX_SEND_PER_MINUTE=2` in `.env.development`, restart bot, fire a 10-group reminder with end hour set ~3 minutes from now. Verify: + + - The run resolves `paused` (~6 groups sent). + - A "Reminder paused" notification appears (with sent/total in body). + - The detail page shows the banner with **Resume** and **Cancel run**. + - Click **Resume** → run continues from the unsent targets, eventually resolving to `success` (or `paused` again if the window closes again). + - Reset `BOT_MAX_SEND_PER_MINUTE` after the test. + +- [ ] **Smoke 7.** Cancel-run: same setup as Smoke 6, but click **Cancel run** instead of Resume. Verify remaining pending targets become `skipped`, run resolves `partial` with errorSummary "Canceled by operator before all groups received the message.", banner disappears. + - [ ] **Final test sweep:** ```bash @@ -1826,4 +2545,4 @@ After all 8 tasks land: NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run ``` - Expected: all green. ~380 total. + Expected: all green. ~395 total (web ≈315 with the new ETA + banner + notification tests; shared adds windowEndAt suite that moved from bot). diff --git a/docs/superpowers/specs/2026-05-10-windowed-fanout-design.md b/docs/superpowers/specs/2026-05-10-windowed-fanout-design.md index 1c29e39..626e28c 100644 --- a/docs/superpowers/specs/2026-05-10-windowed-fanout-design.md +++ b/docs/superpowers/specs/2026-05-10-windowed-fanout-design.md @@ -100,6 +100,34 @@ cannot finish inside the window, send what we can and stop. too would mean holding messages from a 4am cron miss-fire until 6am, which is a v2 conversation. +## Estimated finish time (ETA preview) + +The operator sets the delivery window, but they need a feel for +whether the window is *enough*. We surface an ETA at compose and +edit time so they can widen the window (or shrink the run) before +hitting Schedule. + +- A pure helper `estimateRunDuration({ targetCount, ratePerMinute })` + returns `{ durationMinutes, estimatedFinishAt }` given a fire time. + Calculation: `ceil(targetCount / ratePerMinute)` minutes plus a + 15% buffer for per-group setup latency, with a 1-minute floor. +- Default `ratePerMinute` reads `BOT_MAX_SEND_PER_MINUTE` (40); the + number is hard-coded into the web bundle as a constant — operators + who tune the bot env are responsible for redeploying web. The web + side does NOT read bot env directly. +- Displayed in two places: + - **Wizard Review step**, between the recipients summary and the + Schedule button: + `"~28 minutes · finishes ~10:32 (Asia/Kuala_Lumpur)"` + - **Edit Groups** and **Edit When** pages, near the save button. +- Style: + - Green pill `"Fits in window"` when `estimatedFinishAt <= windowEndAt`. + - Amber pill `"Likely to pause"` when it doesn't, with a one-line + suggestion: *"Widen the window or split into smaller runs."* +- The ETA is advisory, not a hard gate — the operator can still + schedule a run that's likely to pause; pause-and-resume covers + that case. The ETA just removes the surprise. + ## Run loop changes (`fire-reminder.ts`) Up-front, once per run: @@ -182,22 +210,36 @@ UI surfaces of paused runs: Success/Partial/Failed/Skipped/Archived filters. Resume button inline on each paused row. - Reminder detail page's run history shows the same Resume button on - paused rows. + paused rows. A prominent banner at the top of the detail page + surfaces the latest paused run with two buttons side-by-side: + **Resume** (re-enqueues the run via the action above) and + **Cancel run** (marks the run `partial` so it stops appearing as + paused; pending targets flip to `skipped` with `error="canceled by + operator"`). The banner is the operator's "interactive" + resume/cancel choice referenced from the pause notification. - The `reminder.fired` SSE event for status=paused triggers a notification with title "Reminder paused" and body - `"X of Y groups delivered. Resume from the Activity tab."` + `"X of Y groups delivered. Tap to resume or cancel."` Clicking the + notification deep-links to the detail page where the banner lives. + + Note on the Notifications API: page-side `new Notification()` does + not support inline action buttons (only service-worker push + notifications do). The "interactive" choice is therefore one + click into the detail page — fewer surfaces to keep in sync, no + service worker required. ## Notification body The existing `reminder.fired` SSE event already carries `{ status }`. -The notification mapper extends: +We extend it to carry `sent` and `total` counts so the notification +can be specific. The notification mapper: - `success` → unchanged. - `partial` → body mentions delivered/total counts when present. - `paused` → headline `"Reminder paused"`, body - `"X of Y groups delivered. Resume from the Activity tab."` Click - takes the operator to the reminder's detail page where the Resume - button lives. + `"X of Y groups delivered. Tap to resume or cancel."` Click + takes the operator to the reminder's detail page where the + Resume / Cancel banner lives. - `failed` → unchanged. - `skipped` → still filtered (bookkeeping noise). @@ -211,13 +253,16 @@ The notification mapper extends: | `apps/bot/src/scheduler/rate-limiter.ts` (new) | per-account token bucket | ~60 | | `apps/bot/src/scheduler/media-upload-cache.ts` (new) | `prepareWAMessageMedia` results, keyed by mediaId | ~50 | | `apps/bot/src/scheduler/delivery-window.ts` (new) | pure window-end calculator | ~30 | -| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above | ~200 | +| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above | ~220 | | `apps/bot/src/scheduler/reminder-jobs.ts` | `teamSize` config | <10 | | `apps/bot/src/env.ts` | `BOT_FIRE_CONCURRENCY`, `BOT_MAX_SEND_PER_MINUTE`, `BOT_GROUP_CONCURRENCY` | <20 | -| `apps/web/src/actions/reminders.ts` | accept the two new fields | <30 | +| `apps/web/src/actions/reminders.ts` | accept the two new fields + `resumeReminderRunAction` + `cancelReminderRunAction` | ~80 | | `apps/web/src/components/reminder-wizard/when-form-client.tsx` | "Delivery hours" inputs | <40 | | `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same | <30 | -| `apps/web/src/lib/notifications.ts` | partial-status body extension | <15 | +| `apps/web/src/lib/run-eta.ts` (new) | pure ETA calculator | ~40 | +| `apps/web/src/components/reminder-wizard/run-eta-pill.tsx` (new) | shared green/amber pill component | ~50 | +| `apps/web/src/components/reminder-detail/paused-run-banner.tsx` (new) | "Resume / Cancel run" banner | ~70 | +| `apps/web/src/lib/notifications.ts` | paused + partial body extension | <30 | ## Tests @@ -232,8 +277,18 @@ The notification mapper extends: - `media-upload-cache.test.ts` — mock socket: `prepare` called once per unique mediaId regardless of how many groups consume it. - `fire-reminder.test.ts` (extend) — window-end gate marks remaining - targets `skipped`; partial-status error_summary includes account / - delivered / total context. + targets `skipped` (failed-from-the-start path) or leaves them + `pending` and resolves the run `paused` when at least one send + succeeded; resume re-attaches and only re-attempts `pending` rows. +- `run-eta.test.ts` — pure ETA helper: 1000 groups @ 40/min returns + ~29 minutes (with the 15% buffer), edge cases (0 groups → 0, + rate=0 → throws, fractional minutes → rounded up). +- `notifications.test.ts` (extend) — `paused` body reads + `"X of Y groups delivered. Tap to resume or cancel."`; `partial` + body uses sent/total when present. +- `paused-run-banner.test.tsx` — banner only renders when the latest + run's status is `paused`; Resume click triggers the action; + Cancel click triggers the cancel action. ## Tuning knobs (env) @@ -262,6 +317,12 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run. pause mid-fan-out isn't wired). - **Retry-failed-targets** action (paused-resume only re-attempts `pending` rows; `failed` rows stay failed). +- **Native push action buttons** (would require a service worker + + push endpoint; v1 keeps the resume/cancel choice on the detail + page, one click away from the notification). +- **Adaptive ETA from observed rate** (today the ETA uses the + configured `BOT_MAX_SEND_PER_MINUTE`; a v2 could feed back the + actual sustained rate from prior runs). - **Multi-account auto-split** of a single reminder. - **Adaptive rate limiting** (auto-back-off on WA rate-limit response codes; today the operator tunes the env var). @@ -270,16 +331,20 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run. - 1000-group reminder with one image, established account: completes in roughly 30–50 minutes, comfortably inside a 6am–6pm window. +- Wizard Review shows ETA pill before submit. Setting an end hour + that won't fit flips the pill amber and surfaces the "Likely to + pause" hint; the operator can still proceed. - Two reminders on different accounts firing within seconds of each other: both progress simultaneously, neither blocks the other. - A run that hits the window end mid-fan-out: stops cleanly, marks the run `paused`, leaves un-started targets as `pending`, surfaces the paused-status notification with delivered/total counts. -- The operator clicks **Resume** on a paused run — fan-out continues - from the unsent targets, respecting the same per-account rate - limit + window. If it again can't finish, it pauses again with an - updated count. +- The detail page surfaces a Resume / Cancel banner for the paused + run. **Resume** re-enqueues; if it pauses again, the banner + re-appears with an updated count. **Cancel run** flips remaining + targets to `skipped` and resolves the run `partial`; banner + disappears. - A run that hits the window end BEFORE any send (fired too late): resolves `failed`, no resume offered. -- 355 existing tests still pass; ≈30 new tests cover the new helpers - and the paused/resume flow. +- 355 existing tests still pass; ≈40 new tests cover the new helpers, + the paused/resume flow, the ETA preview, and the banner.