Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion cloudflare/src/inbox/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, Runners>;
};

const recorded: Record<Done, string> = {
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) {
Expand Down Expand Up @@ -477,6 +487,11 @@ export function InboxPage(props: InboxProps) {
? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.`
: "Nothing needs a decision right now."}
</p>
{props.done && Object.hasOwn(recorded, props.done) && (
<p class="note" role="status">
{recorded[props.done as Done]}
</p>
)}
{props.missing && (
<p class="note" role="status">
That decision is no longer open. It may have been answered or changed on GitHub.
Expand Down
28 changes: 22 additions & 6 deletions cloudflare/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand All @@ -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.
Expand All @@ -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}
Expand Down Expand Up @@ -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" };
Comment on lines +385 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the active item in refusal links

When an action is submitted from the normal active thread, its Referer is /inbox or the newly introduced /inbox?done=..., neither of which contains project or item. The helper therefore falls through to the generic inbox link rather than linking to the item that produced the refusal; if inbox ordering changes before the user returns, they can land on a different decision. Preserve the active item's identity in the redirect/form instead of requiring these query parameters in the referrer.

Useful? React with 👍 / 👎.

} 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." },
<section class="wrap docs">
<h1>Not recorded</h1>
<h1>Couldn't do that</h1>
<p role="alert">{message}</p>
{bootstrap && (
<form method="post" action={c.req.path}>
Expand All @@ -393,7 +409,7 @@ function refused(c: Context<{ Bindings: Env }>, message: string, bootstrap: bool
</form>
)}
<p>
<a href="/inbox">Return to your decisions</a>.
<a href={to.href}>{to.label}</a>.
</p>
</section>,
status,
Expand Down
27 changes: 15 additions & 12 deletions cloudflare/src/jobs/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,23 @@ async function input(c: Context): Promise<{ body: Record<string, unknown>; 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 {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 25 additions & 7 deletions cloudflare/test/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ async function inbox(query: Record<string, string> = {}) {
function thread(html: string) {
return html.match(/<article class="ql-panel decision"[\s\S]*?<\/article>/)?.[0] ?? "";
}
function submit(path: string, fields: [string, string][]) {
function submit(path: string, fields: [string, string][], headers: Record<string, string> = {}) {
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,
);
}
Expand Down Expand Up @@ -135,14 +135,15 @@ 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(/<h2>In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toContain("Fix");
expect(thread(html)).toContain("none of your runners are online");
expect(thread(html)).toContain("Stop this job");
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/);
Expand All @@ -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("<h1>Couldn&#39;t do that</h1>");
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('<a href="/inbox?project=10&amp;item=pulls%2F2">Return to the item</a>');
// Each click is one request bound to its head; a refusal is never retried.
expect(github.merged).toHaveLength(2);
github.refuse = null;
Expand All @@ -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();
Expand Down Expand Up @@ -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('<p class="note" role="status">Answer recorded.</p>');
expect(thread(next)).toContain("Fix");
expect(next.match(/<h2>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({
Expand Down Expand Up @@ -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(/<h2>Up next<\/h2>[\s\S]*?<\/ol>/)![0]).not.toContain("Idea");
expect(html).toMatch(/Skipped and finished \(1\)[\s\S]*Idea/);
Expand All @@ -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('<p class="note" role="status">');
});

it("keeps paused projects' questions but offers no new work", async () => {
Expand Down
1 change: 0 additions & 1 deletion quest/m0/kix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 0 additions & 20 deletions quest/m0/kix/answer-flow.md

This file was deleted.

Loading