# Spoon Phase 4 — Upstream-Sync Correctness: Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make Spoon's GitHub upstream-sync correct and event-driven: resolve head SHAs from the branch ref (never inferred from a truncated compare list), paginate compares where counts matter, give maintenance threads a stable dedup key (merge-base + head digest, never `Date.now()`), add a signature-verified GitHub webhook that does targeted refreshes on `push` and flips `gitConnections.status` on installation lifecycle events, add Octokit retry/throttling with a distinct "rate-limited" sync status, and fix unbounded `.collect()`s on hot paths. Surface `needs_reauth`/`revoked` in Settings → Integrations. **Architecture:** All work is in `packages/backend/convex` (Convex functions, one `httpAction`, one internalAction, schema indexes) plus a small `apps/next` settings UI change. The webhook `httpAction` runs in Convex's default (non-node) runtime and verifies the HMAC with Web Crypto (`crypto.subtle`), then `ctx.scheduler.runAfter(0, …)` schedules the node-runtime refresh. All Octokit calls stay in the existing `'use node'` modules (`githubClient.ts`, `githubSync.ts`). **Tech Stack:** Convex (functions, httpActions, crons), Octokit (`@octokit/rest` + `@octokit/plugin-retry` + `@octokit/plugin-throttling`), Next.js settings UI. **Depends on:** Phase 1 (auto-sync gating on `autoSyncEnabled` and the `require*` flags lands there; this phase does NOT touch the auto-sync decision at `githubSync.ts:205`). This phase adds head-SHA correctness, dedup, webhooks, and rate-limit handling on top. It is otherwise independent of Phases 1–3. ## Global Constraints - **Backend tests:** live in `packages/backend/tests/unit/*.test.ts`, use `convex-test`. Run from `packages/backend`: `bun run test:unit` (this is `vitest run --project unit`). The test harness pattern is in `packages/backend/tests/unit/harness.test.ts` — copy its `convexTest(schema)`, `import.meta.glob('../../convex/**/*.*s')`, `createUser`, and `authed` helpers. - **convex-test HTTP:** `const t = convexTest(schema, modules); const res = await t.fetch('/webhooks/github', { method: 'POST', headers, body })` returns a `Response`. Use this to test the webhook `httpAction`. - **convex-test with node actions:** functions in `'use node'` files (`githubSync.ts`, `githubClient.ts`, `githubNode.ts`) cannot be unit-tested with convex-test directly (no node runtime in the test VM). For those, extract pure logic into a **non-`'use node'`** helper module and unit-test that; mock Octokit as a plain object literal shaped like the calls used (see Task 1/2 for the mock shape). Do NOT try to `t.action(...)` a node action in unit tests. - Run `bun codegen:convex` from the repo root before `typecheck` whenever you add/rename a Convex function (regenerates `convex/_generated/api.d.ts`). Typecheck with `cd packages/backend && bun run typecheck`. - Octokit plugin deps are NOT yet installed. Task 9 adds `@octokit/plugin-retry` and `@octokit/plugin-throttling` to `packages/backend/package.json` and runs `bun install` from the repo root. - Conventional commits, one commit per task (e.g. `fix(sync): resolve head SHA from branch ref`). - **Webhook signature:** HMAC-SHA256 over the **raw request body** (never a re-serialized JSON), header `X-Hub-Signature-256` formatted `sha256=`, compared with a constant-time comparison. Secret is `process.env.GITHUB_APP_WEBHOOK_SECRET` (already declared in `convex/globals.d.ts:14`). - Ownership/security: the webhook is unauthenticated (GitHub calls it) — it MUST reject on bad/missing signature with HTTP 401 and never trust any user id from the payload; it only maps `installation.id` / repo full-name to existing owned records. --- ## Task 1: `resolveBranchHead` — real head SHA from the branch ref **Problem:** `compareAcrossForkNetwork` sets `headSha = commits[commits.length - 1]?.sha` (`githubClient.ts:132`). GitHub's compare endpoint truncates `commits` to the last page (max ~250 across pages, 100/page here with no pagination), so for a divergence >100 commits the "head" is a middle commit, not the branch tip. Fetch the tip from the branch ref instead. **Files:** - `packages/backend/convex/githubClient.ts` (add `resolveBranchHead` near `getRepository` ~line 82–89). - `packages/backend/tests/unit/github-head.test.ts` (new). **Interfaces:** - Produces: `export const resolveBranchHead = async (octokit: Octokit, owner: string, repo: string, branch: string): Promise<{ sha: string }>` - Implementation: `const result = await octokit.rest.repos.getBranch({ owner, repo, branch }); return { sha: result.data.commit.sha };` - Consumes: an `Octokit` whose `rest.repos.getBranch` resolves `{ data: { commit: { sha } } }`. **Note on testing node modules:** `githubClient.ts` is NOT a `'use node'` file (it has no `'use node'` pragma — verify: it starts with imports, no pragma). It can therefore be imported directly in a vitest unit test and called with a hand-rolled mock Octokit. The mock is a plain object: `{ rest: { repos: { getBranch: async () => ({ data: { commit: { sha: 'abc123' } } }) } } } as unknown as Octokit`. **Steps:** - [ ] Write failing test `tests/unit/github-head.test.ts`: import `resolveBranchHead` from `../../convex/githubClient`; call it with a mock Octokit whose `getBranch` returns `{ data: { commit: { sha: 'deadbeef' } } }` and asserts the returned `sha === 'deadbeef'` and that `getBranch` was called with `{ owner: 'o', repo: 'r', branch: 'main' }` (use a `vi.fn()`). - [ ] Run `cd packages/backend && bun run test:unit` — confirm it FAILS (export does not exist). - [ ] Add `resolveBranchHead` to `githubClient.ts` exactly as in Interfaces. - [ ] Run `bun run test:unit` — confirm PASS. - [ ] `bun codegen:convex` (repo root) + `cd packages/backend && bun run typecheck` — confirm clean. - [ ] Commit: `feat(sync): add resolveBranchHead to fetch branch tip SHA`. --- ## Task 2: Paginate compare + use resolved head; fix `getLastCommitAt` **Problem:** `compareAcrossForkNetwork` (`githubClient.ts:110-137`) requests `per_page: 100` with no pagination and derives `headSha` from the truncated list; `getLastCommitAt` (`githubSync.ts:34-35`) has the same last-element bug. Paginate the commit list (bounded), take `ahead_by`/`merge_base_commit`/`base_commit` from the first page, and set `headSha` from `resolveBranchHead`. **Files:** - `packages/backend/convex/githubClient.ts` (rewrite `compareAcrossForkNetwork` ~110-137; add `headRepo` to its args). - `packages/backend/convex/githubSync.ts` (fix `getLastCommitAt` ~34-35; pass `headRepo` at both `compareAcrossForkNetwork` call sites ~107-122). - `packages/backend/tests/unit/github-head.test.ts` (extend). **Interfaces:** - Changed: `compareAcrossForkNetwork(octokit, args: { owner; repo; baseOwner; baseBranch; headOwner; headRepo; headBranch })` — **new required `headRepo: string`** (the repo the head branch lives in; upstream repo for the upstream compare, fork repo for the fork compare). - The function now internally calls `resolveBranchHead(octokit, args.headOwner, args.headRepo, args.headBranch)` and sets `headSha` from it. - `GitHubCompareSummary` shape (`githubClient.ts:37-44`) is unchanged. **Implementation for `compareAcrossForkNetwork`:** ```ts const COMPARE_PER_PAGE = 100; const COMPARE_MAX_PAGES = 30; // bound: up to 3000 commits collected export const compareAcrossForkNetwork = async ( octokit: Octokit, args: { owner: string; repo: string; baseOwner: string; baseBranch: string; headOwner: string; headRepo: string; headBranch: string; }, ): Promise => { const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`; const first = await octokit.rest.repos.compareCommitsWithBasehead({ owner: args.owner, repo: args.repo, basehead, per_page: COMPARE_PER_PAGE, page: 1, }); const rawCommits = [...first.data.commits]; const totalCommits = first.data.total_commits; let page = 2; while (rawCommits.length < totalCommits && page <= COMPARE_MAX_PAGES) { const next = await octokit.rest.repos.compareCommitsWithBasehead({ owner: args.owner, repo: args.repo, basehead, per_page: COMPARE_PER_PAGE, page, }); if (next.data.commits.length === 0) break; rawCommits.push(...next.data.commits); page += 1; } const head = await resolveBranchHead( octokit, args.headOwner, args.headRepo, args.headBranch, ); return { aheadBy: first.data.ahead_by, mergeBaseSha: first.data.merge_base_commit.sha, headSha: head.sha, baseSha: first.data.base_commit.sha, htmlUrl: first.data.html_url, commits: rawCommits.map(normalizeCompareCommit), }; }; ``` **Implementation for `getLastCommitAt` (`githubSync.ts`):** ```ts const getLastCommitAt = (compare: GitHubCompareSummary) => { const head = compare.commits.find((c) => c.sha === compare.headSha); return ( head?.committedAt ?? compare.commits[compare.commits.length - 1]?.committedAt ); }; ``` **Call-site edits in `githubSync.ts` (~107-122):** add `headRepo: spoon.upstreamRepo` to the upstream compare call and `headRepo: forkRepo` to the fork compare call. **Steps:** - [ ] Extend `tests/unit/github-head.test.ts` with a test for `compareAcrossForkNetwork`: mock Octokit with `compareCommitsWithBasehead` returning page 1 of 100 commits with `total_commits: 150`, page 2 of 50 commits, and `getBranch` returning `{ data: { commit: { sha: 'TIP' } } }`. Assert `result.headSha === 'TIP'` (NOT the last commit in the list), `result.commits.length === 150`, `result.aheadBy` equals the mocked `ahead_by`. Use `merge_base_commit`/`base_commit` objects with `.sha` in the mock so mapping doesn't throw. - [ ] Run `bun run test:unit` — confirm FAIL. - [ ] Rewrite `compareAcrossForkNetwork` and `getLastCommitAt`; add `headRepo` to both call sites. - [ ] Run `bun run test:unit` — confirm PASS. - [ ] `bun codegen:convex` + `bun run typecheck` — confirm clean (the new required `headRepo` arg must be satisfied at both call sites). - [ ] Commit: `fix(sync): paginate compare and resolve head SHA from branch ref`. --- ## Task 3: Stable maintenance-thread dedup key (schema + pure helper) **Problem:** Threads are deduped by `upstreamTo` string with a `Date.now()` fallback (`githubSync.ts:232,271`, `threads.ts:426-440`). When head is unresolved the fallback makes a unique key every run → a brand-new maintenance thread + agent job every scheduled check. Replace with a deterministic key of `mergeBase:head`, and treat "no head" as "cannot dedup → skip". **Files:** - `packages/backend/convex/maintenanceDedup.ts` (new, NOT `'use node'` — pure, unit-testable). - `packages/backend/convex/schema.ts` (add `dedupKey` field + index to `threads` table). - `packages/backend/tests/unit/maintenance-dedup.test.ts` (new). **Interfaces:** - Produces: `export const maintenanceDedupKey = (input: { mergeBaseSha?: string; headSha?: string }): string | null` — returns `null` when `headSha` is missing/empty; otherwise `` `${input.mergeBaseSha ?? 'nobase'}:${input.headSha}` ``. **Schema change (`threads` table):** add `dedupKey: v.optional(v.string())` to the field list and a new index at the bottom of the table def: `.index('by_spoon_dedup', ['spoonId', 'dedupKey'])`. (Optional so existing rows validate; new maintenance threads always set it.) **Implementation (`maintenanceDedup.ts`):** ```ts export const maintenanceDedupKey = (input: { mergeBaseSha?: string; headSha?: string; }): string | null => { const head = input.headSha?.trim(); if (!head) return null; const base = input.mergeBaseSha?.trim() || 'nobase'; return `${base}:${head}`; }; ``` **Steps:** - [ ] Write failing `tests/unit/maintenance-dedup.test.ts`: `maintenanceDedupKey({ mergeBaseSha: 'a', headSha: 'b' }) === 'a:b'`; `maintenanceDedupKey({ headSha: 'b' }) === 'nobase:b'`; `maintenanceDedupKey({ mergeBaseSha: 'a' }) === null`; `maintenanceDedupKey({ mergeBaseSha: 'a', headSha: '' }) === null`. - [ ] Run `bun run test:unit` — confirm FAIL (module missing). - [ ] Create `maintenanceDedup.ts`; add `dedupKey` field + `by_spoon_dedup` index to `threads` in `schema.ts`. - [ ] Run `bun run test:unit` — PASS. `bun codegen:convex` + `bun run typecheck` — clean. - [ ] Commit: `feat(sync): add deterministic maintenance-thread dedup key`. --- ## Task 4: `createMaintenanceThread` / `findOpenMaintenanceThread` dedup by key (indexed) **Problem:** Both dedup helpers `.collect()` every thread for the spoon then `.find(...)` in JS (`threads.ts:389-393` and `426-440`) — unbounded, and keyed on `upstreamTo`. Switch to `dedupKey` via the `by_spoon_dedup` index and stop collecting. **Files:** - `packages/backend/convex/threads.ts` (`findOpenMaintenanceThread` ~382-405; `createMaintenanceThread` ~407-497). - `packages/backend/tests/unit/maintenance-thread.test.ts` (new). **Interfaces:** - Changed `createMaintenanceThread` args: **replace `upstreamTo: v.string()` with `dedupKey: v.string()`** (required; caller guarantees non-null — see Task 5). Keep `upstreamTo` but make it `v.optional(v.string())` (still stored for display). Persist `dedupKey` on the inserted thread row. - Changed `findOpenMaintenanceThread` args: `{ spoonId, ownerId, dedupKey: v.string() }`. - Both now query `.withIndex('by_spoon_dedup', (q) => q.eq('spoonId', args.spoonId).eq('dedupKey', args.dedupKey))`, then filter by `ownerId` and non-terminal status in JS over the (now tiny) result set. **Non-terminal status set (unchanged):** exclude `['resolved', 'ignored', 'failed', 'cancelled']`. **Existing-thread lookup in `createMaintenanceThread`:** ```ts const existing = await ctx.db .query('threads') .withIndex('by_spoon_dedup', (q) => q.eq('spoonId', args.spoonId).eq('dedupKey', args.dedupKey), ) .order('desc') .collect() .then((threads) => threads.find( (thread) => thread.ownerId === args.ownerId && !['resolved', 'ignored', 'failed', 'cancelled'].includes( thread.status, ), ), ); ``` Insert path sets `dedupKey: args.dedupKey` and `upstreamTo: args.upstreamTo` on the new `threads` row. **Steps:** - [ ] Write failing `tests/unit/maintenance-thread.test.ts` using convex-test: create a user + github spoon (copy `githubSpoonInput` + insert pattern from `harness.test.ts`; insert the spoon row directly via `t.mutation(async ctx => ctx.db.insert('spoons', {...}))`). Call `internal.threads.createMaintenanceThread` twice with the SAME `dedupKey` → assert the second returns the same `threadId` and that only one `threads` row exists for that spoon; then call with a DIFFERENT `dedupKey` → asserts a new thread id. (Invoke internal fns via `t.mutation(internal.threads.createMaintenanceThread, args)`.) - [ ] Run `bun run test:unit` — FAIL (arg `dedupKey` not accepted yet). - [ ] Update `createMaintenanceThread` + `findOpenMaintenanceThread` signatures and bodies; ensure the `threads.insert` writes `dedupKey`. - [ ] Run `bun run test:unit` — PASS. `bun codegen:convex` + `bun run typecheck` — clean (call sites in `githubSync.ts` will still be broken until Task 5 — that's expected; if typecheck must be green per-commit, do Task 5 in the same commit; otherwise note it). - [ ] Commit: `refactor(threads): dedup maintenance threads by stable key via index`. > **Sequencing note:** Tasks 4 and 5 both touch the `createMaintenanceThread` contract. If your workflow requires each commit to typecheck green, combine Tasks 4 and 5 into one commit. Otherwise keep them separate and run typecheck at the end of Task 5. --- ## Task 5: `githubSync` call sites — pass `dedupKey`, never `Date.now()`, skip when unresolvable **Problem:** `githubSync.ts` builds `upstreamTo: upstreamCompare.headSha ?? \`${Date.now()}\`` at lines 232 and 271 (and a similar `${Date.now()}` fallback at 398 in `syncForkWithUpstream`). Replace with the deterministic key; when `maintenanceDedupKey(...)` is `null` (no head resolvable), **skip thread creation entirely** and record the skip on the sync run instead. **Files:** - `packages/backend/convex/githubSync.ts` (merge-conflict branch ~223-258; diverged branch ~261-284; `syncForkWithUpstream` conflict branch ~387-411). **Interfaces:** - Consumes: `maintenanceDedupKey` from `./maintenanceDedup`, `internal.threads.createMaintenanceThread` (now takes `dedupKey`). - Import at top of `githubSync.ts`: `import { maintenanceDedupKey } from './maintenanceDedup';` **Pattern (apply at each of the 3 thread-creation sites):** ```ts const dedupKey = maintenanceDedupKey({ mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, headSha: upstreamCompare.headSha, }); if (!dedupKey) { await ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId, status: 'failed', error: 'Could not resolve upstream head SHA; skipping maintenance thread.', }); // return the appropriate summary object for this branch (do NOT create a thread) } else { const threadId = await ctx.runMutation( internal.threads.createMaintenanceThread, { /* existing args, but: */ dedupKey, upstreamTo: upstreamCompare.headSha, // now optional, display only upstreamFrom: upstreamCompare.mergeBaseSha, // ...title, summary, forkHeadAtCreation, mergeBaseAtCreation, relatedSyncRunId, jobType }, ); // existing syncRuns/spoons patches } ``` For the `syncForkWithUpstream` conflict branch (~387), build the key from `state.mergeBaseSha ?? spoon.lastMergeBaseCommit` and `state.upstreamHeadSha ?? spoon.lastUpstreamCommit`; if `null`, skip thread creation and just patch the sync run/ spoon error. **Steps:** - [ ] Grep-confirm there are no remaining `Date.now()}` fallbacks feeding `upstreamTo` in `githubSync.ts` after edits: `grep -n "Date.now()}" packages/backend/convex/githubSync.ts` returns nothing. - [ ] Apply the pattern to all 3 sites; remove the old `upstreamTo: … ?? \`${Date.now()}\`` fallbacks. - [ ] `bun codegen:convex` + `bun run typecheck` — clean. Re-run `bun run test:unit` (Task 4 test still passes). - [ ] Commit: `fix(sync): use stable dedup key and skip threads when head unresolved`. --- ## Task 6: Webhook signature verifier (pure, Web Crypto) + known-vector unit test **Problem:** No webhook verification exists. Implement HMAC-SHA256 over the raw body, constant-time compared against `X-Hub-Signature-256`. Must run in Convex's default (non-node) httpAction runtime, so use Web Crypto (`crypto.subtle`), not `node:crypto`. **Files:** - `packages/backend/convex/githubWebhookVerify.ts` (new, NOT `'use node'`). - `packages/backend/tests/unit/github-webhook-verify.test.ts` (new). **Interfaces:** - Produces: `export const verifyGithubSignature = async (secret: string, rawBody: string, signatureHeader: string | null): Promise` - Produces (helper, exported for reuse/testing): `export const timingSafeEqualHex = (a: string, b: string): boolean` — length-checked, constant-time char compare. **Implementation:** ```ts const toHex = (buffer: ArrayBuffer): string => Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); export const timingSafeEqualHex = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let mismatch = 0; for (let i = 0; i < a.length; i += 1) { mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); } return mismatch === 0; }; export const verifyGithubSignature = async ( secret: string, rawBody: string, signatureHeader: string | null, ): Promise => { if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false; const provided = signatureHeader.slice('sha256='.length); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign( 'HMAC', key, new TextEncoder().encode(rawBody), ); return timingSafeEqualHex(toHex(mac), provided); }; ``` **Known test vector (from GitHub's webhook docs):** secret `It's a Secret to Everybody`, body `Hello, World!` → `sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17`. **Steps:** - [ ] Write failing `tests/unit/github-webhook-verify.test.ts`: - `await verifyGithubSignature("It's a Secret to Everybody", 'Hello, World!', 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17')` → `true`. - Wrong signature → `false`; missing `sha256=` prefix → `false`; `null` header → `false`; correct hex but wrong secret → `false`. - `timingSafeEqualHex('abcd','abcd') === true`, `timingSafeEqualHex('abcd','abce') === false`, different lengths → `false`. - [ ] Run `bun run test:unit` — FAIL (module missing). - [ ] Create `githubWebhookVerify.ts`. - [ ] Run `bun run test:unit` — PASS (the known vector proves the HMAC is correct). `bun run typecheck` — clean. - [ ] Commit: `feat(webhooks): add GitHub webhook signature verifier`. --- ## Task 7: `gitConnections` status-by-installation index + mutation **Problem:** `gitConnections.status` is only ever written `'active'` (`github.ts:61,123`); nothing sets `needs_reauth`/`revoked`. The webhook needs to flip status by GitHub `installation.id`, but there is no index on `installationId`. Add one and an internal mutation. **Files:** - `packages/backend/convex/schema.ts` (`gitConnections` table ~32-56: add `.index('by_installation', ['installationId'])`). - `packages/backend/convex/github.ts` (add internal mutation). - `packages/backend/tests/unit/git-connection-status.test.ts` (new). **Interfaces:** - Produces: `export const setConnectionStatusByInstallation = internalMutation({ args: { installationId: v.string(), status: v.union(v.literal('active'), v.literal('needs_reauth'), v.literal('revoked')) }, handler })` - Body: query `by_installation` for the given `installationId`, patch every matching row with `{ status, updatedAt: Date.now() }`. Return `{ updated: }`. (Use `.collect()` here — installations map to at most a handful of connection rows; this is not a hot path.) **Steps:** - [ ] Write failing `tests/unit/git-connection-status.test.ts` (convex-test): create a user, insert a `gitConnections` row with `installationId: '42'`, `status: 'active'`; call `internal.github.setConnectionStatusByInstallation` with `{ installationId: '42', status: 'needs_reauth' }`; re-read the row and assert `status === 'needs_reauth'`. Add a case where `installationId` matches nothing → `{ updated: 0 }`. - [ ] Run `bun run test:unit` — FAIL. - [ ] Add `by_installation` index + `setConnectionStatusByInstallation` mutation. - [ ] `bun codegen:convex` + `bun run test:unit` PASS + `bun run typecheck` clean. - [ ] Commit: `feat(github): index connections by installation + status mutation`. --- ## Task 8: Find owned spoons for a pushed repo (index + internal query) **Problem:** A `push` webhook carries a repo full-name (`owner/repo`) and `installation.id`. To do a targeted refresh we must map that to owned `spoons`. Pushes to the **fork** change `forkHeadSha`; pushes to the **upstream** (if the app is installed there) change `upstreamHeadSha`. The `spoons` table already has `by_upstream` (`['provider','upstreamOwner','upstreamRepo']`) but no fork index. **Files:** - `packages/backend/convex/schema.ts` (`spoons` table: add `.index('by_fork', ['provider', 'forkOwner', 'forkRepo'])`). - `packages/backend/convex/spoons.ts` (add internal query). - `packages/backend/tests/unit/spoons-for-repo.test.ts` (new). **Interfaces:** - Produces: `export const findForRepo = internalQuery({ args: { owner: v.string(), repo: v.string() }, handler }): Promise<{ spoonId: Id<'spoons'>; ownerId: Id<'users'> }[]>` - Query `by_upstream` with `provider:'github', upstreamOwner: owner, upstreamRepo: repo` → collect. Query `by_fork` with `provider:'github', forkOwner: owner, forkRepo: repo` → collect. Union, dedup by `spoonId`, exclude `status === 'archived'`, return `{ spoonId, ownerId }[]`. **Steps:** - [ ] Write failing `tests/unit/spoons-for-repo.test.ts` (convex-test): insert two github spoons — one where `upstreamOwner/upstreamRepo = 'up/x'`, another where `forkOwner/forkRepo = 'me/x-fork'`. Call `internal.spoons.findForRepo` with `{ owner: 'up', repo: 'x' }` → returns the first spoon. Call with `{ owner: 'me', repo: 'x-fork' }` → returns the second. Call with an unknown repo → `[]`. Add a case where the same spoon matches both (upstream == fork owner/repo edge) → returned once. - [ ] Run `bun run test:unit` — FAIL. - [ ] Add `by_fork` index + `findForRepo` internal query. - [ ] `bun codegen:convex` + `bun run test:unit` PASS + `bun run typecheck` clean. - [ ] Commit: `feat(spoons): find owned spoons by pushed repo (indexed)`. --- ## Task 9: Targeted-refresh internalAction + webhook httpAction route **Problem:** No webhook endpoint. Add a Convex `httpAction` at `/webhooks/github` that verifies the signature, then: on `push` → schedule a targeted refresh for each matching spoon; on `installation`/`installation_repositories` → flip `gitConnections.status`. The hourly cron (`crons.ts`) stays as fallback (unchanged). **Files:** - `packages/backend/convex/githubSync.ts` (add `refreshSpoonById` internalAction wrapping the existing module-local `refreshOwnedSpoon`). - `packages/backend/convex/githubWebhooks.ts` (new — the `httpAction` handler; NOT `'use node'`, uses Web Crypto + scheduler + runQuery/runMutation only). - `packages/backend/convex/http.ts` (register the route). - `packages/backend/tests/unit/github-webhook-route.test.ts` (new). **Interfaces:** - Produces: `export const refreshSpoonById = internalAction({ args: { spoonId: v.id('spoons'), ownerId: v.id('users') }, handler })` — calls `await refreshOwnedSpoon(ctx, ownerId, spoonId, 'scheduled_check')`; catches and swallows errors into a returned `{ success: boolean; error?: string }` so webhook fan-out never throws unhandled. - Produces: `export const handleGithubWebhook = httpAction(async (ctx, request) => Response)` in `githubWebhooks.ts`. - Route (in `http.ts`): `http.route({ path: '/webhooks/github', method: 'POST', handler: handleGithubWebhook });` - **Webhook route path:** `POST /webhooks/github` (full URL is `/webhooks/github` — this is the URL to configure in the GitHub App settings; note it in the final report for deploy docs). **`handleGithubWebhook` behavior:** ```ts export const handleGithubWebhook = httpAction(async (ctx, request) => { const secret = process.env.GITHUB_APP_WEBHOOK_SECRET; if (!secret) return new Response('Webhook not configured', { status: 503 }); const rawBody = await request.text(); const signature = request.headers.get('x-hub-signature-256'); const ok = await verifyGithubSignature(secret, rawBody, signature); if (!ok) return new Response('Invalid signature', { status: 401 }); const event = request.headers.get('x-github-event'); const payload = JSON.parse(rawBody) as GithubWebhookPayload; // narrow type below if (event === 'push') { const fullName: string | undefined = payload.repository?.full_name; if (fullName) { const [owner, repo] = fullName.split('/'); const targets = await ctx.runQuery(internal.spoons.findForRepo, { owner, repo, }); for (const t of targets) { await ctx.scheduler.runAfter(0, internal.githubSync.refreshSpoonById, { spoonId: t.spoonId, ownerId: t.ownerId, }); } } } else if (event === 'installation' || event === 'installation_repositories') { const installationId = String(payload.installation?.id ?? ''); const action = payload.action; // 'deleted' | 'suspend' | 'unsuspend' | 'created' | ... if (installationId) { const status = action === 'deleted' || action === 'suspend' ? ('revoked' as const) : action === 'unsuspend' || action === 'created' ? ('active' as const) : ('needs_reauth' as const); // 'new_permissions_accepted', 'removed', etc. await ctx.runMutation( internal.github.setConnectionStatusByInstallation, { installationId, status }, ); } } // Always 200 for verified events we don't act on, so GitHub doesn't retry. return new Response('ok', { status: 200 }); }); ``` Define a minimal payload type (`repository?: { full_name?: string }; installation?: { id?: number }; action?: string`) rather than importing Octokit webhook types. **Runtime note:** `httpAction` cannot call node actions synchronously, but `ctx.scheduler.runAfter(0, internal.githubSync.refreshSpoonById, …)` schedules the node internalAction fine. `refreshSpoonById` lives in the `'use node'` `githubSync.ts` — that is allowed as a scheduled target. **Steps:** - [ ] Add `refreshSpoonById` internalAction to `githubSync.ts` (wrap `refreshOwnedSpoon` in try/catch, return `{ success, error? }`). - [ ] Create `githubWebhooks.ts` with `handleGithubWebhook`; register the route in `http.ts` (keep `auth.addHttpRoutes(http)`). - [ ] Write `tests/unit/github-webhook-route.test.ts` (convex-test `t.fetch`): - Set the secret for the test: `t.mutation` can't set env; instead the verifier reads `process.env.GITHUB_APP_WEBHOOK_SECRET`. In the test, set `process.env.GITHUB_APP_WEBHOOK_SECRET = 'testsecret'` in a `beforeAll` (vitest allows mutating `process.env`), and compute the expected signature with the same Web Crypto HMAC (import `verifyGithubSignature`'s sibling or recompute via `node:crypto` `createHmac('sha256', secret).update(body).digest('hex')` in the test file — node crypto is available in the vitest process even though the function under test uses Web Crypto). - **Bad signature:** `t.fetch('/webhooks/github', { method:'POST', headers:{'x-github-event':'push','x-hub-signature-256':'sha256=deadbeef'}, body })` → assert `res.status === 401`. - **installation deleted:** seed a `gitConnections` row (installationId `'99'`, status `active`); POST an `installation` event `{ action:'deleted', installation:{ id:99 } }` with a VALID signature → assert `res.status === 200` and the connection row is now `revoked`. - **push:** seed a github spoon whose fork is `me/x-fork`; POST a `push` with `{ repository:{ full_name:'me/x-fork' } }` and valid signature → assert `res.status === 200`. (You cannot assert the node action ran under convex-test; assert the 200 + that no throw occurred. Optionally assert `findForRepo` is exercised by checking a scheduled function was enqueued via `await t.finishInProgressScheduledFunctions()` doesn't throw — but since `refreshSpoonById` is a node action it will no-op/throw in the test VM; prefer NOT to finish scheduled functions here, just assert the 200.) - [ ] Run `bun run test:unit` — iterate to PASS. - [ ] `bun codegen:convex` + `bun run typecheck` — clean. - [ ] Commit: `feat(webhooks): add signature-verified GitHub webhook route`. --- ## Task 10: Octokit retry + throttling plugins; distinguish rate-limited from hard error **Problem:** `getInstallationOctokit` (`githubClient.ts:54-68`) uses a bare `Octokit` with no retry/backoff. On rate limits, refreshes hard-fail and the spoon shows `syncStatus: 'error'` with no distinction. Add the retry + throttling plugins and a distinct `rate_limited` status. **Files:** - `packages/backend/package.json` (add deps). - `packages/backend/convex/githubClient.ts` (compose plugins; add `isRateLimitError` helper). - `packages/backend/convex/schema.ts` (`spoons.syncStatus` union: add `v.literal('rate_limited')`). - `packages/backend/convex/githubSync.ts` (catch block ~291-308: branch on `isRateLimitError`). - `packages/backend/tests/unit/rate-limit.test.ts` (new). **Interfaces:** - Deps: `"@octokit/plugin-retry": "^7.1.0"`, `"@octokit/plugin-throttling": "^9.3.0"` (match the `@octokit/rest@^22` core version line; verify with `bun install` that peer ranges resolve — bump if bun errors). - `getInstallationOctokit` composition: ```ts import { retry } from '@octokit/plugin-retry'; import { throttling } from '@octokit/plugin-throttling'; const SpoonOctokit = Octokit.plugin(retry, throttling); export const getInstallationOctokit = (installationId: string) => new SpoonOctokit({ authStrategy: createAppAuth, auth: { appId: getEnv('GITHUB_APP_ID'), privateKey: normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')), installationId }, userAgent: 'Spoon', request: { headers: { 'X-GitHub-Api-Version': '2022-11-28' } }, throttle: { onRateLimit: (retryAfter, options, _octokit, retryCount) => retryCount < 2, onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => retryCount < 2, }, }); ``` - Produces (pure, exported): `export const isRateLimitError = (error: unknown): boolean` — returns true when `error` looks like an Octokit `RequestError` with `status === 403 || status === 429` and a rate-limit signal (message includes `rate limit`, or `response.headers['x-ratelimit-remaining'] === '0'`, or `x-ratelimit-reset` present). Guard with `typeof error === 'object' && error !== null`. **Catch-block change in `refreshOwnedSpoon` (`githubSync.ts:291`):** ```ts } catch (error) { const message = error instanceof Error ? error.message : String(error); const rateLimited = isRateLimitError(error); await Promise.all([ ctx.runMutation(internal.spoons.patchSyncFields, { spoonId, syncStatus: rateLimited ? 'rate_limited' : 'error', lastGithubRefreshAt: Date.now(), lastCheckedAt: Date.now(), lastError: rateLimited ? `Rate limited by GitHub; will retry. ${message}` : message, }), ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId, status: 'failed', error: message, }), ]); throw new ConvexError(message); } ``` (The hourly cron / webhook already re-drive the refresh, so `rate_limited` is the "will retry" state; no new scheduling needed. `patchSyncFields` already accepts `syncStatus` — extend its arg validator if it enumerates the union; check `spoons.patchSyncFields` ~316 and add `'rate_limited'` there too if the union is inlined.) **Steps:** - [ ] Add the two deps to `packages/backend/package.json`; run `bun install` from repo root; confirm it resolves (bump versions if bun reports peer conflicts against `@octokit/core` used by `@octokit/rest@22`). - [ ] Write failing `tests/unit/rate-limit.test.ts`: `isRateLimitError({ status: 403, response: { headers: { 'x-ratelimit-remaining': '0' } } })` → true; `isRateLimitError({ status: 429 })` → true; `isRateLimitError({ status: 404 })` → false; `isRateLimitError(new Error('boom'))` → false; `isRateLimitError({ status: 403, message: 'secondary rate limit' })` → true. - [ ] Run `bun run test:unit` — FAIL. - [ ] Add `isRateLimitError` + plugin composition to `githubClient.ts`; add `'rate_limited'` to `spoons.syncStatus` in `schema.ts` (and to `patchSyncFields`'s inline union if present); update the catch block. - [ ] `bun codegen:convex` + `bun run test:unit` PASS + `bun run typecheck` clean. - [ ] Commit: `feat(sync): add Octokit retry/throttle and rate-limited status`. --- ## Task 11: Fix unbounded `.collect()`s on hot paths **Problem:** Several hot queries `.collect()` owner-wide then filter/slice in JS: - `spoonCommits.listForSpoon` no-side branch (`spoonCommits.ts:26-33`) collects ALL of an owner's commits. - `agentJobs.countOldWorkspaces` (`agentJobs.ts:1009-1012`) and `deleteOldWorkspaces` (`agentJobs.ts:1031-1034`) collect ALL of an owner's jobs. **Files:** - `packages/backend/convex/spoonCommits.ts` (~26-33). - `packages/backend/convex/agentJobs.ts` (~1001-1044). - `packages/backend/tests/unit/hot-paths.test.ts` (new). **Fix — `spoonCommits.listForSpoon` no-side branch:** the table already has `by_spoon_side`. Replace the owner-wide collect with two indexed `.take()`s (one per side) merged and sorted: ```ts const [upstream, fork] = await Promise.all([ ctx.db.query('spoonCommits').withIndex('by_spoon_side', (q) => q.eq('spoonId', spoonId).eq('side', 'upstream')).order('desc').take(limit ?? 100), ctx.db.query('spoonCommits').withIndex('by_spoon_side', (q) => q.eq('spoonId', spoonId).eq('side', 'fork')).order('desc').take(limit ?? 100), ]); return [...upstream, ...fork].sort((a, b) => (b.committedAt ?? 0) - (a.committedAt ?? 0)).slice(0, limit ?? 100); ``` **Fix — `agentJobs.countOldWorkspaces` / `deleteOldWorkspaces`:** cap the scan with `.take()` on the existing `by_owner` index instead of unbounded `.collect()`. For count, take a bounded window (e.g. `.take(500)`) — document that count is "up to N"; for delete, `deleteOldWorkspaces` already slices to `max` (≤100), so change its collect to `.take(500)` before filtering, keeping the `max` slice. (These are maintenance/settings actions, not per-request hot loops, but the owner-wide `.collect()` is still the audited unbounded read; bounding it removes the growth risk.) **Steps:** - [ ] Write failing `tests/unit/hot-paths.test.ts` (convex-test): seed a spoon with, say, 3 upstream + 3 fork `spoonCommits` rows (insert directly); call `api.spoonCommits.listForSpoon` (authed) with no `side`, `limit: 4` → assert exactly 4 rows returned, newest `committedAt` first, and both sides represented. (This asserts behavior; the index change must keep the same observable ordering.) - [ ] Run `bun run test:unit` — should PASS against current code IF ordering matches; adjust the test to pin ordering so the refactor is covered (make committedAt values interleave upstream/fork so a per-side take + merge is required to get the right top-4). Confirm the test FAILS if you naively `.take(4)` one side — i.e. it genuinely exercises the merge. - [ ] Apply the `spoonCommits.listForSpoon` fix; apply the `.take(500)` bound to both agentJobs functions. - [ ] Run `bun run test:unit` — PASS. `bun run typecheck` — clean. - [ ] Commit: `perf(convex): bound unbounded owner-wide collects on hot paths`. --- ## Task 12: Surface `needs_reauth`/`revoked` in Settings → Integrations **Problem:** `GithubIntegrationPanel` (`apps/next/src/components/integrations/github-integration-panel.tsx`) shows the connection but never surfaces `connection.status`. Users can't see when GitHub access is broken. `api.github.getConnection` already returns the full row (with `status`). **Files:** - `apps/next/src/components/integrations/github-integration-panel.tsx`. **Interfaces:** - Consumes: `connection.status: 'active' | 'needs_reauth' | 'revoked'` (already on the query result). - Add a status badge/alert block in the connected branch (~line 51-61): when `status !== 'active'`, render a prominent warning with a "Reconnect GitHub App" CTA linking to `installUrl` (the existing `installUrl` query). Copy: - `needs_reauth`: "GitHub access needs re-authorization. Reconnect the app to resume syncing." (amber) - `revoked`: "The GitHub App installation was removed. Reinstall to resume syncing." (red) **Implementation sketch:** ```tsx {connection && connection.status !== 'active' ? (

