feat(runs): agent_runs table + tRPC router + settings UI
Read-side only — write paths land with claim_task / complete_task in the next epic. Keyset pagination on started_at, three procedures (listRecent, listForTask, summary), and a /settings/runs view that mirrors the audit page's visual language. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
93565cd94c
commit
f014686412
10 changed files with 3814 additions and 0 deletions
399
apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx
Normal file
399
apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Read-side dashboard for `agent_runs`. Mirrors the audit page's visual
|
||||
* language but is open to all workspace members — runs aren't as
|
||||
* sensitive as the audit log and the value is mostly debugging the
|
||||
* orchestration layer once the MCP write paths land.
|
||||
*
|
||||
* The "Recent runs" section pages with keyset pagination on
|
||||
* `started_at`. Click any row to expand and see the row's `notes` and
|
||||
* `error` text — kept collapsed by default because most runs are
|
||||
* uneventful and the table needs to stay scannable.
|
||||
*/
|
||||
|
||||
const FILTER_OPTIONS = ["all", "succeeded", "failed", "cancelled", "stalled", "open"] as const;
|
||||
type FilterOption = (typeof FILTER_OPTIONS)[number];
|
||||
|
||||
const FILTER_LABEL: Record<FilterOption, string> = {
|
||||
all: "All",
|
||||
succeeded: "Succeeded",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
stalled: "Stalled",
|
||||
open: "Open",
|
||||
};
|
||||
|
||||
const OUTCOME_CHIP: Record<string, string> = {
|
||||
succeeded:
|
||||
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/20",
|
||||
failed:
|
||||
"bg-red-500/15 text-red-700 dark:text-red-300 ring-1 ring-red-500/20",
|
||||
cancelled:
|
||||
"bg-amber-500/15 text-amber-700 dark:text-amber-300 ring-1 ring-amber-500/20",
|
||||
stalled:
|
||||
"bg-orange-500/15 text-orange-700 dark:text-orange-300 ring-1 ring-orange-500/20",
|
||||
open: "bg-sky-500/15 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/20",
|
||||
};
|
||||
|
||||
function formatTimestamp(when: Date | string): string {
|
||||
const d = when instanceof Date ? when : new Date(when);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds as a compact string. Returns "—"
|
||||
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
|
||||
* with a single decimal so quick MCP calls don't all look like "0s".
|
||||
*/
|
||||
export function formatDuration(
|
||||
startedAt: Date | string,
|
||||
finishedAt: Date | string | null,
|
||||
): string {
|
||||
if (!finishedAt) return "—";
|
||||
const start = startedAt instanceof Date ? startedAt : new Date(startedAt);
|
||||
const end = finishedAt instanceof Date ? finishedAt : new Date(finishedAt);
|
||||
const ms = end.getTime() - start.getTime();
|
||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
||||
const minutes = seconds / 60;
|
||||
if (minutes < 60) return `${Math.round(minutes)}m`;
|
||||
const hours = minutes / 60;
|
||||
return `${hours.toFixed(hours < 10 ? 1 : 0)}h`;
|
||||
}
|
||||
|
||||
function formatTokens(n: number | null): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function describeOutcome(
|
||||
outcome: string | null,
|
||||
finishedAt: Date | string | null,
|
||||
): { label: string; key: keyof typeof OUTCOME_CHIP } {
|
||||
if (!finishedAt) return { label: "open", key: "open" };
|
||||
const key = outcome && outcome in OUTCOME_CHIP ? outcome : "open";
|
||||
return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP };
|
||||
}
|
||||
|
||||
export default function RunsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
|
||||
const [filter, setFilter] = React.useState<FilterOption>("all");
|
||||
const [cursor, setCursor] = React.useState<string | undefined>(undefined);
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
|
||||
// Reset pagination + expansion state when the user switches filters so
|
||||
// we never end up showing a cursor that was generated for a different
|
||||
// outcome subset.
|
||||
const onFilterChange = React.useCallback((next: FilterOption) => {
|
||||
setFilter(next);
|
||||
setCursor(undefined);
|
||||
setExpanded(null);
|
||||
}, []);
|
||||
|
||||
const filterArg = filter === "all" ? undefined : filter;
|
||||
|
||||
const summaryQuery = api.runs.summary.useQuery(
|
||||
{ workspace: workspaceSlug ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const runsQuery = api.runs.listRecent.useQuery(
|
||||
{
|
||||
workspace: workspaceSlug ?? "",
|
||||
limit: 25,
|
||||
cursorStartedAt: cursor,
|
||||
outcome: filterArg,
|
||||
},
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Activity className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Agent runs</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every agent session against a task. Read-only — write paths land
|
||||
with the orchestrator MCP tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary card */}
|
||||
<SummaryCard
|
||||
loading={summaryQuery.isLoading}
|
||||
data={summaryQuery.data}
|
||||
error={summaryQuery.error?.message}
|
||||
/>
|
||||
|
||||
{/* Outcome filter pills */}
|
||||
<div className="mt-6 flex flex-wrap gap-1.5">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(option)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
filter === option
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{FILTER_LABEL[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent runs table */}
|
||||
<div className="mt-4">
|
||||
{runsQuery.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : runsQuery.error ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
Couldn't load runs: {runsQuery.error.message}
|
||||
</div>
|
||||
) : !runsQuery.data || runsQuery.data.rows.length === 0 ? (
|
||||
<EmptyState filter={filter} />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-2" aria-label="Expand" />
|
||||
<th className="px-4 py-2 text-left font-medium">Task</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Started</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Duration</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Actor</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Outcome</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{runsQuery.data.rows.map((row) => {
|
||||
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
||||
const isOpen = expanded === row.id;
|
||||
const hasDetail = Boolean(row.notes || row.error);
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<tr
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
hasDetail
|
||||
? "cursor-pointer hover:bg-muted/30"
|
||||
: "hover:bg-muted/20",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (hasDetail) {
|
||||
setExpanded(isOpen ? null : row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-2 align-middle">
|
||||
{hasDetail ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground" />
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
<td className="max-w-[24ch] truncate px-4 py-2 font-medium">
|
||||
{row.taskTitle ?? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.backlogItemId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(row.startedAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-xs tabular-nums">
|
||||
{formatDuration(row.startedAt, row.finishedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{row.actorUserId ? (
|
||||
<span>
|
||||
{row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
OUTCOME_CHIP[key],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-right text-xs tabular-nums">
|
||||
{formatTokens(row.tokensTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && hasDetail ? (
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={6} className="bg-muted/20 px-4 py-3 text-xs">
|
||||
{row.notes ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Notes
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{row.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{row.error ? (
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap rounded bg-background/60 px-2 py-1 font-mono text-[11px] text-destructive">
|
||||
{row.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{runsQuery.data.nextCursor ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor(runsQuery.data.nextCursor ?? undefined)}
|
||||
disabled={runsQuery.isFetching}
|
||||
>
|
||||
{runsQuery.isFetching ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Load older"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ filter }: { filter: FilterOption }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
|
||||
<PlayCircle className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{filter === "all" ? "No runs yet" : `No ${FILTER_LABEL[filter].toLowerCase()} runs`}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Once an agent session runs against a task — via the orchestrator
|
||||
or a manually-inserted row — it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryData = {
|
||||
byOutcome: Record<"succeeded" | "failed" | "cancelled" | "stalled" | "open", number>;
|
||||
tokensTotal: number;
|
||||
};
|
||||
|
||||
function SummaryCard({
|
||||
loading,
|
||||
data,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
data: SummaryData | undefined;
|
||||
error: string | undefined;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
Couldn't load summary: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const cells: Array<{ label: string; value: string; key: keyof typeof OUTCOME_CHIP | "tokens" }> = [
|
||||
{ label: "Succeeded", value: String(data.byOutcome.succeeded), key: "succeeded" },
|
||||
{ label: "Failed", value: String(data.byOutcome.failed), key: "failed" },
|
||||
{ label: "Cancelled", value: String(data.byOutcome.cancelled), key: "cancelled" },
|
||||
{ label: "Stalled", value: String(data.byOutcome.stalled), key: "stalled" },
|
||||
{ label: "Open", value: String(data.byOutcome.open), key: "open" },
|
||||
{ label: "Tokens (7d)", value: formatTokens(data.tokensTotal), key: "tokens" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.label}
|
||||
className="rounded-lg border bg-card px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{cell.label}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{cell.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import {
|
||||
Activity,
|
||||
AppWindow,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
|
|
@ -140,6 +141,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
Audit log
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspaceId && router.push(`/${workspaceId}/settings/runs`)
|
||||
}
|
||||
>
|
||||
<Activity className="size-4 text-muted-foreground" />
|
||||
Agent runs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Create Workspace */}
|
||||
<div className="px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { favoritesRouter } from "@/server/routers/favorites";
|
|||
import { identityRouter } from "@/server/routers/identity";
|
||||
import { invitesRouter } from "@/server/routers/invites";
|
||||
import { auditRouter } from "@/server/routers/audit";
|
||||
import { runsRouter } from "@/server/routers/runs";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -29,6 +30,7 @@ export const appRouter = router({
|
|||
identity: identityRouter,
|
||||
invites: invitesRouter,
|
||||
audit: auditRouter,
|
||||
runs: runsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
216
apps/web/server/routers/runs.ts
Normal file
216
apps/web/server/routers/runs.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
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,
|
||||
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,
|
||||
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;
|
||||
21
packages/database/migrations/0009_loving_rogue.sql
Normal file
21
packages/database/migrations/0009_loving_rogue.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
CREATE TABLE "agent_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"backlog_item_id" uuid NOT NULL,
|
||||
"actor_user_id" uuid,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone,
|
||||
"outcome" varchar(20),
|
||||
"error" text,
|
||||
"tokens_input" integer,
|
||||
"tokens_output" integer,
|
||||
"tokens_total" integer,
|
||||
"notes" text,
|
||||
"metadata" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_backlog_item_id_markdown_backlog_items_id_fk" FOREIGN KEY ("backlog_item_id") REFERENCES "public"."markdown_backlog_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "agent_runs_workspace_id_started_at_idx" ON "agent_runs" USING btree ("workspace_id","started_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "agent_runs_backlog_item_id_started_at_idx" ON "agent_runs" USING btree ("backlog_item_id","started_at" DESC NULLS LAST);
|
||||
3054
packages/database/migrations/meta/0009_snapshot.json
Normal file
3054
packages/database/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,20 @@
|
|||
"when": 1780424597399,
|
||||
"tag": "0007_flaky_kinsey_walden",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1780455565316,
|
||||
"tag": "0008_curly_zzzax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1780455865684,
|
||||
"tag": "0009_loving_rogue",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
81
packages/database/src/schema/agent_runs.ts
Normal file
81
packages/database/src/schema/agent_runs.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
jsonb,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
import { users } from "./users";
|
||||
import { markdownBacklogItems } from "./markdown_backlog";
|
||||
|
||||
/**
|
||||
* One row per agent session against a backlog item. The MCP tools
|
||||
* (`claim_task`, `complete_task`, …) will be the write path in the next
|
||||
* epic; this table is read-side + storage only at this stage. Rows can
|
||||
* be inserted manually for development.
|
||||
*
|
||||
* Conventions captured here so future maintainers don't drift:
|
||||
*
|
||||
* - `outcome` is constrained at the application layer to one of
|
||||
* {succeeded, failed, cancelled, stalled}. The column is nullable
|
||||
* because in-flight runs (no `finished_at`) carry NULL outcomes —
|
||||
* surfaced in the UI as the synthetic "open" bucket.
|
||||
*
|
||||
* - `actorUserId` is null for anonymous / dev sessions where no user
|
||||
* is attached. The settings UI shows "—" for null actors.
|
||||
*
|
||||
* - Token totals follow the Symphony model: a single absolute
|
||||
* `tokens_total` recorded at close-out, not incremental deltas.
|
||||
* `tokens_input` / `tokens_output` are optional breakdowns.
|
||||
*
|
||||
* - `metadata` is JSONB but should stay small (roughly < 1 KB).
|
||||
* Don't dump full transcripts here — that's what an external
|
||||
* object store is for.
|
||||
*
|
||||
* - Indexes are tuned for the two read paths the UI exercises:
|
||||
* `(workspace_id, started_at DESC)` for the recent-runs settings
|
||||
* view and `(backlog_item_id, started_at DESC)` for per-task
|
||||
* history on the task detail panel (deferred to a follow-up task
|
||||
* to avoid stepping on the parent's concurrent work).
|
||||
*/
|
||||
export const agentRuns = pgTable(
|
||||
"agent_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
backlogItemId: uuid("backlog_item_id")
|
||||
.notNull()
|
||||
.references(() => markdownBacklogItems.id, { onDelete: "cascade" }),
|
||||
actorUserId: uuid("actor_user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
startedAt: timestamp("started_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
outcome: varchar("outcome", { length: 20 }),
|
||||
error: text("error"),
|
||||
tokensInput: integer("tokens_input"),
|
||||
tokensOutput: integer("tokens_output"),
|
||||
tokensTotal: integer("tokens_total"),
|
||||
notes: text("notes"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
||||
table.workspaceId,
|
||||
table.startedAt.desc(),
|
||||
),
|
||||
backlogStartedIdx: index("agent_runs_backlog_item_id_started_at_idx").on(
|
||||
table.backlogItemId,
|
||||
table.startedAt.desc(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -12,3 +12,4 @@ export * from "./favorites";
|
|||
export * from "./markdown_backlog";
|
||||
export * from "./cursor_sync";
|
||||
export * from "./audit";
|
||||
export * from "./agent_runs";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { markdownBacklogItems } from "./markdown_backlog";
|
|||
import { cursorSyncMappings } from "./cursor_sync";
|
||||
import { workspaces, workspaceInvites } from "./workspaces";
|
||||
import { auditLog } from "./audit";
|
||||
import { agentRuns } from "./agent_runs";
|
||||
|
||||
export const objectRelations = pgTable(
|
||||
"object_relations",
|
||||
|
|
@ -88,6 +89,21 @@ export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
export const agentRunsRelations = relations(agentRuns, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [agentRuns.workspaceId],
|
||||
references: [workspaces.id],
|
||||
}),
|
||||
backlogItem: one(markdownBacklogItems, {
|
||||
fields: [agentRuns.backlogItemId],
|
||||
references: [markdownBacklogItems.id],
|
||||
}),
|
||||
actor: one(users, {
|
||||
fields: [agentRuns.actorUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
||||
owner: one(users, {
|
||||
fields: [workspaces.ownerUserId],
|
||||
|
|
|
|||
Loading…
Reference in a new issue