From 5577bb894d579befd9689798e116d0403d9b2ea3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 22:10:09 -0700 Subject: [PATCH 01/10] chore(quest): claim quest/m0/kix/prompts Co-Authored-By: Claude Opus 5.5 From d1d3094c5b2ea0167a01b5678a8a4a1c9ac9faa4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 22:32:53 -0700 Subject: [PATCH 02/10] feat(worker): make the decision inbox the signed-in home Add /inbox: one active decision thread across a maintainer's enrolled projects, coalesced per job target, with agent questions, account-limit choices, failed jobs, new pull requests, new issues, and ready quests. Define the agent prompt contract (choices, recommendation, free text, permission vs decision) and validate prompts and answers. Job routes accept inbox form posts, bind decisions to the revision on screen, and refuse stale or repeated answers. Per-maintainer read state marks changed items unread and keeps skips until an item changes. Sign-in now lands on the inbox. Co-Authored-By: Claude Opus 5.5 --- cloudflare/README.md | 33 +- cloudflare/migrations/0008_inbox.sql | 13 + cloudflare/src/inbox/model.ts | 247 +++++++++++++++ cloudflare/src/inbox/page.tsx | 458 +++++++++++++++++++++++++++ cloudflare/src/index.tsx | 83 ++++- cloudflare/src/jobs/prompt.ts | 97 ++++++ cloudflare/src/jobs/protocol.ts | 5 +- cloudflare/src/jobs/routes.ts | 77 ++++- cloudflare/src/layout.tsx | 1 + cloudflare/src/site.css | 136 ++++++++ cloudflare/test/github.test.ts | 27 +- cloudflare/test/inbox.test.ts | 279 ++++++++++++++++ cloudflare/test/jobs.test.ts | 174 ++++------ cloudflare/test/logins.test.ts | 4 +- cloudflare/test/project-fixtures.ts | 141 +++++++++ cloudflare/test/worker.test.ts | 4 +- quest/m0/kix/README.md | 1 - quest/m0/kix/activity.md | 1 - quest/m0/kix/mobile.md | 4 - quest/m0/kix/planning.md | 1 - quest/m0/kix/prompts.md | 45 --- quest/m0/theme/README.md | 1 - quest/m0/theme/landing.md | 1 - 23 files changed, 1619 insertions(+), 214 deletions(-) create mode 100644 cloudflare/migrations/0008_inbox.sql create mode 100644 cloudflare/src/inbox/model.ts create mode 100644 cloudflare/src/inbox/page.tsx create mode 100644 cloudflare/src/jobs/prompt.ts create mode 100644 cloudflare/test/inbox.test.ts create mode 100644 cloudflare/test/project-fixtures.ts delete mode 100644 quest/m0/kix/prompts.md diff --git a/cloudflare/README.md b/cloudflare/README.md index 6b58b444..1af94056 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -111,6 +111,35 @@ An account limit pauses the job with an account decision; kix never switches accounts on its own. An answer requeues the job and resumes its session on the runner that holds it. +A `question` carries one round of independent questions +(`src/jobs/prompt.ts`): `{ kind, questions: [{ header?, question, options: +[{ label, description?, recommended? }], multiple? }] }`, where `kind` is +`decision` (the default) or `permission` for a tool permission request. The +service refuses a prompt it cannot render. An agent question's answer is +`{ answers: [{ choices, text? }] }`, one per question, naming offered labels, +the maintainer's own words, or both; an account question's answer is +`{ runner, account }`. A job request may name the `revision` the maintainer +saw, and is refused once observations moved past it. + +### Decision inbox + +After signing in, a maintainer lands on `/inbox` (`src/inbox/`): one active +decision thread across the projects they enrolled, then what is up next, what +is in progress, and their projects. Each item is one job target (`issues/1`, +`pulls/2`, a quest path), so an issue, its planning job, and that job's +questions stay one item, and webhook updates and reconnects never add another +prompt. Agents waiting on an answer come first, then failed jobs, new pull +requests, new issues, and ready quests. An item offers only actions that +would work: none on a paused project, and only a refresh while observations +are incomplete. Every action is a plain form post to the job routes, which +recheck authority and return to the inbox; prompts render as escaped text and +never preselect the recommendation. + +Each maintainer's read state lives in `inbox_items`, keyed by a version of what +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. + `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. @@ -169,7 +198,9 @@ replay, and ownership. Job tests race dispatch passes, reassign a disconnected runner's job and fence its late writes, and cover routing, questions, account limits, and cancellation. They drive real WebSocket connections through the hub, including a replaced connection and the alarm that reassigns work, and check the -maintainer-only routes. No OAuth credentials or +maintainer-only routes. 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 OAuth credentials or Cloudflare account are required for checks. After changing bindings, regenerate types: diff --git a/cloudflare/migrations/0008_inbox.sql b/cloudflare/migrations/0008_inbox.sql new file mode 100644 index 00000000..4431b451 --- /dev/null +++ b/cloudflare/migrations/0008_inbox.sql @@ -0,0 +1,13 @@ +-- Each maintainer's own view of a project's decision items, keyed by the job +-- target an item coalesces on (`issues/1`, `pulls/2`, a quest path, ...). +-- A version names what the item showed: a question, a GitHub change, a quest's +-- text. A newer version is unread again; a skip lasts until the item changes. +-- Read state is navigation only, never an answer or an approval. +CREATE TABLE inbox_items ( + user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, + item TEXT NOT NULL, + seen TEXT, + skipped TEXT, + PRIMARY KEY(user, repository, item) +); diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts new file mode 100644 index 00000000..0c5a989c --- /dev/null +++ b/cloudflare/src/inbox/model.ts @@ -0,0 +1,247 @@ +// The decision inbox: everything on a maintainer's projects that waits on them, +// one item per job target. An issue, its planning job, and that job's questions +// are one item, so updates, reconnects, and duplicate webhooks coalesce instead +// of adding prompts. Agent text and GitHub data stay untrusted display data. + +import { parse } from "quest/core"; +import { GitHubError, type Enrollment, GitHubService } from "../github/service"; +import type { Activity, ProjectState } from "../github/state"; +import { type Prompt, parsePrompt } from "../jobs/prompt"; +import { hub } from "../jobs/routes"; +import * as store from "../jobs/store"; + +export type JobView = NonNullable>>; +export type QuestionView = JobView["questions"][number]; +/** A maintainer's own read state for one item. */ +export type Read = { repository: number; item: string; seen: string | null; skipped: string | null }; + +/** + * What the item asks of the maintainer: answer an agent, pick an account for + * a limited job, retry a failure, or start work on an issue, PR, or ready + * quest. `null` is work in progress, which only informs. + */ +export type Need = "answer" | "account" | "retry" | "start"; +export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell"; + +export type Item = { + project: Enrollment; + /** The job target the item coalesces on, such as `issues/1` or `quest/a.md`. */ + target: string; + kind: Kind; + title: string; + href: string; + activity: Activity | null; + /** The target's latest job. */ + job: JobView | null; + /** The job's open question, and its prompt when an agent asked it. */ + question: QuestionView | null; + prompt: Prompt | null; + need: Need | null; + /** What the item currently shows; a newer version is unread again and undoes a skip. */ + version: string; + unread: boolean; + /** Skipped by the maintainer, or finished by its job, until the item changes. */ + skipped: boolean; + at: number; +}; + +/** One project's place in the inbox, or why it is unavailable. */ +export type ProjectView = { project: Enrollment; state: ProjectState | null; error: string | null }; + +// FNV-1a: a short, stable version for a quest's text. +function digest(text: string) { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193); + return (hash >>> 0).toString(16); +} + +function kindOf(target: string): Kind { + if (target.startsWith("issues/")) return "issue"; + if (target.startsWith("pulls/")) return "pull"; + if (target.startsWith("plans/")) return "plan"; + return target === "devshell" ? "devshell" : "quest"; +} + +/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */ +export function questHref(project: Enrollment, path: string) { + return `/projects/github/${project.owner}/${project.name}/board/${path.replace(/(\/README)?\.md$/, "")}`; +} + +type Entity = Pick; + +/** The open issues, open pull requests, and ready quests a maintainer can act on. */ +function entities(project: Enrollment, state: ProjectState | null) { + const found = new Map(); + if (!state) return found; + const web = `https://github.com/${project.owner}/${project.name}`; + for (const activity of state.activity) { + // A webhook's pointer has no data until a refresh reads it, but it is already news. + if (activity.missing || (activity.data && activity.data.state !== "OPEN")) continue; + const pull = activity.kind === "pull"; + found.set(`${pull ? "pulls" : "issues"}/${activity.number}`, { + kind: activity.kind, + title: activity.data?.title ?? `${pull ? "Pull request" : "Issue"} #${activity.number}`, + href: `${web}/${pull ? "pull" : "issues"}/${activity.number}`, + activity, + version: String(activity.changedAt), + at: activity.changedAt, + }); + } + for (const path of state.available) { + const content = state.project.documents.find((d) => d.path === path)?.content ?? ""; + found.set(path, { + kind: "quest", + title: parse(path, content).title?.text ?? path, + href: questHref(project, path), + activity: null, + version: digest(content), + at: 0, + }); + } + return found; +} + +function fallback(project: Enrollment, target: string): Entity { + const web = `https://github.com/${project.owner}/${project.name}`; + const kind = kindOf(target); + const number = target.split("/")[1]; + const titles: Record = { + issue: [`Issue #${number}`, `${web}/issues/${number}`], + pull: [`Pull request #${number}`, `${web}/pull/${number}`], + plan: ["New plan", web], + devshell: ["Add a flake devShell", web], + quest: [target, questHref(project, target)], + }; + const [title, href] = titles[kind]; + return { kind, title, href, activity: null, version: "", at: 0 }; +} + +/** Build one project's items from its observations, its latest jobs, and the maintainer's read state. */ +export function decisions(view: ProjectView, jobs: JobView[], reads: Read[]): Item[] { + const { project, state } = view; + const latest = new Map(); + // Jobs arrive newest first. + for (const job of jobs) if (!latest.has(job.target)) latest.set(job.target, job); + const found = entities(project, state); + const items: Item[] = []; + for (const target of new Set([...found.keys(), ...latest.keys()])) { + const entity = found.get(target); + const job = latest.get(target) ?? null; + // GitHub showed a change after the job ended, such as a new push or comment. + const changed = !!entity && !!job && entity.at > job.updatedAt; + let need: Need | null = null; + let version = entity?.version ?? ""; + let question: QuestionView | null = null; + let ended = false; + if (job && (job.state === "queued" || job.state === "running")) version = `job:${job.id}`; + else if (job?.state === "waiting") { + question = job.questions.find((q) => q.attempt === job.attempt && q.answer === null) ?? null; + if (question) need = question.kind === "account" ? "account" : "answer"; + version = `question:${question?.id}`; + } else if (job?.state === "failed" && !changed) { + need = "retry"; + version = `job:${job.id}`; + } else if (entity) { + // Starting new work on a paused project would be refused, so it is not offered. + if (project.state !== "enabled") continue; + need = "start"; + ended = !!job && !changed; + } else continue; + const read = reads.find((r) => r.repository === project.id && r.item === target); + let prompt: Prompt | null = null; + if (question?.kind === "agent") + try { + prompt = parsePrompt(question.prompt); + } catch {} + items.push({ + ...(entity ?? fallback(project, target)), + project, + target, + job, + question, + prompt, + need, + version, + unread: need !== null && read?.seen !== version, + skipped: need !== null && (ended || read?.skipped === version), + at: question?.askedAt ?? (need === "retry" ? job!.updatedAt : (entity?.at ?? job?.updatedAt ?? 0)), + }); + } + return items; +} + +// Agents blocked on an answer come first, then failures, then new PRs, +// issues, and ready quests: the loop of reviewing, planning, and spawning. +const order = (item: Item) => + item.need === "answer" || item.need === "account" + ? 0 + : item.need === "retry" + ? 1 + : ({ pull: 2, issue: 3, quest: 4, plan: 5, devshell: 5 } as const)[item.kind]; + +/** Sort the items: decisions in priority order, then work in progress, then what was set aside. */ +export function arrange(items: Item[]) { + const sorted = [...items].sort((a, b) => order(a) - order(b) || (order(a) === 0 ? a.at - b.at : b.at - a.at)); + return { + decisions: sorted.filter((item) => item.need !== null && !item.skipped), + working: sorted.filter((item) => item.need === null), + skipped: sorted.filter((item) => item.skipped), + }; +} + +export type Runners = Awaited>; + +/** + * Load the inbox across the projects a maintainer enrolled. Every project + * rechecks current GitHub authority; one that fails is reported, not hidden. + */ +export async function load(env: Env, user: string) { + const github = new GitHubService(env); + const projects: ProjectView[] = await Promise.all( + (await github.projects(user)).map(async (project) => { + try { + return { project, state: await github.projectState(project, user), error: null }; + } catch (error) { + if (!(error instanceof GitHubError)) throw error; + return { project, state: null, error: error.message }; + } + }), + ); + const usable = projects.filter((view) => !view.error); + 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) => store.listJobs(env.DB, view.project.id))), + ]); + const items = usable.flatMap((view, i) => decisions(view, jobs[i]!, reads.results)); + // 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( + await Promise.all( + actors.map( + async (actor) => + [actor, await store.listRunners(env.DB, actor, new Set(await hub(env, actor).online()))] as const, + ), + ), + ); + return { projects, items, runners }; +} + +/** Record that the maintainer saw an item as it is now. */ +export function markSeen(db: D1Database, user: string, item: Item) { + return db + .prepare( + "INSERT INTO inbox_items(user,repository,item,seen) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET seen=excluded.seen", + ) + .bind(user, item.project.id, item.target, item.version) + .run(); +} + +/** Set an item aside until it changes. */ +export function skip(db: D1Database, user: string, repository: number, item: string, version: string) { + return db + .prepare( + "INSERT INTO inbox_items(user,repository,item,skipped) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET skipped=excluded.skipped", + ) + .bind(user, repository, item, version) + .run(); +} diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx new file mode 100644 index 00000000..25a87935 --- /dev/null +++ b/cloudflare/src/inbox/page.tsx @@ -0,0 +1,458 @@ +import type { Pull } from "../github/state"; +import { type Prompt, parsePrompt } from "../jobs/prompt"; +import { inboxURL } from "../jobs/routes"; +import { Marker } from "../layout"; +import { projectURL } from "../projects"; +import type { Item, ProjectView, QuestionView, Runners } from "./model"; + +// The decision inbox, phone first: one active decision thread, then what is +// up next, what is in progress, and the projects as secondary views. Plain +// forms post every action, so it works by keyboard and without JavaScript. + +export type InboxProps = { + user: string; + projects: ProjectView[]; + active: Item | null; + /** The requested item is gone, such as a question another maintainer answered. */ + missing: boolean; + decisions: Item[]; + working: Item[]; + skipped: Item[]; + runners: Map; +}; + +const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" }; + +function needLabel(item: Item) { + if (item.need === "answer") + return item.prompt?.kind === "permission" ? "Tool permission request" : "Waiting for your decision"; + if (item.need === "account") return "Account limited"; + if (item.need === "retry") return "Failed"; + if (item.need === "start") + return item.skipped && item.job ? `${kinds[item.kind]} · job ${item.job.state}` : kinds[item.kind]; + return item.job?.state === "queued" ? "Queued" : "Working"; +} + +/** Plainly what the item's job is doing, with the next useful action where there is one. */ +function progress(item: Item, runners: Runners, user: string) { + const job = item.job; + if (!job || item.need === "start") return null; + const whose = job.actor === user ? "your" : "the requester's"; + if (job.state === "queued") + return runners.some((r) => r.online) + ? "Queued for the next free runner." + : `Waiting for a runner: none of ${whose} runners are online. Run quest runner on one of them.`; + if (job.state === "running") { + if (job.cancel) return "Stopping as requested."; + const runner = runners.find((r) => r.id === job.runner); + if (job.lease !== null || !runner?.online) + return "Runner offline. The job moves to another runner if it stays away for two minutes."; + return `Working on ${runner.name}.`; + } + if (item.need === "account") + return "The job's account hit its limit. Choose an account to continue; kix never switches accounts on its own."; + if (item.need === "retry") return "The job failed. Its summary says what happened."; + return null; +} + +function Context(props: { item: Item }) { + const data = props.item.activity?.data; + if (!data) return null; + const pull = props.item.kind === "pull" ? (data as Pull) : null; + return ( + + ); +} + +function parsed(question: QuestionView): Prompt | null { + try { + return parsePrompt(question.prompt); + } catch { + return null; + } +} + +/** The thread's submitted answers, kept across refreshes and reconnects. */ +function History(props: { item: Item }) { + const answered = props.item.job?.questions.filter((q) => q.answer !== null) ?? []; + if (!answered.length) return null; + return ( +
+ Earlier answers ({answered.length}) +
    + {answered.map((q) => { + if (q.kind === "account") { + return
  1. Chose an account to continue on.
  2. ; + } + const prompt = parsed(q); + const replies = (q.answer as { answers?: { choices: string[]; text?: string }[] }).answers ?? []; + return ( +
  3. + {prompt?.questions.map((ask, i) => ( +

    + {ask.question} +
    + {[ + ...(replies[i]?.choices ?? []), + ...(replies[i]?.text ? [replies[i]!.text] : []), + ].join(" · ")} +

    + ))} +
  4. + ); + })} +
+
+ ); +} + +function jobsURL(item: Item) { + return `${projectURL(item.project.owner, item.project.name)}/jobs`; +} + +function AgentForm(props: { item: Item; prompt: Prompt }) { + const { item, prompt } = props; + return ( +
+ + {prompt.kind === "permission" && ( +

+ The agent's tool asks for permission. This is not a product decision about the work. +

+ )} + {prompt.questions.map((ask, i) => ( +
+ + {ask.header && {ask.header}} + {ask.question} + + {ask.options.map((option, j) => ( + + ))} + +