Skip to content
Draft
6 changes: 6 additions & 0 deletions src/app/api/v1/integrations/connections/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/v1/integrations/connections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/v1/integrations/github/connect-project/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down Expand Up @@ -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 },
);
});
});
11 changes: 11 additions & 0 deletions src/app/api/v1/integrations/github/connect-project/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
18 changes: 17 additions & 1 deletion src/app/api/v2/projects/[projectId]/goals/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
});
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 19 additions & 9 deletions src/app/api/v2/projects/[projectId]/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<string, string | null>()),
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),
Expand Down Expand Up @@ -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({
Expand Down
28 changes: 27 additions & 1 deletion src/lib/engine/goal-active-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"]);
});
});
14 changes: 10 additions & 4 deletions src/lib/engine/goal-active-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,17 @@ interface ActiveContribution {
*/
export async function getProjectGoalActiveWork(
projectId: string,
opts: { now?: Date; rollups?: Map<string, ComputeRollup> } = {},
opts: { now?: Date; rollups?: Map<string, ComputeRollup>; goalIds?: string[] } = {},
): Promise<Map<string, GoalActiveWork>> {
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,
Expand All @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions src/lib/engine/goal-blocked-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } },
}),
}),
);
});
});
13 changes: 11 additions & 2 deletions src/lib/engine/goal-blocked-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,18 @@ export async function loadGoalBlockedMachines(goalId: string): Promise<GoalBlock
* machines), then folded in memory — no per-goal fan-out. Goals with no blocked dependent
* machine are absent from the set (the caller defaults to false).
*/
export async function goalsWithBlockedMachine(projectId: string): Promise<Set<string>> {
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<Set<string>> {
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));
Expand Down
16 changes: 16 additions & 0 deletions src/lib/engine/goal-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -19,6 +23,8 @@ const row = <T>(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" }),
);
Expand Down Expand Up @@ -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 },
);
});
});
Loading
Loading