ubiquitous-invention/apps/web/server/routers/backlog.ts
Randall Stillwell 72aa2a5f0c feat(backlog): workflow_prompt with task → epic → plan inheritance
Adds the data layer for per-item agent prompts. Markdown frontmatter
gets an `agent_prompt:` block scalar that survives the importer
round-trip (newlines preserved), and `resolveWorkflowPrompt()` walks
task → epic → plan → built-in default returning both the resolved
string and the source level. Walk is slug-based, not parent_id-based,
because the importer leaves parent_id briefly null mid-transaction.

tRPC `backlog.getWorkflowPrompt` returns ownOverride + effectivePrompt
so future UI can render the override box + preview without two
queries. `backlog.updateWorkflowPrompt` is owner/admin-gated (prompts
change downstream Cursor/Claude behavior) and audit-logged on every
write.

UI deferred — apps/web doesn't have a backlog-item detail panel yet;
the existing object-detail panel is for the objects table. Follow-up
filed at Task-workflow-prompt-task-detail-ui.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:18:18 -05:00

133 lines
4.3 KiB
TypeScript

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,
} from "@tasks/database/markdown-backlog";
import { router, workspaceProcedure } from "@/server/trpc";
import { recordAudit } from "@/server/lib/audit";
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 },
});
return updated;
}),
});
export type BacklogRouter = typeof backlogRouter;
export { DEFAULT_AGENT_PROMPT };