import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, eq } 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; /** * 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({ /** * 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 };