diff --git a/cloudflare/README.md b/cloudflare/README.md index ad3e9b71..75a65771 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -111,6 +111,16 @@ 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. + The current attempt publishes its work over HTTPS at `POST /api/runner/publish`, not over its connection, so slow GitHub writes never hold up the hub. It sends file changes as data; the Worker commits them as the GitHub App to the job's @@ -150,6 +160,30 @@ prefix at once. The cron retries any deletion that failed. An upload racing the disconnect finds its index row gone, queues its job's prefix for the cron again, and deletes its own object. +### 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. A `publication` decision lists the refused +paths or why the branch is blocked, and takes the maintainer's reply as text. +The inbox reads each target's latest job however old it is: every one that +awaits a decision, and any other whose issue, pull request, or quest is still +observed, so finished work stays set aside. Retrying a failed planning +interview keeps its `plans/` target. + +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. @@ -212,8 +246,10 @@ maintainer-only routes. Publication tests run against a stateful fake of GitHub's Git and pull request APIs: fenced and stale writes, workflow path 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. No OAuth credentials or -Cloudflare account are required for checks. +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 +OAuth credentials or Cloudflare account are required for checks. After changing bindings, regenerate types: diff --git a/cloudflare/migrations/0010_inbox.sql b/cloudflare/migrations/0010_inbox.sql new file mode 100644 index 00000000..943bcd1e --- /dev/null +++ b/cloudflare/migrations/0010_inbox.sql @@ -0,0 +1,16 @@ +-- 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) +); + +-- The inbox reads each target's latest job. +CREATE INDEX jobs_target ON jobs(repository, target, createdAt); diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts index 6119b22f..b066fdee 100644 --- a/cloudflare/src/board/model.ts +++ b/cloudflare/src/board/model.ts @@ -88,7 +88,7 @@ export function blobHref(project: Project, path: string, fragment = ""): string return project.web ? `${project.web}/blob/${project.commit}/${encodePath(path)}${fragment}` : null; } -function encodePath(path: string): string { +export function encodePath(path: string): string { return path.split("/").map(encodeURIComponent).join("/"); } diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts new file mode 100644 index 00000000..3bb9baa8 --- /dev/null +++ b/cloudflare/src/inbox/model.ts @@ -0,0 +1,252 @@ +// 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 { encodePath } from "../board/model"; +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"; +import { projectURL } from "../projects"; + +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 `${projectURL(project.owner, project.name)}/board/${encodePath(path.replace(/(\/README)?\.md$/, ""))}`; +} + +export type Entity = Pick; + +/** The open issues, open pull requests, and ready quests a maintainer can act on, by job target. */ +export 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 observed `found` entities, its latest + * jobs, and the maintainer's read state. + */ +export function decisions(view: ProjectView, found: Map, jobs: JobView[], reads: Read[]): Item[] { + const { project } = 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 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 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)); + // 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..982b6256 --- /dev/null +++ b/cloudflare/src/inbox/page.tsx @@ -0,0 +1,509 @@ +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 { 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") { + if (item.question?.kind === "publication") return "Publishing needs your decision"; + 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; +} + +// GitHub's enums, such as CHANGES_REQUESTED, read as plain words. +const words = (value: string) => value.toLowerCase().replaceAll("_", " "); + +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. ; + } + if (q.kind === "publication") return
  3. {typeof q.answer === "string" ? q.answer : ""}
  4. ; + const prompt = parsed(q); + const replies = (q.answer as { answers?: { choices: string[]; text?: string }[] }).answers ?? []; + return ( +
  5. + {prompt?.questions.map((ask, i) => ( +

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

    + ))} +
  6. + ); + })} +
+
+ ); +} + +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) => ( + + ))} + +