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 };