In-flight work from 2026-06-05 that was sitting uncommitted on feat/agent-pipeline-bridge for 10 days. Moved here to a clean branch off main so the bridge branch can stay at its committed state (the DEFERRED Phase 2a snapshot, see stwl-labs/ubiquitous-invention#3). Touched areas: - apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx — runs page UI iteration - apps/web/server/routers/{backlog,runs}.ts — router updates - apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx — new deep-link route - apps/web/components/backlog/workflow-prompt-section.tsx — new component - config/CursorSync.md — coordination doc tweak - docs/operations/README.md + 2026-06-05-db-drift-reconcile-oauth-tables.sql — operations ops note + reconcile script - plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-{workflow-prompt-task-detail-ui,deep-link-runs-to-task-detail}.md — 2 task specs This is a working-tree snapshot, not a finished PR. Rebase, split, or amend as needed when picking it back up. Co-authored-by: Cursor <cursoragent@cursor.com>
262 lines
8.7 KiB
TypeScript
262 lines
8.7 KiB
TypeScript
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,
|
|
exportBacklogItemToMarkdown,
|
|
} from "@tasks/database/markdown-backlog";
|
|
|
|
import { router, workspaceProcedure } from "@/server/trpc";
|
|
import { recordAudit } from "@/server/lib/audit";
|
|
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 },
|
|
});
|
|
|
|
// 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 };
|