ubiquitous-invention/apps/web/server/routers/runs.ts

227 lines
7.5 KiB
TypeScript
Raw Normal View History

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;