- MCP create-backlog-item for natural language Plan/Epic/Task authoring - Worker non-code: optional worktree + per-harness allowlist + doc_review→published - Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test - Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI - Fleet: /fleet rollup across workspaces + SSE /api/fleet/events Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md Convoy: .convoys/second-brain-os-v01 Co-authored-by: orchestrator
1033 lines
38 KiB
TypeScript
1033 lines
38 KiB
TypeScript
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<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 },
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
} 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 };
|