From fb7103a60d201652e3d92afb618c946edb950651 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:44:21 -0700 Subject: [PATCH 1/3] chore(quest): claim quest/m0/kix/harness.md Co-Authored-By: Claude Opus 5.5 From 1a6c75cd8db4de4205c7de324040f0dcf0b6241d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:08:48 -0700 Subject: [PATCH 2/3] feat(kix): run the Worker locally with seeded data just worker-seeded bundles the Worker as wrangler deploy --dry-run does and serves it on Miniflare with in-memory storage, the checked-in migrations, a fake GitHub, and a local sign-in. It works where wrangler dev hangs. The seed goes through the Worker's own routes and runs in the Worker tests too. Running the real fetch exposed two bugs that broke every GitHub App call in the deployed Worker: workerd refuses redirect: "error", and calling fetch as this.#fetch() throws Illegal invocation. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 1 + cloudflare/README.md | 32 +++ cloudflare/dev/github.ts | 226 ++++++++++++++++ cloudflare/dev/seed.ts | 445 +++++++++++++++++++++++++++++++ cloudflare/dev/serve.ts | 129 +++++++++ cloudflare/package.json | 2 + cloudflare/src/github/service.ts | 6 +- cloudflare/src/licenses.ts | 3 +- cloudflare/test/licenses.test.ts | 6 +- cloudflare/test/seed.test.ts | 64 +++++ cloudflare/tsconfig.json | 2 +- justfile | 4 + quest/m0/kix/README.md | 4 +- quest/m0/kix/harness.md | 32 --- 14 files changed, 917 insertions(+), 39 deletions(-) create mode 100644 cloudflare/dev/github.ts create mode 100644 cloudflare/dev/seed.ts create mode 100644 cloudflare/dev/serve.ts create mode 100644 cloudflare/test/seed.test.ts delete mode 100644 quest/m0/kix/harness.md diff --git a/bun.lock b/bun.lock index eeb27339..ba2088a6 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "devDependencies": { "@cloudflare/vitest-plugin": "1.4.2", "@types/node": "26.6.5", + "miniflare": "5.20261012.0-beta", "prettier": "3.9.9", "typescript": "7.0.2", "vitest": "5.0.3", diff --git a/cloudflare/README.md b/cloudflare/README.md index 5fb87f75..16f10d57 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -224,6 +224,38 @@ under `cloudflare/.wrangler/`. There are no remote-only bindings: local development and tests need no Cloudflare login. Wrangler reruns the build when `src/`, `scripts/`, or `docs/` change. +### Seeded local server + +Check UI changes in a browser with representative data, and no OAuth app or +Cloudflare login: + +```sh +just worker-seeded # or: just worker-seeded --port 8788 +``` + +This bundles the Worker as `wrangler deploy --dry-run` does and serves it with +Miniflare at `http://localhost:8787`, on in-memory D1, R2, and Durable +Objects, so every run starts from the same seed. It works where `wrangler dev` +hangs, such as sandboxed agent environments. After applying the checked-in +migrations, `dev/seed.ts` signs in a maintainer and enrolls and observes their +projects through the Worker's own routes: ready, claimed, and blocked quests, +issues (one known only from a webhook, one with a long title), pull requests +with checks and reviews, and a paused second project. It then adds runners and +jobs that are queued, running, failed, or waiting on an agent's questions, a +tool permission, or an account limit. + +`dev/github.ts` fakes GitHub for every outbound request, so nothing leaves +localhost and no real credential is used. Sign in with GitHub completes as the +seeded maintainer. From a shell, sign in with a cookie jar: + +```sh +curl -sL -c jar -b jar -H 'Origin: http://localhost:8787' -d provider=github http://localhost:8787/sign-in +``` + +Check phone and desktop widths with the browser's device emulation. The Worker +tests run the same seed (`test/seed.test.ts`), so it follows schema and route +changes, and later end-to-end tests can start from it. + ## Checks ```sh diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts new file mode 100644 index 00000000..b3902e1f --- /dev/null +++ b/cloudflare/dev/github.ts @@ -0,0 +1,226 @@ +// A fake of the GitHub (and SPDX) endpoints the Worker calls, answering from +// an in-memory world. It backs the seeded local server and the seed test, so it +// never holds or forwards real credentials, and it refuses anything it does not model. +import { createHash } from "node:crypto"; + +export type User = { id: number; login: string; name: string; email: string }; +export type Issue = { number: number; title: string; author: string; comments?: number; closed?: boolean }; +export type Pull = Issue & { + /** Head branch; `quest/` associates the pull request with that quest. */ + branch: string; + checks: "SUCCESS" | "FAILURE" | "PENDING" | null; + review: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; + reviews?: { state: string; author: string }[]; + draft?: boolean; +}; +export type Repo = { + id: number; + owner: string; + name: string; + /** Logins with the maintain role; everyone else can only read. */ + maintainers: string[]; + files: { path: string; content: string }[]; + issues: Issue[]; + pulls: Pull[]; + /** Quest branches pushed without a pull request, such as `quest/m0/docs`. */ + claims: string[]; +}; +export type World = { app: { id: number; slug: string }; installation: number; users: User[]; repos: Repo[] }; + +const sha1 = (text: string) => createHash("sha1").update(text).digest("hex"); +/** The Git object ID of a file's content, which the Worker verifies. */ +export const blob = (content: string) => { + const bytes = Buffer.from(content, "utf8"); + return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"); +}; +export const commitOf = (repo: Repo) => sha1(`commit ${repo.owner}/${repo.name}`); +const treeOf = (repo: Repo) => sha1(`tree ${repo.owner}/${repo.name}`); +export const headOf = (repo: Repo, pull: Pull) => sha1(`head ${repo.owner}/${repo.name}#${pull.number}`); +// GitHub's database IDs differ from numbers; derive stable ones per repository. +export const databaseId = (repo: Repo, number: number) => String(repo.id * 1000 + number); +const updatedAt = "2026-10-20T12:00:00Z"; + +function entity(repo: Repo, item: Issue) { + return { + databaseId: databaseId(repo, item.number), + number: item.number, + title: item.title, + state: item.closed ? "CLOSED" : "OPEN", + updatedAt, + author: { login: item.author }, + comments: { totalCount: item.comments ?? 0 }, + }; +} + +function pullNode(repo: Repo, pull: Pull) { + const oid = headOf(repo, pull); + const reviews = pull.reviews ?? []; + return { + ...entity(repo, pull), + headRefName: pull.branch, + headRefOid: oid, + isDraft: !!pull.draft, + reviewDecision: pull.review, + reviews: { totalCount: reviews.length }, + latestReviews: { + nodes: reviews.map((r) => ({ state: r.state, author: { login: r.author }, commit: { oid } })), + pageInfo: { hasNextPage: false }, + }, + commits: { nodes: [{ commit: { oid, statusCheckRollup: pull.checks ? { state: pull.checks } : null } }] }, + }; +} + +const page = (nodes: T[]) => ({ nodes, pageInfo: { hasNextPage: false, endCursor: null } }); + +function graphql(repo: Repo, query: string) { + const blobs = [...query.matchAll(/(q\d+): object\(oid: "([0-9a-f]{40})"\)/g)]; + if (blobs.length) { + const files = new Map(repo.files.map((file) => [blob(file.content), file.content])); + const objects = Object.fromEntries( + blobs.map(([, alias, oid]) => { + const text = files.get(oid!); + return [alias, text === undefined ? null : { oid, text, isBinary: false, isTruncated: false }]; + }), + ); + return { data: { repository: objects } }; + } + const open = repo.pulls.filter((pull) => !pull.closed); + const result: Record = {}; + if (query.includes("issues(")) result.issues = page(repo.issues.map((issue) => entity(repo, issue))); + if (/(? pullNode(repo, pull))); + if (query.includes("open:pullRequests(")) result.open = page(open.map((pull) => pullNode(repo, pull))); + if (query.includes("refs(")) { + const branches = [...repo.claims, ...open.flatMap((p) => (p.branch.startsWith("quest/") ? [p.branch] : []))]; + result.refs = page( + [...new Set(branches.map((b) => b.replace(/^quest\//, "")))].map((name) => ({ + name, + prefix: "refs/heads/quest/", + })), + ); + } + return { data: { repository: result } }; +} + +function tree(repo: Repo) { + const dirs = new Set(); + for (const file of repo.files) { + const parts = file.path.split("/"); + for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/")); + } + return { + sha: treeOf(repo), + truncated: false, + tree: [ + ...[...dirs].map((path) => ({ path, type: "tree", mode: "040000", sha: sha1(`dir ${path}`) })), + ...repo.files.map((file) => ({ path: file.path, type: "blob", mode: "100644", sha: blob(file.content) })), + ], + }; +} + +/** + * Answer one outbound request from the Worker. Sign-in always completes as the + * world's first user; repository reads check that user's maintainer role. + */ +export function fakeGitHub(world: World) { + const json = (body: unknown, status = 200) => Response.json(body, { status }); + // Called like fetch, with the runtime's checks: workerd's fetch throws when + // called as another object's method, and building the Request validates + // RequestInit as the real fetch does, refusing `redirect: "error"`. + return async function (this: unknown, input: RequestInfo | URL, init?: RequestInit): Promise { + if (this !== undefined && this !== globalThis) + throw new TypeError("Illegal invocation: fetch called as a method"); + const request = new Request(input, init); + const url = new URL(request.url); + const method = request.method; + const body = () => request.text(); + const missing = () => { + console.warn(`fake GitHub: no model for ${method} ${url}`); + return json({ message: "Not modeled by the fake GitHub" }, 501); + }; + const path = url.pathname; + const me = world.users[0]!; + if (url.origin === "https://spdx.org") return json({ licenseId: "MIT", isOsiApproved: true }); + if (url.origin === "https://github.com" && path === "/login/oauth/access_token") + return json({ access_token: "fake-user-token", token_type: "bearer", scope: "read:user,user:email" }); + if (url.origin !== "https://api.github.com") return missing(); + if (path === "/user") return json({ id: me.id, login: me.login, name: me.name, email: me.email }); + if (path === "/user/emails") return json([{ email: me.email, primary: true, verified: true }]); + if (path === "/app") return json({ id: world.app.id, slug: world.app.slug }); + if (path === "/installation/token" && method === "DELETE") return new Response(null, { status: 204 }); + if (/^\/app\/installations\/\d+\/access_tokens$/.test(path)) + return json({ token: "fake-installation-token" }, 201); + const user = path.match(/^\/user\/(\d+)$/); + if (user) { + const found = world.users.find((u) => String(u.id) === user[1]); + return found ? json({ id: found.id, login: found.login }) : json({ message: "Not Found" }, 404); + } + if (path === "/graphql") { + const { query, variables } = JSON.parse(await body()) as { + query: string; + variables: Record; + }; + const repo = world.repos.find((r) => r.owner === variables.owner && r.name === variables.name); + return repo ? json(graphql(repo, query)) : json({ errors: [{ message: "Not Found" }] }); + } + const match = path.match(/^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/); + const repo = match && world.repos.find((r) => r.owner === match[1] && r.name === match[2]); + if (!repo) return json({ message: "Not Found" }, 404); + const rest = match![3] ?? ""; + const all = Object.fromEntries( + ["metadata", "contents", "issues", "pull_requests", "checks", "statuses"].map((p) => [p, "write"]), + ); + if (rest === "") + return json({ + id: repo.id, + name: repo.name, + owner: { login: repo.owner }, + private: false, + visibility: "public", + default_branch: "main", + archived: false, + disabled: false, + license: { spdx_id: "MIT" }, + }); + if (rest === "/installation") + return json({ id: world.installation, app_id: world.app.id, suspended_at: null, permissions: all }); + const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/); + if (permission) { + const found = world.users.find((u) => u.login === permission[1]); + if (!found) return json({ message: "Not Found" }, 404); + const role = repo.maintainers.includes(found.login) ? "maintain" : "read"; + return json({ + user: { id: found.id }, + permission: role === "maintain" ? "write" : "read", + role_name: role, + }); + } + if (rest === "/commits/main") return json({ sha: commitOf(repo), commit: { tree: { sha: treeOf(repo) } } }); + if (rest === `/git/trees/${treeOf(repo)}`) return json(tree(repo)); + const item = rest.match(/^\/(issues|pulls)\/(\d+)$/); + if (item) { + const list: Issue[] = item[1] === "pulls" ? repo.pulls : repo.issues; + const found = list.find((i) => String(i.number) === item[2]); + if (!found) return json({ message: "Not Found" }, 404); + const head = item[1] === "pulls" ? { head: { sha: headOf(repo, found as Pull) } } : {}; + return json({ number: found.number, title: found.title, state: found.closed ? "closed" : "open", ...head }); + } + if (rest.startsWith("/contents/")) { + const file = repo.files.find((f) => f.path === decodeURIComponent(rest.slice("/contents/".length))); + return file ? json({ type: "file", path: file.path }) : json({ message: "Not Found" }, 404); + } + return missing(); + }; +} + +/** Sign a webhook body the way GitHub does. */ +export async function webhookSignature(secret: string, body: string) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); + return "sha256=" + Buffer.from(digest).toString("hex"); +} diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts new file mode 100644 index 00000000..bd53e38a --- /dev/null +++ b/cloudflare/dev/seed.ts @@ -0,0 +1,445 @@ +// Representative kix state for checking UI changes locally, and the base for +// stubbed end-to-end tests. Sign-in, enrollment, observations, and webhooks go +// through the Worker's own routes against the fake GitHub, so they follow the +// current code; only runners and jobs, which need a live runner, are written +// to D1 directly. +import { type Repo, type World, commitOf, databaseId, headOf, webhookSignature } from "./github"; + +const quest = (title: string, goal: string, required: string[] = []) => + `# ${title}\n\n## Goal\n\n${goal}\n` + (required.length ? `\n## Required\n\n${required.join("\n")}\n` : ""); + +/** A fresh world: `seed` adds to it, and the fake GitHub answers from it. */ +export function world(): World { + const rocket: Repo = { + id: 9101, + owner: "example", + name: "rocket", + maintainers: ["dev-maintainer"], + files: [ + { path: "flake.nix", content: "{ outputs = _: { }; }\n" }, + { + path: "quest/README.md", + content: quest("Quests", "Plan the rocket app.", [ + "- [M0: Beta](/quest/m0/README.md) - the first public beta", + "- [M1: Later](/quest/m1/README.md) - after the beta", + ]), + }, + { + path: "quest/m0/README.md", + content: quest("Beta", "Ship the first public beta.", [ + "- [Landing page](/quest/m0/landing.md) - explain the app", + "- [Passkey sign-in](/quest/m0/sign-in.md) - sign in without a password", + "- [Getting started](/quest/m0/docs.md) - a first-run guide", + "- [Crash telemetry](/quest/m0/telemetry.md) - learn about crashes", + "- [ZIP export](/quest/m0/export.md) - take your data with you", + ]), + }, + { path: "quest/m0/landing.md", content: quest("[S] Build the landing page", "Explain the app.") }, + { path: "quest/m0/sign-in.md", content: quest("[M] Add passkey sign-in", "Sign in without a password.") }, + { path: "quest/m0/docs.md", content: quest("[XS] Write the getting-started guide", "A first-run guide.") }, + { + path: "quest/m0/telemetry.md", + content: quest("[S] Report anonymous crash telemetry", "Learn about crashes.", [ + "- [Passkey sign-in](/quest/m0/sign-in.md) - telemetry is opt-in per account", + ]), + }, + { path: "quest/m0/export.md", content: quest("[S] Export a project as a ZIP archive", "Take your data.") }, + { + path: "quest/m1/README.md", + 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", + ]), + }, + { + path: "quest/m1/offline.md", + content: quest( + "[L] Keep working offline and sync every change once the phone reconnects to the network", + "Edits made offline sync without conflicts.", + ), + }, + { path: "quest/m1/themes.md", content: quest("[M] Support custom color themes", "Pick your colors.") }, + ], + issues: [ + { number: 2, title: "Old crash on startup", author: "reporter", closed: true }, + { number: 3, title: "Crash when the config file is empty", author: "reporter", comments: 2 }, + { + number: 7, + title: "Settings page loses unsaved changes when the session expires while the browser tab sits in the background on a phone", + author: "phone-user", + comments: 5, + }, + { number: 8, title: "Add a dark mode toggle", author: "night-owl" }, + ], + pulls: [ + { + number: 12, + title: "Build the landing page hero", + author: "contributor", + branch: "quest/m0/landing", + checks: "SUCCESS", + review: "APPROVED", + reviews: [{ state: "APPROVED", author: "reviewer" }], + comments: 3, + }, + { + number: 14, + title: "Fix the flaky sign-in redirect", + author: "contributor", + branch: "fix/sign-in-redirect", + checks: "FAILURE", + review: "CHANGES_REQUESTED", + reviews: [{ state: "CHANGES_REQUESTED", author: "reviewer" }], + }, + { + number: 15, + title: "Try a shared build cache", + author: "dev-maintainer", + branch: "build-cache", + checks: "PENDING", + review: null, + draft: true, + }, + { + number: 16, + title: "Bump dependencies", + author: "bot", + branch: "deps", + checks: null, + review: "REVIEW_REQUIRED", + }, + ], + claims: ["quest/m0/docs"], + }; + const archive: Repo = { + id: 9102, + owner: "example", + name: "archive", + maintainers: ["dev-maintainer"], + files: [ + { + path: "quest/README.md", + content: quest("Quests", "Retire the old site.", ["- [M0: Retire](/quest/m0/README.md) - retire it"]), + }, + { + path: "quest/m0/README.md", + content: quest("Retire", "Retire the old site.", [ + "- [Clean up](/quest/m0/cleanup.md) - remove old pages", + ]), + }, + { path: "quest/m0/cleanup.md", content: quest("[S] Remove the old pages", "Nothing links to them.") }, + ], + issues: [{ number: 1, title: "Archive the old site", author: "reporter" }], + pulls: [], + claims: [], + }; + return { + app: { id: 123, slug: "quest-dev" }, + installation: 9201, + users: [{ id: 9001, login: "dev-maintainer", name: "Dev Maintainer", email: "maintainer@example.test" }], + repos: [rocket, archive], + }; +} + +/** Send a request to the Worker by path, as a browser on its origin would. */ +export type Fetch = (path: string, init?: RequestInit) => Response | Promise; + +async function expect(response: Response | Promise, status: number, step: string) { + const result = await response; + if (result.status !== status) + throw new Error(`Seed step "${step}" returned ${result.status}: ${(await result.text()).slice(0, 300)}`); + return result; +} + +const cookies = (response: Response) => + response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "); + +const form = (fields: Record) => ({ + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), +}); + +/** + * Sign in as the world's first user, enroll its repositories (the second one + * paused), observe them, and add jobs in every state the inbox shows. + * Returns the signed-in user and session cookie. + */ +export async function seed(options: { fetch: Fetch; db: D1Database; world: World; origin: string; secret: string }) { + const { db, world, origin } = options; + const post = (path: string, fields: Record, cookie: string) => { + const body = form(fields); + return options.fetch(path, { + method: "POST", + redirect: "manual", + headers: { ...body.headers, Origin: origin, Cookie: cookie }, + body: body.body, + }); + }; + + const start = await expect(post("/sign-in", { provider: "github" }, ""), 303, "sign in"); + const state = new URL(start.headers.get("Location")!).searchParams.get("state"); + const callback = await expect( + options.fetch(`/api/auth/callback/github?code=fake&state=${state}`, { + headers: { Cookie: cookies(start) }, + redirect: "manual", + }), + 302, + "OAuth callback", + ); + const cookie = cookies(callback) + .split("; ") + .filter((c) => c.startsWith("better-auth.session_token=")) + .join("; "); + if (!cookie) throw new Error("Seed sign-in returned no session"); + const me = world.users[0]!; + const user = (await db + .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?") + .bind(String(me.id)) + .first<{ userId: string }>())!.userId; + + const [rocket, archive] = world.repos as [Repo, Repo]; + for (const repo of world.repos) { + const path = `/projects/github/${repo.owner}/${repo.name}`; + await expect(post(path, { id: String(repo.id), state: "enabled" }, cookie), 303, `enroll ${repo.name}`); + await expect(post(`${path}/refresh`, {}, cookie), 303, `refresh ${repo.name}`); + } + await expect( + post(`/projects/github/${archive.owner}/${archive.name}`, { id: String(archive.id), state: "paused" }, cookie), + 303, + "pause archive", + ); + + // An issue opened since the refresh: its webhook leaves a pointer with no data yet. + const pointer = { number: 9, title: "Sync stops after the laptop sleeps", author: "reporter" }; + rocket.issues.push(pointer); + const body = JSON.stringify({ + action: "opened", + installation: { id: world.installation }, + repository: { id: rocket.id }, + issue: { id: Number(databaseId(rocket, pointer.number)), number: pointer.number }, + }); + await expect( + options.fetch("/webhooks/github", { + method: "POST", + headers: { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": crypto.randomUUID(), + "X-Hub-Signature-256": await webhookSignature(options.secret, body), + }, + body, + }), + 202, + "issue webhook", + ); + + await jobs(db, user, rocket); + return { user, cookie }; +} + +// Runners and jobs a live runner would have reported, in each state the inbox shows. +async function jobs(db: D1Database, user: string, repo: Repo) { + const now = Date.now(); + const commit = commitOf(repo); + const head = (n: number) => + headOf( + repo, + repo.pulls.find((p) => p.number === n)!, + ); + const runners = [ + ["workstation", "workstation", "linux", 1], + ["laptop", "laptop", "darwin", 0], + ] as const; + const accounts = [ + ["workstation", "claude-max", "claude", "subscription", "Claude Max", 0, 0], + ["laptop", "claude-pro", "claude", "subscription", "Claude Pro", 1, 1], + ["workstation", "api", "anthropic", "api-key", "Anthropic API key", 2, 0], + ] as const; + type Job = { + id: string; + workflow: "plan" | "start" | "iterate"; + target: string; + revision: string; + branch: string; + state: "queued" | "running" | "waiting" | "failed"; + runner?: string; + account?: string; + summary?: string; + question?: { kind: "agent" | "account"; prompt: unknown }; + }; + const list: Job[] = [ + { + id: "seed-plan-config", + workflow: "plan", + target: "issues/3", + revision: commit, + branch: "plan/issue-3", + state: "waiting", + runner: "workstation", + account: "claude-max", + summary: "Read the issue and the config loader. Three choices need you before planning.", + question: { + kind: "agent", + prompt: { + kind: "decision", + questions: [ + { + header: "Behavior", + question: "What should an empty config file do?", + options: [ + { + label: "Use defaults", + description: "Treat it like a missing file.", + recommended: true, + }, + { label: "Fail with an error", description: "Name the file and the expected format." }, + ], + }, + { + header: "Scope", + question: "Which other config edge cases should the quest cover?", + multiple: true, + options: [ + { label: "Whitespace only", recommended: true }, + { label: "Comments only" }, + { label: "Byte order mark" }, + ], + }, + { + question: "Should the fix ship as a patch release?", + options: [{ label: "Yes", recommended: true }, { label: "No, wait for the beta" }], + }, + ], + }, + }, + }, + { + id: "seed-start-sign-in", + workflow: "start", + target: "quest/m0/sign-in.md", + revision: commit, + branch: "quest/m0/sign-in", + state: "waiting", + runner: "laptop", + account: "claude-pro", + summary: "Stopped at the account's usage limit after adding the passkey routes.", + question: { kind: "account", prompt: { runner: "laptop", account: "claude-pro" } }, + }, + { + id: "seed-iterate-cache", + workflow: "iterate", + target: "pulls/15", + revision: head(15), + branch: "build-cache", + state: "waiting", + runner: "workstation", + account: "claude-max", + summary: "Wants to populate the build cache.", + question: { + kind: "agent", + prompt: { + kind: "permission", + questions: [ + { + question: "Allow `nix build .#cache` to write to the shared cache?", + options: [{ label: "Allow once" }, { label: "Deny" }], + }, + ], + }, + }, + }, + { + id: "seed-iterate-redirect", + workflow: "iterate", + target: "pulls/14", + revision: head(14), + branch: "fix/sign-in-redirect", + state: "failed", + runner: "workstation", + account: "claude-max", + summary: "just test failed: two sign-in tests time out against the new redirect.", + }, + { + id: "seed-plan-settings", + workflow: "plan", + target: "issues/7", + revision: commit, + branch: "plan/issue-7", + state: "running", + runner: "workstation", + account: "claude-max", + summary: "Reproducing the lost settings in a background tab.", + }, + { + id: "seed-start-offline", + workflow: "start", + target: "quest/m1/offline.md", + revision: commit, + branch: "quest/m1/offline", + state: "queued", + }, + ]; + await db.batch([ + ...runners.map(([id, name, platform, vm]) => + db + .prepare( + "INSERT INTO runners(id,owner,name,tokenHash,platform,vm,createdAt,seenAt) VALUES(?,?,?,?,?,?,?,?)", + ) + .bind(id, user, name, `seed-${id}`, platform, vm, now, now), + ), + ...accounts.map(([runner, id, provider, kind, label, rank, limited]) => + db + .prepare( + "INSERT INTO runner_accounts(runner,id,provider,kind,label,rank,limited) VALUES(?,?,?,?,?,?,?)", + ) + .bind(runner, id, provider, kind, label, rank, limited), + ), + ...list.flatMap((job, i) => { + const attempt = job.runner ? 1 : 0; + // Older jobs first, so each one's question keeps a stable order. + const at = now - (list.length - i) * 60_000; + return [ + db + .prepare( + "INSERT INTO jobs(id,repository,workflow,target,revision,branch,actor,trusted,state,attempt,runner,account,delivered,summary,createdAt,updatedAt) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ) + .bind( + job.id, + repo.id, + job.workflow, + job.target, + job.revision, + job.branch, + user, + job.workflow === "iterate" ? 0 : 1, + job.state, + attempt, + job.runner ?? null, + job.account ?? null, + attempt, + job.summary ?? null, + at, + now, + ), + ...(job.question + ? [ + db + .prepare( + "INSERT INTO job_questions(id,job,attempt,kind,prompt,askedAt) VALUES(?,?,?,?,?,?)", + ) + .bind( + `${job.id}-q`, + job.id, + attempt, + job.question.kind, + JSON.stringify(job.question.prompt), + at, + ), + ] + : []), + ]; + }), + ]); +} diff --git a/cloudflare/dev/serve.ts b/cloudflare/dev/serve.ts new file mode 100644 index 00000000..c499cf1c --- /dev/null +++ b/cloudflare/dev/serve.ts @@ -0,0 +1,129 @@ +// Serve the real Worker on localhost with seeded data, a fake GitHub, and a +// local sign-in, for checking UI changes in a browser. It runs the bundle that +// `wrangler deploy --dry-run` writes (see the `seeded` package script) on +// Miniflare directly, since `wrangler dev`'s proxy hangs in some sandboxes. +// Storage is in memory, so every run starts from the same seed. +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { + Miniflare, + Response as MiniflareResponse, + type V4WorkerOptionsShape, + convertV4MiniflareOptions, +} from "miniflare"; +import { unstable_getMiniflareWorkerOptions, unstable_splitSqlQuery } from "wrangler"; +import { privateKey } from "../test/github-key"; +import { fakeGitHub } from "./github"; +import { seed, world } from "./seed"; + +const root = join(import.meta.dirname, ".."); +const { values } = parseArgs({ options: { port: { type: "string", default: "8787" } } }); +const port = Number(values.port); +const origin = `http://localhost:${port}`; +const secret = "dev-webhook-secret"; +const data = world(); +const github = fakeGitHub(data); + +// Bindings, assets, and Durable Objects come from the deploy config. Wrangler +// types these with its own copy of Miniflare's types; the version is the same. +const { workerOptions } = unstable_getMiniflareWorkerOptions(join(root, "wrangler.jsonc")) as unknown as { + workerOptions: V4WorkerOptionsShape & { modulesRules?: unknown }; +}; +// The bundle is a single ES module, and Miniflare 5 cannot convert module rules. +const { modulesRules: _, ...app } = workerOptions; + +// GitHub's authorize page would leave localhost, so the entry Worker sends that +// redirect straight to the callback, which the fake GitHub completes as the +// seeded maintainer. Everything else, WebSockets included, passes through. +const entry = `export default { + async fetch(request, env) { + const response = await env.APP.fetch(request); + const location = response.headers.get("Location"); + if (!location?.startsWith("https://github.com/login/oauth/authorize")) return response; + const callback = new URL("/api/auth/callback/github", request.url); + callback.searchParams.set("code", "fake"); + callback.searchParams.set("state", new URL(location).searchParams.get("state") ?? ""); + const headers = new Headers(response.headers); + headers.set("Location", callback.href); + return new Response(null, { status: response.status, headers }); + }, +};`; + +const mf = new Miniflare( + convertV4MiniflareOptions({ + host: "127.0.0.1", + port, + workers: [ + { + name: "entry", + modules: true, + script: entry, + compatibilityDate: app.compatibilityDate, + serviceBindings: { APP: "app" }, + }, + { + ...app, + name: "app", + modules: true, + scriptPath: join(root, ".wrangler/seeded/index.js"), + // Local values only: never a credential from .dev.vars, and no Google sign-in. + bindings: { + ...app.bindings, + AUTH_URL: origin, + AUTH_SECRET: "dev-only-secret-that-is-at-least-32-characters", + GITHUB_CLIENT_ID: "dev-client-id", + GITHUB_CLIENT_SECRET: "dev-client-secret", + GITHUB_APP_ID: String(data.app.id), + GITHUB_APP_PRIVATE_KEY: privateKey, + GITHUB_WEBHOOK_SECRET: secret, + GITHUB_FORK_OWNER: "quest-dev-forks", + GOOGLE_CLIENT_ID: "", + GOOGLE_CLIENT_SECRET: "", + }, + // Every outbound request goes to the fake; nothing reaches the network. + outboundService: async (request) => { + const body = ["GET", "HEAD"].includes(request.method) ? undefined : await request.text(); + const response = await github(request.url, { method: request.method, body }); + return new MiniflareResponse(await response.arrayBuffer(), { + status: response.status, + headers: [...response.headers], + }); + }, + }, + ], + }), +); +const stop = async () => { + await mf.dispose(); + process.exit(0); +}; +process.on("SIGINT", stop); +process.on("SIGTERM", stop); +await mf.ready; + +// Apply the checked-in migrations in order, as `wrangler d1 migrations apply` does. +const db = (await mf.getD1Database("DB", "app")) as unknown as D1Database; +const migrations = join(root, "migrations"); +for (const file of readdirSync(migrations) + .filter((f) => f.endsWith(".sql")) + .sort()) { + const statements = unstable_splitSqlQuery(readFileSync(join(migrations, file), "utf8")); + await db.batch(statements.map((sql) => db.prepare(sql))); +} + +const { cookie } = await seed({ + fetch: (path, init) => mf.dispatchFetch(`${origin}${path}`, init as never) as unknown as Promise, + db, + world: data, + origin, + secret, +}); +const [login] = data.users.map((user) => user.login); +console.log(` +Seeded kix at ${origin}/inbox, signed in as ${login}. Ctrl-C stops it. +Sign in with GitHub completes locally. From a shell, send the session cookie: + curl -H 'Cookie: ${cookie}' ${origin}/inbox +or sign in with a cookie jar: + curl -sL -c jar -b jar -H 'Origin: ${origin}' -d provider=github ${origin}/sign-in +`); diff --git a/cloudflare/package.json b/cloudflare/package.json index 78472557..09486a1e 100644 --- a/cloudflare/package.json +++ b/cloudflare/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "wrangler dev", + "seeded": "wrangler deploy --dry-run --outdir .wrangler/seeded && bun dev/serve.ts", "build": "bun scripts/build.ts", "check": "bun run build && wrangler types --check && tsc --noEmit && prettier --check . && wrangler deploy --dry-run", "test": "bun run build && vitest run", @@ -22,6 +23,7 @@ "devDependencies": { "@cloudflare/vitest-plugin": "1.4.2", "@types/node": "26.6.5", + "miniflare": "5.20261012.0-beta", "prettier": "3.9.9", "typescript": "7.0.2", "vitest": "5.0.3", diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 0cb2e241..cd89627c 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -131,7 +131,8 @@ function pullNumber(target: string) { export class GitHubService { #env: GitHubEnvironment; #fetch: typeof fetch; - constructor(env: GitHubEnvironment, request: typeof fetch = fetch) { + // Workers' fetch throws "Illegal invocation" when called as this.#fetch(), so wrap it. + constructor(env: GitHubEnvironment, request: typeof fetch = (input, init) => fetch(input, init)) { this.#env = env; this.#fetch = request; } @@ -148,7 +149,8 @@ export class GitHubService { async #api(token: string, path: string, method = "GET", body?: unknown): Promise { const response = await this.#fetch(`https://api.github.com${path}`, { method, - redirect: "error", + // Workers refuse `redirect: "error"`; a redirect is not ok, so it fails below. + redirect: "manual", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: "application/vnd.github+json", diff --git a/cloudflare/src/licenses.ts b/cloudflare/src/licenses.ts index f2e81f9b..5f1cbd6d 100644 --- a/cloudflare/src/licenses.ts +++ b/cloudflare/src/licenses.ts @@ -5,7 +5,8 @@ export async function osiApproved(identifier: string | undefined, request: typeof fetch = fetch): Promise { if (!identifier || identifier === "NOASSERTION" || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*\+?$/.test(identifier)) return false; const response = await request(`https://spdx.org/licenses/${encodeURIComponent(identifier)}.json`, { - redirect: "error", + // Workers refuse `redirect: "error"`; a redirect is not ok, so it fails below. + redirect: "manual", cache: "no-store", headers: { Accept: "application/json" }, }); diff --git a/cloudflare/test/licenses.test.ts b/cloudflare/test/licenses.test.ts index c1454cbb..1f27286e 100644 --- a/cloudflare/test/licenses.test.ts +++ b/cloudflare/test/licenses.test.ts @@ -19,11 +19,15 @@ describe("current SPDX OSI approval", () => { expect(await osiApproved("MIT", request)).toBe(false); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenLastCalledWith("https://spdx.org/licenses/MIT.json", { - redirect: "error", + redirect: "manual", cache: "no-store", headers: { Accept: "application/json" }, }); }); + it("fails closed on a redirect", async () => { + const request = vi.fn().mockResolvedValue(new Response(null, { status: 302, headers: { Location: "/" } })); + await expect(osiApproved("MIT", request)).rejects.toThrow(); + }); it("fails closed on malformed JSON", async () => { const request = vi.fn().mockResolvedValue(new Response("not json")); await expect(osiApproved("MIT", request)).rejects.toThrow(); diff --git a/cloudflare/test/seed.test.ts b/cloudflare/test/seed.test.ts new file mode 100644 index 00000000..d15aabe0 --- /dev/null +++ b/cloudflare/test/seed.test.ts @@ -0,0 +1,64 @@ +// The seeded local server's data must load on the current schema and routes, +// and show every inbox state it exists to check. +import { env } from "cloudflare:workers"; +import { afterEach, expect, it, vi } from "vitest"; +import { fakeGitHub } from "../dev/github"; +import { seed, world } from "../dev/seed"; +import { app } from "../src/index"; +import { privateKey } from "./github-key"; +import { origin } from "./login-fixtures"; + +const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; +const db = env.DB; +afterEach(() => vi.restoreAllMocks()); + +it("seeds a representative inbox through the Worker's own routes", async () => { + await db.batch([ + db.prepare("DELETE FROM jobs"), + db.prepare("DELETE FROM runners"), + db.prepare("DELETE FROM github_projects"), + db.prepare("DELETE FROM github_installations"), + ]); + const data = world(); + vi.spyOn(globalThis, "fetch").mockImplementation(fakeGitHub(data)); + const request = (path: string, init?: RequestInit) => app.request(`${origin}${path}`, init, testEnv); + const { cookie } = await seed({ + fetch: request, + db, + world: data, + origin, + secret: env.GITHUB_WEBHOOK_SECRET, + }); + + const inbox = await request("/inbox", { headers: { Cookie: cookie } }); + expect(inbox.status).toBe(200); + const html = await inbox.text(); + for (const text of [ + // The active decision: the oldest agent prompt, with its three questions. + "What should an empty config file do?", + "Which other config edge cases should the quest cover?", + "Should the fix ship as a patch release?", + // Up next and in progress. + "Add passkey sign-in", + "Try a shared build cache", + "Fix the flaky sign-in redirect", + "Build the landing page hero", + "Bump dependencies", + "Add a dark mode toggle", + "Issue #9", + "Export a project as a ZIP archive", + "Settings page loses unsaved changes when the session expires", + "Keep working offline", + // The paused project. + "example/archive", + ]) + expect(html).toContain(text); + // Claimed and blocked quests, and closed issues, are not offered. + for (const text of ["Write the getting-started guide", "Report anonymous crash telemetry", "Old crash on startup"]) + expect(html).not.toContain(text); + + // Every seeded decision opens on its own. + const items = [...html.matchAll(/href="(\/inbox\?project=[^"]+)"/g)].map((m) => m[1]!.replaceAll("&", "&")); + expect(items.length).toBeGreaterThan(5); + for (const href of new Set(items)) expect((await request(href, { headers: { Cookie: cookie } })).status).toBe(200); +}); diff --git a/cloudflare/tsconfig.json b/cloudflare/tsconfig.json index 3a6bd7a4..e921addc 100644 --- a/cloudflare/tsconfig.json +++ b/cloudflare/tsconfig.json @@ -13,5 +13,5 @@ "jsx": "react-jsx", "jsxImportSource": "hono/jsx" }, - "include": ["src", "scripts", "test", "vitest.config.ts", "worker-configuration.d.ts"] + "include": ["src", "scripts", "dev", "test", "vitest.config.ts", "worker-configuration.d.ts"] } diff --git a/justfile b/justfile index abd7636b..dfb91c6d 100644 --- a/justfile +++ b/justfile @@ -58,6 +58,10 @@ worker-check: worker-test: bun run --cwd cloudflare test +# Serve the Worker on localhost with seeded data and a fake GitHub, for checking UI. +worker-seeded *args: + bun run --cwd cloudflare seeded {{args}} + # Apply auth migrations locally and start the Worker. worker-dev: bun run --cwd cloudflare migrate diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 529aa45f..c9a343ab 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -20,7 +20,8 @@ answer planning prompts, publish a draft quest PR, merge through GitHub's rules, start a ready quest, follow its summaries, answer an implementation question from a push notification, take its runner offline so the job is reassigned to the other, and receive its contribution PR notification. Exercise duplicate events, -interrupted sessions, and an exhausted account with stubbed agents before a +interrupted sessions, and an exhausted account with stubbed agents, starting +from the seeded local server's fake GitHub and seed (`cloudflare/dev/`), before a live rehearsal on the operator's runner. ## Plan @@ -94,7 +95,6 @@ operator-paid-hosting decisions: ## Required -- [Local harness](/quest/m0/kix/harness.md) - the Worker served locally with seeded data and mocked GitHub for browser checks - [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest - [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 diff --git a/quest/m0/kix/harness.md b/quest/m0/kix/harness.md deleted file mode 100644 index 25a45cc1..00000000 --- a/quest/m0/kix/harness.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Run the Worker locally with seeded data - -## Goal - -One checked-in command, such as `just dev-seeded`, serves the real Worker -locally with representative seeded data, mocked GitHub, and a local sign-in, -so agents and maintainers can check UI changes in a browser at phone and -desktop widths. It works inside sandboxed agent environments, where -`wrangler dev` currently hangs. - -## Plan - -Decided 2026-10-19 as a follow-up of the decision inbox (#144). Its -browser check needed a throwaway harness because `wrangler dev`'s internal -proxy hangs in the agent sandbox on a DNS lookup of "dummy": - -- Check in a seeded harness rather than debugging `wrangler dev` for one - agent environment. Bundle the Worker as `wrangler deploy --dry-run` does, - serve it with Miniflare on local D1 and R2, and apply the checked-in - migrations. -- Mock GitHub's REST API and short-circuit the OAuth redirect so sign-in - completes locally. The mock serves only localhost and never holds real - credentials; it is development tooling, not a production code path. -- Seed representative state: agent prompts with several questions, an - account limit, failed, queued and running jobs, PRs with checks and - reviews, issues including a pointer-only one and a long title, ready - quests, and a paused project. -- Keep the seed and mock reusable as the base for the kix epic's stubbed - end-to-end test, rather than a second fixture set. Share fixtures with - the Worker tests where practical. -- Document the command in `cloudflare/README.md` as the way to check UI - changes in a browser. From 3c0ddbbeb4ad915172952d6cf402b0326e46b3df Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:10:40 -0700 Subject: [PATCH 3/3] feat(kix): model inbox merges in the fake GitHub Main's inbox merge reads mergeStateStatus and merges through the pulls merge endpoint, so the fake reports both and the seed has a clean pull request to merge. Co-Authored-By: Claude Opus 5.5 --- cloudflare/README.md | 2 +- cloudflare/dev/github.ts | 14 ++++++++++++++ cloudflare/dev/seed.ts | 1 + cloudflare/test/seed.test.ts | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/cloudflare/README.md b/cloudflare/README.md index 16f10d57..1cf5e3db 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -240,7 +240,7 @@ hangs, such as sandboxed agent environments. After applying the checked-in migrations, `dev/seed.ts` signs in a maintainer and enrolls and observes their projects through the Worker's own routes: ready, claimed, and blocked quests, issues (one known only from a webhook, one with a long title), pull requests -with checks and reviews, and a paused second project. It then adds runners and +with checks and reviews (one clean to merge), and a paused second project. It then adds runners and jobs that are queued, running, failed, or waiting on an agent's questions, a tool permission, or an account limit. diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index b3902e1f..857705c4 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -12,6 +12,8 @@ export type Pull = Issue & { review: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; reviews?: { state: string; author: string }[]; draft?: boolean; + /** GitHub's mergeStateStatus; only CLEAN merges. Defaults to BLOCKED, or DRAFT for a draft. */ + merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE"; }; export type Repo = { id: number; @@ -61,6 +63,7 @@ function pullNode(repo: Repo, pull: Pull) { headRefOid: oid, isDraft: !!pull.draft, reviewDecision: pull.review, + mergeStateStatus: pull.merge ?? (pull.draft ? "DRAFT" : "BLOCKED"), reviews: { totalCount: reviews.length }, latestReviews: { nodes: reviews.map((r) => ({ state: r.state, author: { login: r.author }, commit: { oid } })), @@ -180,6 +183,7 @@ export function fakeGitHub(world: World) { archived: false, disabled: false, license: { spdx_id: "MIT" }, + allow_squash_merge: true, }); if (rest === "/installation") return json({ id: world.installation, app_id: world.app.id, suspended_at: null, permissions: all }); @@ -204,6 +208,16 @@ export function fakeGitHub(world: World) { const head = item[1] === "pulls" ? { head: { sha: headOf(repo, found as Pull) } } : {}; return json({ number: found.number, title: found.title, state: found.closed ? "closed" : "open", ...head }); } + const merge = rest.match(/^\/pulls\/(\d+)\/merge$/); + if (merge && method === "PUT") { + const pull = repo.pulls.find((p) => String(p.number) === merge[1] && !p.closed); + if (!pull) return json({ message: "Not Found" }, 404); + const { sha } = JSON.parse(await body()) as { sha: string }; + if (sha !== headOf(repo, pull)) return json({ message: "Head branch was modified" }, 409); + if (pull.merge !== "CLEAN") return json({ message: "Pull Request is not mergeable" }, 405); + pull.closed = true; + return json({ sha: commitOf(repo), merged: true, message: "Pull Request successfully merged" }); + } if (rest.startsWith("/contents/")) { const file = repo.files.find((f) => f.path === decodeURIComponent(rest.slice("/contents/".length))); return file ? json({ type: "file", path: file.path }) : json({ message: "Not Found" }, 404); diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts index bd53e38a..ea880316 100644 --- a/cloudflare/dev/seed.ts +++ b/cloudflare/dev/seed.ts @@ -81,6 +81,7 @@ export function world(): World { review: "APPROVED", reviews: [{ state: "APPROVED", author: "reviewer" }], comments: 3, + merge: "CLEAN", }, { number: 14, diff --git a/cloudflare/test/seed.test.ts b/cloudflare/test/seed.test.ts index d15aabe0..16dd1163 100644 --- a/cloudflare/test/seed.test.ts +++ b/cloudflare/test/seed.test.ts @@ -61,4 +61,19 @@ it("seeds a representative inbox through the Worker's own routes", async () => { const items = [...html.matchAll(/href="(\/inbox\?project=[^"]+)"/g)].map((m) => m[1]!.replaceAll("&", "&")); expect(items.length).toBeGreaterThan(5); for (const href of new Set(items)) expect((await request(href, { headers: { Cookie: cookie } })).status).toBe(200); + + // 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( + /action="([^"]+\/pulls\/12\/merge)"[\s\S]*?name="head" value="([0-9a-f]{40})"/, + ); + expect(head).not.toBeNull(); + const merged = await request(head![1]!, { + method: "POST", + redirect: "manual", + headers: { Cookie: cookie, Origin: origin, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ head: head![2]! }).toString(), + }); + expect(merged.status).toBe(303); + expect(data.repos[0]!.pulls.find((p) => p.number === 12)!.closed).toBe(true); });