2026-06-02 23:18:18 -04:00
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
|
import { z } from "zod";
|
2026-06-15 16:35:39 -04:00
|
|
|
import { and, eq, isNull } from "drizzle-orm";
|
2026-06-02 23:18:18 -04:00
|
|
|
|
|
|
|
|
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,
|
2026-06-02 23:18:18 -04:00
|
|
|
} 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";
|
2026-06-02 23:18:18 -04:00
|
|
|
|
|
|
|
|
const MAX_PROMPT_LENGTH = 20_000;
|
|
|
|
|
|
2026-06-15 16:35:39 -04:00
|
|
|
// 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");
|
|
|
|
|
|
2026-06-02 23:18:18 -04:00
|
|
|
/**
|
|
|
|
|
* 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({
|
2026-06-15 16:35:39 -04:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
2026-06-02 23:18:18 -04:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:18:18 -04:00
|
|
|
return updated;
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export type BacklogRouter = typeof backlogRouter;
|
|
|
|
|
export { DEFAULT_AGENT_PROMPT };
|