import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, desc, eq, gte, isNull, lt } from "drizzle-orm"; import { agentRuns, markdownBacklogItems, users, } from "@tasks/database/schema"; import { router, workspaceProcedure } from "@/server/trpc"; const DEFAULT_LIMIT = 25; const MAX_LIMIT = 100; /** * Storage outcomes are a NULL column for in-flight runs and one of the four * normalized terminal values otherwise. The settings UI also surfaces the * synthetic "open" bucket which translates to `outcome IS NULL`. */ const TERMINAL_OUTCOMES = ["succeeded", "failed", "cancelled", "stalled"] as const; const FILTER_OUTCOMES = [...TERMINAL_OUTCOMES, "open"] as const; const outcomeFilterSchema = z.enum(FILTER_OUTCOMES).optional(); type FilterOutcome = (typeof FILTER_OUTCOMES)[number]; function outcomePredicate(outcome: FilterOutcome | undefined) { if (!outcome) return undefined; if (outcome === "open") return isNull(agentRuns.finishedAt); return eq(agentRuns.outcome, outcome); } /** * Read-side surface for `agent_runs`. Write paths land in the next epic * with `claim_task` / `complete_task` MCP tools — this router is * intentionally read-only. Pagination uses keyset on `started_at` to * stay scalable; we accept the rare-tie risk on identical timestamps * (refresh fixes it) instead of paying for an `(started_at, id)` tuple * comparison. */ export const runsRouter = router({ listRecent: workspaceProcedure .input( z.object({ limit: z.number().int().min(1).max(MAX_LIMIT).optional(), cursorStartedAt: z.string().datetime().optional(), outcome: outcomeFilterSchema, }), ) .query(async ({ ctx, input }) => { const limit = input.limit ?? DEFAULT_LIMIT; const cursor = input.cursorStartedAt ? new Date(input.cursorStartedAt) : null; const filters = [eq(agentRuns.workspaceId, ctx.workspace.id)]; if (cursor) filters.push(lt(agentRuns.startedAt, cursor)); const outcomeWhere = outcomePredicate(input.outcome); if (outcomeWhere) filters.push(outcomeWhere); const rows = await ctx.db .select({ id: agentRuns.id, backlogItemId: agentRuns.backlogItemId, taskTitle: markdownBacklogItems.title, taskKind: markdownBacklogItems.kind, // Path components for the task detail deep-link in /settings/runs. // Plans/epics don't have a detail page yet, so the client renders // them unlinked; we still expose the slugs so a future plans-tree // browser can use them without another roundtrip. taskSlug: markdownBacklogItems.slug, taskPlanSlug: markdownBacklogItems.planSlug, taskEpicSlug: markdownBacklogItems.epicSlug, actorUserId: agentRuns.actorUserId, actorName: users.name, actorEmail: users.email, startedAt: agentRuns.startedAt, finishedAt: agentRuns.finishedAt, outcome: agentRuns.outcome, error: agentRuns.error, tokensInput: agentRuns.tokensInput, tokensOutput: agentRuns.tokensOutput, tokensTotal: agentRuns.tokensTotal, notes: agentRuns.notes, metadata: agentRuns.metadata, }) .from(agentRuns) .leftJoin( markdownBacklogItems, eq(agentRuns.backlogItemId, markdownBacklogItems.id), ) .leftJoin(users, eq(agentRuns.actorUserId, users.id)) .where(and(...filters)) .orderBy(desc(agentRuns.startedAt)) .limit(limit + 1); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? page[page.length - 1]!.startedAt.toISOString() : null; return { rows: page, nextCursor }; }), listForTask: workspaceProcedure .input( z.object({ backlogItemId: z.string().uuid(), outcome: outcomeFilterSchema, }), ) .query(async ({ ctx, input }) => { // Tenant boundary check before exposing run history. The backlog // item must live in the caller's workspace; otherwise we 404 to // avoid leaking existence across tenants. const [item] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where( and( eq(markdownBacklogItems.id, input.backlogItemId), eq(markdownBacklogItems.workspaceId, ctx.workspace.id), ), ) .limit(1); if (!item) { throw new TRPCError({ code: "NOT_FOUND", message: "Backlog item not found in this workspace.", }); } const filters = [ eq(agentRuns.workspaceId, ctx.workspace.id), eq(agentRuns.backlogItemId, input.backlogItemId), ]; const outcomeWhere = outcomePredicate(input.outcome); if (outcomeWhere) filters.push(outcomeWhere); const rows = await ctx.db .select({ id: agentRuns.id, backlogItemId: agentRuns.backlogItemId, taskTitle: markdownBacklogItems.title, taskKind: markdownBacklogItems.kind, taskSlug: markdownBacklogItems.slug, taskPlanSlug: markdownBacklogItems.planSlug, taskEpicSlug: markdownBacklogItems.epicSlug, actorUserId: agentRuns.actorUserId, actorName: users.name, actorEmail: users.email, startedAt: agentRuns.startedAt, finishedAt: agentRuns.finishedAt, outcome: agentRuns.outcome, error: agentRuns.error, tokensInput: agentRuns.tokensInput, tokensOutput: agentRuns.tokensOutput, tokensTotal: agentRuns.tokensTotal, notes: agentRuns.notes, metadata: agentRuns.metadata, }) .from(agentRuns) .leftJoin( markdownBacklogItems, eq(agentRuns.backlogItemId, markdownBacklogItems.id), ) .leftJoin(users, eq(agentRuns.actorUserId, users.id)) .where(and(...filters)) .orderBy(desc(agentRuns.startedAt)) .limit(10); return { rows }; }), summary: workspaceProcedure.query(async ({ ctx }) => { // 7-day rolling window. We compute the cutoff in JS to keep the // query plan stable and the index on (workspace_id, started_at) // usable; a `now() - interval` predicate would also work but // cooperating with parameterized SQL is friendlier to drizzle. const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const rows = await ctx.db .select({ outcome: agentRuns.outcome, finishedAt: agentRuns.finishedAt, tokensTotal: agentRuns.tokensTotal, }) .from(agentRuns) .where( and( eq(agentRuns.workspaceId, ctx.workspace.id), gte(agentRuns.startedAt, cutoff), ), ); const byOutcome: Record< "succeeded" | "failed" | "cancelled" | "stalled" | "open", number > = { succeeded: 0, failed: 0, cancelled: 0, stalled: 0, open: 0, }; let tokensTotal = 0; for (const row of rows) { if (row.finishedAt === null) { byOutcome.open += 1; } else if ( row.outcome === "succeeded" || row.outcome === "failed" || row.outcome === "cancelled" || row.outcome === "stalled" ) { byOutcome[row.outcome] += 1; } if (typeof row.tokensTotal === "number") { tokensTotal += row.tokensTotal; } } return { byOutcome, tokensTotal }; }), }); export type RunsRouter = typeof runsRouter;