ubiquitous-invention/apps/web/server/routers/backlog.ts

263 lines
8.7 KiB
TypeScript
Raw Normal View History

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, eq, isNull } from "drizzle-orm";
import { markdownBacklogItems } 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,
} 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";
const MAX_PROMPT_LENGTH = 20_000;
// Slugs come straight out of URL segments. They were already enforced
// to be `[a-z0-9-]` by the importer, but a malformed URL segment could
// otherwise be used to fish for tenant-scoped rows by smuggling SQL-ish
// characters. We re-enforce the shape at the procedure boundary so a
// 400 lands client-side instead of an opaque "no row" 404.
const slugSchema = z
.string()
.min(1)
.max(200)
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Invalid slug shape");
/**
* 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({
/**
* Hydrate a Plan/Epic/Task detail page by URL path. Returns the task
* row plus the parent epic + plan titles (used in breadcrumbs and the
* "Inherited from ..." badge) and the caller's workspace role so the
* UI can pre-gate edit affordances without a second roundtrip.
*
* 404s on any of: task missing, epic missing (epic_slug doesn't match
* a row), or plan missing. Crossing those into one error message
* deliberately the operator just needs to know "this path doesn't
* resolve in this workspace", not which of the three legs is broken.
*/
getTaskByPath: workspaceProcedure
.input(
z.object({
planSlug: slugSchema,
epicSlug: slugSchema,
taskSlug: slugSchema,
}),
)
.query(async ({ ctx, input }) => {
const [task] = await ctx.db
.select({
id: markdownBacklogItems.id,
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
status: markdownBacklogItems.status,
priority: markdownBacklogItems.priority,
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
repoPath: markdownBacklogItems.repoPath,
updatedAt: markdownBacklogItems.updatedAt,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "task"),
eq(markdownBacklogItems.planSlug, input.planSlug),
eq(markdownBacklogItems.epicSlug, input.epicSlug),
eq(markdownBacklogItems.slug, input.taskSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task not found at plans/${input.planSlug}/${input.epicSlug}/${input.taskSlug} in this workspace.`,
});
}
const [epic] = await ctx.db
.select({
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "epic"),
eq(markdownBacklogItems.planSlug, input.planSlug),
eq(markdownBacklogItems.slug, input.epicSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
const [plan] = await ctx.db
.select({
slug: markdownBacklogItems.slug,
title: markdownBacklogItems.title,
})
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
eq(markdownBacklogItems.kind, "plan"),
eq(markdownBacklogItems.slug, input.planSlug),
isNull(markdownBacklogItems.archivedAt),
),
)
.limit(1);
return {
task,
epic: epic ?? null,
plan: plan ?? null,
callerRole: ctx.workspace.role,
};
}),
/**
* 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;
}),
});
export type BacklogRouter = typeof backlogRouter;
export { DEFAULT_AGENT_PROMPT };