ubiquitous-invention/apps/web/server/routers/runs.ts
Randall Stillwell 6581f6a482 wip: agent-coordination iteration (runs+backlog+plans UI + 2 task plans + db-drift SQL)
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>
2026-06-15 15:35:39 -05:00

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;