In-flight work from 2026-06-05 that was sitting uncommitted on feat/agent-pipeline-bridge for 10 days. Moved here to a clean branch off main so the bridge branch can stay at its committed state (the DEFERRED Phase 2a snapshot, see stwl-labs/ubiquitous-invention#3). Touched areas: - apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx — runs page UI iteration - apps/web/server/routers/{backlog,runs}.ts — router updates - apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx — new deep-link route - apps/web/components/backlog/workflow-prompt-section.tsx — new component - config/CursorSync.md — coordination doc tweak - docs/operations/README.md + 2026-06-05-db-drift-reconcile-oauth-tables.sql — operations ops note + reconcile script - plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-{workflow-prompt-task-detail-ui,deep-link-runs-to-task-detail}.md — 2 task specs This is a working-tree snapshot, not a finished PR. Rebase, split, or amend as needed when picking it back up. Co-authored-by: Cursor <cursoragent@cursor.com>
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import Link from "next/link";
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Compose a URL to the task detail page when we have the full path
|
|
* triple. Returns null when:
|
|
* - the backlog item is a plan or epic (no detail page exists yet),
|
|
* - any path segment is missing (a row joined against a deleted item
|
|
* or one with the importer mid-flight),
|
|
* - or the workspace slug isn't known yet (initial render).
|
|
*
|
|
* The runs page renders the title unlinked when this returns null.
|
|
*/
|
|
function buildTaskHref(args: {
|
|
workspaceSlug: string | undefined;
|
|
taskKind: string | null;
|
|
planSlug: string | null;
|
|
epicSlug: string | null;
|
|
taskSlug: string | null;
|
|
}): string | null {
|
|
if (
|
|
args.taskKind !== "task" ||
|
|
!args.workspaceSlug ||
|
|
!args.planSlug ||
|
|
!args.epicSlug ||
|
|
!args.taskSlug
|
|
) {
|
|
return null;
|
|
}
|
|
return `/${args.workspaceSlug}/plans/${args.planSlug}/${args.epicSlug}/${args.taskSlug}`;
|
|
}
|
|
|
|
/**
|
|
* Read `client` / `model` out of a run's metadata blob. The column is
|
|
* JSONB so we're treating it as unstructured here — short-circuit on
|
|
* null and ignore values that aren't strings (defensive against
|
|
* hand-inserted rows from psql).
|
|
*/
|
|
function describeAgent(metadata: unknown): {
|
|
client: string | null;
|
|
model: string | null;
|
|
label: string | null;
|
|
} {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return { client: null, model: null, label: null };
|
|
}
|
|
const m = metadata as Record<string, unknown>;
|
|
const client = typeof m.client === "string" ? m.client : null;
|
|
const model = typeof m.model === "string" ? m.model : null;
|
|
if (!client && !model) return { client, model, label: null };
|
|
if (client && model) return { client, model, label: `${client} · ${model}` };
|
|
return { client, model, label: client ?? model };
|
|
}
|
|
|
|
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 agent = describeAgent(row.metadata);
|
|
const isOpen = expanded === row.id;
|
|
const hasDetail = Boolean(row.notes || row.error);
|
|
const taskHref = buildTaskHref({
|
|
workspaceSlug,
|
|
taskKind: row.taskKind,
|
|
planSlug: row.taskPlanSlug,
|
|
epicSlug: row.taskEpicSlug,
|
|
taskSlug: row.taskSlug,
|
|
});
|
|
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 ? (
|
|
taskHref ? (
|
|
<Link
|
|
href={taskHref}
|
|
// Stop the click so the row's expand/collapse
|
|
// handler doesn't also fire on link clicks.
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="hover:underline focus-visible:underline focus-visible:outline-none"
|
|
>
|
|
{row.taskTitle}
|
|
</Link>
|
|
) : (
|
|
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">
|
|
<div className="flex flex-col leading-tight">
|
|
{row.actorUserId ? (
|
|
<span>
|
|
{row.actorName ??
|
|
row.actorEmail ??
|
|
row.actorUserId.slice(0, 8)}
|
|
</span>
|
|
) : (
|
|
<span className="italic text-muted-foreground">—</span>
|
|
)}
|
|
{agent.label ? (
|
|
<span
|
|
className="truncate text-[10px] text-muted-foreground"
|
|
title={agent.label}
|
|
>
|
|
{agent.label}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|