import { mkdir, writeFile, rename, access, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { randomUUID } from "node:crypto"; import { parseDocument, Scalar } from "yaml"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, asc, eq } from "drizzle-orm"; import { markdownBacklogItems, objects } from "@tasks/database/schema"; import { resolveWorkflowPrompt, DEFAULT_AGENT_PROMPT, exportBacklogItemToMarkdown, hashFileContents, } from "@tasks/database/markdown-backlog"; import { parentRepoPath } from "@tasks/database/markdown-backlog"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordAudit } from "@/server/lib/audit"; import { resolveRepoRoot } from "@/server/lib/repo-root"; import { findConvoyBySlug, insertConvoyEvent } from "@/server/lib/convoy/convoy-service"; const MAX_PROMPT_LENGTH = 20_000; const SLUG_RE = /^[a-z0-9-]+$/; const SLUG_STRICT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function todayIso(): string { return new Date().toISOString().slice(0, 10); } function defaultBodyForKind(kind: string, title: string): string { if (kind === "plan") return `# ${title}\n\nShort summary of the initiative and why it exists.\n`; if (kind === "epic") return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`; return `# ${title}\n\nOne or two sentences.\n`; } function repoPathFor(input: { kind: string; slug: string; planSlug?: string; epicSlug?: string }): string { if (input.kind === "plan") return `plans/Plan-${input.slug}/Plan-${input.slug}.md`; if (input.kind === "epic") return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`; return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`; } function buildFrontmatterYaml(input: { kind: string; slug: string; title: string; planSlug: string | null; epicSlug: string | null; status: string; priority: string; agentPrompt: string | null; }): string { const doc = parseDocument(""); doc.set("kind", input.kind); doc.set("slug", input.slug); doc.set("title", input.title); if (input.planSlug) doc.set("plan_slug", input.planSlug); if (input.epicSlug) doc.set("epic_slug", input.epicSlug); doc.set("status", input.status); doc.set("priority", input.priority); doc.set("updated_at", todayIso()); if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") { const isMultiline = input.agentPrompt.includes("\n"); const node = doc.createNode(input.agentPrompt); if (node instanceof Scalar) node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN; doc.set("agent_prompt", node); } const yamlText = String(doc).replace(/\n$/, ""); return yamlText; } async function atomicWrite(absPath: string, contents: string): Promise { const dir = dirname(absPath); await mkdir(dir, { recursive: true }); const tmp = join(dir, `.${randomUUID()}.tmp`); await writeFile(tmp, contents, "utf8"); await rename(tmp, absPath); } async function fileExists(absPath: string): Promise { try { await access(absPath); return true; } catch { return false; } } const slugSchema = z.string().min(1).max(200).regex(SLUG_RE, "slug must match ^[a-z0-9-]+$"); const slugStrictSchema = z.string().min(1).max(120).regex(SLUG_STRICT_RE, "slug must be kebab-case"); /** * 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; }), listTree: workspaceProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ id: markdownBacklogItems.id, kind: markdownBacklogItems.kind, slug: markdownBacklogItems.slug, planSlug: markdownBacklogItems.planSlug, epicSlug: markdownBacklogItems.epicSlug, title: markdownBacklogItems.title, status: markdownBacklogItems.status, priority: markdownBacklogItems.priority, repoPath: markdownBacklogItems.repoPath, bodyMarkdown: markdownBacklogItems.bodyMarkdown, workflowPrompt: markdownBacklogItems.workflowPrompt, frontmatter: markdownBacklogItems.frontmatter, updatedAt: markdownBacklogItems.updatedAt, parentId: markdownBacklogItems.parentId, }) .from(markdownBacklogItems) .where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id)) .orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug)); type Row = (typeof rows)[number]; type TaskNode = Row; type EpicNode = Row & { tasks: TaskNode[] }; type PlanNode = Row & { epics: EpicNode[] }; const planMap = new Map(); const epicMap = new Map(); // key planSlug/epicSlug for (const r of rows) { if (r.kind === "plan") { planMap.set(r.slug, { ...r, epics: [] }); } } for (const r of rows) { if (r.kind === "epic") { const key = `${r.planSlug}/${r.slug}`; const node: EpicNode = { ...r, tasks: [] }; epicMap.set(key, node); const plan = planMap.get(r.planSlug); if (plan) plan.epics.push(node); else { // Orphan epic: ensure plan placeholder exists so tree still renders const placeholder: PlanNode = { id: `placeholder-${r.planSlug}`, kind: "plan", slug: r.planSlug, planSlug: r.planSlug, epicSlug: null, title: r.planSlug, status: null, priority: null, repoPath: `plans/Plan-${r.planSlug}/Plan-${r.planSlug}.md`, bodyMarkdown: "", workflowPrompt: null, frontmatter: null, updatedAt: r.updatedAt, parentId: null, epics: [node], }; planMap.set(r.planSlug, placeholder); } } } for (const r of rows) { if (r.kind === "task") { const epicKey = `${r.planSlug}/${r.epicSlug ?? ""}`; const epic = epicMap.get(epicKey); if (epic) epic.tasks.push(r); else { // Orphan task: attach to epic placeholder under plan let epicNode = epicMap.get(epicKey); if (!epicNode && r.epicSlug) { epicNode = { id: `placeholder-${r.planSlug}-${r.epicSlug}`, kind: "epic", slug: r.epicSlug, planSlug: r.planSlug, epicSlug: null, title: r.epicSlug, status: null, priority: null, repoPath: `plans/Plan-${r.planSlug}/Epic-${r.epicSlug}/Epic-${r.epicSlug}.md`, bodyMarkdown: "", workflowPrompt: null, frontmatter: null, updatedAt: r.updatedAt, parentId: null, tasks: [], }; epicMap.set(epicKey, epicNode); const plan = planMap.get(r.planSlug); if (plan) plan.epics.push(epicNode); else { const placeholderPlan: PlanNode = { id: `placeholder-${r.planSlug}`, kind: "plan", slug: r.planSlug, planSlug: r.planSlug, epicSlug: null, title: r.planSlug, status: null, priority: null, repoPath: `plans/Plan-${r.planSlug}/Plan-${r.planSlug}.md`, bodyMarkdown: "", workflowPrompt: null, frontmatter: null, updatedAt: r.updatedAt, parentId: null, epics: [epicNode], }; planMap.set(r.planSlug, placeholderPlan); } } epicNode?.tasks.push(r); } } } const plans = Array.from(planMap.values()).sort((a, b) => a.slug.localeCompare(b.slug)); for (const p of plans) { p.epics.sort((a, b) => a.slug.localeCompare(b.slug)); for (const e of p.epics) e.tasks.sort((a, b) => a.slug.localeCompare(b.slug)); } return { plans }; }), getBacklogItem: workspaceProcedure .input(z.object({ backlogItemId: z.string().uuid() })) .query(async ({ ctx, input }) => { const [row] = await ctx.db .select() .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." }); } return row; }), createPlan: workspaceProcedure .input( z.object({ slug: slugSchema, title: z.string().min(1).max(500), bodyMarkdown: z.string().max(50_000).optional(), agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(), status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(), priority: z.enum(["P0", "P1", "P2", "P3"]).optional(), }), ) .mutation(async ({ ctx, input }) => { const slug = input.slug.trim().toLowerCase(); if (!SLUG_RE.test(slug)) { throw new TRPCError({ code: "BAD_REQUEST", message: "slug must match ^[a-z0-9-]+$" }); } const title = input.title.trim(); const status = input.status ?? "draft"; const priority = input.priority ?? "P2"; const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null; const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("plan", title); const repoPath = repoPathFor({ kind: "plan", slug }); const repoRoot = resolveRepoRoot(); if (repoRoot) { const absPath = join(repoRoot, ...repoPath.split("/")); if (await fileExists(absPath)) { throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); } const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) { throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); } const yamlText = buildFrontmatterYaml({ kind: "plan", slug, title, planSlug: null, epicSlug: null, status, priority, agentPrompt, }); const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`; const normalizedBody = body.endsWith("\n") ? body : `${body}\n`; const raw = `---\n${yamlText}\n---${normalizedBody}`; const contentHash = hashFileContents(raw); const frontmatter: Record = { kind: "plan", slug, title, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; await atomicWrite(absPath, raw); const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "plan", slug, planSlug: slug, epicSlug: null, parentId: null, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: normalizedBody.replace(/^\n/, ""), contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create plan" }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "backlog.create_plan", targetType: "markdown_backlog_item", targetId: inserted.id, metadata: { slug, repoPath }, }); return inserted; } else { // No repo root (production) — DB only const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`; const rawForHash = `---\n${buildFrontmatterYaml({ kind: "plan", slug, title, planSlug: null, epicSlug: null, status, priority, agentPrompt, })}\n---\n${body}`; const contentHash = hashFileContents(rawForHash); const frontmatter: Record = { kind: "plan", slug, title, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "plan", slug, planSlug: slug, epicSlug: null, parentId: null, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: body, contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create plan" }); return inserted; } }), createEpic: workspaceProcedure .input( z.object({ planSlug: slugSchema, slug: slugSchema, title: z.string().min(1).max(500), bodyMarkdown: z.string().max(50_000).optional(), agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(), status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(), priority: z.enum(["P0", "P1", "P2", "P3"]).optional(), }), ) .mutation(async ({ ctx, input }) => { const planSlug = input.planSlug.trim().toLowerCase(); const slug = input.slug.trim().toLowerCase(); const title = input.title.trim(); const status = input.status ?? "draft"; const priority = input.priority ?? "P2"; const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null; const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("epic", title); // Verify plan exists in DB for this workspace const [planRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where( and( eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.kind, "plan"), eq(markdownBacklogItems.slug, planSlug), ), ) .limit(1); if (!planRow) { throw new TRPCError({ code: "NOT_FOUND", message: `Plan '${planSlug}' not found` }); } const repoPath = repoPathFor({ kind: "epic", slug, planSlug }); const repoRoot = resolveRepoRoot(); if (repoRoot) { const absPath = join(repoRoot, ...repoPath.split("/")); if (await fileExists(absPath)) { throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); } const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); const yamlText = buildFrontmatterYaml({ kind: "epic", slug, title, planSlug, epicSlug: null, status, priority, agentPrompt, }); const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`; const normalizedBody = body.endsWith("\n") ? body : `${body}\n`; const raw = `---\n${yamlText}\n---${normalizedBody}`; const contentHash = hashFileContents(raw); const frontmatter: Record = { kind: "epic", slug, title, plan_slug: planSlug, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; await atomicWrite(absPath, raw); const parentPath = parentRepoPath(repoPath); let parentId: string | null = null; if (parentPath) { const [parentRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, parentPath))) .limit(1); parentId = parentRow?.id ?? planRow.id; } const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "epic", slug, planSlug, epicSlug: null, parentId, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: normalizedBody.replace(/^\n/, ""), contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create epic" }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "backlog.create_epic", targetType: "markdown_backlog_item", targetId: inserted.id, metadata: { slug, planSlug, repoPath }, }); return inserted; } else { const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`; const rawForHash = `---\n${buildFrontmatterYaml({ kind: "epic", slug, title, planSlug, epicSlug: null, status, priority, agentPrompt, })}\n---\n${body}`; const contentHash = hashFileContents(rawForHash); const frontmatter: Record = { kind: "epic", slug, title, plan_slug: planSlug, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "epic", slug, planSlug, epicSlug: null, parentId: planRow.id, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: body, contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create epic" }); return inserted; } }), createTask: workspaceProcedure .input( z.object({ planSlug: slugSchema, epicSlug: slugSchema, slug: slugSchema, title: z.string().min(1).max(500), bodyMarkdown: z.string().max(50_000).optional(), agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(), status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(), priority: z.enum(["P0", "P1", "P2", "P3"]).optional(), }), ) .mutation(async ({ ctx, input }) => { const planSlug = input.planSlug.trim().toLowerCase(); const epicSlug = input.epicSlug.trim().toLowerCase(); const slug = input.slug.trim().toLowerCase(); const title = input.title.trim(); const status = input.status ?? "ready"; const priority = input.priority ?? "P2"; const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null; const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("task", title); const [planRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where( and( eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.kind, "plan"), eq(markdownBacklogItems.slug, planSlug), ), ) .limit(1); if (!planRow) throw new TRPCError({ code: "NOT_FOUND", message: `Plan '${planSlug}' not found` }); const [epicRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where( and( eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.kind, "epic"), eq(markdownBacklogItems.slug, epicSlug), eq(markdownBacklogItems.planSlug, planSlug), ), ) .limit(1); if (!epicRow) throw new TRPCError({ code: "NOT_FOUND", message: `Epic '${epicSlug}' not found under plan '${planSlug}'` }); const repoPath = repoPathFor({ kind: "task", slug, planSlug, epicSlug }); const repoRoot = resolveRepoRoot(); if (repoRoot) { const absPath = join(repoRoot, ...repoPath.split("/")); if (await fileExists(absPath)) { throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); } const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); const yamlText = buildFrontmatterYaml({ kind: "task", slug, title, planSlug, epicSlug, status, priority, agentPrompt, }); const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`; const normalizedBody = body.endsWith("\n") ? body : `${body}\n`; const raw = `---\n${yamlText}\n---${normalizedBody}`; const contentHash = hashFileContents(raw); const frontmatter: Record = { kind: "task", slug, title, plan_slug: planSlug, epic_slug: epicSlug, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; await atomicWrite(absPath, raw); const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "task", slug, planSlug, epicSlug, parentId: epicRow.id, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: normalizedBody.replace(/^\n/, ""), contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create task" }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "backlog.create_task", targetType: "markdown_backlog_item", targetId: inserted.id, metadata: { slug, planSlug, epicSlug, repoPath }, }); return inserted; } else { const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`; const rawForHash = `---\n${buildFrontmatterYaml({ kind: "task", slug, title, planSlug, epicSlug, status, priority, agentPrompt, })}\n---\n${body}`; const contentHash = hashFileContents(rawForHash); const frontmatter: Record = { kind: "task", slug, title, plan_slug: planSlug, epic_slug: epicSlug, status, priority, updated_at: todayIso(), }; if (agentPrompt) frontmatter.agent_prompt = agentPrompt; const [existingRow] = await ctx.db .select({ id: markdownBacklogItems.id }) .from(markdownBacklogItems) .where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath))) .limit(1); if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` }); const [inserted] = await ctx.db .insert(markdownBacklogItems) .values({ workspaceId: ctx.workspace.id, kind: "task", slug, planSlug, epicSlug, parentId: epicRow.id, repoPath, title, status, priority, owner: null, frontmatter, bodyMarkdown: body, contentHash, workflowPrompt: agentPrompt, }) .returning(); if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create task" }); return inserted; } }), updateTaskBody: workspaceProcedure .input( z.object({ backlogItemId: z.string().uuid(), bodyMarkdown: z.string().min(1).max(100_000), }), ) .mutation(async ({ ctx, input }) => { const [row] = await ctx.db .select() .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." }); if (row.kind !== "task") throw new TRPCError({ code: "BAD_REQUEST", message: "Only tasks support body updates via this endpoint." }); const newBody = input.bodyMarkdown.endsWith("\n") ? input.bodyMarkdown : `${input.bodyMarkdown}\n`; const repoRoot = resolveRepoRoot(); let contentHash = row.contentHash; if (repoRoot && row.repoPath) { const absPath = join(repoRoot, ...row.repoPath.split("/")); try { const rawBefore = await readFile(absPath, "utf8"); const fmMatch = rawBefore.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/); let newRaw: string; if (fmMatch) { const bodyOffset = fmMatch[0].length; // Keep frontmatter verbatim, replace body const frontmatterPart = rawBefore.slice(0, bodyOffset); // Ensure body starts without duplicate frontmatter newline handling: frontmatterPart already ends with newline const bodyPart = newBody.startsWith("\n") ? newBody.slice(1) : newBody; // frontmatterPart ends with "" or newline; ensure separation newRaw = frontmatterPart + bodyPart; if (!newRaw.endsWith("\n")) newRaw += "\n"; } else { newRaw = newBody; } contentHash = hashFileContents(newRaw); await atomicWrite(absPath, newRaw); } catch (e) { console.warn("[backlog.updateTaskBody] file write failed:", e); // still update DB; compute hash from DB perspective const frontmatterYaml = buildFrontmatterYaml({ kind: row.kind, slug: row.slug, title: row.title, planSlug: row.planSlug, epicSlug: row.epicSlug, status: row.status ?? "ready", priority: row.priority ?? "P2", agentPrompt: row.workflowPrompt, }); const syntheticRaw = `---\n${frontmatterYaml}\n---\n${newBody}`; contentHash = hashFileContents(syntheticRaw); } } else { const frontmatterYaml = buildFrontmatterYaml({ kind: row.kind, slug: row.slug, title: row.title, planSlug: row.planSlug, epicSlug: row.epicSlug, status: row.status ?? "ready", priority: row.priority ?? "P2", agentPrompt: row.workflowPrompt, }); const syntheticRaw = `---\n${frontmatterYaml}\n---\n${newBody}`; contentHash = hashFileContents(syntheticRaw); } const [updated] = await ctx.db .update(markdownBacklogItems) .set({ bodyMarkdown: newBody, contentHash, updatedAt: new Date() }) .where(and(eq(markdownBacklogItems.id, row.id), eq(markdownBacklogItems.workspaceId, ctx.workspace.id))) .returning(); 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: "backlog.update_task_body", targetType: "markdown_backlog_item", targetId: row.id, metadata: { repoPath: row.repoPath }, }); return updated; }), startConvoy: workspaceProcedure .input(z.object({ backlogItemId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [row] = await ctx.db .select() .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." }); if (row.kind !== "task") throw new TRPCError({ code: "BAD_REQUEST", message: "Only tasks can start a convoy." }); const planSlug = row.planSlug; const taskSlug = row.slug; const convoySlug = `${planSlug}-${taskSlug}`.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, ""); const parsedSlug = slugStrictSchema.safeParse(convoySlug); if (!parsedSlug.success) { throw new TRPCError({ code: "BAD_REQUEST", message: `Derived convoy slug '${convoySlug}' is invalid` }); } const existing = await findConvoyBySlug(ctx.db, ctx.workspace.id, convoySlug); if (existing) { return { convoyId: existing.id, convoySlug, alreadyExists: true as const }; } if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners and admins can start convoys." }); } const successMetric = row.title ?? taskSlug; const ideaMarkdown = row.bodyMarkdown?.trim() ? row.bodyMarkdown.trim() : `# ${row.title}\n\nTask: ${row.repoPath}`; const content = { convoySlug, classification: "feature" as const, skipFlags: [] as string[], successMetric, backlogItemId: row.id, repoPath: row.repoPath, planSlug, epicSlug: row.epicSlug ?? undefined, briefFiles: [row.repoPath], }; const [created] = await ctx.db .insert(objects) .values({ type: "project", title: convoySlug, workspaceId: ctx.workspace.id, description: ideaMarkdown, status: "draft", content, }) .returning(); if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create convoy." }); await insertConvoyEvent(ctx.db, { workspaceId: ctx.workspace.id, convoyId: created.id, role: "role-conductor", actor: "role-conductor", eventType: "status_change", metadata: { from: null, to: "draft", source: "plans_start", backlogItemId: row.id, repoPath: row.repoPath }, }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "backlog.start_convoy", targetType: "project", targetId: created.id, metadata: { convoySlug, backlogItemId: row.id, repoPath: row.repoPath }, }); return { convoyId: created.id, convoySlug, alreadyExists: false as const }; }), }); export type BacklogRouter = typeof backlogRouter; export { DEFAULT_AGENT_PROMPT };