From d43c559051489dc85488a82af360202242a9ae04 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:38:52 -0700 Subject: [PATCH 1/3] chore(quest): claim quest/m0/kix/inbox-accuracy.md Quest-Claim: 3dd62499cca25163 From dd2a391af90ccaba17bbbb9bdba33533f0833f6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:35:43 -0700 Subject: [PATCH 2/3] feat(kix): keep the board and inbox accurate on busy projects A draft PR its starter still holds (no `## Decisions needed`) shows as Claimed on the board and in progress in the inbox; a handed-back draft or ready PR is turned in. The hand-back rule moves to quest/core so the CLI and the Worker agree. GitHub's UNKNOWN merge state keeps a recorded PR's last known state at the same head, and defers its change time at a new head until a definite state arrives. readState reads only open entities and unread pointers through a partial index, the activity log pages, and a closed entity's row is pruned once it has been closed past RETENTION (30 days, pending the user's choice). The seed adds a held draft, a handed-back draft, and an UNKNOWN PR, and its test opens only the items it checks. Co-Authored-By: Claude Opus 5.5 --- cloudflare/GITHUB.md | 24 +++- cloudflare/dev/github.ts | 6 +- cloudflare/dev/seed.ts | 37 ++++++ cloudflare/migrations/0015_activity_reads.sql | 9 ++ cloudflare/src/board/model.ts | 5 +- cloudflare/src/board/pages.tsx | 15 ++- cloudflare/src/board/project.ts | 2 + cloudflare/src/github/state.ts | 109 ++++++++++++++-- cloudflare/src/inbox/model.ts | 14 ++- cloudflare/src/inbox/page.tsx | 5 +- cloudflare/src/index.tsx | 20 ++- cloudflare/src/projects.tsx | 19 ++- cloudflare/test/inbox.test.ts | 25 ++++ cloudflare/test/projects.test.ts | 117 +++++++++++++++++- cloudflare/test/seed.test.ts | 30 ++++- design/theme.md | 4 +- quest/m0/kix/README.md | 2 +- quest/m0/kix/execution.md | 5 +- quest/m0/kix/inbox-accuracy.md | 51 +++----- src/cli/claims.ts | 6 +- src/core/claim.ts | 10 ++ src/core/index.ts | 1 + 22 files changed, 434 insertions(+), 82 deletions(-) create mode 100644 cloudflare/migrations/0015_activity_reads.sql create mode 100644 src/core/claim.ts diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 2e7deb4a..a5fc12aa 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -391,15 +391,33 @@ pushes/comments/reviews, and reconciliation all update the same item. Webhooks retain identity pointers only, never issue/PR bodies. Authoritative API reads add escaped titles, state, comment/review counts, current PR head, that head's check rollup, aggregate review decision, and up to 20 latest reviewer records with an -explicit pagination flag. Raw bodies and individual check logs are not cached. +explicit pagination flag. Raw bodies and individual check logs are not cached: +a PR's body is kept only as whether it has a `## Decisions needed` section. Unavailable/deleted entities retain their notification identity and are marked missing rather than disappearing or presenting old state as current. +GitHub reports `mergeStateStatus` `UNKNOWN` while it computes mergeability and +may send no event once it has. A read that sees it on a recorded PR at the same +head keeps the last known merge state, so nothing looks new; at a new head it +stores `UNKNOWN` but leaves the item's change time, so it becomes news once a +definite state arrives. A newly seen PR is news either way. + +Closed entities only appear in the activity log, so a read that finds an entity +closed for longer than `RETENTION` (`cloudflare/src/github/state.ts`) deletes its +row instead of updating it, unless a webhook named it again since. History +rereads every entity, so a pass prunes every expired row. The board, inbox, jobs +and `/data` read only open entities and unread webhook pointers, through a +partial index, and the activity log pages 50 rows at a time, so neither read +grows with a repository's history. + A PR associates with a quest only when its head branch exactly matches the quest's path without `.md` (or an epic's `/README.md`). An unrelated human PR remains in activity without a guessed association. Open associated PRs and observed quest branch claims exclude that quest from candidate data; branch claims display as -Claimed on the board. Runner/run associations belong to job coordination. +Claimed on the board. So does a draft PR its starter still holds, one without a +`## Decisions needed` section, which the inbox lists as in progress rather than +as a decision; a ready PR or a handed-back draft is Ready to turn in. +Runner/run associations belong to job coordination. Every cached read and refresh proves current maintainer authority, live repository identity/public visibility, installation access, and enrollment for that @@ -414,7 +432,7 @@ observation tied to its commit, **never execution permission**: job coordination must validate current eligibility, fresh revision, locks, and PRs before a start. Review/check data is similarly descriptive and cannot approve a merge. -The `/data` endpoint exposes the same authenticated snapshot/activity with +The `/data` endpoint exposes the same authenticated snapshot and open activity with `Cache-Control: no-store` for future decision and job consumers. The activity page is a secondary read view; the durable decision inbox and its unread/answer state belong to the prompts workflow. Neither incoming activity nor refreshing diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index de23f825..406d9feb 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -12,8 +12,10 @@ export type Pull = Issue & { review: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; reviews?: { state: string; author: string }[]; draft?: boolean; + /** A `## Decisions needed` section in it hands a draft back to the maintainer. */ + body?: string; /** GitHub's mergeStateStatus; only CLEAN merges. Defaults to BLOCKED, or DRAFT for a draft. */ - merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE"; + merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE" | "UNKNOWN"; }; export type Repo = { id: number; @@ -49,6 +51,7 @@ function entity(repo: Repo, item: Issue) { title: item.title, state: item.closed ? "CLOSED" : "OPEN", updatedAt, + closedAt: item.closed ? updatedAt : null, author: { login: item.author }, comments: { totalCount: item.comments ?? 0 }, }; @@ -59,6 +62,7 @@ function pullNode(repo: Repo, pull: Pull) { const reviews = pull.reviews ?? []; return { ...entity(repo, pull), + body: pull.body ?? "", headRefName: pull.branch, headRefOid: oid, isDraft: !!pull.draft, diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts index 6de077f8..12d3a5b9 100644 --- a/cloudflare/dev/seed.ts +++ b/cloudflare/dev/seed.ts @@ -49,8 +49,10 @@ export function world(): World { content: quest("Later", "Work after the beta.", [ "- [Offline mode](/quest/m1/offline.md) - keep working without a network", "- [Themes](/quest/m1/themes.md) - custom colors", + "- [Search](/quest/m1/search.md) - find anything", ]), }, + { path: "quest/m1/search.md", content: quest("[S] Search across projects", "Find anything.") }, { path: "quest/m1/offline.md", content: quest( @@ -109,6 +111,38 @@ export function world(): World { checks: null, review: "REVIEW_REQUIRED", }, + // A claim its starter still holds: work in progress, not a decision. + { + number: 17, + title: "Draft the theme picker", + author: "contributor", + branch: "quest/m1/themes", + checks: "PENDING", + review: null, + draft: true, + body: "Started on the picker.", + }, + // A draft handed back with decisions: it waits on the maintainer. + { + number: 18, + title: "Search quest titles", + author: "contributor", + branch: "quest/m1/search", + checks: "SUCCESS", + review: null, + draft: true, + body: "Titles are indexed.\n\n## Decisions needed\n\n1. Index quest bodies too?\n", + }, + // Clean when first read; `seed` rereads it while GitHub reports UNKNOWN. + { + number: 19, + title: "Speed up cold start", + author: "contributor", + branch: "perf/cold-start", + checks: "SUCCESS", + review: "APPROVED", + merge: "CLEAN", + }, ], claims: ["quest/m0/docs"], }; @@ -213,6 +247,9 @@ export async function seed(options: { fetch: Fetch; db: D1Database; world: World "pause archive", ); + // GitHub recomputes a recorded PR's mergeability, such as after a push to + // its base. The read below keeps its last known state rather than marking it new. + rocket.pulls.find((pull) => pull.number === 19)!.merge = "UNKNOWN"; // An issue opened since the refresh: its webhook rereads the project in the background. const pointer = { number: 9, title: "Sync stops after the laptop sleeps", author: "reporter" }; rocket.issues.push(pointer); diff --git a/cloudflare/migrations/0015_activity_reads.sql b/cloudflare/migrations/0015_activity_reads.sql new file mode 100644 index 00000000..79eb7851 --- /dev/null +++ b/cloudflare/migrations/0015_activity_reads.sql @@ -0,0 +1,9 @@ +-- Activity reads that every board, inbox, and background read makes stay +-- proportional to open work, not to a repository's history. `readState` reads +-- only open entities and unread webhook pointers, in this index's order (its +-- WHERE term matches the index's exactly), and a background read takes the +-- entities webhooks named. The paged activity log, read only on its own page, +-- sorts the repository's rows, which retention bounds. +CREATE INDEX github_project_activity_live ON github_project_activity(repository,firstSeen,kind,id) + WHERE (data IS NULL OR json_extract(data,'$.state')='OPEN'); +CREATE INDEX github_project_activity_stale ON github_project_activity(repository,changedAt,id) WHERE stale>0; diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts index 94450e01..9a866e3f 100644 --- a/cloudflare/src/board/model.ts +++ b/cloudflare/src/board/model.ts @@ -14,7 +14,8 @@ export type Status = | { kind: "turn-in"; change: Change } | { kind: "available" } | { kind: "pending" } - | { kind: "accepted" } + /** Claimed by a quest branch, or by the draft PR its starter still holds. */ + | { kind: "accepted"; change?: Change } | { kind: "blocked"; requires: Ref[] }; /** A link to another quest, or a blocker's own words when it names none. */ @@ -135,7 +136,7 @@ export function readBoard(project: Project): Board { const status = (doc: Doc): Status => { const change = changes.get(doc.path); - if (change) return { kind: "turn-in", change }; + if (change) return change.owned ? { kind: "accepted", change } : { kind: "turn-in", change }; if (project.claims?.includes(doc.path)) return { kind: "accepted" }; if (free.has(doc.path)) return { kind: "available" }; if (open.has(doc.path)) return { kind: "pending" }; diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx index 847786cb..c39389dd 100644 --- a/cloudflare/src/board/pages.tsx +++ b/cloudflare/src/board/pages.tsx @@ -340,10 +340,17 @@ function Actions(props: { board: Board; quest: Quest; user: User | null; path: s if (status.kind === "accepted") return ( -

- A quest branch already claims this work. Review its current GitHub state before starting another - implementation. -

+ {status.change ? ( +

+ {status.change.author} is working on this in a draft PR. It + waits on you once they mark it ready or hand back decisions. +

+ ) : ( +

+ A quest branch already claims this work. Review its current GitHub state before starting another + implementation. +

+ )}
); if (status.kind === "pending") diff --git a/cloudflare/src/board/project.ts b/cloudflare/src/board/project.ts index 2ef3e6ee..1d2a8944 100644 --- a/cloudflare/src/board/project.ts +++ b/cloudflare/src/board/project.ts @@ -34,6 +34,8 @@ export type Change = { href: string; /** The pull request's number, when the change is one observed on GitHub. */ number?: number; + /** A draft its author still holds, without a `## Decisions needed` section: a claim, not yet turned in. */ + owned?: boolean; }; /** An issue filed from a fork, quarantined under `issues/` until a maintainer promotes it. */ diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts index a0e0e41b..35181293 100644 --- a/cloudflare/src/github/state.ts +++ b/cloudflare/src/github/state.ts @@ -1,4 +1,4 @@ -import { parse, ready } from "quest/core"; +import { handedBack, parse, ready } from "quest/core"; import type { Project, Snapshot } from "../board/project"; import { GitHubError, type GitHubRepository, ProjectChanged } from "./service"; import { positiveId } from "./webhook"; @@ -17,6 +17,8 @@ type Entity = { title: string; state: string; updatedAt: string; + /** When it closed or merged; absent in older observations. */ + closedAt?: string | null; author: { login: string } | null; comments: { totalCount: number }; }; @@ -24,6 +26,8 @@ export type Pull = Entity & { headRefName: string; headRefOid: string; isDraft: boolean; + /** Its body has a `## Decisions needed` section; the body itself is not kept. Absent in older observations. */ + handedBack?: boolean; reviewDecision: string | null; /** GitHub's merge readiness for the App, such as CLEAN, BLOCKED, or DIRTY; absent in older observations. */ mergeStateStatus?: string; @@ -66,8 +70,15 @@ export type ProjectState = { failure: string | null; }; -const entityFields = "databaseId:fullDatabaseId number title state updatedAt author { login } comments { totalCount }"; -const pullFields = `${entityFields} headRefName headRefOid isDraft reviewDecision mergeStateStatus reviews { totalCount } latestReviews(first:20) { nodes { state author {login} commit {oid} } pageInfo {hasNextPage} } commits(last:1) { nodes { commit { oid statusCheckRollup { state } } } }`; +/** + * How long a closed issue or PR stays in the activity log. Only the log shows + * closed entities, so after this its row is pruned when history next reads it. + */ +export const RETENTION = 30 * 24 * 60 * 60 * 1000; + +const entityFields = + "databaseId:fullDatabaseId number title state updatedAt closedAt author { login } comments { totalCount }"; +const pullFields = `${entityFields} body headRefName headRefOid isDraft reviewDecision mergeStateStatus reviews { totalCount } latestReviews(first:20) { nodes { state author {login} commit {oid} } pageInfo {hasNextPage} } commits(last:1) { nodes { commit { oid statusCheckRollup { state } } } }`; // Cursor variables are declared only for unfinished connections (GraphQL rejects // unused variables). Fixed page sizes bound subrequests, independent of tree size. export function activityQuery({ history, occupancy }: Scan, stale: Stale[] = []) { @@ -142,6 +153,26 @@ function validate(kind: "issue" | "pull", item: Entity) { } } +/** What is kept of an observed entity: a PR's body only as whether it hands its draft back. */ +function observed(kind: "issue" | "pull", item: Entity): Entity { + if (kind === "issue") return item; + const { body, ...pull } = item as Pull & { body?: unknown }; + return { ...pull, handedBack: typeof body === "string" && handedBack(body) } as Pull; +} + +/** + * A draft its starter still holds: claimed work in progress until its body + * hands it back with `## Decisions needed`, as the guide defines. + */ +export function owned(pull: Pull) { + return pull.isDraft && !pull.handedBack; +} + +/** Closed for longer than `RETENTION`. */ +function expired(item: Entity, now: number) { + return item.state !== "OPEN" && typeof item.closedAt === "string" && Date.parse(item.closedAt) < now - RETENTION; +} + /** Entities that webhooks named since they were last read by number, oldest first. */ export async function staleEntities(db: D1Database, repository: number) { return ( @@ -201,7 +232,7 @@ export async function reconcile( if (cursor === false) continue; for (const item of connection!.nodes) { validate(kind, item); - entities.set(`${kind}/${item.databaseId}`, { kind, item, stale: 0 }); + entities.set(`${kind}/${item.databaseId}`, { kind, item: observed(kind, item), stale: 0 }); if (open) updated.occupancy.pulls.push(item.databaseId); } } @@ -213,12 +244,30 @@ export async function reconcile( if (item) validate(entity.kind, item); // A number GitHub no longer resolves to this entity, such as a deleted issue. if (!item || item.databaseId !== entity.id) gone.push(entity); - else entities.set(`${entity.kind}/${entity.id}`, { kind: entity.kind, item, stale: entity.stale }); + else + entities.set(`${entity.kind}/${entity.id}`, { + kind: entity.kind, + item: observed(entity.kind, item), + stale: entity.stale, + }); }); + // Long-closed entities lose their rows, unless a webhook named one again since it was read. + const kept = [...entities.values()].filter((entity) => !expired(entity.item, now)); + const pruned = [...entities.values()] + .filter((entity) => expired(entity.item, now)) + .map(({ kind, item, stale }) => ({ kind, id: item.databaseId, stale })); updated.occupancy.claims = [...new Set(updated.occupancy.claims)]; updated.occupancy.pulls = [...new Set(updated.occupancy.pulls)]; const current = "EXISTS(SELECT 1 FROM github_project_state WHERE repository=? AND revision=? AND epoch=?)"; - // json_each keeps the transaction to four statements and a few bindings, + // GitHub reports UNKNOWN while it computes mergeability, and may send no + // event once it has. A recorded PR at the same head keeps its last known + // merge state; at a new head it keeps its changedAt, so the change is news + // once a definite state arrives. A newly seen PR is news either way. + const prior = "github_project_activity.data"; + const unknown = `json_extract(excluded.data,'$.mergeStateStatus')='UNKNOWN' AND ${prior} IS NOT NULL`; + const keeps = `${unknown} AND json_extract(${prior},'$.headRefOid')=json_extract(excluded.data,'$.headRefOid') AND json_extract(${prior},'$.mergeStateStatus') IS NOT NULL`; + const data = `CASE WHEN ${keeps} THEN json_set(excluded.data,'$.mergeStateStatus',json_extract(${prior},'$.mergeStateStatus')) ELSE excluded.data END`; + // json_each keeps the transaction to five statements and a few bindings, // within D1's free query budget and SQLite's bound-parameter limit. const statements = [ db @@ -226,9 +275,14 @@ export async function reconcile( `INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn,stale) SELECT ?,json_extract(value,'$.kind'),json_extract(value,'$.item.databaseId'),json_extract(value,'$.item.number'),?,?,json_extract(value,'$.item'),?,json_extract(value,'$.stale') FROM json_each(?) WHERE ${current} - ON CONFLICT(repository,kind,id) DO UPDATE SET number=excluded.number,changedAt=CASE WHEN github_project_activity.data IS NOT excluded.data THEN excluded.changedAt ELSE github_project_activity.changedAt END,data=excluded.data,seenIn=excluded.seenIn,missing=0,stale=CASE WHEN excluded.stale>0 AND github_project_activity.stale=excluded.stale THEN 0 ELSE github_project_activity.stale END`, + ON CONFLICT(repository,kind,id) DO UPDATE SET number=excluded.number,changedAt=CASE WHEN ${unknown} AND NOT (${keeps}) THEN github_project_activity.changedAt WHEN ${prior} IS NOT ${data} THEN excluded.changedAt ELSE github_project_activity.changedAt END,data=${data},seenIn=excluded.seenIn,missing=0,stale=CASE WHEN excluded.stale>0 AND github_project_activity.stale=excluded.stale THEN 0 ELSE github_project_activity.stale END`, ) - .bind(repo.id, now, now, history.id, JSON.stringify([...entities.values()]), repo.id, revision, epoch), + .bind(repo.id, now, now, history.id, JSON.stringify(kept), repo.id, revision, epoch), + db + .prepare( + `DELETE FROM github_project_activity WHERE repository=? AND EXISTS(SELECT 1 FROM json_each(?) WHERE json_extract(value,'$.kind')=github_project_activity.kind AND json_extract(value,'$.id')=github_project_activity.id AND json_extract(value,'$.stale')=github_project_activity.stale) AND ${current}`, + ) + .bind(repo.id, JSON.stringify(pruned), repo.id, revision, epoch), db .prepare( `UPDATE github_project_activity SET missing=1,stale=CASE WHEN stale=json_extract(value,'$.stale') THEN 0 ELSE stale END @@ -267,6 +321,37 @@ export function startable(state: ProjectState, quest: string, retry: number | nu ); } +/** Open entities and unread webhook pointers; migration 0015's partial index matches this term exactly. */ +const LIVE = "(data IS NULL OR json_extract(data,'$.state')='OPEN')"; + +type Row = Omit & { data: string | null }; +const parsed = (rows: Row[]): Activity[] => + rows.map((item) => ({ ...item, data: item.data ? JSON.parse(item.data) : null })); + +/** Where `readActivity` resumes: the last shown row's position, newest first. */ +const CURSOR = /^(\d+)-(issue|pull)-([1-9]\d*)$/; + +/** + * One page of a project's activity log, open and closed alike, newest first, + * and the cursor of the next page. An unreadable cursor starts from the top. + */ +export async function readActivity(db: D1Database, repository: number, before: string | null = null, limit = 50) { + const at = before ? CURSOR.exec(before) : null; + const rows = ( + await db + .prepare( + `SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity + WHERE repository=?1 AND (?2 IS NULL OR (firstSeen,kind,id)<(?2,?3,?4)) + ORDER BY firstSeen DESC,kind DESC,id DESC LIMIT ?5`, + ) + .bind(repository, at ? Number(at[1]) : null, at?.[2] ?? null, at?.[3] ?? null, limit + 1) + .all() + ).results; + const items = parsed(rows.slice(0, limit)); + const last = items.at(-1); + return { items, next: rows.length > limit && last ? `${last.firstSeen}-${last.kind}-${last.id}` : null }; +} + export async function readState(db: D1Database, repo: GitHubRepository): Promise { const records = await db.batch([ db @@ -274,9 +359,11 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise "SELECT snapshot,scan,observedAt,revision,invalidated,failure FROM github_project_state WHERE repository=?", ) .bind(repo.id), + // Only open entities and unread pointers decide the board, the inbox, and + // jobs, so this read grows with open work, not history. db .prepare( - "SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity WHERE repository=? ORDER BY firstSeen DESC,id DESC", + `SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity WHERE repository=? AND ${LIVE} ORDER BY firstSeen DESC,kind DESC,id DESC`, ) .bind(repo.id), ]); @@ -293,8 +380,7 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise if (!row?.snapshot || !row.scan) return null; const snapshot: Snapshot = JSON.parse(row.snapshot); const scan: Scan = JSON.parse(row.scan); - const rows = records[1]!.results as (Omit & { data: string | null })[]; - const activity: Activity[] = rows.map((item) => ({ ...item, data: item.data ? JSON.parse(item.data) : null })); + const activity = parsed(records[1]!.results as Row[]); const paths = new Set(snapshot.documents.map((d) => d.path)); // A PR's activity row can still say OPEN until history rereads it, so only // PRs in the latest occupancy pass count as open. @@ -311,6 +397,7 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise author: pull.author?.login ?? "GitHub contributor", href: `https://github.com/${repo.owner}/${repo.name}/pull/${pull.number}`, number: pull.number, + owned: owned(pull), }, ] : []; diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index 79c8f26c..028fee69 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -6,7 +6,7 @@ import { parse } from "quest/core"; import { encodePath } from "../board/model"; import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service"; -import { type Activity, type ProjectState, readState } from "../github/state"; +import { type Activity, type ProjectState, type Pull, owned, readState } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { hub } from "../jobs/routes"; import * as store from "../jobs/store"; @@ -106,6 +106,11 @@ export function entities(project: Enrollment, state: ProjectState | null) { return found; } +/** A PR whose observed draft its starter still holds, so nothing waits on the maintainer. */ +function held(entity: Pick) { + return entity.kind === "pull" && !!entity.activity?.data && owned(entity.activity.data as Pull); +} + function fallback(project: Enrollment, target: string): Entity { const web = `https://github.com/${project.owner}/${project.name}`; const kind = kindOf(target); @@ -151,8 +156,11 @@ export function decisions(view: ProjectView, found: Map, jobs: J } 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; + // A draft its starter still holds is their work in progress until they hand it back. + if (!held(entity)) { + need = "start"; + ended = !!job && !changed; + } } else continue; const read = reads.find((r) => r.repository === project.id && r.item === target); let prompt: Prompt | null = null; diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index ee844133..17269849 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -44,12 +44,15 @@ function needLabel(item: Item) { 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"; + if (!item.job) return "Claimed draft"; + 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 === null) + return "Its starter holds this draft. 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 (job.state === "queued") diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index c24adb9d..28f656f9 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -10,6 +10,7 @@ import { type Project, findProject, home } from "./board/project"; import { DocIndex, DocPage, findDoc } from "./docs"; import { Home } from "./home"; import { type Enrollment, GitHubError, GitHubService } from "./github/service"; +import { readActivity } from "./github/state"; import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects"; import { githubWebhooks } from "./github/webhook"; import * as inbox from "./inbox/model"; @@ -200,19 +201,26 @@ async function liveProject(c: Context<{ Bindings: Env }>, refresh = false) { const github = new GitHubService(c.env); const repo = await github.enrolledProject(c.req.param("owner")!, c.req.param("repository")!, session.user.id); if (!repo) throw new GitHubError("Project is not enrolled"); - return github.projectState(repo, session.user.id, refresh); + return { repo, state: await github.projectState(repo, session.user.id, refresh) }; } app.get("/projects/github/:owner/:repository/state", async (c) => { - const state = await liveProject(c); + const { repo, state } = await liveProject(c); + // The activity log is the one view of closed entities, so it pages. + const log = await readActivity(c.env.DB, repo.id, c.req.query("before") ?? null); return page( c, { title: "Project activity", description: "Observed GitHub project state." }, - , + , ); }); app.get("/projects/github/:owner/:repository/data", async (c) => { c.header("Cache-Control", "no-store"); - return c.json(await liveProject(c)); + return c.json((await liveProject(c)).state); }); app.post("/projects/github/:owner/:repository/refresh", async (c) => { await liveProject(c, true); @@ -290,7 +298,7 @@ app.get("/inbox", async (c) => { ); }); app.get("/projects/github/:owner/:repository/board", async (c) => { - const state = await liveProject(c); + const { state } = await liveProject(c); if (!state) return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303); return page( c, @@ -302,7 +310,7 @@ app.get("/projects/github/:owner/:repository/board", async (c) => { ); }); app.get("/projects/github/:owner/:repository/board/quest/*", async (c) => { - const state = await liveProject(c); + const { state } = await liveProject(c); if (!state) return c.notFound(); let segments: string[]; try { diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index a6dce431..03065ef5 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -1,4 +1,4 @@ -import type { ProjectState, Pull } from "./github/state"; +import type { Activity, ProjectState, Pull } from "./github/state"; import type { Enrollment, ProjectPreview } from "./github/service"; import { Difficulty, Marker } from "./layout"; import { title } from "./board/model"; @@ -213,7 +213,13 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E ); } -export function ProjectStatePage(props: { owner: string; repository: string; state: ProjectState | null }) { +export function ProjectStatePage(props: { + owner: string; + repository: string; + state: ProjectState | null; + /** One page of the activity log, and where the next begins. */ + log: { items: Activity[]; next: string | null }; +}) { const base = projectURL(props.owner, props.repository); const state = props.state; return ( @@ -262,9 +268,9 @@ export function ProjectStatePage(props: { owner: string; repository: string; sta

No observed branch claims.

)}

Issues and pull requests

- {state.activity.length ? ( + {props.log.items.length ? (
    - {state.activity.map((item) => ( + {props.log.items.map((item) => (
  • No activity observed yet.

    )} + {props.log.next && ( +

    + Older activity +

    + )} ) : (

    Refresh to read this enrolled project's quest tree and activity.

    diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 94f2c255..4f8516cc 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -152,6 +152,31 @@ describe("decision inbox", () => { expect(thread(html)).not.toContain("Plan this issue"); }); + it("shows a draft its starter holds as work in progress until it hands back decisions", async () => { + const draft = (handedBack: boolean) => + db + .prepare( + "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?)),changedAt=changedAt+1 WHERE id='21'", + ) + .bind(String(handedBack)) + .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>

  • `))?.[0] ?? ""; + expect(row("Support custom color themes")).toContain("Claimed"); + expect(row("Search across projects")).toContain("Ready to turn in"); + + // The items this test checks open on their own. Each inbox page rechecks + // GitHub authority, so the inbox tests cover the other threads. + const open = async (target: string) => { + const response = await request(inboxURL(9101, target), { headers: { Cookie: cookie } }); + expect(response.status).toBe(200); + return response.text(); + }; + expect(await open("pulls/17")).toContain("Its starter holds this draft"); + // GitHub's UNKNOWN on a recorded pull request keeps the clean state it last reported. + expect(await open("pulls/19")).toContain("/pulls/19/merge"); // The clean pull request merges through the fake GitHub. - const pull = await request("/inbox?project=9101&item=pulls/12", { headers: { Cookie: cookie } }); - const head = (await pull.text()).match( + const head = (await open("pulls/12")).match( /action="([^"]+\/pulls\/12\/merge)"[\s\S]*?name="head" value="([0-9a-f]{40})"/, ); expect(head).not.toBeNull(); diff --git a/design/theme.md b/design/theme.md index 893197f5..e65e22b9 100644 --- a/design/theme.md +++ b/design/theme.md @@ -57,7 +57,9 @@ A quest shows its most advanced state: Ready to turn in, then Accepted by, then Available or Requires. Accepted by comes from a live runner lock: an active job in kix.quest's database, which moves when its runner goes offline; open changes carry Ready to turn in. On GitHub, a draft PR from the quest branch, opened -right away and from a fork too, claims work. +right away and from a fork too, claims work: the board shows it as Claimed +by its starter until its body hands it back with a `## Decisions needed` +section or it is marked ready, and only then as Ready to turn in. None of these states adds a section to the quest file. Tokens are display only: a run's spend and donated tokens show with an amber diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 0438fedb..479d091d 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -99,7 +99,7 @@ operator-paid-hosting decisions: - [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR - [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID - [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest//README` branch -- [Inbox accuracy](/quest/m0/kix/inbox-accuracy.md) - draft claims, unknown merge states, and paged, pruned activity +- [Activity retention](/quest/m0/kix/inbox-accuracy.md) - the user picks how long closed activity stays - [Job recovery](/quest/m0/kix/recovery.md) - retries keep their answers, and blocked branches can start over - [Runner](/quest/m0/kix/runner.md) - `quest runner` on Linux and macOS with Nix devShells, accounts, and the Claude Code adapter - [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners diff --git a/quest/m0/kix/execution.md b/quest/m0/kix/execution.md index e583a57b..fb2928f9 100644 --- a/quest/m0/kix/execution.md +++ b/quest/m0/kix/execution.md @@ -27,8 +27,9 @@ time: - A start job claims through the bot exactly as `quest start` does: a draft PR. kix treats PRs that local sessions hold as owned, shows that owner on the card, and never acts on them: a `quest:*` label, or a draft with no - `## Decisions needed` section. A stale one is offered to the maintainer for - takeover. + `## Decisions needed` section. The inbox already lists such a draft as in + progress, with no actions; the label half remains. A stale one is offered + to the maintainer for takeover. - A PR with no `quest:*` label that is ready, or a draft with `## Decisions needed`, is a decision item offering the `/quest-complete` actions, one decision per PR: mark ready, merge (the diff --git a/quest/m0/kix/inbox-accuracy.md b/quest/m0/kix/inbox-accuracy.md index 345635ce..72d0f55f 100644 --- a/quest/m0/kix/inbox-accuracy.md +++ b/quest/m0/kix/inbox-accuracy.md @@ -1,38 +1,25 @@ -# [M] Keep the inbox accurate on busy projects +# [XS] Choose the activity retention period ## Goal -The board and decision inbox show each quest and PR in its real state, mark -items unread only when something a maintainer would act on changed, and stay -fast on repositories with long histories. +The Worker prunes a closed issue or PR's activity row after a retention period +the user chose from measured row counts, so the activity log +keeps the closed history maintainers use and storage stays bounded. ## Plan -Decided 2026-10-21 from the follow-ups of -[kixelated/quest#132](https://github.com/kixelated/quest/pull/132), -[kixelated/quest#157](https://github.com/kixelated/quest/pull/157), and -[kixelated/quest#163](https://github.com/kixelated/quest/pull/163): - -- **Draft claims.** The board classifies a draft PR without a - `## Decisions needed` section as claimed work in progress, owned by its - starter, and a draft with that section as waiting on the maintainer, as the - guide defines. Today a draft without the section can show as Accepted. -- **Unknown merge state.** GitHub reports `mergeStateStatus` `UNKNOWN` while it - computes mergeability. On an already-recorded PR, treat it as not yet known: - keep the item's previous state and do not mark it unread until a definite - state arrives. A newly seen PR still surfaces as unread, since GitHub may - send no further event once the computation finishes. -- **Retention (user).** Page every activity read (`readState` in - `cloudflare/src/github/state.ts` reads every row on each `/board`, `/state`, - `/data`, and inbox view), and prune a closed entity's activity rows once it - has been closed for a fixed period. Choose the period from measured row - counts on a busy pilot repository and record it here. Rejected: paging - without pruning (unbounded storage) and dropping rows on close (the board - loses closed history). -- Extend the seeded dev server (`cloudflare/dev/seed.ts`) with a claimed - draft, a draft that hands back decisions, and an `UNKNOWN` PR, so - `cloudflare/test/seed.test.ts` covers each state. -- Keep `cloudflare/test/seed.test.ts` within the Worker suite's 5-second test - timeout as the seed grows; it was the only test to time out under 8× CPU - overload in [kixelated/quest#177](https://github.com/kixelated/quest/pull/177). - Slim it rather than raising its timeout. +- Retention (user): `RETENTION` in `cloudflare/src/github/state.ts` is 30 + days until the user decides. Pruning, paged reads, draft claims, and the + `UNKNOWN` merge state shipped with it. +- Activity rows mirror GitHub entities one to one, so the counts come from + GitHub. Measured 2026-10-21 on + [moq-dev/moq](https://github.com/moq-dev/moq), the busiest candidate pilot: + 6,265 issues and PRs, 98 open, and 766 closed in the last 7 days, 2,380 in + 30, 3,687 in 90, and 4,795 in 180. Steady-state rows are the open count plus + those closed within the period: about 0.9k at 7 days, 2.5k at 30, and 3.8k + at 90. [kixelated/quest](https://github.com/kixelated/quest) keeps every + row under any period (192 in total). +- Only the paged activity log shows closed rows; the board, inbox, and jobs + read open rows through a partial index whatever the period. +- Once the period is chosen, set `RETENTION` with its reason in the comment + beside it, and delete this quest. diff --git a/src/cli/claims.ts b/src/cli/claims.ts index b5ff04d2..637d365b 100644 --- a/src/cli/claims.ts +++ b/src/cli/claims.ts @@ -3,6 +3,7 @@ // here first, and `quest start` records a claim the same way. import { execFileSync } from "node:child_process"; +import { handedBack } from "../core"; import { type GitHubDependencies, type Repository, @@ -13,9 +14,6 @@ import { string, } from "./github"; -/** The heading that hands a draft back to the maintainer; only its presence matters. */ -const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im; - /** A quest's branch: its path without `.md`. */ export function questBranch(path: string): string { return path.replace(/\.md$/, ""); @@ -134,7 +132,7 @@ export async function openPullRequests(github: GitHub, repository: Repository): draft: row.draft === true, created: Date.parse(string(row.created_at)), updated: Date.parse(string(row.updated_at)), - handedBack: typeof row.body === "string" && HAND_BACK.test(row.body), + handedBack: typeof row.body === "string" && handedBack(row.body), }; }); } diff --git a/src/core/claim.ts b/src/core/claim.ts new file mode 100644 index 00000000..5079c0bf --- /dev/null +++ b/src/core/claim.ts @@ -0,0 +1,10 @@ +// How a quest's PR says whose move it is. A draft is its starter's claim until +// its body hands it back with a `## Decisions needed` section (the guide's +// Execution section); the CLI and the Worker read that the same way. + +const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im; + +/** Whether a PR body hands its draft back to the maintainer; only the heading's presence matters. */ +export function handedBack(body: string): boolean { + return HAND_BACK.test(body); +} diff --git a/src/core/index.ts b/src/core/index.ts index 005bbbe1..69f6004f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -23,6 +23,7 @@ export { resolve, rooted, } from "./doc"; +export { handedBack } from "./claim"; export { type Missed, type Moved, type Source, move, relocate, remove } from "./edit"; export { comparePaths, normalize } from "./path"; export { type Blocker, blockers, label, lookup, ready, renderBlocker } from "./ready"; From a3ca546dcf17c6d165c324fc9c2e16a7e18ff917 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 08:40:31 -0700 Subject: [PATCH 3/3] feat(kix): settle 30-day activity retention and finish the quest The user chose 30 days, so RETENTION is final and the remaining inbox-accuracy quest is deleted with its links. Co-Authored-By: Claude Opus 5.5 --- cloudflare/src/github/state.ts | 2 ++ quest/m0/kix/README.md | 1 - quest/m0/kix/inbox-accuracy.md | 25 ------------------------- 3 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 quest/m0/kix/inbox-accuracy.md diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts index 35181293..3c2bbbec 100644 --- a/cloudflare/src/github/state.ts +++ b/cloudflare/src/github/state.ts @@ -73,6 +73,8 @@ export type ProjectState = { /** * How long a closed issue or PR stays in the activity log. Only the log shows * closed entities, so after this its row is pruned when history next reads it. + * 30 days keeps a month of closed history while bounding storage: about 2.5k + * rows on moq-dev/moq (measured 2026-10-21), against 6.3k unpruned and growing. */ export const RETENTION = 30 * 24 * 60 * 60 * 1000; diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 479d091d..2cba0716 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -99,7 +99,6 @@ operator-paid-hosting decisions: - [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR - [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID - [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest//README` branch -- [Activity retention](/quest/m0/kix/inbox-accuracy.md) - the user picks how long closed activity stays - [Job recovery](/quest/m0/kix/recovery.md) - retries keep their answers, and blocked branches can start over - [Runner](/quest/m0/kix/runner.md) - `quest runner` on Linux and macOS with Nix devShells, accounts, and the Claude Code adapter - [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners diff --git a/quest/m0/kix/inbox-accuracy.md b/quest/m0/kix/inbox-accuracy.md deleted file mode 100644 index 72d0f55f..00000000 --- a/quest/m0/kix/inbox-accuracy.md +++ /dev/null @@ -1,25 +0,0 @@ -# [XS] Choose the activity retention period - -## Goal - -The Worker prunes a closed issue or PR's activity row after a retention period -the user chose from measured row counts, so the activity log -keeps the closed history maintainers use and storage stays bounded. - -## Plan - -- Retention (user): `RETENTION` in `cloudflare/src/github/state.ts` is 30 - days until the user decides. Pruning, paged reads, draft claims, and the - `UNKNOWN` merge state shipped with it. -- Activity rows mirror GitHub entities one to one, so the counts come from - GitHub. Measured 2026-10-21 on - [moq-dev/moq](https://github.com/moq-dev/moq), the busiest candidate pilot: - 6,265 issues and PRs, 98 open, and 766 closed in the last 7 days, 2,380 in - 30, 3,687 in 90, and 4,795 in 180. Steady-state rows are the open count plus - those closed within the period: about 0.9k at 7 days, 2.5k at 30, and 3.8k - at 90. [kixelated/quest](https://github.com/kixelated/quest) keeps every - row under any period (192 in total). -- Only the paged activity log shows closed rows; the board, inbox, and jobs - read open rows through a partial index whatever the period. -- Once the period is chosen, set `RETENTION` with its reason in the comment - beside it, and delete this quest.