diff --git a/cloudflare/.dev.vars.example b/cloudflare/.dev.vars.example index 2927eb7a..1fd165b2 100644 --- a/cloudflare/.dev.vars.example +++ b/cloudflare/.dev.vars.example @@ -14,3 +14,9 @@ GITHUB_APP_ID=replace-with-app-id GITHUB_APP_PRIVATE_KEY=replace-with-quoted-PEM-private-key GITHUB_WEBHOOK_SECRET=replace-with-random-webhook-secret GITHUB_FORK_OWNER=replace-with-service-controlled-fork-owner + +# Optional decision notifications: `bun scripts/vapid.ts` prints a key pair. +# Set VAPID_PUBLIC_KEY here, and list VAPID_PRIVATE_KEY under +# `secrets.required` in wrangler.jsonc while trying it, as with Google. +# VAPID_PUBLIC_KEY=replace-with-vapid-public-key +# VAPID_PRIVATE_KEY=replace-with-vapid-private-key diff --git a/cloudflare/README.md b/cloudflare/README.md index 6370227b..f60ad2c2 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -2,8 +2,9 @@ A deployable foundation for Quest on Cloudflare, and the site at `https://kix.quest`: the quest log for your repo and your agents, on the web. -Hono serves HTML directly, so there is no client bundle; only passkeys need a -small script (`src/passkey.js`). Every page renders +Hono serves HTML directly, so there is no client bundle; only passkeys +(`src/passkey.js`) and the installed app (`src/app.js` and `src/sw.js`) need +small scripts. Every page renders inside the shared layout in `src/layout.tsx`, which follows [the quest log theme](../design/theme.md): the home page (`src/home.tsx`), the docs under `/docs` (`src/docs.tsx`), the quest board under `/repos` @@ -28,9 +29,11 @@ other docs become `/docs/` and other repository paths point at GitHub. It also snapshots this repository's `quest/` tree and finished quests into `build/board.json`, which supplies the public board. Enrolled repositories use authenticated GitHub snapshots through the same board model. A shallow clone has no -history, so its board shows no finished work. It also copies `design/theme/` and `src/site.css` into `build/public/`, which the -Worker serves as static assets (`/theme/theme.css`, `/theme/logo.svg`, -`/theme/og.png`, and `/site.css`). `build/` is ignored by Git. +history, so its board shows no finished work. It also copies `design/theme/`, +`src/site.css`, the scripts, and the web app manifest into `build/public/`, +which the Worker serves as static assets (`/theme/theme.css`, +`/theme/logo.svg`, `/theme/og.png`, `/site.css`, `/sw.js`, and the like). +`build/` is ignored by Git. Better Auth uses D1 for accounts and sessions; adding social providers or plugins belongs in `src/auth.ts`. It handles OAuth @@ -202,6 +205,49 @@ the item showed (a question, a GitHub change, a quest's text). A newer version is unread again, and skipping an item lasts until it changes. A finished or stopped job sets its item aside the same way. +### Installed app and notifications + +kix.quest installs as a progressive web app (`src/manifest.webmanifest`, +starting at `/inbox`) on Android, desktop browsers, and iOS/iPadOS 16.4+ once +added to the Home Screen. Signed-in pages load `src/app.js`, which registers +the service worker `src/sw.js`, sets the installed app's icon badge to the +unread decision count where the Badging API exists, and drives the +notification switch on `/inbox` and `/account`. The nav shows the same count. +It reads stored observations without rechecking GitHub on every page, so it +reveals a number and nothing else. + +A device turns notifications on only from a click, which asks the browser for +permission and stores its push subscription (`push_subscriptions`) for that +user and session. Subscriptions must name a known push service. Signing out +deletes the session's subscriptions, and the sign-out form first drops the +browser's own, which discards pushes still queued for it (a sign-out without +JavaScript leaves those to arrive within the 24-hour TTL). A push service +reporting one gone (404 or 410) deletes it. The switch shows a device as notified only while the +server stores its subscription for the signed-in user, so whoever signs in +next on that browser turns notifications on with their own click. A rotated +subscription replaces the old one only for the user who enabled it, and the +switch drops a browser subscription made for an earlier VAPID key pair. Each push +rechecks that its recipient still maintains the project, as the inbox does. + +Only decisions push (`src/push/notify.ts`): an agent's question, a +publication decision, an account limit, a failed job, a new or reopened issue, +and a pull request opened ready for review or marked ready. Drafts, +progress, presence, and finished work never push, and neither does anything on +a paused project. Each push goes once, from the first delivery or the attempt +that changed, to the devices of the project's members, whose inbox shows it. It +carries the project, the item's title, and the unread count; one +notification tag and push `Topic` per item replace an earlier one instead of +stacking. Tapping it opens the item in the inbox. Notifications have no answer +buttons: iOS Web Push does not support them, and every answer goes through the +inbox's stale- and duplicate-answer checks. + +`src/push/webpush.ts` implements RFC 8291 message encryption and RFC 8292 VAPID +signing with WebCrypto, so there is no push library; tests check it against +RFC 8291's worked example. Notifications are on only when both +`VAPID_PUBLIC_KEY` (in `wrangler.jsonc`) and the `VAPID_PRIVATE_KEY` secret are +set. `bun scripts/vapid.ts` prints a new pair; replacing it stops every +existing subscription until each device turns notifications on again. + `cloudflare/` is a Bun workspace of the repository root, so the Worker imports the CLI's core as `quest/core` (`../src/core`), the same parser, checks, and readiness the CLI runs. @@ -261,7 +307,8 @@ jobs that are queued, running, failed, or waiting on an agent's questions, a tool permission, or an account limit. `dev/github.ts` fakes GitHub for every outbound request, so nothing leaves -localhost and no real credential is used. Sign in with GitHub completes as the +localhost and no real credential is used. Notifications are off, since a push +would reach a real push service. Sign in with GitHub completes as the seeded maintainer. From a shell, sign in with a cookie jar: ```sh @@ -298,7 +345,9 @@ limits and their decisions, lost branch updates and deliveries, iterated heads, and token scopes. Output tests use local R2 for chunk ordering, retries, the caps, expiry, and cleanup after a disconnect. Inbox tests answer prompts through the forms, reject stale revisions and repeated answers, coalesce -duplicate webhooks, and hide a project whose maintainer lost authority. No +duplicate webhooks, and hide a project whose maintainer lost authority. Push +tests encrypt RFC 8291's example, verify VAPID signatures, decrypt what a +subscribed device receives, and check that only decisions push, once each. No OAuth credentials or Cloudflare account are required for checks. After changing bindings, regenerate types: @@ -347,7 +396,10 @@ a production Google OAuth web client with redirect URI `https://kix.quest/api/auth/callback/google`, set its public client ID as `GOOGLE_CLIENT_ID` in `wrangler.jsonc`, and set the secret with `bun run wrangler secret put GOOGLE_CLIENT_SECRET`; without both, Google is -not offered. The secret is optional, so it is not listed under `secrets.required`. Deploys fail until every +not offered. The secret is optional, so it is not listed under `secrets.required`. +Notifications are optional the same way: run `bun scripts/vapid.ts`, set its +public key as `VAPID_PUBLIC_KEY` in `wrangler.jsonc`, and set the private key +with `bun run wrangler secret put VAPID_PRIVATE_KEY`. Deploys fail until every required secret is set, so register the GitHub App first ([GITHUB.md](GITHUB.md) steps 1 and 2), then set secrets interactively: diff --git a/cloudflare/dev/serve.ts b/cloudflare/dev/serve.ts index c499cf1c..77f8b3a5 100644 --- a/cloudflare/dev/serve.ts +++ b/cloudflare/dev/serve.ts @@ -80,6 +80,9 @@ const mf = new Miniflare( GITHUB_FORK_OWNER: "quest-dev-forks", GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "", + // Notifications stay off: a push would leave localhost for a real push service. + VAPID_PUBLIC_KEY: "", + VAPID_PRIVATE_KEY: "", }, // Every outbound request goes to the fake; nothing reaches the network. outboundService: async (request) => { diff --git a/cloudflare/migrations/0014_push.sql b/cloudflare/migrations/0014_push.sql new file mode 100644 index 00000000..eda18cae --- /dev/null +++ b/cloudflare/migrations/0014_push.sql @@ -0,0 +1,16 @@ +-- Web Push subscriptions, one per browser or installed app. The endpoint +-- names the device's push channel and moves to whoever enables +-- notifications on it last. Signing out deletes the subscriptions that +-- session enabled, and a push service reporting one gone deletes it. +CREATE TABLE push_subscriptions ( + endpoint TEXT PRIMARY KEY NOT NULL, + user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + -- The session that enabled it; not a foreign key, so an expired session + -- keeps notifying until its device signs out. + session TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + createdAt INTEGER NOT NULL +); +CREATE INDEX push_subscriptions_user ON push_subscriptions(user); +CREATE INDEX push_subscriptions_session ON push_subscriptions(session); diff --git a/cloudflare/scripts/build.ts b/cloudflare/scripts/build.ts index e2a11d30..94cc8fe8 100644 --- a/cloudflare/scripts/build.ts +++ b/cloudflare/scripts/build.ts @@ -1,7 +1,9 @@ // Build-time inputs for the Worker, written to build/ (ignored by Git): // - build/docs.json: the repository's docs/*.md rendered to HTML. // - build/board.json: this repository's quest tree and finished quests, for the board. -// - build/public/: static assets, the theme from design/theme/, src/site.css, and src/passkey.js. +// - build/public/: static assets: the theme from design/theme/, src/site.css, the +// scripts src/passkey.js, src/app.js, and the service worker src/sw.js, and the +// web app manifest. // Wrangler runs this before dev and deploy; the check and test scripts run it first. import { execFileSync } from "node:child_process"; import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -113,4 +115,5 @@ writeFileSync(join(build, "docs.json"), JSON.stringify(rendered)); writeFileSync(join(build, "board.json"), JSON.stringify(snapshot)); cpSync(join(design, "theme"), join(build, "public", "theme"), { recursive: true }); cpSync(join(worker, "src", "site.css"), join(build, "public", "site.css")); -cpSync(join(worker, "src", "passkey.js"), join(build, "public", "passkey.js")); +for (const file of ["passkey.js", "app.js", "sw.js", "manifest.webmanifest"]) + cpSync(join(worker, "src", file), join(build, "public", file)); diff --git a/cloudflare/scripts/vapid.ts b/cloudflare/scripts/vapid.ts new file mode 100644 index 00000000..7431948a --- /dev/null +++ b/cloudflare/scripts/vapid.ts @@ -0,0 +1,9 @@ +// Print a new Web Push (VAPID) key pair: the public key for VAPID_PUBLIC_KEY +// in wrangler.jsonc, and the private key for the VAPID_PRIVATE_KEY secret. +// Replacing the pair stops every existing subscription; devices re-enable. +import { base64url } from "../src/push/webpush"; + +const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; +const publicKey = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); +const { d } = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; +console.log(`VAPID_PUBLIC_KEY=${base64url(publicKey)}\nVAPID_PRIVATE_KEY=${d}`); diff --git a/cloudflare/src/account.tsx b/cloudflare/src/account.tsx index 32817612..079a51e3 100644 --- a/cloudflare/src/account.tsx +++ b/cloudflare/src/account.tsx @@ -1,5 +1,6 @@ import type { Child } from "hono/jsx"; import { type Provider, providerNames } from "./auth"; +import { Notifications } from "./layout"; // Sign-in choices and the signed-in account's methods. OAuth uses plain forms; // only the WebAuthn ceremonies need /passkey.js, which reveals its buttons @@ -84,6 +85,9 @@ export function AccountPage(props: { linked: Provider[]; providers: Provider[]; passkeys: Passkey[]; + /** The VAPID public key, or null where notifications are not configured. */ + notifications: string | null; + devices: number; error?: string; }) { const unlinked = props.providers.filter((provider) => !props.linked.includes(provider)); @@ -144,6 +148,24 @@ export function AccountPage(props: { Keep another passkey or a linked provider to recover access; there is no administrator recovery.

