From 89638dc2edbb3e540e4b9d229918f5835e37a2c2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:21:50 -0700 Subject: [PATCH 1/7] chore(quest): claim quest/m0/kix/mobile.md Co-Authored-By: Claude Opus 5.5 From 8a1a9907c551515564fd7d0880484768c28d3a96 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:44:50 -0700 Subject: [PATCH 2/7] feat(kix): install as a phone app with decision push notifications kix.quest installs as a PWA with a web app manifest, app icons, and a service worker. Maintainers turn on Web Push per device from /inbox or /account; only decisions push (agent questions, publication decisions, account limits, failed jobs, new issues, PRs ready for review), once each, coalesced per item by notification tag and push Topic, and a tap opens the item in the inbox. Subscriptions live in D1 per user and session, are deleted on sign-out and when the push service reports them gone. RFC 8291 encryption and VAPID signing use WebCrypto directly. The nav and the installed app's icon show the unread decision count, and the phone nav is tighter. Co-Authored-By: Claude Opus 5.5 --- cloudflare/.dev.vars.example | 6 + cloudflare/README.md | 58 ++++- cloudflare/migrations/0011_push.sql | 16 ++ cloudflare/scripts/build.ts | 7 +- cloudflare/scripts/vapid.ts | 9 + cloudflare/src/account.tsx | 22 ++ cloudflare/src/app.js | 82 +++++++ cloudflare/src/env.d.ts | 9 +- cloudflare/src/github/webhook.ts | 3 + cloudflare/src/inbox/model.ts | 44 +++- cloudflare/src/inbox/page.tsx | 5 +- cloudflare/src/index.tsx | 28 ++- cloudflare/src/jobs/hub.ts | 23 +- cloudflare/src/jobs/publish.ts | 7 +- cloudflare/src/jobs/routes.ts | 8 +- cloudflare/src/jobs/store.ts | 13 +- cloudflare/src/layout.tsx | 34 ++- cloudflare/src/manifest.webmanifest | 16 ++ cloudflare/src/push/notify.ts | 233 ++++++++++++++++++++ cloudflare/src/push/webpush.ts | 150 +++++++++++++ cloudflare/src/site.css | 51 +++++ cloudflare/src/sw.js | 66 ++++++ cloudflare/test/env.d.ts | 2 + cloudflare/test/jobs.test.ts | 48 ++++- cloudflare/test/project-fixtures.ts | 8 +- cloudflare/test/publish.test.ts | 9 +- cloudflare/test/push.test.ts | 312 +++++++++++++++++++++++++++ cloudflare/vitest.config.ts | 4 + cloudflare/worker-configuration.d.ts | 5 +- cloudflare/wrangler.jsonc | 9 +- design/theme.md | 12 ++ design/theme/icon-192.png | Bin 0 -> 1360 bytes design/theme/icon-512.png | Bin 0 -> 4147 bytes design/theme/icon.svg | 9 + quest/m0/kix/README.md | 1 - quest/m0/kix/guide.md | 1 - quest/m0/kix/mobile.md | 44 ---- quest/m1/mobile-app.md | 6 +- 38 files changed, 1259 insertions(+), 101 deletions(-) create mode 100644 cloudflare/migrations/0011_push.sql create mode 100644 cloudflare/scripts/vapid.ts create mode 100644 cloudflare/src/app.js create mode 100644 cloudflare/src/manifest.webmanifest create mode 100644 cloudflare/src/push/notify.ts create mode 100644 cloudflare/src/push/webpush.ts create mode 100644 cloudflare/src/sw.js create mode 100644 cloudflare/test/push.test.ts create mode 100644 design/theme/icon-192.png create mode 100644 design/theme/icon-512.png create mode 100644 design/theme/icon.svg delete mode 100644 quest/m0/kix/mobile.md 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 75a65771..2028d7e8 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 @@ -184,6 +187,42 @@ 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 a push service reporting one gone +(404 or 410) deletes it. + +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 everyone whose inbox shows the project. 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. @@ -248,7 +287,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: @@ -297,7 +338,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/migrations/0011_push.sql b/cloudflare/migrations/0011_push.sql new file mode 100644 index 00000000..eda18cae --- /dev/null +++ b/cloudflare/migrations/0011_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..47b6839d --- /dev/null +++ b/cloudflare/src/app.js @@ -0,0 +1,82 @@ +// 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), + }); + if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || "Request failed."); +} + +function key(base64url) { + const text = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); + return Uint8Array.from(text, (c) => c.charCodeAt(0)); +} + +for (const control of document.querySelectorAll("[data-push]")) { + 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 = !!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; + const subscription = await ( + await registration + ).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) }); + await post("/push/subscribe", subscription.toJSON()); + 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 }); + await subscription.unsubscribe(); + } + return "Notifications are off for this device."; + }), + ); + show().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/webhook.ts b/cloudflare/src/github/webhook.ts index 717653d1..9c64436c 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"; export function positiveId(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; @@ -168,5 +169,7 @@ githubWebhooks.post("/webhooks/github", async (c) => { } // Observations never approve or launch work. Trusted consumers reconcile // current GitHub state rather than treating delayed webhook bodies as facts. + // A new decision item still notifies, once, from the first delivery. + if (observed.has(event)) c.executionCtx.waitUntil(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 3bb9baa8..e7404fc0 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 } 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"; @@ -195,6 +195,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 a maintainer enrolled. Every project * rechecks current GitHub authority; one that fails is reported, not hidden. @@ -211,13 +241,11 @@ export async function load(env: Env, user: string) { } }), ); - 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 982b6256..13cd9c8f 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -1,7 +1,7 @@ import type { Pull } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { 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"; @@ -19,6 +19,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 kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" }; @@ -455,6 +457,7 @@ export function InboxPage(props: InboxProps) { ? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.` : "Nothing needs a decision right now."}

+ {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 21eb839f..ecf6fb69 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -18,6 +18,7 @@ import { purge, retain } from "./jobs/output"; import { BootstrapRequired, fromForm, jobs, resume, stop } from "./jobs/routes"; import { JobError } 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, @@ -244,8 +247,7 @@ 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); - return page( + const response = await page( c, { title: "Decisions", description: "Your decision inbox: questions, issues, pull requests, and ready quests." }, { working={working} skipped={skipped} runners={runners} + notifications={push.keys(c.env)?.publicKey ?? null} />, ); + // Seen once shown, so the nav's count agrees with the page that shows it. + if (active?.need) await inbox.markSeen(c.env.DB, user, active); + return response; }); app.get("/projects/github/:owner/:repository/board", async (c) => { const state = await liveProject(c); @@ -451,11 +457,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, "/"); }); @@ -463,10 +473,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) => ( @@ -475,6 +487,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 26fa9414..4eb74382 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, Pull } from "../github/state"; import { RUNNER_HEADER } from "./hub"; @@ -106,10 +107,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 1b852b07..b2ed7ae0 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 }) { + + - +