diff --git a/src/app/api/v1/integrations/connections/[id]/route.ts b/src/app/api/v1/integrations/connections/[id]/route.ts index 79509b5ca..1da23699c 100644 --- a/src/app/api/v1/integrations/connections/[id]/route.ts +++ b/src/app/api/v1/integrations/connections/[id]/route.ts @@ -10,9 +10,11 @@ */ import { NextRequest } from "next/server"; +import { revalidateTag } from "next/cache"; import { db } from "@/lib/db"; import { authorizeConnection } from "@/lib/auth/with-integration-auth"; import { publish } from "@/lib/realtime/publish"; +import { projectGithubConnectionTag } from "@/lib/engine/goal-github"; type Params = { params: Promise<{ id: string }> }; @@ -66,6 +68,10 @@ export async function DELETE(_req: NextRequest, { params }: Params) { await db.integrationConnection.delete({ where: { id } }); + // The connection is gone — heal the goals page's cached "has GitHub connection" + // boolean (G#368) so prAdvanceStuck flips without waiting out the cache TTL. + revalidateTag(projectGithubConnectionTag(conn.projectId), { expire: 0 }); + await publish(`project:${conn.projectId}`, "integration.connection.removed", { connectionId: id, integrationId: conn.integrationId, diff --git a/src/app/api/v1/integrations/connections/route.ts b/src/app/api/v1/integrations/connections/route.ts index 10f2c00e5..b41916c26 100644 --- a/src/app/api/v1/integrations/connections/route.ts +++ b/src/app/api/v1/integrations/connections/route.ts @@ -11,11 +11,13 @@ */ import { NextRequest } from "next/server"; +import { revalidateTag } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { authorizeProject, authorizeProjectWrite } from "@/lib/auth/with-project-auth"; import { enqueue } from "@/lib/queue/enqueue"; import { publish } from "@/lib/realtime/publish"; +import { projectGithubConnectionTag } from "@/lib/engine/goal-github"; const createSchema = z.object({ integrationId: z.string().uuid(), @@ -114,6 +116,10 @@ export async function POST(req: NextRequest) { }, }); + // A connection now exists — heal the goals page's cached "has GitHub connection" + // boolean (G#368) so prAdvanceStuck flips without waiting out the cache TTL. + revalidateTag(projectGithubConnectionTag(projectId), { expire: 0 }); + // Trigger initial backfill. await enqueue( "integrations", diff --git a/src/app/api/v1/integrations/github/connect-project/route.test.ts b/src/app/api/v1/integrations/github/connect-project/route.test.ts index 92b3d1486..56622a800 100644 --- a/src/app/api/v1/integrations/github/connect-project/route.test.ts +++ b/src/app/api/v1/integrations/github/connect-project/route.test.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/integrations/crypto", () => ({ encrypt: vi.fn((s: string) => `enc vi.mock("@/lib/queue/enqueue", () => ({ enqueue: vi.fn().mockResolvedValue({}) })); vi.mock("@/worker/jobs/github-scaffold", () => ({ GITHUB_SCAFFOLD_JOB: "github.scaffold" })); vi.mock("@/lib/observability/logger", () => ({ log: { info: vi.fn(), warn: vi.fn() } })); +// The route revalidates the cached hasProjectGithubConnection boolean (G#368); +// next/cache's real revalidateTag needs a Next request context vitest doesn't have. +vi.mock("next/cache", () => ({ revalidateTag: vi.fn() })); vi.mock("@/lib/db", () => ({ db: { integration: { upsert: vi.fn() }, @@ -69,4 +72,17 @@ describe("connect-project webhook subscription (drift guard)", () => { const body = JSON.parse((hookCall![1] as RequestInit).body as string); expect(body.events).toEqual([...GITHUB_WEBHOOK_EVENTS]); }); + + it("revalidates the cached hasProjectGithubConnection boolean on connect (G#368)", async () => { + const { revalidateTag } = await import("next/cache"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ id: 42 }) }), + ); + await POST(postReq({ projectId: "11111111-1111-4111-8111-111111111111", repo: "owner/repo" })); + expect(revalidateTag).toHaveBeenCalledWith( + "project-github-connection:11111111-1111-4111-8111-111111111111", + { expire: 0 }, + ); + }); }); diff --git a/src/app/api/v1/integrations/github/connect-project/route.ts b/src/app/api/v1/integrations/github/connect-project/route.ts index 9f1b7f03a..c8100b380 100644 --- a/src/app/api/v1/integrations/github/connect-project/route.ts +++ b/src/app/api/v1/integrations/github/connect-project/route.ts @@ -15,6 +15,7 @@ */ import { NextRequest } from "next/server"; +import { revalidateTag } from "next/cache"; import { z } from "zod"; import { randomBytes } from "node:crypto"; import { db } from "@/lib/db"; @@ -28,6 +29,7 @@ import { import { enqueue } from "@/lib/queue/enqueue"; import { GITHUB_SCAFFOLD_JOB } from "@/worker/jobs/github-scaffold"; import { log } from "@/lib/observability/logger"; +import { projectGithubConnectionTag } from "@/lib/engine/goal-github"; const connectSchema = z.object({ projectId: z.string().uuid(), @@ -163,6 +165,10 @@ export async function POST(req: NextRequest) { select: { id: true }, }); + // A GitHub connection now exists — heal the goals page's cached "has GitHub + // connection" boolean (G#368) so prAdvanceStuck flips without waiting out the TTL. + revalidateTag(projectGithubConnectionTag(projectId), { expire: 0 }); + await Promise.all([ enqueue( "integrations", @@ -202,6 +208,7 @@ export async function DELETE(req: NextRequest) { where: { id: parsed.data.connectionId }, select: { id: true, + projectId: true, externalProjectId: true, config: true, connectedByUserId: true, @@ -237,6 +244,10 @@ export async function DELETE(req: NextRequest) { await db.integrationConnection.delete({ where: { id: conn.id } }); + // The connection is gone — heal the goals page's cached "has GitHub connection" + // boolean (G#368) so prAdvanceStuck flips without waiting out the cache TTL. + revalidateTag(projectGithubConnectionTag(conn.projectId), { expire: 0 }); + log.info({ event: "github.project.disconnected", connectionId: conn.id }); return new Response(null, { status: 204 }); } diff --git a/src/app/api/v2/projects/[projectId]/goals/route.test.ts b/src/app/api/v2/projects/[projectId]/goals/route.test.ts index 412f1bf43..a830f7f13 100644 --- a/src/app/api/v2/projects/[projectId]/goals/route.test.ts +++ b/src/app/api/v2/projects/[projectId]/goals/route.test.ts @@ -101,7 +101,8 @@ describe("GET goals (list)", () => { ); const res = await GET(getReq(), params); const { compute } = await res.json(); - expect(goalComputeRollups).toHaveBeenCalledWith("p"); + // Scoped to the listed goals' ids (G#368) — never the whole project. + expect(goalComputeRollups).toHaveBeenCalledWith("p", ["g1", "g2"]); expect(compute).toEqual({ g1: { computeMs: 1500, runCount: 2, wallClock: { startedAt: "2026-06-10T00:00:00.000Z", endedAt: "2026-06-10T01:00:00.000Z" } }, }); @@ -261,6 +262,21 @@ describe("GET goals — pagination path (?set=)", () => { expect(listEngineGoals).not.toHaveBeenCalled(); }); + it("scopes every per-goal aggregate to the page's goalIds (G#368)", async () => { + vi.mocked(listEngineGoalsPage).mockResolvedValue( + row({ goals: [goal("g1"), goal("g2")], nextCursor: null }), + ); + await GET(getReq("?set=active"), params); + const ids = ["g1", "g2"]; + expect(goalTaskCompletions).toHaveBeenCalledWith("p", ids); + expect(goalAutopilotScoping).toHaveBeenCalledWith("p", ids); + expect(goalUnresolvedMustFix).toHaveBeenCalledWith("p", ids); + expect(goalsWithBlockedMachine).toHaveBeenCalledWith("p", ids); + expect(goalsWithOpenAutoRework).toHaveBeenCalledWith("p", ids); + expect(goalMachineActivity).toHaveBeenCalledWith("p", ids); + expect(getProjectGoalActiveWork).toHaveBeenCalledWith("p", expect.objectContaining({ goalIds: ids })); + }); + it("401 when not authorized", async () => { vi.mocked(authorizeProject).mockResolvedValue(row(new Response(null, { status: 401 }))); const res = await GET(getReq("?set=active"), params); diff --git a/src/app/api/v2/projects/[projectId]/goals/route.ts b/src/app/api/v2/projects/[projectId]/goals/route.ts index 6df78cb02..fa109ea0f 100644 --- a/src/app/api/v2/projects/[projectId]/goals/route.ts +++ b/src/app/api/v2/projects/[projectId]/goals/route.ts @@ -88,6 +88,10 @@ export async function assembleGoalRows( const goalIds = goals.map((g) => g.id); const ownerIds = [...new Set(goals.map((g) => g.ownerId).filter((id): id is string => !!id))]; + // Every aggregate below is scoped to THIS page's goalIds (G#368): these helpers default + // to project-wide scans when unscoped, and a 50-row page fetch was paying for the whole + // project's task/run/finding history on every request. + // Multi-repo (S6): one batched query for every goal's repo-targets (no per-goal fan-out), // grouped by goalId, ordered by connection age so the primary repo renders first. const repoRows = await db.engineGoalRepo.findMany({ @@ -110,21 +114,21 @@ export async function assembleGoalRows( const [completions, scoping, mustFix, blockedMachineGoals, openAutoRework, activity, edgeCounts, ownerNames, activeWork, githubConnected] = await Promise.all([ - goalTaskCompletions(projectId), - goalAutopilotScoping(projectId), - goalUnresolvedMustFix(projectId), - goalsWithBlockedMachine(projectId), + goalTaskCompletions(projectId, goalIds), + goalAutopilotScoping(projectId, goalIds), + goalUnresolvedMustFix(projectId, goalIds), + goalsWithBlockedMachine(projectId, goalIds), // Goals whose fleet is auto-reworking right now (open system rework task in flight) // — the "the fleet is fixing it" signal the Kanban strip reads. - goalsWithOpenAutoRework(projectId), - goalMachineActivity(projectId), + goalsWithOpenAutoRework(projectId, goalIds), + goalMachineActivity(projectId, goalIds), listGoalGoalEdges(goalIds), ownerIds.length ? db.user .findMany({ where: { id: { in: ownerIds } }, select: { id: true, name: true } }) .then((rows) => new Map(rows.map((u) => [u.id, u.name] as const))) : Promise.resolve(new Map()), - getProjectGoalActiveWork(projectId), + getProjectGoalActiveWork(projectId, { goalIds }), // ONE project-level existence check (no token decrypt) — feeds every goal's // prAdvanceStuck below, never a per-goal query. hasProjectGithubConnection(projectId), @@ -227,12 +231,18 @@ export async function GET(req: NextRequest, { params }: Params) { } // Legacy path: bare rows + compute map + machineActivity map (backward compat). - const [goals, compute, blockedMachineGoals, machineActivity] = await Promise.all([ + const [goals, blockedMachineGoals, machineActivity] = await Promise.all([ listEngineGoals({ projectId, ...parsed.data }), - goalComputeRollups(projectId), goalsWithBlockedMachine(projectId), goalMachineActivity(projectId), ]); + // Scoped to the listed goals (≤ GOAL_LIST_CAP) — unscoped, this loaded every EngineRun + // ever recorded on the project's goaled tasks (G#368). Sequenced after the list read + // because it needs the ids; the run read itself is now bounded. + const compute = await goalComputeRollups( + projectId, + goals.map((g) => g.id), + ); const data = goals.map((g) => ({ ...g, hasBlockedMachine: blockedMachineGoals.has(g.id) })); // Signal a hit cap so the caller can distinguish "capped" from "complete". return NextResponse.json({ diff --git a/src/lib/engine/goal-active-work.test.ts b/src/lib/engine/goal-active-work.test.ts index 8b87afd44..8f3c27bbf 100644 --- a/src/lib/engine/goal-active-work.test.ts +++ b/src/lib/engine/goal-active-work.test.ts @@ -126,6 +126,24 @@ describe("getProjectGoalActiveWork", () => { }); }); + it("scopes the run + task reads to opts.goalIds when provided (G#368)", async () => { + await getProjectGoalActiveWork("p", { now: NOW, rollups: new Map(), goalIds: ["g1", "g2"] }); + expect(db.engineRun.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: "running", task: { projectId: "p", goalId: { in: ["g1", "g2"] } } }, + }), + ); + expect(db.engineTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + projectId: "p", + goalId: { in: ["g1", "g2"] }, + runtimeState: runtime_state.running, + }, + }), + ); + }); + it("returns an empty map (and skips the goal/user reads) when nothing is active", async () => { const out = await getProjectGoalActiveWork("p", { now: NOW, rollups: new Map() }); expect(out.size).toBe(0); @@ -224,6 +242,14 @@ describe("getProjectGoalActiveWork", () => { vi.mocked(db.engineTask.findMany).mockResolvedValue(row([task("g1")])); vi.mocked(db.engineGoal.findMany).mockResolvedValue(row([{ id: "g1", ownerId: null }])); await getProjectGoalActiveWork("p", { now: NOW }); - expect(goalComputeRollups).toHaveBeenCalledWith("p"); + expect(goalComputeRollups).toHaveBeenCalledWith("p", undefined); + }); + + it("forwards opts.goalIds to the goalComputeRollups fallback so the run scan stays page-scoped (G#368)", async () => { + const { goalComputeRollups } = await import("./goal-read"); + vi.mocked(db.engineTask.findMany).mockResolvedValue(row([task("g1")])); + vi.mocked(db.engineGoal.findMany).mockResolvedValue(row([{ id: "g1", ownerId: null }])); + await getProjectGoalActiveWork("p", { now: NOW, goalIds: ["g1", "g2"] }); + expect(goalComputeRollups).toHaveBeenCalledWith("p", ["g1", "g2"]); }); }); diff --git a/src/lib/engine/goal-active-work.ts b/src/lib/engine/goal-active-work.ts index 11d050085..1d0a9933e 100644 --- a/src/lib/engine/goal-active-work.ts +++ b/src/lib/engine/goal-active-work.ts @@ -97,13 +97,17 @@ interface ActiveContribution { */ export async function getProjectGoalActiveWork( projectId: string, - opts: { now?: Date; rollups?: Map } = {}, + opts: { now?: Date; rollups?: Map; goalIds?: string[] } = {}, ): Promise> { const now = opts.now ?? new Date(); + // Optional page scope (`opts.goalIds`): the paginated Goals surface passes its page ids so + // a page fetch reads only those goals' runs/tasks. Omitted ⇒ project-wide (every other + // caller — me/counts, the dashboard widget — keeps the pre-existing behaviour). + const goalFilter = opts.goalIds ? { in: opts.goalIds } : { not: null }; const [runningRuns, runningTasks, rollups] = await Promise.all([ db.engineRun.findMany({ - where: { status: "running", task: { projectId, goalId: { not: null } } }, + where: { status: "running", task: { projectId, goalId: goalFilter } }, select: { status: true, leaseExpiresAt: true, @@ -112,10 +116,12 @@ export async function getProjectGoalActiveWork( }, }), db.engineTask.findMany({ - where: { projectId, goalId: { not: null }, runtimeState: runtime_state.running }, + where: { projectId, goalId: goalFilter, runtimeState: runtime_state.running }, select: { goalId: true, assigneeId: true, fleetOwnerId: true }, }), - opts.rollups ? Promise.resolve(opts.rollups) : goalComputeRollups(projectId), + // Forward the page scope so a scoped call never triggers a project-wide run scan + // (G#368) — and the rollup is computed at most once per request either way. + opts.rollups ? Promise.resolve(opts.rollups) : goalComputeRollups(projectId, opts.goalIds), ]); // goalId → the active contributions on it (one per live run / running task). diff --git a/src/lib/engine/goal-blocked-machine.test.ts b/src/lib/engine/goal-blocked-machine.test.ts index 6b9f7d930..22e5fc4d6 100644 --- a/src/lib/engine/goal-blocked-machine.test.ts +++ b/src/lib/engine/goal-blocked-machine.test.ts @@ -114,4 +114,16 @@ describe("goalsWithBlockedMachine (list)", () => { const out = await goalsWithBlockedMachine("p"); expect(out.size).toBe(0); }); + + it("scopes the run read to the page's goalIds when provided (G#368)", async () => { + runFindMany.mockResolvedValueOnce([]); + await goalsWithBlockedMachine("p", ["g1", "g2"]); + expect(runFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + task: { projectId: "p", goalId: { in: ["g1", "g2"] } }, + }), + }), + ); + }); }); diff --git a/src/lib/engine/goal-blocked-machine.ts b/src/lib/engine/goal-blocked-machine.ts index fb08905c0..6d197d5ea 100644 --- a/src/lib/engine/goal-blocked-machine.ts +++ b/src/lib/engine/goal-blocked-machine.ts @@ -72,9 +72,18 @@ export async function loadGoalBlockedMachines(goalId: string): Promise> { +export async function goalsWithBlockedMachine( + projectId: string, + // Optional page scope: restrict to these goals (the paginated Goals surface passes its + // page ids). Omitted ⇒ project-wide, the pre-existing behaviour every other caller keeps. + goalIds?: string[], +): Promise> { const runs = await db.engineRun.findMany({ - where: { status: "running", endedAt: null, task: { projectId, goalId: { not: null } } }, + where: { + status: "running", + endedAt: null, + task: { projectId, goalId: goalIds ? { in: goalIds } : { not: null } }, + }, select: { machineId: true, task: { select: { goalId: true } } }, }); const machineIds = uuidMachineIds(runs.map((r) => r.machineId)); diff --git a/src/lib/engine/goal-github.test.ts b/src/lib/engine/goal-github.test.ts index d3431caef..705f26f12 100644 --- a/src/lib/engine/goal-github.test.ts +++ b/src/lib/engine/goal-github.test.ts @@ -10,7 +10,11 @@ vi.mock("@/lib/integrations/github-token", () => ({ PAT_SENTINEL: "pat", })); vi.mock("@/lib/observability/logger", () => ({ log: { warn: vi.fn(), info: vi.fn(), error: vi.fn() } })); +// hasProjectGithubConnection is served through unstable_cache (G#368); passthrough the +// wrapper in tests (no Next incremental-cache in vitest) and assert the key/tags wiring. +vi.mock("next/cache", () => ({ unstable_cache: vi.fn() })); +const { unstable_cache } = await import("next/cache"); const { db } = await import("@/lib/db"); const { resolveCredentialForRepo } = await import("@/lib/integrations/github-token"); const { resolveGoalMergeGitHub, hasProjectGithubConnection } = await import("./goal-github"); @@ -19,6 +23,8 @@ const row = (v: T) => v as never; beforeEach(() => { vi.resetAllMocks(); + // resetAllMocks clears implementations — re-arm the unstable_cache passthrough. + vi.mocked(unstable_cache).mockImplementation(((fn: () => unknown) => fn) as never); vi.mocked(db.integrationConnection.findFirst).mockResolvedValue( row({ externalProjectId: "pulzze/account-server" }), ); @@ -78,4 +84,14 @@ describe("hasProjectGithubConnection (existence-only, no token decrypt)", () => select: { id: true }, }); }); + + it("serves through unstable_cache keyed by projectId, tagged for revalidation, with a TTL (G#368)", async () => { + vi.mocked(db.integrationConnection.findFirst).mockResolvedValue(row(null)); + await hasProjectGithubConnection("p"); + expect(unstable_cache).toHaveBeenCalledWith( + expect.any(Function), + ["project-github-connection", "p"], + { tags: ["project-github-connection:p"], revalidate: 60 }, + ); + }); }); diff --git a/src/lib/engine/goal-github.ts b/src/lib/engine/goal-github.ts index f22a1f586..d80b97a47 100644 --- a/src/lib/engine/goal-github.ts +++ b/src/lib/engine/goal-github.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Interactor, Inc. // SPDX-License-Identifier: AGPL-3.0-or-later +import { unstable_cache } from "next/cache"; import { db } from "@/lib/db"; import { log } from "@/lib/observability/logger"; import { @@ -90,17 +91,40 @@ export async function resolveProjectGitHub( return null; } +/** Cache tag for a project's `hasProjectGithubConnection` boolean — revalidated by the + * connection connect/disconnect routes so the cached value heals immediately after a + * user links or unlinks GitHub. */ +export function projectGithubConnectionTag(projectId: string): string { + return `project-github-connection:${projectId}`; +} + +// How long the cached existence boolean is trusted between writes. A safety net for +// paths that can't revalidateTag (org-level `Integration.enabled` toggles, direct DB +// changes, the BullMQ worker) — the project-scoped connect/disconnect routes revalidate +// the tag explicitly, so user-driven changes never wait on this. +const GITHUB_CONNECTION_CACHE_TTL_S = 60; + /** Cheap "is a GitHub connection configured" check — an existence probe, NOT a token * resolution (no decrypt, no external call). Used where callers only need to know * whether a connection exists (e.g. flagging a stalled PR-advance as genuinely stuck * vs still in flight) and would otherwise pay `resolveProjectGitHub`'s decrypt cost - * needlessly on every read. */ + * needlessly on every read. + * + * G#368: served through `unstable_cache` (keyed by projectId, tagged for the + * connect/disconnect routes, short TTL) — this feeds EVERY goals page/row assembly, and + * a connection existing/not-existing changes far too rarely to re-query per request. */ export async function hasProjectGithubConnection(projectId: string): Promise { - const connection = await db.integrationConnection.findFirst({ - where: { projectId, integration: { provider: "github", enabled: true } }, - select: { id: true }, - }); - return connection != null; + return unstable_cache( + async () => { + const connection = await db.integrationConnection.findFirst({ + where: { projectId, integration: { provider: "github", enabled: true } }, + select: { id: true }, + }); + return connection != null; + }, + ["project-github-connection", projectId], + { tags: [projectGithubConnectionTag(projectId)], revalidate: GITHUB_CONNECTION_CACHE_TTL_S }, + )(); } /** The outcome of resolving a goal-MERGE credential (S4). A discriminated result diff --git a/src/lib/engine/goal-machine-activity.test.ts b/src/lib/engine/goal-machine-activity.test.ts index e428d603f..71b8dc3cf 100644 --- a/src/lib/engine/goal-machine-activity.test.ts +++ b/src/lib/engine/goal-machine-activity.test.ts @@ -102,6 +102,22 @@ describe("goalMachineActivity", () => { expect(out.get("g1")!.runningMachines[0].blockedReasonKind).toBe("machine_offline"); }); + it("scopes both reads to the page's goalIds when given an array (G#368)", async () => { + await goalMachineActivity("p", ["g1", "g2"]); + expect(runFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + task: { projectId: "p", goalId: { in: ["g1", "g2"] } }, + }), + }), + ); + expect(taskGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ goalId: { in: ["g1", "g2"] } }), + }), + ); + }); + it("includes a goal with only queued work (no active run)", async () => { taskGroupBy.mockResolvedValueOnce([{ goalId: "g2", _count: { _all: 3 } }]); const out = await goalMachineActivity("p"); diff --git a/src/lib/engine/goal-machine-activity.ts b/src/lib/engine/goal-machine-activity.ts index 24e1547ee..c7c9aeecb 100644 --- a/src/lib/engine/goal-machine-activity.ts +++ b/src/lib/engine/goal-machine-activity.ts @@ -92,15 +92,17 @@ function uuidMachineIds(machineIds: Array): string[] * Returns a Map keyed by goalId. A goal appears iff it has ≥1 active run OR ≥1 queued task; * goals with no current engine activity are absent and the caller defaults to "no activity". * - * Optional `goalId` narrows the same queries to a single goal — the goal-detail page wants - * one goal's activity without scanning every run in the project. The returned map then has - * at most that one entry. + * Optional `goalId` narrows the same queries to a single goal (the goal-detail page wants + * one goal's activity without scanning every run in the project) or to a set of goals (the + * paginated Goals surface passes its page ids). The returned map then only has entries for + * the requested goal(s). */ export async function goalMachineActivity( projectId: string, - goalId?: string, + goalId?: string | string[], ): Promise> { - const goalFilter = goalId !== undefined ? goalId : { not: null }; + const goalFilter = + goalId === undefined ? { not: null } : Array.isArray(goalId) ? { in: goalId } : goalId; const [activeRuns, queuedGroups] = await Promise.all([ db.engineRun.findMany({ where: { status: "running", endedAt: null, task: { projectId, goalId: goalFilter } }, diff --git a/src/lib/engine/goal-read.test.ts b/src/lib/engine/goal-read.test.ts index 634ec4282..7ad09f987 100644 --- a/src/lib/engine/goal-read.test.ts +++ b/src/lib/engine/goal-read.test.ts @@ -183,6 +183,15 @@ describe("goalTaskCompletions", () => { }); }); + it("scopes the grouped query to the page's goalIds when provided (G#368)", async () => { + await goalTaskCompletions("p", ["g1", "g2"]); + expect(db.engineTask.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ goalId: { in: ["g1", "g2"] } }), + }), + ); + }); + it("excludes meta task types so a goal whose only open task is planning isn't flagged open (no cross-surface drift)", async () => { // The shared countable filter means a planning task never reaches the groupBy result, // so a goal with 1 done implementation + 1 open planning task reads as complete here — @@ -282,6 +291,14 @@ describe("goalComputeRollups", () => { }); }); + it("narrows the read to the page's goals when a goalIds array is given (G#368)", async () => { + await goalComputeRollups("p", ["g1", "g2"]); + expect(db.engineRun.findMany).toHaveBeenCalledWith({ + where: { task: { projectId: "p", goalId: { in: ["g1", "g2"] } } }, + select: { computeMs: true, startedAt: true, endedAt: true, task: { select: { goalId: true } } }, + }); + }); + it("rolls multiple tasks' runs up to their goal, summing across runs (null computeMs → 0)", async () => { vi.mocked(db.engineRun.findMany).mockResolvedValue( row([ @@ -399,6 +416,15 @@ describe("goalAutopilotScoping", () => { }); }); + it("scopes to the page's goalIds when provided (G#368)", async () => { + await goalAutopilotScoping("p", ["g1"]); + expect(db.engineTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ goalId: { in: ["g1"] } }), + }), + ); + }); + it("returns the set of goalIds with an active planning task", async () => { vi.mocked(db.engineTask.findMany).mockResolvedValue( row([{ goalId: "g1" }, { goalId: "g2" }]), @@ -435,6 +461,15 @@ describe("goalsWithOpenAutoRework", () => { }); }); + it("scopes to the page's goalIds when provided (G#368)", async () => { + await goalsWithOpenAutoRework("p", ["g1"]); + expect(db.engineTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ goalId: { in: ["g1"] } }), + }), + ); + }); + it("returns the set of goalIds with an open system rework task in flight", async () => { vi.mocked(db.engineTask.findMany).mockResolvedValue( row([{ goalId: "g1" }, { goalId: "g2" }]), @@ -709,6 +744,14 @@ describe("goalUnresolvedMustFix", () => { }); }); + it("scopes to the page's goalIds when provided (G#368)", async () => { + await goalUnresolvedMustFix("p", ["g1", "g2"]); + const arg = vi.mocked(db.reviewFinding.findMany).mock.calls[0][0] as { + where: { task: Record }; + }; + expect(arg.where.task).toMatchObject({ goalId: { in: ["g1", "g2"] } }); + }); + it("folds the rows into a per-goal count map (NULL lastReviewedSha → conservative count)", async () => { vi.mocked(db.reviewFinding.findMany).mockResolvedValue( row([finding("g1"), finding("g1"), finding("g2"), finding(null)]), diff --git a/src/lib/engine/goal-read.ts b/src/lib/engine/goal-read.ts index df60cc5a1..6f8d21d68 100644 --- a/src/lib/engine/goal-read.ts +++ b/src/lib/engine/goal-read.ts @@ -203,10 +203,18 @@ export interface GoalTaskCompletion { */ export async function goalTaskCompletions( projectId: string, + // Optional page scope: restrict the grouped read to these goals (the paginated Goals + // surface passes its 50 page ids so a page fetch never scans project-wide task history). + // Omitted ⇒ project-wide, the pre-existing behaviour every other caller keeps. + goalIds?: string[], ): Promise> { const groups = await db.engineTask.groupBy({ by: ["goalId", "runtimeState"], - where: { projectId, goalId: { not: null }, type: { in: countableTaskTypeList() } }, + where: { + projectId, + goalId: goalIds ? { in: goalIds } : { not: null }, + type: { in: countableTaskTypeList() }, + }, _count: { _all: true }, }); @@ -261,19 +269,23 @@ export async function goalTaskCompletions( * real time the goal consumed). The fold (null-compute→0, run count, min-start/max-end * span) is the SHARED `rollupComputeByKey`, so per-task and per-goal totals stay consistent. * - * Optional `goalId` narrows the same query to one goal — the goal-detail surface wants a - * single rollup without scanning every run in the project. Goals with no runs are absent - * from the map; the caller substitutes `emptyComputeRollup()`. + * Optional `goalId` narrows the same query to one goal (the goal-detail surface wants a + * single rollup without scanning every run in the project) or to a set of goals (the + * paginated Goals surface passes its page ids — G#368: unscoped, this read loads EVERY + * EngineRun ever recorded on the project's goaled tasks, a cost that grows forever with + * project history). Goals with no runs are absent from the map; the caller substitutes + * `emptyComputeRollup()`. */ export async function goalComputeRollups( projectId: string, - goalId?: string, + goalId?: string | string[], ): Promise> { const runs = await db.engineRun.findMany({ where: { task: { projectId, - goalId: goalId !== undefined ? goalId : { not: null }, + goalId: + goalId === undefined ? { not: null } : Array.isArray(goalId) ? { in: goalId } : goalId, }, }, select: { @@ -638,14 +650,18 @@ export async function listParkedGoals(projectId: string): Promise * * Goals with no counted must-fix finding are absent from the map (the caller defaults to 0). */ -export async function goalUnresolvedMustFix(projectId: string): Promise> { +export async function goalUnresolvedMustFix( + projectId: string, + // Optional page scope — see goalTaskCompletions. Omitted ⇒ project-wide. + goalIds?: string[], +): Promise> { const rows = await db.reviewFinding.findMany({ where: { mustFix: true, status: { in: UNRESOLVED }, task: { projectId, - goalId: { not: null }, + goalId: goalIds ? { in: goalIds } : { not: null }, type: { in: countableTaskTypeList() }, runtimeState: { not: runtime_state.cancelled }, }, @@ -683,12 +699,16 @@ export async function goalUnresolvedMustFix(projectId: string): Promise> { +export async function goalAutopilotScoping( + projectId: string, + // Optional page scope — see goalTaskCompletions. Omitted ⇒ project-wide. + goalIds?: string[], +): Promise> { const rows = await db.engineTask.findMany({ where: { projectId, type: task_type.planning, - goalId: { not: null }, + goalId: goalIds ? { in: goalIds } : { not: null }, runtimeState: { notIn: [runtime_state.done, runtime_state.cancelled] }, }, select: { goalId: true }, @@ -712,12 +732,16 @@ export async function goalAutopilotScoping(projectId: string): Promise> { +export async function goalsWithOpenAutoRework( + projectId: string, + // Optional page scope — see goalTaskCompletions. Omitted ⇒ project-wide. + goalIds?: string[], +): Promise> { const rows = await db.engineTask.findMany({ where: { projectId, autoRework: true, - goalId: { not: null }, + goalId: goalIds ? { in: goalIds } : { not: null }, runtimeState: { notIn: [runtime_state.done, runtime_state.cancelled] }, }, select: { goalId: true }, diff --git a/tests/unit/api/google-calendar-integrations-connections.test.ts b/tests/unit/api/google-calendar-integrations-connections.test.ts index 852b8e1cd..02643bb8a 100644 --- a/tests/unit/api/google-calendar-integrations-connections.test.ts +++ b/tests/unit/api/google-calendar-integrations-connections.test.ts @@ -28,6 +28,9 @@ vi.mock("@/lib/auth/with-project-auth", () => { const __wpaMock = { }; return { ...__wpaMock, authorizeProjectWrite: __wpaMock.authorizeProject }; }); vi.mock("@/lib/queue/enqueue", () => ({ enqueue: vi.fn().mockResolvedValue({ jobRunId: "job-1", fresh: true }) })); vi.mock("@/lib/realtime/publish", () => ({ publish: vi.fn().mockResolvedValue(undefined) })); +// The connections route revalidates the cached hasProjectGithubConnection boolean (G#368); +// real revalidateTag needs a Next request store vitest doesn't have. +vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), unstable_cache: vi.fn((fn) => fn) })); import { GET as GET_CALLBACK } from "@/app/api/integrations/google-calendar/callback/route"; import { GET as GET_CONNECT } from "@/app/api/integrations/google-calendar/connect/route"; diff --git a/tests/unit/api/integrations-connection-id.test.ts b/tests/unit/api/integrations-connection-id.test.ts index 1efdfc4ed..13319318b 100644 --- a/tests/unit/api/integrations-connection-id.test.ts +++ b/tests/unit/api/integrations-connection-id.test.ts @@ -17,6 +17,9 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/realtime/publish", () => ({ publish: vi.fn() })); +// The route revalidates the cached hasProjectGithubConnection boolean (G#368); +// real revalidateTag needs a Next request store vitest doesn't have. +vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), unstable_cache: vi.fn((fn) => fn) })); import { GET, DELETE } from "@/app/api/v1/integrations/connections/[id]/route"; import { authorizeConnection } from "@/lib/auth/with-integration-auth"; diff --git a/tests/unit/api/integrations-connections.test.ts b/tests/unit/api/integrations-connections.test.ts index 6e0ea6cb6..4ea5526f3 100644 --- a/tests/unit/api/integrations-connections.test.ts +++ b/tests/unit/api/integrations-connections.test.ts @@ -19,6 +19,9 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/queue/enqueue", () => ({ enqueue: vi.fn() })); vi.mock("@/lib/realtime/publish", () => ({ publish: vi.fn() })); +// The route revalidates the cached hasProjectGithubConnection boolean (G#368); +// real revalidateTag needs a Next request store vitest doesn't have. +vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), unstable_cache: vi.fn((fn) => fn) })); import { GET, POST } from "@/app/api/v1/integrations/connections/route"; import { authorizeProject } from "@/lib/auth/with-project-auth"; diff --git a/tests/unit/api/integrations-github-connect-project.test.ts b/tests/unit/api/integrations-github-connect-project.test.ts index f5b5607f6..c05aa8ea0 100644 --- a/tests/unit/api/integrations-github-connect-project.test.ts +++ b/tests/unit/api/integrations-github-connect-project.test.ts @@ -45,6 +45,9 @@ vi.mock("@/lib/integrations/crypto", () => ({ vi.mock("@/lib/queue/enqueue", () => ({ enqueue: mockEnqueue })); vi.mock("@/lib/observability/logger", () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); vi.mock("@/worker/jobs/github-scaffold", () => ({ GITHUB_SCAFFOLD_JOB: "github-scaffold" })); +// The route revalidates the cached hasProjectGithubConnection boolean (G#368); +// real revalidateTag needs a Next request store vitest doesn't have. +vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), unstable_cache: vi.fn((fn: () => unknown) => fn) })); // ─── Helpers ────────────────────────────────────────────────────────────────── const PROJECT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";