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>
226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
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;
|