+ {props.notifications && ( + +

+ Get a notification when something needs your decision: an agent's question, an account limit, a + stopped job, a new issue, or a pull request ready for review. Tapping it opens the decision. +

+

+ {props.devices === 1 + ? "1 device is notified." + : `${props.devices || "No"} devices are notified.`}{" "} + Signing out turns notifications off for that device. +

+ + +
+ )} ); } diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js new file mode 100644 index 00000000..2aa4ab57 --- /dev/null +++ b/cloudflare/src/app.js @@ -0,0 +1,141 @@ +// The installed app, for signed-in pages: the service worker that shows +// decision notifications, the app icon's unread badge, and the +// notification switch. Served as /app.js; pages work without it. +const unread = Number(document.body.dataset.unread); +if ("setAppBadge" in navigator) + (unread > 0 ? navigator.setAppBadge(unread) : navigator.clearAppBadge()).catch(() => {}); + +const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; +const registration = supported ? navigator.serviceWorker.register("/sw.js") : null; + +async function post(path, body) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const result = await response.json().catch(() => null); + if (!response.ok) throw new Error(result?.error || "Request failed."); + return result; +} + +function key(base64url) { + const text = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); + return Uint8Array.from(text, (c) => c.charCodeAt(0)); +} + +// The browser's subscription, dropping one made for an earlier key pair: it +// cannot receive pushes signed with the current key, and it blocks subscribing +// with the new one. +async function current(publicKey) { + const subscription = await (await registration).pushManager.getSubscription(); + const made = subscription?.options.applicationServerKey; + const want = key(publicKey); + if (!made || (made.byteLength === want.length && new Uint8Array(made).every((b, i) => b === want[i]))) + return subscription; + await post("/push/unsubscribe", { endpoint: subscription.endpoint }).catch(() => {}); + await subscription.unsubscribe(); + return null; +} + +// Signing out stops this device's notifications at once and clears the app +// badge, which signed-out pages never update. The server forgets the session's +// subscriptions, and dropping the browser's own also discards any push the push +// service still holds for it. A sign-out without JavaScript leaves those to +// arrive within their TTL. +for (const form of document.querySelectorAll('form[action="/sign-out"]')) { + form.addEventListener( + "submit", + (event) => { + event.preventDefault(); + const steps = [ + "clearAppBadge" in navigator ? navigator.clearAppBadge() : null, + supported + ? registration + .then((found) => found.pushManager.getSubscription()) + .then((subscription) => subscription?.unsubscribe()) + : null, + ]; + Promise.allSettled(steps).then(() => form.submit()); + }, + { once: true }, + ); +} + +// Whether the server notifies this browser's subscription for whoever is +// signed in. It forgets one when a session signs out, and never hands one to +// another user, so the browser's own subscription is not enough to show "on". +const controls = document.querySelectorAll("[data-push]"); +let stored = false; +const checked = (async () => { + if (!supported || !controls.length || Notification.permission !== "granted") return; + const subscription = await current(controls[0].dataset.push); + if (subscription) stored = (await post("/push/check", { endpoint: subscription.endpoint })).stored === true; +})(); + +for (const control of controls) { + const status = control.querySelector("[data-push-status]"); + const enable = control.querySelector("[data-push-enable]"); + const disable = control.querySelector("[data-push-disable]"); + + async function show(message) { + const subscription = supported && (await (await registration).pushManager.getSubscription()); + const on = stored && !!subscription && Notification.permission === "granted"; + const blocked = supported && Notification.permission === "denied"; + // The inbox's compact control steps aside once there is nothing to switch on. + control.hidden = (on || blocked) && "compact" in control.dataset && !message; + enable.hidden = !supported || on || blocked; + disable.hidden = !on; + status.textContent = + message ?? + (!supported + ? "This browser can't show notifications here. On an iPhone or iPad, add Quest to your Home Screen from the Share menu, then open it from there." + : blocked + ? "Notifications are blocked for this site in your browser's settings." + : on + ? "This device is notified when something needs your decision." + : "Get a notification on this device when something needs your decision."); + } + + async function run(button, action) { + button.disabled = true; + try { + await show(await action()); + } catch (error) { + await show(error.message || "Something went wrong."); + } finally { + button.disabled = false; + } + } + + // The permission prompt comes only from this click. + enable.addEventListener("click", () => + run(enable, async () => { + if ((await Notification.requestPermission()) !== "granted") return undefined; + await current(control.dataset.push); + const subscription = await ( + await registration + ).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) }); + await post("/push/subscribe", subscription.toJSON()); + stored = true; + return "Notifications are on for this device."; + }), + ); + disable.addEventListener("click", () => + run(disable, async () => { + const subscription = await (await registration).pushManager.getSubscription(); + if (subscription) { + await post("/push/unsubscribe", { endpoint: subscription.endpoint }); + stored = false; + await subscription.unsubscribe(); + } + return "Notifications are off for this device."; + }), + ); + checked + .then( + () => show(), + (error) => show(error.message || "Something went wrong."), + ) + .catch(() => {}); +} diff --git a/cloudflare/src/env.d.ts b/cloudflare/src/env.d.ts index cbd51769..739af924 100644 --- a/cloudflare/src/env.d.ts +++ b/cloudflare/src/env.d.ts @@ -1,6 +1,9 @@ -// Optional Google sign-in secret, which `wrangler types` cannot declare without -// making every deploy require it. Wrangler dev loads only declared secrets, so -// to try Google locally, temporarily add it to `secrets.required`. +// Optional secrets, which `wrangler types` cannot declare without making every +// deploy require them. Wrangler dev loads only declared secrets, so to try one +// locally, temporarily add it to `secrets.required`. interface Env { + // Google sign-in, offered only with GOOGLE_CLIENT_ID. GOOGLE_CLIENT_SECRET?: string; + // The Web Push VAPID private key; notifications are offered only with VAPID_PUBLIC_KEY. + VAPID_PRIVATE_KEY?: string; } diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 2871cfa0..bfca9d67 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -425,6 +425,16 @@ export class GitHubService { ).results; } + /** Whether a user currently maintains a project, as its inbox items require. */ + async maintains(repo: GitHubRepository, userId: string) { + try { + return await this.#authorized(repo, userId, {}, async () => true); + } catch (error) { + if (error instanceof GitHubError) return false; + throw error; + } + } + /** Let any current maintainer reach stop controls without a valid preview. */ async enrolledProject(owner: string, repository: string, userId: string): Promise { return this.#withInstallation({ owner, name: repository }, {}, async (token, installation) => { diff --git a/cloudflare/src/github/webhook.ts b/cloudflare/src/github/webhook.ts index adb4f181..5b0c2faa 100644 --- a/cloudflare/src/github/webhook.ts +++ b/cloudflare/src/github/webhook.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; +import { arrived } from "../push/notify"; import { GitHubService } from "./service"; export function positiveId(value: unknown): value is number { @@ -205,7 +206,18 @@ githubWebhooks.post("/webhooks/github", async (c) => { } // Observations never approve or launch work. The background read reconciles // current GitHub state rather than treating delayed webhook bodies as facts. - if (reads !== null && committed[reads]!.meta.changes > 0) - c.executionCtx.waitUntil(new GitHubService(c.env).observe(repository)); + // A new decision item still notifies, once, from the first delivery, after + // the read so a title the webhook lacks can come from GitHub. + const read = reads !== null && committed[reads]!.meta.changes > 0; + if (read || observed.has(event)) + c.executionCtx.waitUntil( + (async () => { + try { + if (read) await new GitHubService(c.env).observe(repository); + } finally { + if (observed.has(event)) await arrived(c.env, repository, installation, event, payload); + } + })(), + ); return c.json({ status: "accepted" }, 202); }); diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index efad07e6..79c8f26c 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -6,7 +6,7 @@ import { parse } from "quest/core"; import { encodePath } from "../board/model"; import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service"; -import type { Activity, ProjectState } from "../github/state"; +import { type Activity, type ProjectState, readState } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { hub } from "../jobs/routes"; import * as store from "../jobs/store"; @@ -198,6 +198,36 @@ export function arrange(items: Item[]) { export type Runners = Awaited>; +/** Every project's items, from its observations, its jobs, and the maintainer's read state. */ +async function collect(db: D1Database, user: string, views: ProjectView[]) { + const found = views.map((view) => entities(view.project, view.state)); + const [reads, jobs] = await Promise.all([ + db.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(), + Promise.all(views.map((view, i) => store.listInboxJobs(db, view.project.id, [...found[i]!.keys()]))), + ]); + return views.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results)); +} + +/** + * How many decisions are new to a maintainer, for the nav and the app icon + * badge. It reads only stored observations, without rechecking GitHub + * authority on every page, so it reveals a count and nothing else. + */ +export async function unread(env: Env, user: string) { + const projects = await new GitHubService(env).projects(user); + const views = await Promise.all( + projects.map(async (project) => ({ project, state: await readState(env.DB, project), error: null })), + ); + return (await collect(env.DB, user, views)).filter((item) => item.need !== null && !item.skipped && item.unread) + .length; +} + +/** What a notification calls an item: its issue, pull request, or quest title, as the inbox shows it. */ +export async function title(db: D1Database, project: Enrollment, target: string) { + const state = await readState(db, project); + return (entities(project, state).get(target) ?? fallback(project, target)).title; +} + /** * Load the inbox across the projects the user is a member of. Every project * rechecks current GitHub authority. A denied check ends the membership and @@ -217,13 +247,11 @@ export async function load(env: Env, user: string) { }), ); const projects = views.filter((view) => view !== null); - const usable = projects.filter((view) => !view.error); - const found = usable.map((view) => entities(view.project, view.state)); - const [reads, jobs] = await Promise.all([ - env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(), - Promise.all(usable.map((view, i) => store.listInboxJobs(env.DB, view.project.id, [...found[i]!.keys()]))), - ]); - const items = usable.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results)); + const items = await collect( + env.DB, + user, + projects.filter((view) => !view.error), + ); // Jobs run on their requester's runners, so presence comes from each requester's hub. const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))]; const runners = new Map( diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index 6e78500c..ee844133 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -1,7 +1,7 @@ import { type Pull, startable } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; -import { Marker } from "../layout"; +import { Marker, Notifications } from "../layout"; import { projectURL } from "../projects"; import type { Item, ProjectView, QuestionView, Runners } from "./model"; @@ -21,6 +21,8 @@ export type InboxProps = { working: Item[]; skipped: Item[]; runners: Map; + /** The VAPID public key, or null where notifications are not configured. */ + notifications: string | null; }; const recorded: Record = { @@ -498,6 +500,7 @@ export function InboxPage(props: InboxProps) { {recorded[props.done as Done]}

)} + {props.missing && (

That decision is no longer open. It may have been answered or changed on GitHub. diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index 2cbbcfb5..c24adb9d 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -18,6 +18,7 @@ import { purge, retain } from "./jobs/output"; import { BootstrapRequired, fromForm, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; import { JobError, active } from "./jobs/store"; import { Layout, type Page, type User } from "./layout"; +import * as push from "./push/notify"; export const app = new Hono<{ Bindings: Env }>(); // Browsers send `Origin: null` on form posts under the default "no-referrer" @@ -28,6 +29,7 @@ app.get("/health", (c) => c.json({ status: "ok" })); // Short link for the one-line setup paste. app.get("/setup", (c) => c.redirect("https://github.com/kixelated/quest/blob/main/SETUP.md")); app.all("/api/auth/*", (c) => createAuth(c.env).handler(c.req.raw)); +app.route("/", push.subscriptions); // The page a visitor is on, still percent-encoded, to return to after signing in. function here(c: Context): string { @@ -38,15 +40,16 @@ function here(c: Context): string { // Every HTML page renders in the shared layout, with the visitor's session in the nav. async function page( c: Context<{ Bindings: Env }>, - props: Omit, + props: Omit, body: Child | ((user: User | null) => Child), status: ContentfulStatusCode = 200, ) { const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); const user = session?.user ?? null; + const unread = user ? await inbox.unread(c.env, user.id) : 0; c.header("Cache-Control", "no-store"); return c.html( - + {typeof body === "function" ? body(user) : body} , status, @@ -264,7 +267,11 @@ app.get("/inbox", async (c) => { const target = c.req.query("item"); const chosen = target ? items.find((i) => String(i.project.id) === project && i.target === target) : undefined; const active = chosen ?? decisions[0] ?? null; - if (active?.need) await inbox.markSeen(c.env.DB, user, active); + // Seen once shown, before the nav and the app badge count what is new. + if (active?.need) { + await inbox.markSeen(c.env.DB, user, active); + active.unread = false; + } return page( c, { title: "Decisions", description: "Your decision inbox: questions, issues, pull requests, and ready quests." }, @@ -278,6 +285,7 @@ app.get("/inbox", async (c) => { working={working} skipped={skipped} runners={runners} + notifications={push.keys(c.env)?.publicKey ?? null} />, ); }); @@ -487,11 +495,15 @@ app.post("/sign-in", async (c) => { ); }); app.post("/sign-out", async (c) => { - const response = await createAuth(c.env).api.signOut({ + const auth = createAuth(c.env); + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + const response = await auth.api.signOut({ headers: c.req.raw.headers, asResponse: true, }); if (!response.ok) return response; + // This device stops notifying once it signs out. + if (session) await push.forget(c.env.DB, session.session.id); return redirect(response, "/"); }); @@ -499,10 +511,12 @@ app.post("/sign-out", async (c) => { app.get("/account", async (c) => { const auth = createAuth(c.env); const headers = c.req.raw.headers; - if (!(await auth.api.getSession({ headers }))) return c.redirect("/sign-in?next=/account", 303); - const [accounts, passkeys] = await Promise.all([ + const session = await auth.api.getSession({ headers }); + if (!session) return c.redirect("/sign-in?next=/account", 303); + const [accounts, passkeys, devices] = await Promise.all([ auth.api.listUserAccounts({ headers }), auth.api.listPasskeys({ headers }), + push.count(c.env.DB, session.user.id), ]); const linked = providers(c.env).filter((p) => accounts.some((account) => account.providerId === p)); return page(c, { title: "Account", description: "Your Quest sign-in methods and passkeys." }, (user) => ( @@ -511,6 +525,8 @@ app.get("/account", async (c) => { linked={linked} providers={providers(c.env)} passkeys={passkeys} + notifications={push.keys(c.env)?.publicKey ?? null} + devices={devices} error={c.req.query("error")} /> )); diff --git a/cloudflare/src/jobs/hub.ts b/cloudflare/src/jobs/hub.ts index 834d8304..b29e9cd7 100644 --- a/cloudflare/src/jobs/hub.ts +++ b/cloudflare/src/jobs/hub.ts @@ -1,4 +1,5 @@ import { DurableObject } from "cloudflare:workers"; +import * as push from "../push/notify"; import * as output from "./output"; import { MessageError, type RunnerMessage, parse } from "./protocol"; import * as store from "./store"; @@ -147,14 +148,28 @@ export class RunnerHub extends DurableObject { message.session, now, ); - case "question": - return store.ask(db, runner, message.job, message.attempt, message, now); + case "question": { + const { question, cancel, asked } = await store.ask( + db, + runner, + message.job, + message.attempt, + message, + now, + ); + if (asked && !cancel) this.ctx.waitUntil(push.job(this.env, message.job, "question")); + return { question, cancel }; + } case "output": // Past the attempt's cap the reply says `truncated`, and the job carries on. return output.append(this.env, runner, message, now); - case "result": - await store.finish(db, runner, message.job, message.attempt, message, now); + case "result": { + const state = await store.finish(db, runner, message.job, message.attempt, message, now); + // A limit waits on an account choice; a failure waits on a retry. + if (state === "waiting" || state === "failed") + this.ctx.waitUntil(push.job(this.env, message.job, state === "waiting" ? "account" : "failed")); return {}; + } } } } diff --git a/cloudflare/src/jobs/publish.ts b/cloudflare/src/jobs/publish.ts index 83aebc38..56391d42 100644 --- a/cloudflare/src/jobs/publish.ts +++ b/cloudflare/src/jobs/publish.ts @@ -102,12 +102,13 @@ export function parsePublication(body: Record): Publication { return { job, attempt, head: body.head as string | null, session, message, changes, pull }; } -type Decision = { decision: string; cancel: boolean }; +/** A pause on a decision; `asked` is false for a retry of one already asked. */ +type Decision = { decision: string; cancel: boolean; asked: boolean }; type Published = { head: string | null; pull?: { number: number; url: string; draft: boolean } }; /** Pause the attempt on a decision the maintainer must make before this can publish. */ async function decide(db: D1Database, runner: Runner, input: Publication, prompt: unknown): Promise { - const { question, cancel } = await store.ask( + const { question, cancel, asked } = await store.ask( db, runner, input.job, @@ -120,7 +121,7 @@ async function decide(db: D1Database, runner: Runner, input: Publication, prompt }, Date.now(), ); - return { decision: question, cancel }; + return { decision: question, cancel, asked }; } /** diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index b94a995d..fecb0308 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -1,5 +1,6 @@ import { type Context, Hono } from "hono"; import { createAuth } from "../auth"; +import * as push from "../push/notify"; import { GitHubError, GitHubService, refName, type Stopped } from "../github/service"; import { type ProjectState, type Pull, startable } from "../github/state"; import { RUNNER_HEADER } from "./hub"; @@ -109,10 +110,13 @@ jobs.post("/api/runner/publish", async (c) => { const length = Number(c.req.header("Content-Length")); if (!Number.isSafeInteger(length) || length > LIMITS.request) return c.json({ error: `A Content-Length of at most ${LIMITS.request} bytes is required` }, 413); - const result = await publish(c.env, runner, parsePublication(await json(c))); + const input = parsePublication(await json(c)); + const result = await publish(c.env, runner, input); if (!("decision" in result)) return c.json(result); await hub(c.env, runner.owner).dispatch(runner.owner); - return c.json(result, 202); + if (result.asked && !result.cancel) c.executionCtx.waitUntil(push.job(c.env, input.job, "publication")); + const { decision, cancel } = result; + return c.json({ decision, cancel }, 202); }); // A signed-in user registers their own machines and sees their presence. diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts index adaaa313..e5d7e24a 100644 --- a/cloudflare/src/jobs/store.ts +++ b/cloudflare/src/jobs/store.ts @@ -389,7 +389,7 @@ export async function ask( .bind(input.summary, input.session, now, job, attempt, runner.id, id), ]); if (question!.meta.changes === 1) - return { question: id, cancel: !!(update!.results[0] as { cancel: number }).cancel }; + return { question: id, cancel: !!(update!.results[0] as { cancel: number }).cancel, asked: true }; // An identical retry after the first request landed; anything else is refused. const existing = await db .prepare( @@ -407,13 +407,14 @@ export async function ask( ) .first<{ id: string; cancel: number }>(); if (!existing) throw new JobError("Attempt is no longer current"); - return { question: existing.id, cancel: !!existing.cancel }; + return { question: existing.id, cancel: !!existing.cancel, asked: false }; } /** * Finish the current attempt. An account limit pauses the job with a * decision instead of moving it to another account. A requested cancel wins - * over whatever the runner reports, keeping its partial result. + * over whatever the runner reports, keeping its partial result. Returns the + * job's new state, or null for a retry of a report that already landed. */ export async function finish( db: D1Database, @@ -447,7 +448,7 @@ export async function finish( statements.push( db .prepare( - "UPDATE jobs SET state=CASE WHEN cancel=1 THEN 'cancelled' ELSE ? END,summary=?,result=?,lease=NULL,updatedAt=? WHERE id=? AND attempt=? AND runner=? AND state='running'", + "UPDATE jobs SET state=CASE WHEN cancel=1 THEN 'cancelled' ELSE ? END,summary=?,result=?,lease=NULL,updatedAt=? WHERE id=? AND attempt=? AND runner=? AND state='running' RETURNING state", ) .bind( input.outcome === "limited" ? "waiting" : input.outcome, @@ -460,7 +461,8 @@ export async function finish( ), ); const results = await db.batch(statements); - if (results.at(-1)!.meta.changes === 1) return; + const finished = results.at(-1)!.results[0] as { state: JobState } | undefined; + if (finished) return finished.state; // An identical retry after the first report landed is accepted; anything else is refused. const landed = await db .prepare( @@ -469,6 +471,7 @@ export async function finish( .bind(job, attempt, body, stored, attempt, runner.id) .first(); if (!landed) throw new JobError("Attempt is no longer current"); + return null; } /** How long an unrecorded branch update blocks another publication of the same job. */ diff --git a/cloudflare/src/layout.tsx b/cloudflare/src/layout.tsx index 2f25a760..0a511f31 100644 --- a/cloudflare/src/layout.tsx +++ b/cloudflare/src/layout.tsx @@ -22,6 +22,8 @@ export type Page = { // The current path, where signing in returns. path: string; user: User | null; + /** The signed-in maintainer's new decisions, for the nav and the installed app's badge. */ + unread: number; crumbs?: Crumb[]; }; @@ -40,20 +42,27 @@ export function Layout(props: Page & { children: Child }) { + + - +