Submatter/apps/web/server/routers/backlog.ts

1034 lines
38 KiB
TypeScript
Raw Normal View History

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,
feat(markdown-backlog): close the sync loop with DB → frontmatter export Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter <pkg>` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
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";
feat(markdown-backlog): close the sync loop with DB → frontmatter export Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter <pkg>` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
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<void> {
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<boolean> {
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 },
});
feat(markdown-backlog): close the sync loop with DB → frontmatter export Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter <pkg>` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
// 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<string, PlanNode>();
const epicMap = new Map<string, EpicNode>(); // 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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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);
feat(markdown-backlog): close the sync loop with DB → frontmatter export Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter <pkg>` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
}
} 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);
feat(markdown-backlog): close the sync loop with DB → frontmatter export Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter <pkg>` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:21:22 -04:00
}
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 };