diff --git a/cloudflare/README.md b/cloudflare/README.md index 9de6305d..c531f753 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -261,6 +261,33 @@ 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. +A pull request someone holds is in progress with no actions, and names its +holder: the author of a draft without a `## Waiting` (or older `## Decisions +needed`) section, or the `quest:*` label a session holds it with. Such a draft +quiet for a week becomes a "quiet draft" decision offering to take it over +with an iterate job, or to skip it until it changes; a labeled hold is never +offered, since its session or job releases it. Every other open pull request +offers merge, iterate, and skip, and a draft handed back with `## Waiting` +also offers "Mark ready for review" (`POST +/projects/github///pulls//ready` with the `head` on screen), +which the App does once no job of that pull request is active. + +Each project's "Start ready quests on your runners" box opts the viewer in to +starting its ready quests without a click, optionally only in some milestones +(`POST /projects/github///autostart`, stored in `autostart`; +`src/jobs/autostart.ts`). Whenever a dispatch pass leaves one of the +opted-in maintainer's runners with a free slot and a usable account, their +hub starts the next unclaimed ready quest in `quest unblocked` order, +round-robin across their opted-in projects, as an ordinary start job they +requested, after the same current GitHub checks as their own start. A quest +that already had a job, or that they skipped in the inbox at its current +text, waits for them, so a skipped, stopped, or failed start never reruns on +its own. The scheduled sweep wakes every opted-in hub, so quests that became +ready since the last runner event start too. Losing the maintainer role ends +the opt-in. A start job's agent first checks the quest is still worth +starting, and asks one decision when in doubt: start, skip, deprioritize to a +later milestone, or delete. + Beside the decisions (below them on a phone), an Agents card shows each job on the maintainer's projects (`src/inbox/activity.ts`): every target's latest job while it is queued, running, or waiting, and an ended one while the pull diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index aedb2868..efbe568a 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -16,6 +16,8 @@ export type Issue = { body?: string; comments?: Comment[]; closed?: boolean; + /** When GitHub last saw activity on it; a fixed past date when absent. */ + updatedAt?: string; }; export type Thread = { path: string; line: number | null; resolved?: boolean; outdated?: boolean; comments: Comment[] }; export type Pull = Issue & { @@ -208,7 +210,7 @@ function entity(repo: Repo, item: Issue) { number: item.number, title: item.title, state: (item as Pull).merged ? "MERGED" : item.closed ? "CLOSED" : "OPEN", - updatedAt, + updatedAt: item.updatedAt ?? updatedAt, closedAt: item.closed ? updatedAt : null, author: { login: item.author }, comments: { totalCount: item.comments?.length ?? 0 }, @@ -712,6 +714,7 @@ export function fakeGitHub(world: World) { mergeable_state: pull.mergeableState ?? (pull.merge === "DIRTY" ? "dirty" : "clean"), title: pull.title, body: pull.body ?? "", + labels: (pull.labels ?? []).map((name) => ({ name })), draft: !!pull.draft, user: { login: pull.author }, created_at: new Date(pull.createdAt ?? Date.parse(updatedAt)).toISOString(), diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts index a3fdf8de..fcd82f87 100644 --- a/cloudflare/dev/seed.ts +++ b/cloudflare/dev/seed.ts @@ -107,6 +107,7 @@ export function world(): World { review: "CHANGES_REQUESTED", reviews: [{ state: "CHANGES_REQUESTED", author: "reviewer" }], }, + // A draft its starter left quiet for weeks: offered for takeover. { number: 15, title: "Try a shared build cache", @@ -124,7 +125,7 @@ export function world(): World { checks: null, review: "REVIEW_REQUIRED", }, - // A claim its starter still holds: work in progress, not a decision. + // A claim its starter still holds and touched just now: work in progress, not a decision. { number: 17, title: "Draft the theme picker", @@ -134,6 +135,7 @@ export function world(): World { review: null, draft: true, body: "Started on the picker.", + updatedAt: new Date().toISOString(), }, // A draft handed back with decisions: it waits on the maintainer. { diff --git a/cloudflare/migrations/0023_autostart.sql b/cloudflare/migrations/0023_autostart.sql new file mode 100644 index 00000000..220baf70 --- /dev/null +++ b/cloudflare/migrations/0023_autostart.sql @@ -0,0 +1,14 @@ +-- A maintainer's opt-in to start a project's ready quests on their own +-- runners whenever one of their job slots frees, within the listed milestone +-- directories (a JSON array), or every milestone when NULL. It lasts only as +-- long as the membership: a lost maintainer role, a disconnect, or a deleted +-- account ends it, so regaining access never resumes starts unasked. +CREATE TABLE autostart ( + repository INTEGER NOT NULL, + user TEXT NOT NULL, + milestones TEXT, + createdAt INTEGER NOT NULL, + PRIMARY KEY(repository, user), + FOREIGN KEY(repository, user) REFERENCES github_project_members(repository, user) ON DELETE CASCADE +); +CREATE INDEX autostart_user ON autostart(user); diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index bd7878c1..9f0cb1cf 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -7,6 +7,7 @@ import { heldToken, check, isQuest, + handedBack, parse, planInit, ready, @@ -1120,6 +1121,44 @@ export class GitHubService { return (await this.#signed(token, repo, pull, revision)) || (await this.#approved(token, repo, pull, revision)); } + /** + * Mark a draft ready for review at the exact head the maintainer saw, as + * the App, accepting it as handed back. GitHub's own rules still decide + * whether and how it merges. A draft its starter still holds, or that a + * session holds with a `quest:*` label, is theirs to mark, as the inbox + * offers it. + */ + markReady(repo: GitHubRepository, userId: string, pull: number, head: string) { + const permissions = { pull_requests: "write" } as const; + return this.#authorized(repo, userId, permissions, async (token, current) => { + this.#public(current); + const live = await this.#api<{ + state: string; + draft: boolean; + node_id: string; + body: string | null; + labels: { name: string }[]; + head: { sha: string }; + }>(token, `${repositoryPath(repo)}/pulls/${number(pull)}`); + if (live.state !== "open" || live.head.sha !== sha(head)) + throw new GitHubError("The pull request's head moved since you looked; review it again", 409); + if (live.draft) { + // Read live, since a stale form may follow a starter resuming the draft. + if (!handedBack(live.body ?? "") || live.labels.some((label) => isHoldLabel(label.name))) + throw new GitHubError( + "Only a draft handed back with ## Waiting and no hold can be marked ready", + 409, + ); + const result = await this.#api<{ errors?: unknown[] }>(token, "/graphql", "POST", { + query: "mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { clientMutationId } }", + variables: { id: live.node_id }, + }); + if (result.errors?.length) throw new GitHubError("Could not mark the pull request ready for review"); + } + await this.#invalidate(repo); + }); + } + /** * Approve one exact open pull request head for host runners, as the * maintainer `userId` saw it. The approval lapses when the head moves, and diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index bf2ce422..9f073270 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -7,6 +7,8 @@ import { parse, questBranch } from "quest/core"; import { encodePath } from "../board/model"; import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service"; import { type Activity, type ProjectState, type Pull, owned, readState } from "../github/state"; +import { type Autostart, digest } from "../jobs/autostart"; +import * as autostart from "../jobs/autostart"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { hub } from "../jobs/routes"; import * as store from "../jobs/store"; @@ -20,11 +22,11 @@ export type Read = { repository: number; item: string; seen: string | null; skip /** * What the item asks of the maintainer: answer an agent, pick an account for * a limited job, approve another maintainer's answer to their own job, trust - * a pull request head no runner may run yet, retry a failure, or start work - * on an issue, PR, or ready quest. `null` is work in progress, which only - * informs. + * a pull request head no runner may run yet, retry a failure, start work + * on an issue, PR, or ready quest, or take over a draft its starter left + * quiet for `STALE`. `null` is work in progress, which only informs. */ -export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start"; +export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start" | "takeover"; export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell"; export type Item = { @@ -42,6 +44,8 @@ export type Item = { /** The job's open question, or its answer awaiting approval, and its prompt when an agent asked it. */ question: QuestionView | null; prompt: Prompt | null; + /** Who holds a PR nobody may act on: its draft's author, or the hold label a session holds it with. */ + holder: string | null; /** A "trust" decision's exact head, and GitHub's view of its commits beyond the default branch. */ head: { sha: string; compare: string } | null; need: Need | null; @@ -54,14 +58,13 @@ export type Item = { }; /** 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); -} +export type ProjectView = { + project: Enrollment; + state: ProjectState | null; + error: string | null; + /** The viewer's opt-in to start this project's ready quests on their runners. */ + autostart?: Autostart | null; +}; function kindOf(target: string): Kind { if (target.startsWith("issues/")) return "issue"; @@ -145,13 +148,31 @@ export function entities(project: Enrollment, state: ProjectState | null) { } /** - * A PR whose observed draft its starter still holds, or that a session holds - * with a hold label, such as a merge job whose landing is queued, so nothing - * waits on the maintainer. + * How long a draft its starter holds can stay quiet before the maintainer is + * offered to take it over. A week outlasts a weekend away from a local + * session while still surfacing abandoned claims. + */ +export const STALE = 7 * 24 * 60 * 60 * 1000; + +/** + * Who holds a PR, so nothing waits on the maintainer: the hold label a + * session holds it with, such as a merge job whose landing is queued, or the + * author of an observed draft they have not handed back. Null when free. */ -function held(entity: Pick) { +function holder(entity: Pick) { const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null; - return !!pull && (owned(pull) || !!pull.hold); + if (pull?.hold) return pull.hold; + if (pull && owned(pull)) return pull.author?.login ?? "its starter"; + return null; +} + +/** + * Whether a held draft has been quiet past `STALE`. Only a starter's draft is + * offered: a hold label names a live session or job, which releases its own. + */ +function abandoned(entity: Pick, now: number) { + const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null; + return !!pull && !pull.hold && owned(pull) && Date.parse(pull.updatedAt) < now - STALE; } /** What an item is called and links to when nothing observed names its target. */ @@ -180,6 +201,7 @@ export function decisions( jobs: JobView[], reads: Read[], user: string, + now = Date.now(), ): Item[] { const { project } = view; const latest = new Map(); @@ -196,6 +218,7 @@ export function decisions( let question: QuestionView | null = null; let head: Item["head"] = null; let ended = false; + const holds = entity ? holder(entity) : null; if (job?.state === "queued" && store.onPull(job.workflow) && !job.trusted) { // Its head has commits no current maintainer signed, so no runner takes it until one approves the head. const sha = job.head ?? job.revision; @@ -217,10 +240,13 @@ export function decisions( } else if (entity) { // Starting new work on a paused project would be refused, so it is not offered. if (project.state !== "enabled") continue; - // A draft its starter still holds is their work in progress until they hand it back. - if (!held(entity)) { + // A draft its starter still holds is their work in progress until they hand it back or leave it. + if (!holds) { need = "start"; ended = !!job && !changed; + } else if (abandoned(entity, now)) { + need = "takeover"; + ended = !!job && !changed; } } else continue; const read = reads.find((r) => r.repository === project.id && r.item === target); @@ -239,6 +265,7 @@ export function decisions( question, prompt, head, + holder: holds, need, version, unread: need !== null && read?.seen !== version, @@ -323,7 +350,11 @@ export async function load(env: Env, user: string) { const views = await Promise.all( (await github.projects(user)).map(async (project): Promise => { try { - return { project, state: await github.projectState(project, user), error: null }; + const [state, scope] = await Promise.all([ + github.projectState(project, user), + autostart.read(env.DB, project.id, user), + ]); + return { project, state, error: null, autostart: scope }; } catch (error) { if (error instanceof NotMaintainer) return null; if (!(error instanceof GitHubError)) throw error; diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index c07cb7fa..30427cf5 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -37,6 +37,8 @@ const recorded: Record = { stop: "Stop requested.", skip: "Skipped until it changes.", merge: "Pull request merged.", + autostart: "Ready quest setting saved.", + ready: "Marked ready for review.", }; const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" }; @@ -54,7 +56,8 @@ function needLabel(item: Item) { if (item.skipped && item.job) return `${kinds[item.kind]} ยท job ${item.job.state}`; return item.followups ? "Follow-ups to plan" : kinds[item.kind]; } - if (!item.job) return "Claimed draft"; + if (item.need === "takeover") return "Quiet draft"; + if (!item.job) return item.holder ? `Held by ${item.holder}` : "Claimed draft"; if (item.job.state === "waiting") return "Awaiting approval"; return item.job.state === "queued" ? "Queued" : "Working"; } @@ -62,8 +65,13 @@ function needLabel(item: Item) { /** 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; + const pull = item.kind === "pull" ? (item.activity?.data as Pull | null | undefined) : null; + if (item.need === "takeover") + return `This draft, started by ${item.holder}, has been quiet since ${pull?.updatedAt.slice(0, 10)}. Take it over to iterate on it with your runners, or skip it until it changes.`; if (!job && item.need === null) - return "Its starter holds this draft. It waits on you once they mark it ready or hand back decisions."; + return pull?.hold + ? `A session holds this pull request with its ${pull.hold} label and releases it when done.` + : `This draft is held by ${item.holder ?? "its starter"}. It waits on you once they mark it ready or hand back decisions.`; if (!job || item.need === "start") return null; const whose = job.actor === user ? "your" : "the requester's"; if (item.need === "trust") @@ -406,7 +414,7 @@ function work(item: Item, view: ProjectView) { workflow: "iterate", target: item.target, revision: (item.activity!.data as Pull).headRefOid, - label: "Iterate on this PR", + label: item.need === "takeover" ? "Take over and iterate" : "Iterate on this PR", }; if (item.kind === "issue") return { workflow: "plan", target: item.target, revision: commit, label: "Plan this issue" }; @@ -434,6 +442,15 @@ function landing(item: Item) { return { pull, clean: ["CLEAN", "HAS_HOOKS"].includes(pull.mergeStateStatus ?? "") }; } +/** + * The observed draft its starter handed back with a `## Waiting` section and + * no session holds, which the maintainer may mark ready. + */ +function handedBack(item: Item) { + const pull = item.kind === "pull" ? (item.activity?.data as Pull | null | undefined) : null; + return pull?.state === "OPEN" && pull.isDraft && pull.handedBack && !pull.hold ? pull : null; +} + function Refresh(props: { project: Item["project"] }) { return (
@@ -446,12 +463,14 @@ function Refresh(props: { project: Item["project"] }) { function Actions(props: { item: Item; view: ProjectView | undefined; runners: Runners }) { const { item, view } = props; const base = projectURL(item.project.owner, item.project.name); - const starts = item.need === "start" || item.need === "retry"; + const starts = item.need === "start" || item.need === "retry" || item.need === "takeover"; const reason = starts ? blocked(item, view) : null; // Only an unblocked item has the observations its work is bound to. const job = starts && !reason ? work(item, view!) : null; // Only an idle item offers merge: no queued, running, or waiting job can still push to its head. const merge = starts && !reason ? landing(item) : null; + // A draft handed back with its waits can be accepted as it is; a draft its starter holds is theirs to mark. + const draft = item.need === "start" && !reason ? handedBack(item) : null; const active = item.job && ["queued", "running", "waiting"].includes(item.job.state) && !item.job.cancel; return (
@@ -473,6 +492,12 @@ function Actions(props: { item: Item; view: ProjectView | undefined; runners: Ru )} + {draft && ( +
+ + +
+ )} {reason ? ( <>

{reason}

@@ -496,7 +521,7 @@ function Actions(props: { item: Item; view: ProjectView | undefined; runners: Ru Review on GitHub )} - {(item.need === "start" || item.need === "retry") && !item.skipped && ( + {starts && !item.skipped && (
@@ -683,6 +708,46 @@ function NewPlan(props: { project: ProjectView["project"]; commit: string; runne ); } +/** + * The viewer's opt-in to start the project's ready quests on their own + * runners whenever a slot frees, in `quest unblocked` order, optionally + * within some milestones. + */ +function Autostart(props: { view: ProjectView }) { + const { project, autostart } = props.view; + const action = `${projectURL(project.owner, project.name)}/autostart`; + const id = `autostart-${project.id}`; + const scope = autostart?.milestones?.join(", ") ?? ""; + return ( +
+ ); +} + function Project(props: { view: ProjectView; runners: Runners }) { const { project, state, error } = props.view; const base = projectURL(project.owner, project.name); @@ -713,6 +778,7 @@ function Project(props: { view: ProjectView; runners: Runners }) { {!error && project.state === "enabled" && state?.project.available && ( )} + {!error && } ); } diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index a734cd5a..663756a1 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -26,6 +26,7 @@ import { activeHighlights, cards, highlights } from "./inbox/activity"; import * as inbox from "./inbox/model"; import { AgentCards, InboxPage } from "./inbox/page"; import { purge, retain } from "./jobs/output"; +import * as autostart from "./jobs/autostart"; import { INBOX_HEADER } from "./jobs/hub"; import * as landing from "./jobs/landing"; import { BootstrapRequired, fromForm, hub, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; @@ -755,6 +756,7 @@ export default { fetch: app.fetch, async scheduled(controller, env) { await landing.sweep(env); + await autostart.sweep(env); if (controller.cron !== landing.SWEEP_CRON) await retain(env, Date.now()); }, } satisfies ExportedHandler; diff --git a/cloudflare/src/jobs/autostart.ts b/cloudflare/src/jobs/autostart.ts new file mode 100644 index 00000000..df1899cc --- /dev/null +++ b/cloudflare/src/jobs/autostart.ts @@ -0,0 +1,219 @@ +// Starting ready work without a click: a maintainer who opts a project in +// lends it their runners, and whenever one of their slots frees, kix starts +// the next unclaimed ready quest in `quest unblocked` order, as +// `/quest-spawn` does locally. Each start is an ordinary start job, so the +// agent's first step still checks the quest is worth the slot and turns a +// doubtful one into a decision. + +import { questBranch, unblocked, parse } from "quest/core"; +import { GitHubError, NotMaintainer, GitHubService, refName, type Enrollment } from "../github/service"; +import { type ProjectState, readState, startable } from "../github/state"; +import * as store from "./store"; +import { JobError } from "./store"; + +/** A milestone directory under `quest/`, such as `m0`. */ +const MILESTONE = /^m\d{1,4}$/; + +/** A maintainer's opt-in on one project: every milestone, or only those named. */ +export type Autostart = { milestones: string[] | null }; + +/** FNV-1a: a short, stable version for a quest's text, which an inbox skip records. */ +export 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); +} + +/** Parse a form's milestone list, such as `m0, m1`; empty means every milestone. */ +export function milestones(value: unknown): string[] | null { + if (value === undefined || value === "") return null; + if (typeof value !== "string") throw new JobError("Invalid milestones"); + const names = [...new Set(value.split(/[\s,]+/).filter(Boolean))]; + if (names.length > 32 || names.some((name) => !MILESTONE.test(name))) + throw new JobError("Name milestones like m0, m1"); + return names.length ? names : null; +} + +/** A start job for `target`, when observations show it ready and unclaimed. */ +export function startJob(state: ProjectState, target: unknown, failed: store.Published | null) { + if (typeof target !== "string" || !startable(state, target, failed)) + throw new JobError("Only ready, unclaimed quests on the default branch can start"); + const branch = questBranch(target); + if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name"); + return { workflow: "start" as const, target, revision: state.project.commit, branch, trusted: true }; +} + +/** + * The quests to start next on one project, best first: ready and unclaimed + * in `quest unblocked` order, within the opted-in milestones, and never a + * quest that already had a job, so a skipped, cancelled, or failed start + * waits for the maintainer instead of rerunning, nor one the maintainer + * skipped in the inbox at its current text. + */ +export function candidates(state: ProjectState, scope: Autostart, taken: Set, skipped: Set) { + const { available, documents } = state.project; + if (!available) return []; + let ranked: string[]; + try { + ranked = unblocked(documents.map((d) => parse(d.path, d.content))).map((quest) => quest.path); + } catch { + // A tree `quest check` rejects has no ranking; its board says why. + return []; + } + const text = new Map(documents.map((d) => [d.path, d.content])); + return ranked.filter( + (path) => + available.includes(path) && + covers(scope, path) && + !taken.has(path) && + !skipped.has(`${path}\n${digest(text.get(path) ?? "")}`), + ); +} + +/** Take one item from each queue in turn, so one busy project cannot starve another. */ +function interleave(queues: T[][]): T[] { + const out: T[] = []; + for (let i = 0; queues.some((queue) => i < queue.length); i++) + for (const queue of queues) if (i < queue.length) out.push(queue[i]!); + return out; +} + +/** + * The owner's opt-ins on enabled projects, the one they started work on least + * recently first, so projects take turns across passes even when each pass + * fills a single slot. A membership's end deletes its opt-in. + */ +export async function optIns(db: D1Database, owner: string) { + const { results } = await db + .prepare( + `SELECT p.id,p.owner,p.name,p.state,a.milestones FROM autostart a JOIN github_projects p ON p.id=a.repository + WHERE a.user=? AND p.state='enabled' + ORDER BY COALESCE((SELECT MAX(createdAt) FROM jobs WHERE repository=p.id AND actor=a.user AND workflow='start'),0),a.createdAt,p.id`, + ) + .bind(owner) + .all(); + return results.map(({ milestones, ...project }) => ({ + project, + scope: { milestones: milestones ? (JSON.parse(milestones) as string[]) : null }, + })); +} + +/** The owner's opt-in on one project, or null. */ +export async function read(db: D1Database, repository: number, user: string): Promise { + const row = await db + .prepare("SELECT milestones FROM autostart WHERE repository=? AND user=?") + .bind(repository, user) + .first<{ milestones: string | null }>(); + return row ? { milestones: row.milestones ? JSON.parse(row.milestones) : null } : null; +} + +/** Whether an opt-in covers `quest`'s milestone; `createJob` checks the same in SQL. */ +function covers(scope: Autostart, quest: string) { + return !scope.milestones || scope.milestones.includes(quest.split("/")[1]!); +} + +/** Opt a project in for `user`'s runners, or out with `null`. */ +export async function save(db: D1Database, repository: number, user: string, scope: Autostart | null, now: number) { + if (!scope) { + await db.prepare("DELETE FROM autostart WHERE repository=? AND user=?").bind(repository, user).run(); + return; + } + await db + .prepare( + "INSERT INTO autostart(repository,user,milestones,createdAt) VALUES(?,?,?,?) ON CONFLICT(repository,user) DO UPDATE SET milestones=excluded.milestones", + ) + .bind(repository, user, scope.milestones ? JSON.stringify(scope.milestones) : null, now) + .run(); +} + +/** + * Start up to `free` quests across the owner's opted-in projects, from stored + * observations first, then each through the same current GitHub checks as a + * maintainer's own start. Returns how many jobs it created. + */ +export async function autostart(env: Env, owner: string, free: number) { + if (free <= 0) return 0; + const opted = await optIns(env.DB, owner); + if (!opted.length) return 0; + const queues = await Promise.all( + opted.map(async ({ project, scope }) => { + const [state, jobs, skips] = await Promise.all([ + readState(env.DB, project), + env.DB.prepare("SELECT DISTINCT target FROM jobs WHERE repository=?") + .bind(project.id) + .all<{ target: string }>(), + env.DB.prepare( + "SELECT item,skipped FROM inbox_items WHERE user=? AND repository=? AND skipped IS NOT NULL", + ) + .bind(owner, project.id) + .all<{ item: string; skipped: string }>(), + ]); + if (!state) return []; + const taken = new Set(jobs.results.map((row) => row.target)); + const skipped = new Set(skips.results.map((row) => `${row.item}\n${row.skipped}`)); + return candidates(state, scope, taken, skipped).map((target) => ({ project, target })); + }), + ); + const github = new GitHubService(env); + // A project that cannot take a start now, such as one without a flake, is not asked again this pass. + const refused = new Set(); + let started = 0; + for (const { project, target } of interleave(queues)) { + if (started >= free) break; + if (refused.has(project.id)) continue; + try { + // The skip check at insertion compares the quest's text as GitHub has it now. + let version = ""; + const { job, enabled, flake } = await github.jobSource(project, owner, (state) => { + version = digest(state.project.documents.find((d) => d.path === target)?.content ?? ""); + return startJob(state, target, null); + }); + if (!enabled || !flake) { + refused.add(project.id); + continue; + } + // The insert rechecks the opt-in, which may have ended or narrowed while this pass read GitHub, + // and the quest's own exclusions, since a skip or another pass's job may have committed meanwhile. + const created = await store.createJob( + env.DB, + { + ...job, + repository: project.id, + actor: owner, + pinRunner: null, + pinAccount: null, + automatic: { version }, + }, + Date.now(), + ); + if (created.created) started++; + } catch (error) { + if (error instanceof NotMaintainer) { + // Lost authority ends the opt-in: it lent runners to a project its owner no longer maintains. + await save(env.DB, project.id, owner, null, Date.now()); + refused.add(project.id); + } else if (error instanceof store.NotOptedIn) refused.add(project.id); + else if (error instanceof JobError) continue; + else if (error instanceof GitHubError) refused.add(project.id); + else throw error; + } + } + return started; +} + +/** Wake every hub with an opt-in, so quests that became ready since the last runner event start. */ +export async function sweep(env: Env) { + const { results } = await env.DB.prepare( + "SELECT DISTINCT a.user FROM autostart a JOIN github_projects p ON p.id=a.repository WHERE p.state='enabled'", + ).all<{ user: string }>(); + // One failing hub must not stop the others, or the scheduled retention after the sweep. + const settled = await Promise.allSettled( + results.map(({ user }) => env.RUNNERS.get(env.RUNNERS.idFromName(user)).dispatch(user)), + ); + for (const outcome of settled) + if (outcome.status === "rejected") + console.error({ + event: "autostart_sweep_failed", + name: outcome.reason instanceof Error ? outcome.reason.name : "unknown", + }); +} diff --git a/cloudflare/src/jobs/hub.ts b/cloudflare/src/jobs/hub.ts index 81c54d5c..1e162ad3 100644 --- a/cloudflare/src/jobs/hub.ts +++ b/cloudflare/src/jobs/hub.ts @@ -1,5 +1,6 @@ import { DurableObject } from "cloudflare:workers"; import * as push from "../push/notify"; +import { autostart } from "./autostart"; import * as landing from "./landing"; import * as output from "./output"; import { MessageError, type RunnerMessage, parse } from "./protocol"; @@ -27,6 +28,8 @@ export class RunnerHub extends DurableObject { // Handlers interleave at each D1 await. Runner messages and dispatch passes // run one at a time, so a pass cannot assign from a status it has outlived. #serial: Promise = Promise.resolve(); + // One autostart pass at a time, so two passes cannot fill the same free slot. + #starting = false; async fetch(request: Request) { const viewer = request.headers.get(INBOX_HEADER); @@ -197,7 +200,7 @@ export class RunnerHub extends DurableObject { const reported = (ws.deserializeAttachment() as RunnerAttachment).slots; if (reported !== null) slots.set(runner, reported); } - const { assign, cancel, ended, wake } = await store.dispatch( + const { assign, cancel, ended, wake, idle } = await store.dispatch( this.env.DB, owner, slots, @@ -215,6 +218,23 @@ export class RunnerHub extends DurableObject { if (assign.length || cancel.length || ended.length) await this.#notify(owner); if (wake === null) await this.ctx.storage.deleteAlarm(); else await this.ctx.storage.setAlarm(wake); + // GitHub reads stay outside the serialized pass, so runner messages never wait on them. + if (idle > 0) this.ctx.waitUntil(this.#autostart(owner, idle)); + } + + /** Fill free slots with the owner's opted-in ready quests, then route what it created. */ + async #autostart(owner: string, free: number) { + if (this.#starting) return; + this.#starting = true; + let started = 0; + try { + started = await autostart(this.env, owner, free); + } catch (error) { + console.error({ event: "autostart_failed", name: error instanceof Error ? error.name : "unknown" }); + } finally { + this.#starting = false; + } + if (started) await this.dispatch(owner); } async #handle(ws: WebSocket, attachment: RunnerAttachment, message: RunnerMessage) { diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index a084ffde..5dad6770 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -1,9 +1,9 @@ import { type Context, Hono } from "hono"; -import { questBranch } from "quest/core"; import { createAuth } from "../auth"; import * as push from "../push/notify"; -import { GitHubError, GitHubService, refName, type Stopped } from "../github/service"; -import { type ProjectState, type Pull, startable } from "../github/state"; +import { GitHubError, GitHubService, type Stopped } from "../github/service"; +import type { ProjectState, Pull } from "../github/state"; +import * as autostart from "./autostart"; import { INBOX_HEADER, RUNNER_HEADER } from "./hub"; import * as landing from "./landing"; import * as output from "./output"; @@ -46,7 +46,7 @@ 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" | "approve" | "trust" | "start" | "stop" | "skip" | "merge"; +export type Done = "answer" | "approve" | "trust" | "start" | "stop" | "skip" | "merge" | "autostart" | "ready"; /** * 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. @@ -300,13 +300,7 @@ function resolve( !item.missing && item.data?.state === "OPEN", ); - if (workflow === "start") { - if (typeof target !== "string" || !startable(state, target, failed)) - throw new JobError("Only ready, unclaimed quests on the default branch can start"); - const branch = questBranch(target); - if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name"); - return { workflow, target, revision: commit, branch, trusted: true }; - } + if (workflow === "start") return autostart.startJob(state, target, failed); if (workflow === "plan") { // A new interview gets its own target. Retrying a failed one keeps it and // its request; planning a finished job's follow-ups targets that job's ID. @@ -406,6 +400,19 @@ jobs.post(base, async (c) => { return reply(c, form, "start", created, created.created ? 201 : 200); }); +// A maintainer lends their own runners to a project's ready quests, or stops. +jobs.post("/projects/github/:owner/:repository/autostart", async (c) => { + const { user, repo } = await maintained(c); + const { body, form } = await input(c); + // A form sends `1` or `0`; the JSON API, a boolean. + const on = form ? { "1": true, "0": false }[String(body.enabled)] : body.enabled; + if (typeof on !== "boolean") throw new JobError("Choose whether to start ready quests"); + const scope = on ? { milestones: autostart.milestones(body.milestones) } : null; + await autostart.save(c.env.DB, repo.id, user, scope, Date.now()); + if (on) await hub(c.env, user).dispatch(user); + return reply(c, form, "autostart", null); +}); + jobs.post(`${base}/:job/answer`, async (c) => { const { user, repo, job } = await owned(c); const { body, form } = await input(c); @@ -479,6 +486,20 @@ jobs.post("/projects/github/:owner/:repository/pulls/:number/approve", async (c) return reply(c, form, "trust", null); }); +// A maintainer accepts a handed-back draft as it is, at the head they saw. +jobs.post("/projects/github/:owner/:repository/pulls/:number/ready", async (c) => { + const { user, repo, github } = await maintained(c); + const { body, form } = await input(c); + const number = c.req.param("number"); + if (!/^[1-9]\d{0,14}$/.test(number) || typeof body.head !== "string") + throw new JobError("Pull request and head required"); + // A job may still push to the pull request, so it stays as the job leaves it. + if (await store.active(c.env.DB, repo.id, `pulls/${number}`)) + throw new JobError("Stop this pull request's job before marking it ready"); + await github.markReady(repo, user, Number(number), body.head); + return reply(c, form, "ready", null); +}); + /** The longest reply to a publication decision, in characters as the inbox form counts them. */ export const PUBLICATION_REPLY = 1000; diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts index af464153..8f6cb988 100644 --- a/cloudflare/src/jobs/store.ts +++ b/cloudflare/src/jobs/store.ts @@ -339,7 +339,7 @@ export type Claimed = NonNullable>> & { owner * A running job has a lease exactly while its runner is disconnected; an * expired lease moves the job. Then queued work is assigned. Returns what to * send each runner, the merge jobs it cancelled that still hold their pull - * request, and when to run again. Every assignment its runner has + * request, when to run again, and how many usable slots stay free. Every assignment its runner has * not acknowledged is sent again, since a delivery can be lost after the * claim commits. Until acknowledged, an assignment holds one of the runner's * reported free slots, which it could not count yet. @@ -484,6 +484,10 @@ export async function dispatch( } assign.push({ runner, job: { ...job, ...project } }); } + // Free slots left on runners with a usable account, which ready work may fill. + let idle = 0; + for (const [runner, n] of free) + if (n > 0 && accounts.some((offer) => offer.runner === runner && !offer.limited)) idle += n; const wake = await db .prepare("SELECT MIN(lease) AS at FROM jobs WHERE actor=? AND state='running' AND lease IS NOT NULL") .bind(owner) @@ -493,6 +497,7 @@ export async function dispatch( cancel: [...(cancels!.results as (Attempt & { runner: string })[]), ...revoked], ended: ended(cancelled!), wake: revoked.length ? now : (wake?.at ?? null), + idle, }; } @@ -735,8 +740,28 @@ export type NewJob = Pick { + const doc = (path: string, body: string) => ({ path, content: `# [S] ${path}\n\n## Goal\n\nDo it.\n${body}` }); + const documents = [ + { path: "quest/README.md", content: "# Project\n\n## Goal\n\nBuild it.\n" }, + { path: "quest/m0/README.md", content: "# Now\n\n## Goal\n\nFirst.\n" }, + { path: "quest/m1/README.md", content: "# Next\n\n## Goal\n\nLater.\n" }, + doc("quest/m1/a.md", ""), + doc("quest/m1/b.md", ""), + doc("quest/m1/c.md", "\n## Required\n\n- [B](/quest/m1/b.md) - first\n"), + doc("quest/m0/d.md", ""), + doc("quest/m0/claimed.md", ""), + ]; + const state = (available: string[] | null) => ({ project: { documents, available } }) as unknown as ProjectState; + const all = { milestones: null }; + const none = new Set(); + + it("ranks unclaimed ready quests as quest unblocked does, within the opted-in milestones", () => { + const available = ["quest/m0/d.md", "quest/m1/a.md", "quest/m1/b.md"]; + // Milestone first, then downstream impact: b unblocks c. + expect(candidates(state(available), all, none, none)).toEqual([ + "quest/m0/d.md", + "quest/m1/b.md", + "quest/m1/a.md", + ]); + expect(candidates(state(available), { milestones: ["m1"] }, none, none)).toEqual([ + "quest/m1/b.md", + "quest/m1/a.md", + ]); + // Unknown occupancy starts nothing. + expect(candidates(state(null), all, none, none)).toEqual([]); + }); + + it("leaves a quest that already had a job, or that the maintainer skipped at its current text", () => { + const available = ["quest/m0/d.md", "quest/m1/b.md"]; + const d = documents.find((x) => x.path === "quest/m0/d.md")!.content; + expect(candidates(state(available), all, new Set(["quest/m0/d.md"]), none)).toEqual(["quest/m1/b.md"]); + expect(candidates(state(available), all, none, new Set([`quest/m0/d.md\n${digest(d)}`]))).toEqual([ + "quest/m1/b.md", + ]); + // A skip of older text lapses once the quest changes. + expect(candidates(state(available), all, none, new Set(["quest/m0/d.md\nold"]))).toHaveLength(2); + }); + + it("reads a milestone list, refusing anything else", () => { + expect(milestones(undefined)).toBeNull(); + expect(milestones("m0, m1 m0")).toEqual(["m0", "m1"]); + expect(() => milestones("m0,../x")).toThrow("Name milestones like m0, m1"); + }); +}); + +describe("starting ready quests on free slots", () => { + let user = ""; + let cookie = ""; + let repo: Repo; + beforeEach(async () => { + ({ user, cookie, repo } = await maintainerOf()); + }); + afterEach(() => vi.restoreAllMocks()); + const sockets: WebSocket[] = []; + afterEach(() => { + for (const ws of sockets.splice(0)) ws.close(); + }); + + function opt(fields: [string, string][]) { + return app.request( + `${origin}/projects/github/upstream/project/autostart`, + { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) }, + testEnv, + ); + } + async function connect(slots: number) { + const { token } = await store.createRunner(db, user, "laptop", "linux", false, 0); + const response = await app.request( + `${origin}/api/runner/connect`, + { headers: { Authorization: `Bearer ${token}`, Upgrade: "websocket" } }, + testEnv, + ); + const ws = response.webSocket!; + ws.accept(); + sockets.push(ws); + const inbox: any[] = []; + const waiting: ((message: any) => void)[] = []; + ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data as string); + const next = waiting.shift(); + if (next) next(message); + else inbox.push(message); + }); + const next = (): Promise => + inbox.length ? Promise.resolve(inbox.shift()) : new Promise((resolve) => waiting.push(resolve)); + const account = { id: "main", provider: "anthropic", kind: "subscription", label: "Main", limited: false }; + ws.send(JSON.stringify({ id: 1, type: "status", accounts: [account], attempts: [], slots })); + expect(await next()).toMatchObject({ type: "reply", id: 1 }); + return next; + } + const jobs = async () => + (await db.prepare("SELECT target,workflow,actor FROM jobs WHERE repository=10").all()).results; + + it("starts the next ready quest once opted in, and only once", async () => { + const next = await connect(2); + // Without an opt-in, a free slot starts nothing. + expect(await autostart(testEnv, user, 2)).toBe(0); + const saved = await opt([["enabled", "1"]]); + expect(saved.status).toBe(303); + expect(await next()).toMatchObject({ + type: "assign", + job: { workflow: "start", target: "quest/first.md", attempt: 1 }, + instructions: expect.stringContaining("worth starting"), + }); + expect(await jobs()).toEqual([{ target: "quest/first.md", workflow: "start", actor: user }]); + // A slot is still free, but nothing else is ready, and a quest with a job is not started again. + expect(await autostart(testEnv, user, 1)).toBe(0); + const [{ id }] = (await db.prepare("SELECT id FROM jobs").all<{ id: string }>()).results; + await store.cancel(db, id!, user, Date.now()); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await jobs()).toHaveLength(1); + }); + + it("keeps to the chosen milestones and stops when opted out", async () => { + expect( + ( + await opt([ + ["enabled", "1"], + ["milestones", "m3"], + ]) + ).status, + ).toBe(303); + expect(await db.prepare("SELECT milestones FROM autostart WHERE user=?").bind(user).first()).toEqual({ + milestones: '["m3"]', + }); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect((await opt([["enabled", "0"]])).status).toBe(303); + expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); + expect((await opt([["enabled", "maybe"]])).status).toBe(409); + }); + + it("creates no start the maintainer opted out of while it read GitHub", async () => { + await save(db, 10, user, { milestones: null }, 0); + const github = vi.mocked(globalThis.fetch).getMockImplementation()!; + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + // The flake read is the last GitHub request before the job is created. + if (String(input).includes("/contents/flake.nix")) await save(db, 10, user, null, 0); + return github(input, init); + }); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await jobs()).toEqual([]); + // Narrowing the milestones works the same way, checked in the same insert. + await save(db, 10, user, { milestones: ["m3"] }, 0); + const start = { + repository: 10, + actor: user, + workflow: "start", + target: "quest/m0/x.md", + revision: head, + branch: "quest/m0/x", + trusted: true, + automatic: { version: "v1" }, + } as const; + await expect(store.createJob(db, start, 0)).rejects.toThrow(store.NotOptedIn); + await save(db, 10, user, { milestones: ["m0"] }, 0); + expect((await store.createJob(db, start, 0)).created).toBe(true); + }); + + it("creates no start the maintainer skipped, or another pass started, while it read GitHub", async () => { + await save(db, 10, user, { milestones: null }, 0); + const quest = documents.find((d) => d.path === "quest/first.md")!; + const github = vi.mocked(globalThis.fetch).getMockImplementation()!; + let during = async () => {}; + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + if (String(input).includes("/contents/flake.nix")) await during(); + return github(input, init); + }); + // A skip at the quest's current text commits while the pass reads GitHub. + during = async () => void (await skip(db, user, 10, quest.path, digest(quest.content))); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await jobs()).toEqual([]); + // A skip at an older text no longer applies. + await skip(db, user, 10, quest.path, "stale"); + // Another pass's start commits and is cancelled while this one reads GitHub. + during = async () => { + during = async () => {}; + const start = { ...startOf(quest.path), automatic: { version: digest(quest.content) } }; + const { id } = await store.createJob(db, start, 0); + await store.cancel(db, id, user, 0); + }; + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await jobs()).toHaveLength(1); + }); + + function startOf(target: string) { + return { + repository: 10, + actor: user, + workflow: "start", + target, + revision: head, + branch: target.replace(/\.md$/, ""), + trusted: true, + } as const; + } + + it("takes turns across projects, the least recently started first", async () => { + await project(11, user); + await save(db, 10, user, { milestones: null }, 0); + await save(db, 11, user, { milestones: null }, 1); + expect((await optIns(db, user)).map((o) => o.project.id)).toEqual([10, 11]); + await store.createJob( + db, + { + repository: 10, + actor: user, + workflow: "start", + target: "quest/x.md", + revision: head, + branch: "quest/x", + trusted: true, + }, + 5, + ); + expect((await optIns(db, user)).map((o) => o.project.id)).toEqual([11, 10]); + }); + + it("ends an opt-in with its membership, so regaining access never resumes it", async () => { + await save(db, 10, user, { milestones: null }, 0); + await db.prepare("DELETE FROM github_project_members WHERE repository=10 AND user=?").bind(user).run(); + expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); + }); + + it("ends the opt-in of a maintainer who lost their role", async () => { + await opt([["enabled", "1"]]); + await db.prepare("DELETE FROM jobs").run(); + for (const login of Object.keys(repo.roles!)) repo.roles![login] = "read"; + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); + expect(await jobs()).toEqual([]); + }); + + it("marks a handed-back draft ready at the head on screen", async () => { + const pr = repo.pulls[0]!; + pr.draft = true; + const ready = (sha: string) => + app.request( + `${origin}/projects/github/upstream/project/pulls/2/ready`, + { + method: "POST", + headers: { Cookie: cookie, Origin: origin }, + body: new URLSearchParams([["head", sha]]), + }, + testEnv, + ); + expect((await ready("e".repeat(40))).status).toBe(403); + expect(pr.draft).toBe(true); + // Only a draft handed back as GitHub has it now, such as after a stale form, and held by no session. + pr.body = "Still on it."; + expect((await ready(head)).status).toBe(403); + pr.body = "Handed back.\n\n## Waiting\n\n- Maintainer: accept it as is\n"; + pr.labels = ["quest:active"]; + expect((await ready(head)).status).toBe(403); + expect(pr.draft).toBe(true); + pr.labels = []; + // A start job that published this draft keeps its quest as its target, yet may still push to it. + const { id } = await store.createJob(db, startOf("quest/first.md"), 0); + await store.delivered(db, id, 2); + expect((await ready(head)).status).toBe(409); + expect(pr.draft).toBe(true); + await store.cancel(db, id, user, 0); + // So may a job on the pull request itself. + const iterate = await store.createJob( + db, + { ...startOf("quest/first.md"), workflow: "iterate", target: "pulls/2", branch: pr.branch }, + 0, + ); + expect((await ready(head)).status).toBe(409); + await store.cancel(db, iterate.id, user, 0); + expect((await ready(head)).status).toBe(303); + expect(pr.draft).toBe(false); + }); +}); diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 33234a87..12fab339 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Pull, Repo } from "../dev/github"; import { app } from "../src/index"; import { QUIET, cards } from "../src/inbox/activity"; -import { questHref } from "../src/inbox/model"; +import { STALE, questHref } from "../src/inbox/model"; import * as output from "../src/jobs/output"; import * as store from "../src/jobs/store"; import { INBOX_HEADER } from "../src/jobs/hub"; @@ -198,28 +198,44 @@ describe("decision inbox", () => { }); it("shows a draft its starter holds as work in progress until it hands back decisions", async () => { - const draft = (handedBack: boolean) => + const draft = (handedBack: boolean, updatedAt = new Date().toISOString()) => db .prepare( - "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?)),changedAt=changedAt+1 WHERE id='21'", + "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?),'$.updatedAt',?),changedAt=changedAt+1 WHERE id='21'", ) - .bind(String(handedBack)) + .bind(String(handedBack), updatedAt) .run(); await draft(false); const html = await inbox(); expect(thread(html)).not.toContain(">Fix<"); expect(html.match(/

In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toMatch( - /Fix<\/a>