Skip to content

BookingFlagWorkflow

BookingFlagWorkflow lives in src/workflows/booking-flag.ts. One instance per booking. Cloudflare Workflows handles the sleep-survives-restart semantics — instances can sleep for weeks and resume cleanly.

Spawn / re-spawn

ensureBookingFlagWorkflow(env, bookingId, opts?) is the entry point. It accepts an optional { force?: boolean } parameter (default true):

CallerforceWhy
/webhook/beds24/booking (new/modify/cancel)true (default)A push notification means the booking actually changed — refresh state
/admin/booking/{id}/respawntrue (default)Manual op, user wants a fresh workflow
DailyFlagSyncWorkflow (06:00Z cron)falseSafety-net sweep. If the existing instance is healthy, leave it ticking

Algorithm:

  1. Fetch booking from Beds24
  2. If status is cancelled: delete the workflow_instances row, email cleaners that the booking was cancelled (BA AA 308252 migration), return
  3. If force=false AND an existing instance is healthy (queued/running/paused/waiting/complete): return { created: false, reason: "already-tracked" } — no churn
  4. If an existing workflow instance is tracked, call terminate() on it
  5. Create a new workflow instance
  6. Record (workflow_name, resource_id, instance_id) in workflow_instances

The force=false path was added May 2026 to stop daily-sync from terminating + recreating every active booking’s workflow every morning. The respawn-on-every-modify path stays force=true so webhook deliveries always pick up fresh booking data.

The phase pattern

private async runSomePhase(
step: WorkflowStep,
bookingId: number,
booking: Booking,
): Promise<void> {
const fireAt = computeFireAt(booking);
if (fireAt.getTime() > Date.now()) {
await step.sleepUntil("until-some-phase", fireAt);
}
await step.do("some-phase", async () => {
const fresh = await readState(this.env, bookingId);
if (alreadyDone(fresh)) return;
await doTheWork();
await writeStateMarkingDone();
});
}

step.sleepUntil is what makes Workflows magical — the worker shuts down and resumes cleanly when the sleep expires.

Phase list (in execution order)

Phases run sequentially in this order. See end-to-end timeline for fire-at times.

#MethodSourceWhat it does
1runCohostNotifycohost-notify.tsTP front-desk notification
2runAirbnbConfirm + runBookingComPaidFlagchannel-specific.tsChannel-specific spawn-time work
3runCleaningHeadsUpbooking-flag.tsFirst cleaner notification
4runPreArrivalSingle("T14")pre-arrival-messages.tsA-14: welcome email + yellow flag
5runPreArrivalSingle("T3")pre-arrival-messages.tsA-3: nudge + pink “late” flag
6runEarlyCheckinAvailabilityCheckbooking-flag.tsA-2: M4c capture/decline email
7runFlagPhase('arrival')booking-flag.tsA-1: arrival flag color
8runPreArrivalSingle("T1")pre-arrival-messages.tsA-1: final reminder
9runArrivalDayBillEmailbooking-flag.tsA+0: “who’s checking in today”
10runPreArrivalSingle("T0")pre-arrival-messages.tsA+0: lock-box codes / nudge
11runStillDirtyCheckbooking-flag.tsA+0 3pm: cleaning-incomplete alert
12runArrivalPlusOneCheckinChasebooking-flag.tsA+1: check-in form reminder
13runLateCheckoutAvailabilityCheckbooking-flag.tsD-3: M4d guest picker link
14runCleaningT2Reminderbooking-flag.tsD-2: cleaner ping
15runCheckoutEvePhasebooking-flag.tsD-1: “Checkout tomorrow” guest msg
16runCleaningTimeCommitbooking-flag.tsD-1: cleaner time-pick request
17runDepartureDayBillEmailbooking-flag.tsD+0: “who’s checking out today”
18runFlagPhase('departure')booking-flag.tsD+0: departure flag color

The four pre-arrival phases used to be wrapped in a single runPreArrivalMessages call. May 2026 refactor unbundled them so they could interleave with M4c, arrival-flag, and T-1 in correct temporal order — previously M4c (A-2) and M4d (D-3) fired LATE because they were listed after a bundled call that already slept past their targets.

Why per-booking?

Considered cron-driven sweeps. Rejected because:

  • A booking made today for 5 days from now needs the T-3 message tomorrow — cron sweep would miss the window unless it ran hourly
  • Workflows let each booking own its own clock — no cross-booking race
  • Replay/restart is built in

The exception: account-wide sweeps (delivery summary, watchdog) still use cron because they’re not per-resource.

Shadow mode

booking-flag row in feature_flags has its own shadow_mode. When 1:

  • Predictions still get written to shadow_predictions
  • Each phase’s apply step is skipped — no real sends or writes

Currently shadow_mode=0 (live).

Debugging a stuck workflow

Terminal window
npx wrangler workflows instances list booking-flag
npx wrangler workflows instances describe booking-flag <instance-id>
# Force a respawn from admin
curl -X POST -H "authorization: Bearer $SECRET" \
https://stayonthesnow-pms.minnetonka.workers.dev/admin/booking/86704187/respawn

See /admin/delivery-status?booking=<id> for the per-booking communication timeline.