{connection.status === 'revoked' ? 'GitHub App installation removed' : 'GitHub access needs re-authorization'}

{connection.status === 'revoked' ? 'Reinstall the app to resume syncing.' : 'Reconnect the app to resume syncing.'}

{installUrl ? ( Reconnect GitHub App ) : null}
) : null} ``` **Steps:** - [ ] Add the status warning block to `github-integration-panel.tsx` (guard on `connection` being present; place above or within the connected grid). - [ ] Verify types: `bun run typecheck` in `apps/next` (or repo-root typecheck task) — the `status` field is already typed via the generated api. - [ ] Manual check (no automated Next component test required for this phase): run the app, temporarily set a connection row's `status` to `needs_reauth` (via Convex dashboard or a test mutation) and confirm the banner + reconnect CTA render on Settings → Integrations. - [ ] Commit: `feat(settings): surface GitHub connection needs_reauth/revoked`. --- ## Manual verification checklist (end of phase) - [ ] `cd packages/backend && bun run test:unit` — all green. - [ ] `bun codegen:convex` (root) then `cd packages/backend && bun run typecheck` — clean. - [ ] Deploy notes for the operator: configure the GitHub App webhook URL to `/webhooks/github`, subscribe to `push`, `installation`, and `installation_repositories` events, and set `GITHUB_APP_WEBHOOK_SECRET` in the Convex deployment env (matching the App's webhook secret). - [ ] Trigger a real `push` to a watched fork → confirm a targeted refresh runs (spoon `lastGithubRefreshAt` updates within seconds, not the hourly cron window). - [ ] Uninstall/reinstall the App → confirm `gitConnections.status` flips to `revoked`/`active` and the Settings → Integrations banner reflects it. - [ ] Force a divergence >100 commits (or a fork far behind) → confirm `upstreamHeadSha` equals the real branch tip and only ONE maintenance thread is created across repeated scheduled checks.