From 530062cc448fd69dc1747a5406eaeaa77e20d0fb Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 17 Jun 2026 09:39:09 -0600 Subject: [PATCH] feat: persist authenticated agent task reports Add Bearer token auth to POST /api/agents/[agentName]/tasks/report. Persist one AgentRun row per valid report with: - agentName from route param - runType from taskType - status derived from outcome (failed->failed, blocked->blocked, else completed) - issueId resolved from repoFullName + issueNumber when matching Issue exists - touchedIssueUrls built from issue and PR references - summary and errorMessage from report fields Response includes { ok, agentName, report, agentRunId }. Validation failures and unauthorized requests do not create AgentRun. Closes #413 Closes #409 --- .../[agentName]/tasks/report/route.test.ts | 444 ++++++++++++++---- .../agents/[agentName]/tasks/report/route.ts | 75 +++ 2 files changed, 433 insertions(+), 86 deletions(-) diff --git a/src/app/api/agents/[agentName]/tasks/report/route.test.ts b/src/app/api/agents/[agentName]/tasks/report/route.test.ts index 195279d..66d3e39 100644 --- a/src/app/api/agents/[agentName]/tasks/report/route.test.ts +++ b/src/app/api/agents/[agentName]/tasks/report/route.test.ts @@ -1,44 +1,104 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -const { mocks } = vi.hoisted(() => ({ +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks, mockAgentRun } = vi.hoisted(() => ({ + mockAgentRun: { + create: vi.fn().mockResolvedValue({ + id: "run-1", + agentName: "test-agent", + runType: "implement", + status: "completed", + startedAt: new Date(), + finishedAt: new Date(), + summary: null, + errorMessage: null, + touchedIssueUrls: [], + }), + }, mocks: { - issueFindMany: vi.fn(), - issueUpdate: vi.fn(), - prFixUpdate: vi.fn(), - leaseDelete: vi.fn(), + repoFindUnique: vi.fn().mockResolvedValue(null), + issueFindUnique: vi.fn().mockResolvedValue(null), }, })); vi.mock("@/lib/prisma", () => ({ prisma: { - issue: { - findMany: mocks.issueFindMany, - update: mocks.issueUpdate, + agentRun: mockAgentRun, + repository: { + findUnique: mocks.repoFindUnique, }, - prFixQueueItem: { - update: mocks.prFixUpdate, - }, - lease: { - delete: mocks.leaseDelete, + issue: { + findUnique: mocks.issueFindUnique, }, }, })); import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; -function postRequest(body: unknown, agentName = "test-agent") { +function postRequest(body: unknown, agentName = "test-agent", includeAuth = true) { + const headers: Record = { "Content-Type": "application/json" }; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; return POST( new Request(`http://localhost/api/agents/${agentName}/tasks/report`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(body), }), { params: Promise.resolve({ agentName }) }, ); } -describe("POST /api/agents/[agentName]/tasks/report", () => { +describe("POST /api/agents/[agentName]/tasks/report — auth", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + }); + + it("returns 401 when no authorization header is provided", async () => { + const res = await postRequest( + { taskType: "implement", outcome: "pr_opened" }, + "test-agent", + false, + ); + expect(res.status).toBe(401); + }); + + it("returns 401 when token is incorrect", async () => { + const res = await POST( + new Request("http://localhost/api/agents/test-agent/tasks/report", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }, + body: JSON.stringify({ taskType: "implement", outcome: "pr_opened" }), + }), + { params: Promise.resolve({ agentName: "test-agent" }) }, + ); + expect(res.status).toBe(401); + }); + + it("accepts valid Bearer auth with correct token", async () => { + const res = await postRequest({ taskType: "implement", outcome: "pr_opened" }); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/agents/[agentName]/tasks/report — validation", () => { beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); vi.clearAllMocks(); }); @@ -56,7 +116,6 @@ describe("POST /api/agents/[agentName]/tasks/report", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); - expect(body.agentName).toBe("test-agent"); expect(body.report.taskType).toBe("implement"); expect(body.report.outcome).toBe("pr_opened"); }); @@ -106,7 +165,10 @@ describe("POST /api/agents/[agentName]/tasks/report", () => { const res = await POST( new Request("http://localhost/api/agents/test-agent/tasks/report", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${mockToken}`, + }, body: "not-json", }), { params: Promise.resolve({ agentName: "test-agent" }) }, @@ -193,15 +255,96 @@ describe("POST /api/agents/[agentName]/tasks/report", () => { expect(body.report.pullRequestNumber).toBe(10); }); - it("response includes the route agentName", async () => { - const res = await postRequest( - { taskType: "implement", outcome: "pr_opened" }, - "my-special-agent", - ); + it("accepts all valid outcomes", async () => { + const validOutcomes = [ + "pr_opened", + "pr_updated", + "issue_updated", + "issue_closed", + "blocked", + "failed", + "no_changes_needed", + ]; + + for (const outcome of validOutcomes) { + const res = await postRequest({ taskType: "implement", outcome }); + expect(res.status).toBe(200); + } + }); + + it("accepts all valid taskTypes", async () => { + const validTaskTypes = ["implement", "followup-pr", "groom"]; + + for (const taskType of validTaskTypes) { + const res = await postRequest({ taskType, outcome: "no_changes_needed" }); + expect(res.status).toBe(200); + } + }); + + it("returns 400 when repoFullName is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: 123, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when pullRequestUrl is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestUrl: 123, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when summary is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + summary: true, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when error is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "failed", + error: 500, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when issueNumber is a decimal", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + issueNumber: 42.5, + }); + expect(res.status).toBe(400); + }); + it("returns 400 when pullRequestNumber is a decimal", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestNumber: 10.7, + }); + expect(res.status).toBe(400); + }); + + it("does not echo secrets or auth data", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + }); expect(res.status).toBe(200); const body = await res.json(); - expect(body.agentName).toBe("my-special-agent"); + expect(body).not.toHaveProperty("token"); + expect(body).not.toHaveProperty("authorization"); + expect(body).not.toHaveProperty("bearer"); }); it("does not require harness-specific fields", async () => { @@ -215,36 +358,185 @@ describe("POST /api/agents/[agentName]/tasks/report", () => { expect("harness" in body).toBe(false); expect("workflowRepo" in body).toBe(false); }); +}); - it("does not mutate issue state", async () => { - await postRequest({ +describe("POST /api/agents/[agentName]/tasks/report — AgentRun persistence", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + }); + + it("authorized valid report creates an AgentRun", async () => { + const res = await postRequest({ taskType: "implement", outcome: "pr_opened", repoFullName: "org/repo", issueNumber: 42, + pullRequestUrl: "https://github.com/org/repo/pull/10", + summary: "Implemented the feature", }); - expect(mocks.issueUpdate).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.agentRunId).toBe("run-1"); + + expect(mockAgentRun.create).toHaveBeenCalledTimes(1); + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.agentName).toBe("test-agent"); + expect(call.runType).toBe("implement"); + expect(call.status).toBe("completed"); + expect(call.summary).toBe("Implemented the feature"); }); - it("does not mutate PR-fix queue state", async () => { - await postRequest({ + it("issue report links issueId when matching repo + issue number exists", async () => { + mocks.repoFindUnique.mockResolvedValue({ id: "repo-1" }); + mocks.issueFindUnique.mockResolvedValue({ id: "issue-42" }); + + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: "org/repo", + issueNumber: 42, + }); + + expect(res.status).toBe(200); + + // Verify repository lookup + expect(mocks.repoFindUnique).toHaveBeenCalledWith({ + where: { fullName: "org/repo" }, + select: { id: true }, + }); + expect(mocks.issueFindUnique).toHaveBeenCalledWith({ + where: { repositoryId_number: { repositoryId: "repo-1", number: 42 } }, + select: { id: true }, + }); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.issueId).toBe("issue-42"); + }); + + it("PR-only report stores touched PR URL", async () => { + const res = await postRequest({ taskType: "followup-pr", outcome: "pr_updated", repoFullName: "org/repo", pullRequestNumber: 10, }); - expect(mocks.prFixUpdate).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.touchedIssueUrls).toContain("https://github.com/org/repo/pull/10"); + expect(call.issueId).toBeNull(); + }); + + it("report with pullRequestUrl stores that URL", async () => { + const res = await postRequest({ + taskType: "followup-pr", + outcome: "pr_updated", + pullRequestUrl: "https://github.com/org/repo/pull/10", + }); + + expect(res.status).toBe(200); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.touchedIssueUrls).toContain("https://github.com/org/repo/pull/10"); + }); + + it("report with both issue and PR stores both URLs", async () => { + mocks.repoFindUnique.mockResolvedValue({ id: "repo-1" }); + mocks.issueFindUnique.mockResolvedValue({ id: "issue-42" }); + + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: "org/repo", + issueNumber: 42, + pullRequestUrl: "https://github.com/org/repo/pull/10", + }); + + expect(res.status).toBe(200); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.touchedIssueUrls).toContain("https://github.com/org/repo/issues/42"); + expect(call.touchedIssueUrls).toContain("https://github.com/org/repo/pull/10"); }); - it("does not release leases", async () => { + it("failed report maps to failed status and stores error", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "failed", + error: "Something went wrong", + }); + + expect(res.status).toBe(200); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.status).toBe("failed"); + expect(call.errorMessage).toBe("Something went wrong"); + }); + + it("blocked report maps to blocked status", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "blocked", + summary: "Blocked on external dependency", + }); + + expect(res.status).toBe(200); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.status).toBe("blocked"); + expect(call.summary).toBe("Blocked on external dependency"); + }); + + it("validation failures do not create AgentRun", async () => { + await postRequest({ taskType: "invalid-type", outcome: "pr_opened" }); + expect(mockAgentRun.create).not.toHaveBeenCalled(); + + await postRequest({ taskType: "implement", outcome: "invalid-outcome" }); + expect(mockAgentRun.create).not.toHaveBeenCalled(); + await postRequest({ taskType: "implement", outcome: "pr_opened", + issueNumber: "not-a-number", }); + expect(mockAgentRun.create).not.toHaveBeenCalled(); + }); + + it("unauthorized requests do not create AgentRun", async () => { + await postRequest( + { taskType: "implement", outcome: "pr_opened" }, + "test-agent", + false, + ); + expect(mockAgentRun.create).not.toHaveBeenCalled(); + }); + + it("response includes agentRunId", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.agentRunId).toBe("run-1"); + }); + + it("response includes the route agentName", async () => { + const res = await postRequest( + { taskType: "implement", outcome: "pr_opened" }, + "my-special-agent", + ); - expect(mocks.leaseDelete).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.agentName).toBe("my-special-agent"); }); it("preserves optional fields in response", async () => { @@ -269,83 +561,63 @@ describe("POST /api/agents/[agentName]/tasks/report", () => { expect(body.report.error).toBe("Cannot proceed without API access"); }); - it("accepts all valid outcomes", async () => { - const validOutcomes = [ - "pr_opened", - "pr_updated", - "issue_updated", - "issue_closed", - "blocked", - "failed", - "no_changes_needed", - ]; - - for (const outcome of validOutcomes) { - const res = await postRequest({ taskType: "implement", outcome }); - expect(res.status).toBe(200); - } - }); - - it("accepts all valid taskTypes", async () => { - const validTaskTypes = ["implement", "followup-pr", "groom"]; - - for (const taskType of validTaskTypes) { - const res = await postRequest({ taskType, outcome: "no_changes_needed" }); - expect(res.status).toBe(200); - } - }); + it("sets issueId to null when repo not found", async () => { + mocks.repoFindUnique.mockResolvedValue(null); - it("returns 400 when repoFullName is not a string", async () => { - const res = await postRequest({ + await postRequest({ taskType: "implement", outcome: "pr_opened", - repoFullName: 123, + repoFullName: "nonexistent/repo", + issueNumber: 42, }); - expect(res.status).toBe(400); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.issueId).toBeNull(); }); - it("returns 400 when pullRequestUrl is not a string", async () => { - const res = await postRequest({ + it("sets issueId to null when issue not found in repo", async () => { + mocks.repoFindUnique.mockResolvedValue({ id: "repo-1" }); + mocks.issueFindUnique.mockResolvedValue(null); + + await postRequest({ taskType: "implement", outcome: "pr_opened", - pullRequestUrl: 123, + repoFullName: "org/repo", + issueNumber: 999, }); - expect(res.status).toBe(400); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.issueId).toBeNull(); }); - it("returns 400 when summary is not a string", async () => { - const res = await postRequest({ + it("sets issueId to null when no repoFullName or issueNumber", async () => { + await postRequest({ taskType: "implement", outcome: "pr_opened", - summary: true, }); - expect(res.status).toBe(400); - }); - it("returns 400 when error is not a string", async () => { - const res = await postRequest({ - taskType: "implement", - outcome: "failed", - error: 500, - }); - expect(res.status).toBe(400); + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.issueId).toBeNull(); }); - it("returns 400 when issueNumber is a decimal", async () => { - const res = await postRequest({ + it("uses correct timestamps", async () => { + await postRequest({ taskType: "implement", outcome: "pr_opened", - issueNumber: 42.5, }); - expect(res.status).toBe(400); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.startedAt).toBeInstanceOf(Date); + expect(call.finishedAt).toBeInstanceOf(Date); }); - it("returns 400 when pullRequestNumber is a decimal", async () => { - const res = await postRequest({ + it("stores empty touchedIssueUrls when no URLs available", async () => { + await postRequest({ taskType: "implement", - outcome: "pr_opened", - pullRequestNumber: 10.7, + outcome: "no_changes_needed", }); - expect(res.status).toBe(400); + + const call = mockAgentRun.create.mock.calls[0][0].data; + expect(call.touchedIssueUrls).toEqual([]); }); }); diff --git a/src/app/api/agents/[agentName]/tasks/report/route.ts b/src/app/api/agents/[agentName]/tasks/report/route.ts index af2f277..5c8dc5b 100644 --- a/src/app/api/agents/[agentName]/tasks/report/route.ts +++ b/src/app/api/agents/[agentName]/tasks/report/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { authorizeRequest } from "@/lib/auth"; const VALID_TASK_TYPES = ["implement", "followup-pr", "groom"] as const; type ValidTaskType = (typeof VALID_TASK_TYPES)[number]; @@ -25,12 +27,62 @@ export interface TaskReportBody { error?: string; } +function deriveStatus(outcome: ValidOutcome): string { + if (outcome === "failed") return "failed"; + if (outcome === "blocked") return "blocked"; + return "completed"; +} + +async function resolveIssueId( + repoFullName: string | undefined, + issueNumber: number | undefined, +): Promise { + if (!repoFullName || issueNumber === undefined) return null; + + const repo = await prisma.repository.findUnique({ + where: { fullName: repoFullName }, + select: { id: true }, + }); + + if (!repo) return null; + + const issue = await prisma.issue.findUnique({ + where: { repositoryId_number: { repositoryId: repo.id, number: issueNumber } }, + select: { id: true }, + }); + + return issue?.id ?? null; +} + +function buildTouchedUrls( + report: TaskReportBody, +): string[] { + const urls: string[] = []; + + if (report.repoFullName && report.issueNumber !== undefined) { + urls.push(`https://github.com/${report.repoFullName}/issues/${report.issueNumber}`); + } + + if (report.pullRequestUrl) { + urls.push(report.pullRequestUrl); + } else if (report.repoFullName && report.pullRequestNumber !== undefined) { + urls.push(`https://github.com/${report.repoFullName}/pull/${report.pullRequestNumber}`); + } + + return urls; +} + export async function POST( request: Request, { params }: { params: Promise<{ agentName: string }> }, ) { const { agentName } = await params; + // Authenticate + if (!(await authorizeRequest(request)).authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let body: unknown; try { body = await request.json(); @@ -95,9 +147,32 @@ export async function POST( error: raw.error as string | undefined, }; + // Resolve issueId from repoFullName + issueNumber + const issueId = await resolveIssueId(report.repoFullName, report.issueNumber); + + // Build touched URLs + const touchedIssueUrls = buildTouchedUrls(report); + + // Persist AgentRun + const now = new Date(); + const run = await prisma.agentRun.create({ + data: { + agentName, + runType: report.taskType, + status: deriveStatus(report.outcome), + startedAt: now, + finishedAt: now, + summary: report.summary, + errorMessage: report.error, + touchedIssueUrls, + issueId, + }, + }); + return NextResponse.json({ ok: true, agentName, report, + agentRunId: run.id, }); }