ubiquitous-invention/apps/web/server/routers/backlog.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

262 lines
8.7 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, eq, isNull } from "drizzle-orm";
import { markdownBacklogItems } from "@tasks/database/schema";
import {
resolveWorkflowPrompt,
DEFAULT_AGENT_PROMPT,
exportBacklogItemToMarkdown,
} from "@tasks/database/markdown-backlog";
import { router, workspaceProcedure } from "@/server/trpc";
import { recordAudit } from "@/server/lib/audit";
import { resolveRepoRoot } from "@/server/lib/repo-root";
const MAX_PROMPT_LENGTH = 20_000;
// Slugs come straight out of URL segments. They were already enforced
// to be `[a-z0-9-]` by the importer, but a malformed URL segment could
// otherwise be used to fish for tenant-scoped rows by smuggling SQL-ish
// characters. We re-enforce the shape at the procedure boundary so a
// 400 lands client-side instead of an opaque "no row" 404.
const slugSchema = z
.string()
.min(1)
.max(200)
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Invalid slug shape");
/**
* tRPC procedures for managing the markdown-backlog rows in the DB,
* specifically the parts of the row that the markdown importer DOESN'T
* own — currently just `workflow_prompt` overrides set via UI rather
* than frontmatter.
*
* Read-side procedures here are deliberately narrow; the heavy listing
* is in the markdown importer's downstream UI (Plans tree). The job of
* THIS router is "let me edit the workflow prompt without re-importing
* from disk."
*/
export const backlogRouter = router({
/**
* Hydrate a Plan/Epic/Task detail page by URL path. Returns the task
* row plus the parent epic + plan titles (used in breadcrumbs and the
* "Inherited from ..." badge) and the caller's workspace role so the
* UI can pre-gate edit affordances without a second roundtrip.
*
* 404s on any of: task missing, epic missing (epic_slug doesn't match
* a row), or plan missing. Crossing those into one error message
* deliberately — the operator just needs to know "this path doesn't
* resolve in this workspace", not which of the three legs is broken.
*/
getTaskByPath: workspaceProcedure
.input(
z.object({
planSlug: slugSchema,
epicSlug: slugSchema,
taskSlug: slugSchema,
}),
)
.query(async ({ ctx, input }) => {
const [task] = await ctx.db
.select({
id: markdownBacklogItems.id,
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
status: markdownBacklogItems.status,
priority: markdownBacklogItems.priority,
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
repoPath: markdownBacklogItems.repoPath,
updatedAt: markdownBacklogItems.updatedAt,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "task"),
eq(markdownBacklogItems.planSlug, input.planSlug),
eq(markdownBacklogItems.epicSlug, input.epicSlug),
eq(markdownBacklogItems.slug, input.taskSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task not found at plans/${input.planSlug}/${input.epicSlug}/${input.taskSlug} in this workspace.`,
});
}
const [epic] = await ctx.db
.select({
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "epic"),
eq(markdownBacklogItems.planSlug, input.planSlug),
eq(markdownBacklogItems.slug, input.epicSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
const [plan] = await ctx.db
.select({
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "plan"),
eq(markdownBacklogItems.slug, input.planSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
return {
task,
epic: epic ?? null,
plan: plan ?? null,
callerRole: ctx.workspace.role,
};
}),
/**
* Return both the item's own (possibly null) override and the resolved
* effective prompt with its source level. Used by the future task
* detail panel to render the "Override" textarea pre-filled and the
* "Effective" preview correctly.
*/
getWorkflowPrompt: workspaceProcedure
.input(z.object({ backlogItemId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const [row] = await ctx.db
.select({
id: markdownBacklogItems.id,
workflowPrompt: markdownBacklogItems.workflowPrompt,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.id, input.backlogItemId),
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
),
)
.limit(1);
if (!row) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Backlog item not found in this workspace.",
});
}
const resolved = await resolveWorkflowPrompt(ctx.db, {
workspaceId: ctx.workspace.id,
backlogItemId: input.backlogItemId,
});
return {
ownOverride: row.workflowPrompt,
effectivePrompt: resolved.prompt,
source: resolved.source,
};
}),
/**
* Set or clear the item's own workflow_prompt override. Passing null /
* empty string clears the override (the item then inherits). Owner /
* admin only — agent prompts can change how Cursor/Claude behave in a
* downstream session, so they're not a "any member can edit" surface.
*/
updateWorkflowPrompt: workspaceProcedure
.input(
z.object({
backlogItemId: z.string().uuid(),
workflowPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can edit agent prompts.",
});
}
// Empty string normalizes to null so the inheritance walk sees "no
// override here, keep walking" instead of "explicitly empty prompt."
const next =
input.workflowPrompt && input.workflowPrompt.trim()
? input.workflowPrompt.trim()
: null;
const [updated] = await ctx.db
.update(markdownBacklogItems)
.set({ workflowPrompt: next, updatedAt: new Date() })
.where(
and(
eq(markdownBacklogItems.id, input.backlogItemId),
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
),
)
.returning({
id: markdownBacklogItems.id,
workflowPrompt: markdownBacklogItems.workflowPrompt,
});
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Backlog item not found in this workspace.",
});
}
await recordAudit(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
action: next ? "backlog.workflow_prompt_set" : "backlog.workflow_prompt_clear",
targetType: "markdown_backlog_item",
targetId: input.backlogItemId,
metadata: { length: next?.length ?? 0 },
});
// Project the prompt change back to the markdown file in dev. We
// swallow errors here — the DB write already succeeded and the
// operator can re-run the importer if the file is out of sync.
const repoRoot = resolveRepoRoot();
if (repoRoot) {
try {
const result = await exportBacklogItemToMarkdown(ctx.db, {
workspaceId: ctx.workspace.id,
backlogItemId: input.backlogItemId,
repoRootAbs: repoRoot,
});
if (!result.ok) {
console.warn(
`[backlog.updateWorkflowPrompt] markdown export skipped (${result.reason}): ${result.detail}`,
);
}
} catch (e) {
console.warn(
"[backlog.updateWorkflowPrompt] markdown export threw:",
e,
);
}
}
return updated;
}),
});
export type BacklogRouter = typeof backlogRouter;
export { DEFAULT_AGENT_PROMPT };