134 lines
4.3 KiB
TypeScript
134 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 };
|