diff --git a/cloudflare/README.md b/cloudflare/README.md index 5fb87f75..802d4182 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -173,8 +173,11 @@ 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 availability is pending. 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 +recheck authority and move on to the next active decision (or the empty +state) with a short notice such as "Answer recorded"; the item acted on stays +reachable from its list with its earlier answers. A refused action says why +and links back to its item. 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 diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index 7b9a16ff..06c675e0 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -1,6 +1,6 @@ import type { Pull } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; -import { PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; +import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; import { Marker } from "../layout"; import { projectURL } from "../projects"; import type { Item, ProjectView, QuestionView, Runners } from "./model"; @@ -15,12 +15,22 @@ export type InboxProps = { active: Item | null; /** The requested item is gone, such as a question another maintainer answered. */ missing: boolean; + /** What the previous form recorded, from the redirect's query. */ + done: string | undefined; decisions: Item[]; working: Item[]; skipped: Item[]; runners: Map; }; +const recorded: Record = { + answer: "Answer recorded.", + start: "Job requested.", + stop: "Stop requested.", + skip: "Skipped until it changes.", + merge: "Pull request merged.", +}; + const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" }; function needLabel(item: Item) { @@ -477,6 +487,11 @@ export function InboxPage(props: InboxProps) { ? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.` : "Nothing needs a decision right now."}

+ {props.done && Object.hasOwn(recorded, props.done) && ( +

+ {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 22078cfd..2cbbcfb5 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -15,7 +15,7 @@ import { githubWebhooks } from "./github/webhook"; import * as inbox from "./inbox/model"; import { InboxPage } from "./inbox/page"; import { purge, retain } from "./jobs/output"; -import { BootstrapRequired, fromForm, jobs, resume, stop } from "./jobs/routes"; +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"; @@ -230,7 +230,7 @@ app.post("/projects/github/:owner/:repository/skip", async (c) => { if (typeof item !== "string" || typeof version !== "string" || item.length > 300 || version.length > 100) return c.json({ error: "Item and version required" }, 400); await inbox.skip(c.env.DB, session.user.id, repo.id, item, version); - return c.redirect("/inbox", 303); + return c.redirect(nextURL("skip"), 303); }); // A merge is the maintainer's own click on the head they saw; GitHub's rules decide whether it lands. app.post("/projects/github/:owner/:repository/pulls/:number/merge", async (c) => { @@ -250,7 +250,7 @@ app.post("/projects/github/:owner/:repository/pulls/:number/merge", async (c) => // The merged item is finished for this maintainer until GitHub's next observation drops it. if (typeof version === "string" && version.length <= 100) await inbox.skip(c.env.DB, session.user.id, repo.id, target, version); - return c.redirect("/inbox", 303); + return c.redirect(nextURL("merge"), 303); }); // The signed-in home: one decision at a time across the maintainer's projects. @@ -273,6 +273,7 @@ app.get("/inbox", async (c) => { projects={projects} active={active} missing={!!target && !chosen} + done={c.req.query("done")} decisions={decisions} working={working} skipped={skipped} @@ -377,12 +378,27 @@ app.notFound((c) => ), ); +/** The inbox item a refused form was posted from, or the inbox itself. */ +function back(c: Context<{ Bindings: Env }>) { + try { + const from = new URL(c.req.header("Referer") ?? ""); + const project = from.searchParams.get("project"); + const item = from.searchParams.get("item"); + const here = from.origin === new URL(c.env.AUTH_URL).origin && from.pathname === "/inbox"; + if (here && /^\d{1,15}$/.test(project ?? "") && item) + return { href: inboxURL(Number(project), item), label: "Return to the item" }; + } catch {} + return { href: "/inbox", label: "Return to your decisions" }; +} + +// Any action can be refused, not only an answer, so the page names none. function refused(c: Context<{ Bindings: Env }>, message: string, bootstrap: boolean, status: 403 | 409) { + const to = back(c); return page( c, - { title: "Not recorded", description: "The decision was not recorded." }, + { title: "Couldn't do that", description: "The request was refused." },

-

Not recorded

+

Couldn't do that

{message}

{bootstrap && (
@@ -393,7 +409,7 @@ function refused(c: Context<{ Bindings: Env }>, message: string, bootstrap: bool
)}

- Return to your decisions. + {to.label}.

, status, diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index 29842a7e..3df49478 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -33,20 +33,23 @@ async function input(c: Context): Promise<{ body: Record; form: const body = Object.fromEntries(Object.entries(form).filter(([, v]) => typeof v === "string" && v !== "")); return { body, form }; } -/** Answer the JSON API, or return a form to the decision it acted on. */ -function reply( - c: Context, - form: Form | null, - item: { repository: number; target: string }, - body: unknown, - status: 200 | 201 = 200, -) { - if (form) return c.redirect(inboxURL(item.repository, item.target), 303); +/** Answer the JSON API, or move a form on to the next decision. */ +function reply(c: Context, form: Form | null, done: Done, body: unknown, status: 200 | 201 = 200) { + if (form) return c.redirect(nextURL(done), 303); return body === null ? c.body(null, 204) : c.json(body, status); } export function inboxURL(repository: number, target: string) { return `/inbox?${new URLSearchParams({ project: String(repository), item: target })}`; } +/** What an inbox form just recorded, which the next decision's page notes. */ +export type Done = "answer" | "start" | "stop" | "skip" | "merge"; +/** + * The inbox's next active decision, or its empty state, after a form recorded + * one. The item acted on stays reachable from its list, with its earlier answers. + */ +export function nextURL(done: Done) { + return `/inbox?${new URLSearchParams({ done })}`; +} /** The default branch has no flake for the job's devShell; a bootstrap job can add one. */ export class BootstrapRequired extends JobError { @@ -252,7 +255,7 @@ jobs.post(base, async (c) => { Date.now(), ); await hub(c.env, user).dispatch(user); - return reply(c, form, { repository: repo.id, target: job.target }, created, created.created ? 201 : 200); + return reply(c, form, "start", created, created.created ? 201 : 200); }); jobs.post(`${base}/:job/answer`, async (c) => { @@ -276,7 +279,7 @@ jobs.post(`${base}/:job/answer`, async (c) => { ); // Jobs run on their requester's runners, whoever answers. await hub(c.env, job.actor).dispatch(job.actor); - return reply(c, form, job, null); + return reply(c, form, "answer", null); }); /** The longest reply to a publication decision, in characters as the inbox form counts them. */ @@ -306,7 +309,7 @@ jobs.post(`${base}/:job/cancel`, async (c) => { const { user, job } = await owned(c); await store.cancel(c.env.DB, job.id, user, Date.now()); await hub(c.env, job.actor).dispatch(job.actor); - return reply(c, fromForm(c) ? {} : null, job, null); + return reply(c, fromForm(c) ? {} : null, "stop", null); }); // Raw output stays behind a per-request maintainer check; summaries are the default view. diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 021e3a04..5474615b 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -39,10 +39,10 @@ async function inbox(query: Record = {}) { function thread(html: string) { return html.match(/
/)?.[0] ?? ""; } -function submit(path: string, fields: [string, string][]) { +function submit(path: string, fields: [string, string][], headers: Record = {}) { return app.request( `${origin}${path}`, - { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) }, + { method: "POST", headers: { Cookie: cookie, Origin: origin, ...headers }, body: new URLSearchParams(fields) }, testEnv, ); } @@ -135,7 +135,7 @@ describe("decision inbox", () => { ["revision", head], ]); expect(started.status).toBe(303); - expect(started.headers.get("Location")).toBe("/inbox?project=10&item=pulls%2F2"); + expect(started.headers.get("Location")).toBe("/inbox?done=start"); const html = await inbox({ project: "10", item: "pulls/2" }); expect(html.match(/

In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toContain("Fix"); expect(thread(html)).toContain("none of your runners are online"); @@ -143,6 +143,7 @@ describe("decision inbox", () => { const [job] = await store.listJobs(db, 10); const stopped = await submit(`${jobs}/${job!.id}/cancel`, []); expect(stopped.status).toBe(303); + expect(stopped.headers.get("Location")).toBe("/inbox?done=stop"); expect((await store.readJob(db, job!.id))!.state).toBe("cancelled"); // A stopped job sets its item aside until the pull request changes. expect(await inbox()).toMatch(/Skipped and finished \(1\)[\s\S]*Fix/); @@ -158,11 +159,18 @@ describe("decision inbox", () => { expect(moved.status).toBe(403); expect(await moved.text()).toContain("head moved since you looked; review it again"); github.refuse = "At least 1 approving review is required by reviewers with write access."; - const refused = await submit(merge, [["head", head]]); + const refused = await submit(merge, [["head", head]], { + Referer: `${origin}/inbox?project=10&item=pulls%2F2`, + }); expect(refused.status).toBe(403); - expect(await refused.text()).toContain( + // Any refused action reads neutrally, says why, and leads back to its item. + const explained = await refused.text(); + expect(explained).toContain("

Couldn't do that

"); + expect(explained).not.toContain("Not recorded"); + expect(explained).toContain( "GitHub refused the merge: At least 1 approving review is required by reviewers with write access.", ); + expect(explained).toContain('Return to the item'); // Each click is one request bound to its head; a refusal is never retried. expect(github.merged).toHaveLength(2); github.refuse = null; @@ -172,7 +180,7 @@ describe("decision inbox", () => { ["version", "0"], ]); expect(merged.status).toBe(303); - expect(merged.headers.get("Location")).toBe("/inbox"); + expect(merged.headers.get("Location")).toBe("/inbox?done=merge"); expect(github.merged.at(-1)).toEqual({ sha: head, merge_method: "squash" }); // The merged item is finished, and no new work starts before GitHub is read again. const html = await inbox(); @@ -268,6 +276,12 @@ describe("decision inbox", () => { ).toBe(409); const answered = await submit(`${jobs}/${id}/answer`, answer); expect(answered.status).toBe(303); + // The inbox moves on to the next decision and notes the answer there. + expect(answered.headers.get("Location")).toBe("/inbox?done=answer"); + const next = await inbox({ done: "answer" }); + expect(next).toContain('

Answer recorded.

'); + expect(thread(next)).toContain("Fix"); + expect(next.match(/

In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toContain("First"); const job = (await store.readJob(db, id))!; expect(job.state).toBe("queued"); expect(job.questions[0]!.answer).toEqual({ @@ -452,6 +466,7 @@ describe("decision inbox", () => { ["version", "0"], ]); expect(skipped.status).toBe(303); + expect(skipped.headers.get("Location")).toBe("/inbox?done=skip"); let html = await inbox(); expect(html.match(/

Up next<\/h2>[\s\S]*?<\/ol>/)![0]).not.toContain("Idea"); expect(html).toMatch(/Skipped and finished \(1\)[\s\S]*Idea/); @@ -462,10 +477,13 @@ describe("decision inbox", () => { it("reports a project without current authority and hides its items", async () => { github.role = "write"; - const html = await inbox(); + const html = await inbox({ done: "answer" }); expect(html).toContain("Unavailable: Current GitHub maintainer authority required"); expect(html).not.toContain("Idea"); + // With no decision left, the notice shows on the empty state. + expect(html).toContain("Answer recorded."); expect(html).toContain("Nothing needs a decision right now."); + expect(await inbox({ done: "constructor" })).not.toContain('

'); }); it("keeps paused projects' questions but offers no new work", async () => { diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 6fc6af67..2020c36b 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -104,7 +104,6 @@ operator-paid-hosting decisions: - [Webhook reconcile](/quest/m0/kix/reconcile.md) - new issues and PRs arrive in the inbox ready to act on, without a manual refresh - [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR - [Co-maintainer inbox](/quest/m0/kix/members.md) - every current maintainer sees enrolled projects in their inbox -- [Answer flow](/quest/m0/kix/answer-flow.md) - move to the next decision after an answer, with neutral refusals - [Retry from the branch](/quest/m0/kix/retry.md) - a retried job continues from the head its failed attempt published - [Decision paper trail](/quest/m0/kix/paper-trail.md) - planning PRs carry the recorded questions and answers, rendered by the service - [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries diff --git a/quest/m0/kix/answer-flow.md b/quest/m0/kix/answer-flow.md deleted file mode 100644 index 8497c1d6..00000000 --- a/quest/m0/kix/answer-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# [XS] Move to the next decision after an answer - -## Goal - -After a maintainer answers a decision in the inbox, the page moves on to the -next active decision with a short "Answer recorded" notice. When an action is -refused, the page has a neutral title and says why, whatever the action was. - -## Plan - -Decided 2026-10-19 as a follow-up of the decision inbox (#144), which stays -on the item just answered, and titles every refusal "Not recorded" even for -a refused "Refresh from GitHub": - -- Redirect to the next active decision, or to the inbox's empty state, and - show the notice there. The answered item stays reachable under its - earlier answers. -- Retitle the refused-action page neutrally (for example "Couldn't do - that"), with the specific reason and a link back to the item. -- Keep the form posts working without JavaScript. Update the inbox tests.