APNs relay for Prefect for iOS: pushes a flow run's pause to your phone
prefect-push README.md
5.3 kB · 105 lines
Markdown
at main

prefect-push #

tells Prefect for iOS when a flow run starts waiting on a person. it watches one Prefect server's /api/events/out socket and, when a run pauses or suspends, sends each registered phone an APNs alert (with Resume and Cancel actions) and starts a "waiting on you" live activity. when the run moves on, it ends the activity.

bun and typescript, no runtime dependencies: bun:sqlite for the registry, node:http2 and node:crypto for APNs token auth. expo's push service can't start live activities, so this talks to APNs directly.

how it behaves #

  • one relay watches one server. it signs in with that server's read-only credential and never changes anything on it.
  • registering a device is authorized by the Prefect server: the relay forwards the caller's Authorization header to POST /flows/count and accepts whoever the server accepts (cached for five minutes).
  • a pause is announced once. pauses are keyed by their state id, so an event and a listing, or a reconnect, don't double-send.
  • nothing is missed while disconnected. the socket reconnects with backoff, and on every subscribe (and each minute) the relay lists what is paused: anything new and under 15 minutes old is announced, anything that stopped waiting has its activity ended. on a first boot this means old pauses are recorded quietly rather than announced.
  • a pause about to time out is announced once more. with five minutes left, a pause that was given at least ten gets one more alert ("Times out in 4 min. …"), from the server's own pause_timeout. a suspend without a timeout never does.
  • a device on the wrong APNs host is corrected, not dropped. the wrong host answers BadDeviceToken just as it does for a dead token, and a TestFlight build cannot always tell which environment its tokens belong to. the other host is tried first and the device's environment updated.
  • dead tokens are dropped: a 410, BadDeviceToken from both hosts, Unregistered or DeviceTokenNotForTopic removes the device (or just its push-to-start token).
  • without an APNs key it runs and says so in /health ("apns": false); nothing is sent.

api #

all under BASE_PATH (/push in production). everything but /health needs the Prefect server's credential.

GET /health { ok, subscribed, apns, devices, waiting }
POST /devices { id, token, start_token?, environment, server_id }: register or refresh. environment is the app's aps-environment; server_id is echoed in every push so the app knows which saved server a run belongs to
DELETE /devices/{id} unregister
PUT /devices/{id}/activities/{run_id} { token }: the push token of a run's live activity, used to end it
POST /devices/{id}/test send a test alert

what it sends #

  • alert: says what the run made, then what it asks, read as the app reads it (src/context.ts, in step with prefect-ios src/waiting.ts). the context is the run's work.waiting body, else the markdown description stored with its input, else its approval artifact, else the newest event naming the run, from before the pause, with a string payload.body. the title is work.waiting's title, else the artifact's description, else the body's opening heading or bold line, else the input's one question, else a prompt-like parameter, else "Suspended until you resume it"; the subtitle is the flow; the body is the context's list items joined (or its opening text) and then the question. category waiting-reply when the pause asks for one line of text (the app offers Reply), waiting-input for any other input, else waiting. body: { runId, serverId, url } is what expo-notifications hands the app as the notification's data.
  • live activity start (apns-push-type: liveactivity, topic <bundle>.push-type.liveactivity): attributes-type LiveActivityAttributes and content state { name: "WaitingRun", props }, the shapes expo-widgets declares. WaitingProps in src/payload.ts must stay in step with the app's.
  • live activity end: the same props with ended set to the new state's name, dismissed a minute later.

run it #

bun install
bun run check
PREFECT_API_URL=https://prefect.example.com/api PREFECT_API_AUTH_STRING=user:pass bun src/main.ts
env
PREFECT_API_URL API root of the server to watch (required)
PREFECT_API_AUTH_STRING user:pass; read-only is enough. unset for an open server
APNS_KEY or APNS_KEY_PATH the .p8 token signing key, inline or as a file
APNS_KEY_ID, APNS_TEAM_ID the key's id and the Apple team's
APNS_TOPIC the app's bundle id (default io.zzstoatzz.prefect)
DB_PATH sqlite file (default prefect-push.db)
PORT, BASE_PATH default 8080 and none

deploy #

it runs in the prefect namespace beside prefect-server, from a ConfigMap of src/ on the stock bun image, at https://prefect-server.waow.tech/push.

just deploy     # check, ship the committed source, wait for /health
just apns-key ~/Downloads/AuthKey_XXXXXXXXXX.p8 XXXXXXXXXX TEAMID
just status
just logs -f