wip: agent-coordination iteration (runs+backlog+plans UI + 2 task plans + db-drift SQL)

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>
This commit is contained in:
Randall Stillwell 2026-06-15 15:35:39 -05:00
parent 491d196384
commit 6581f6a482
10 changed files with 1112 additions and 11 deletions

View file

@ -0,0 +1,243 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChevronRight, FileText } from "lucide-react";
import type { inferRouterOutputs } from "@trpc/server";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { WorkflowPromptSection } from "@/components/backlog/workflow-prompt-section";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/root";
/**
* Task detail page rendered from URL slugs that mirror the `plans/`
* directory layout. There's no plans-tree browser yet, so this is the
* smallest surface that lets an operator land directly on a single task
* (e.g. from a `/settings/runs` row, or by typing the URL) and edit its
* workflow prompt.
*
* Path: /<workspaceSlug>/plans/<planSlug>/<epicSlug>/<taskSlug>
*
* 404 here means "no such task in this workspace at that path." We surface
* the error inline rather than calling `notFound()` so the path components
* stay visible in the breadcrumb easier to copy-edit a typo.
*/
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
draft: {
label: "Draft",
className:
"border-transparent bg-muted text-muted-foreground hover:bg-muted/80",
},
ready: {
label: "Ready",
className:
"border-transparent bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
in_progress: {
label: "In progress",
className:
"border-transparent bg-blue-500/15 text-blue-700 dark:text-blue-300",
},
blocked: {
label: "Blocked",
className:
"border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
done: {
label: "Done",
className:
"border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
},
cancelled: {
label: "Cancelled",
className:
"border-transparent bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function StatusBadge({ status }: { status: string | null | undefined }) {
const entry = status && STATUS_BADGE[status];
if (!entry) {
return (
<Badge variant="outline" className="font-normal">
{status ?? "unknown"}
</Badge>
);
}
return (
<Badge className={cn(entry.className, "font-medium")}>{entry.label}</Badge>
);
}
export default function TaskDetailPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const planSlug = params?.planSlug as string | undefined;
const epicSlug = params?.epicSlug as string | undefined;
const taskSlug = params?.taskSlug as string | undefined;
const ready = Boolean(workspaceSlug && planSlug && epicSlug && taskSlug);
const detailQuery = api.backlog.getTaskByPath.useQuery(
{
workspace: workspaceSlug ?? "",
planSlug: planSlug ?? "",
epicSlug: epicSlug ?? "",
taskSlug: taskSlug ?? "",
},
{ enabled: ready },
);
if (!ready) {
return (
<div className="mx-auto max-w-3xl px-8 py-10 text-sm text-muted-foreground">
Missing path segments.
</div>
);
}
return (
<div className="mx-auto max-w-3xl px-8 py-10">
<Breadcrumb
workspaceSlug={workspaceSlug!}
planSlug={planSlug!}
epicSlug={epicSlug!}
taskSlug={taskSlug!}
planTitle={detailQuery.data?.plan?.title ?? null}
epicTitle={detailQuery.data?.epic?.title ?? null}
taskTitle={detailQuery.data?.task.title ?? null}
/>
{detailQuery.isLoading ? (
<div className="mt-6 space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-40 w-full" />
</div>
) : detailQuery.error ? (
<div className="mt-6 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{detailQuery.error.message}
</div>
) : detailQuery.data ? (
<TaskBody workspaceSlug={workspaceSlug!} data={detailQuery.data} />
) : null}
</div>
);
}
function Breadcrumb({
workspaceSlug,
planSlug,
epicSlug,
taskSlug,
planTitle,
epicTitle,
taskTitle,
}: {
workspaceSlug: string;
planSlug: string;
epicSlug: string;
taskSlug: string;
planTitle: string | null;
epicTitle: string | null;
taskTitle: string | null;
}) {
// There isn't a plans browser route yet, so the only meaningful link
// is back up to the workspace root. Plan/epic crumbs render as text
// until those landing pages exist.
return (
<nav
aria-label="Breadcrumb"
className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground"
>
<Link
href={`/${workspaceSlug}`}
className="hover:text-foreground hover:underline"
>
{workspaceSlug}
</Link>
<ChevronRight className="size-3" />
<span className="text-muted-foreground/80">plans</span>
<ChevronRight className="size-3" />
<span title={planSlug}>{planTitle ?? planSlug}</span>
<ChevronRight className="size-3" />
<span title={epicSlug}>{epicTitle ?? epicSlug}</span>
<ChevronRight className="size-3" />
<span className="font-medium text-foreground" title={taskSlug}>
{taskTitle ?? taskSlug}
</span>
</nav>
);
}
type DetailData = inferRouterOutputs<AppRouter>["backlog"]["getTaskByPath"];
function TaskBody({
workspaceSlug,
data,
}: {
workspaceSlug: string;
data: DetailData;
}) {
const canEdit = data.callerRole === "owner" || data.callerRole === "admin";
return (
<>
<header className="mt-6 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileText className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold leading-tight">
{data.task.title || "Untitled task"}
</h1>
<StatusBadge status={data.task.status} />
{data.task.priority ? (
<Badge variant="outline" className="font-normal">
{data.task.priority}
</Badge>
) : null}
</div>
<p className="mt-1 text-xs text-muted-foreground">
<span className="font-mono">{data.task.repoPath}</span>
</p>
</div>
</header>
<div className="mt-6 space-y-6">
<WorkflowPromptSection
workspace={workspaceSlug}
backlogItemId={data.task.id}
canEdit={canEdit}
sourceContext={{
taskTitle: data.task.title,
epicTitle: data.epic?.title ?? null,
planTitle: data.plan?.title ?? null,
}}
/>
<section className="rounded-lg border bg-card p-4">
<h2 className="text-sm font-semibold">Body</h2>
<p className="mb-3 text-xs text-muted-foreground">
Markdown imported from <span className="font-mono">{data.task.repoPath}</span>. Edit the file in your repo and re-run the importer to update.
</p>
{data.task.bodyMarkdown.trim() ? (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed">
{data.task.bodyMarkdown}
</pre>
) : (
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
No body content.
</div>
)}
</section>
</div>
</>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
Activity, Activity,
@ -99,6 +100,35 @@ function describeOutcome(
return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP }; return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP };
} }
/**
* Compose a URL to the task detail page when we have the full path
* triple. Returns null when:
* - the backlog item is a plan or epic (no detail page exists yet),
* - any path segment is missing (a row joined against a deleted item
* or one with the importer mid-flight),
* - or the workspace slug isn't known yet (initial render).
*
* The runs page renders the title unlinked when this returns null.
*/
function buildTaskHref(args: {
workspaceSlug: string | undefined;
taskKind: string | null;
planSlug: string | null;
epicSlug: string | null;
taskSlug: string | null;
}): string | null {
if (
args.taskKind !== "task" ||
!args.workspaceSlug ||
!args.planSlug ||
!args.epicSlug ||
!args.taskSlug
) {
return null;
}
return `/${args.workspaceSlug}/plans/${args.planSlug}/${args.epicSlug}/${args.taskSlug}`;
}
/** /**
* Read `client` / `model` out of a run's metadata blob. The column is * Read `client` / `model` out of a run's metadata blob. The column is
* JSONB so we're treating it as unstructured here short-circuit on * JSONB so we're treating it as unstructured here short-circuit on
@ -231,6 +261,13 @@ export default function RunsPage() {
const agent = describeAgent(row.metadata); const agent = describeAgent(row.metadata);
const isOpen = expanded === row.id; const isOpen = expanded === row.id;
const hasDetail = Boolean(row.notes || row.error); const hasDetail = Boolean(row.notes || row.error);
const taskHref = buildTaskHref({
workspaceSlug,
taskKind: row.taskKind,
planSlug: row.taskPlanSlug,
epicSlug: row.taskEpicSlug,
taskSlug: row.taskSlug,
});
return ( return (
<React.Fragment key={row.id}> <React.Fragment key={row.id}>
<tr <tr
@ -256,7 +293,21 @@ export default function RunsPage() {
) : null} ) : null}
</td> </td>
<td className="max-w-[24ch] truncate px-4 py-2 font-medium"> <td className="max-w-[24ch] truncate px-4 py-2 font-medium">
{row.taskTitle ?? ( {row.taskTitle ? (
taskHref ? (
<Link
href={taskHref}
// Stop the click so the row's expand/collapse
// handler doesn't also fire on link clicks.
onClick={(e) => e.stopPropagation()}
className="hover:underline focus-visible:underline focus-visible:outline-none"
>
{row.taskTitle}
</Link>
) : (
row.taskTitle
)
) : (
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
{row.backlogItemId.slice(0, 8)} {row.backlogItemId.slice(0, 8)}
</span> </span>

View file

@ -0,0 +1,267 @@
"use client";
import * as React from "react";
import { Loader2, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
/**
* Editor for a backlog item's `workflow_prompt` override.
*
* Three visible parts:
* 1. Source badge where the effective prompt actually comes from
* (the task itself, an inherited epic/plan, or the built-in default).
* 2. Effective prompt preview read-only, what an agent would receive
* from `claim_task` right now.
* 3. Override textarea the editable knob. Empty/whitespace clears the
* override and reverts to inheritance.
*
* Edit affordances (Save / Clear) are hidden entirely when the caller
* isn't an owner/admin so the click can't fail. The server still enforces
* the same gate on `backlog.updateWorkflowPrompt` UI hiding is a
* convenience, not a security boundary.
*/
const MAX_PROMPT_LENGTH = 20_000;
type Source = "task" | "epic" | "plan" | "default";
type SourceContext = {
/** Title to render inside the source badge (e.g. epic title). */
taskTitle: string;
epicTitle: string | null;
planTitle: string | null;
};
function describeSource(source: Source, ctx: SourceContext): string {
switch (source) {
case "task":
return "From this task";
case "epic":
return ctx.epicTitle
? `Inherited from epic: ${ctx.epicTitle}`
: "Inherited from epic";
case "plan":
return ctx.planTitle
? `Inherited from plan: ${ctx.planTitle}`
: "Inherited from plan";
case "default":
return "Built-in default";
}
}
function badgeVariantForSource(
source: Source,
): "default" | "secondary" | "muted" {
if (source === "task") return "default";
if (source === "default") return "muted";
return "secondary";
}
export function WorkflowPromptSection({
workspace,
backlogItemId,
canEdit,
sourceContext,
}: {
/** Workspace UUID or slug — passed to every tRPC call. */
workspace: string;
backlogItemId: string;
canEdit: boolean;
sourceContext: SourceContext;
}) {
const utils = api.useUtils();
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
{ workspace, backlogItemId },
{ enabled: Boolean(workspace && backlogItemId) },
);
// Draft is initialized from the server's ownOverride and kept locally
// until save/clear. We track the last loaded value so re-renders that
// come from a successful invalidation don't stomp on unrelated edits
// the user might have made in the textarea between request and refetch.
const [draft, setDraft] = React.useState<string>("");
const [lastLoaded, setLastLoaded] = React.useState<string | null>(null);
React.useEffect(() => {
const next = promptQuery.data?.ownOverride ?? "";
if (promptQuery.data && next !== lastLoaded) {
setDraft(next);
setLastLoaded(next);
}
}, [promptQuery.data, lastLoaded]);
const updateMut = api.backlog.updateWorkflowPrompt.useMutation({
onSuccess: async () => {
// Re-fetch the resolved prompt + source. Don't optimistically
// mutate — the source badge can flip in non-obvious ways
// (e.g. clearing a task override might re-expose an epic or plan
// inheritance rather than the default), so it's safer to round-trip.
await utils.backlog.getWorkflowPrompt.invalidate({
workspace,
backlogItemId,
});
},
});
if (promptQuery.isLoading) {
return (
<section className="space-y-3 rounded-lg border bg-card p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
</section>
);
}
if (promptQuery.error) {
return (
<section className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
Couldn&apos;t load workflow prompt: {promptQuery.error.message}
</section>
);
}
const data = promptQuery.data;
if (!data) return null;
const source = data.source;
const ownOverride = data.ownOverride ?? "";
const hasOverride = Boolean(ownOverride.trim());
const dirty = draft !== ownOverride;
const tooLong = draft.length > MAX_PROMPT_LENGTH;
return (
<section className="space-y-4 rounded-lg border bg-card p-4">
<header className="flex flex-wrap items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10">
<Sparkles className="size-4 text-primary" />
</div>
<div className="flex-1">
<h2 className="text-sm font-semibold">Workflow prompt</h2>
<p className="text-xs text-muted-foreground">
What an agent receives when it claims this task via MCP.
</p>
</div>
<Badge
variant={badgeVariantForSource(source)}
className="font-normal"
title={`source=${source}`}
>
{describeSource(source, sourceContext)}
</Badge>
</header>
<div>
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Effective prompt
</div>
<pre
className={cn(
"whitespace-pre-wrap rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed",
"text-foreground",
)}
>
{data.effectivePrompt}
</pre>
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<label
htmlFor="workflow-prompt-override"
className="text-[10px] uppercase tracking-wide text-muted-foreground"
>
Override for this task
</label>
{hasOverride ? (
<span className="text-[10px] text-muted-foreground">
Currently overriding inherited prompt
</span>
) : (
<span className="text-[10px] text-muted-foreground">
Empty = inherit
</span>
)}
</div>
<textarea
id="workflow-prompt-override"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={!canEdit || updateMut.isPending}
rows={8}
maxLength={MAX_PROMPT_LENGTH + 100}
placeholder={
canEdit
? "Leave empty to inherit from the epic, plan, or built-in default."
: "Only owners and admins can edit this prompt."
}
className={cn(
"block w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-relaxed",
"ring-offset-background placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
/>
<div className="mt-1 flex items-center justify-between text-[10px] text-muted-foreground">
<span className={tooLong ? "text-destructive" : undefined}>
{draft.length.toLocaleString()} / {MAX_PROMPT_LENGTH.toLocaleString()}
</span>
{updateMut.error ? (
<span className="text-destructive">
{updateMut.error.message}
</span>
) : null}
</div>
</div>
{canEdit ? (
<div className="flex items-center justify-end gap-2">
{hasOverride ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={updateMut.isPending}
onClick={() => {
updateMut.mutate({
workspace,
backlogItemId,
workflowPrompt: null,
});
setDraft("");
}}
>
{updateMut.isPending && updateMut.variables?.workflowPrompt === null ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Clear override"
)}
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={!dirty || tooLong || updateMut.isPending}
onClick={() =>
updateMut.mutate({
workspace,
backlogItemId,
workflowPrompt: draft.trim() ? draft : null,
})
}
>
{updateMut.isPending && updateMut.variables?.workflowPrompt !== null ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
) : null}
</section>
);
}

View file

@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { markdownBacklogItems } from "@tasks/database/schema"; import { markdownBacklogItems } from "@tasks/database/schema";
import { import {
@ -15,6 +15,17 @@ import { resolveRepoRoot } from "@/server/lib/repo-root";
const MAX_PROMPT_LENGTH = 20_000; 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, * tRPC procedures for managing the markdown-backlog rows in the DB,
* specifically the parts of the row that the markdown importer DOESN'T * specifically the parts of the row that the markdown importer DOESN'T
@ -27,6 +38,98 @@ const MAX_PROMPT_LENGTH = 20_000;
* from disk." * from disk."
*/ */
export const backlogRouter = router({ 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 * Return both the item's own (possibly null) override and the resolved
* effective prompt with its source level. Used by the future task * effective prompt with its source level. Used by the future task

View file

@ -61,6 +61,13 @@ export const runsRouter = router({
backlogItemId: agentRuns.backlogItemId, backlogItemId: agentRuns.backlogItemId,
taskTitle: markdownBacklogItems.title, taskTitle: markdownBacklogItems.title,
taskKind: markdownBacklogItems.kind, taskKind: markdownBacklogItems.kind,
// Path components for the task detail deep-link in /settings/runs.
// Plans/epics don't have a detail page yet, so the client renders
// them unlinked; we still expose the slugs so a future plans-tree
// browser can use them without another roundtrip.
taskSlug: markdownBacklogItems.slug,
taskPlanSlug: markdownBacklogItems.planSlug,
taskEpicSlug: markdownBacklogItems.epicSlug,
actorUserId: agentRuns.actorUserId, actorUserId: agentRuns.actorUserId,
actorName: users.name, actorName: users.name,
actorEmail: users.email, actorEmail: users.email,
@ -135,6 +142,9 @@ export const runsRouter = router({
backlogItemId: agentRuns.backlogItemId, backlogItemId: agentRuns.backlogItemId,
taskTitle: markdownBacklogItems.title, taskTitle: markdownBacklogItems.title,
taskKind: markdownBacklogItems.kind, taskKind: markdownBacklogItems.kind,
taskSlug: markdownBacklogItems.slug,
taskPlanSlug: markdownBacklogItems.planSlug,
taskEpicSlug: markdownBacklogItems.epicSlug,
actorUserId: agentRuns.actorUserId, actorUserId: agentRuns.actorUserId,
actorName: users.name, actorName: users.name,
actorEmail: users.email, actorEmail: users.email,

View file

@ -54,6 +54,33 @@ Set `MARKDOWN_BACKLOG_REPO_ROOT=off` (or `0`, or empty string) in the environmen
When unset, the exporter defaults to `process.cwd()` — which works in dev because Cursor spawns the MCP from the repo root. When unset, the exporter defaults to `process.cwd()` — which works in dev because Cursor spawns the MCP from the repo root.
## MCP server lifecycle (dev gotcha)
The MCP server is spawned **once per Cursor chat session** via [.cursor/mcp.json](../.cursor/mcp.json) as `pnpm -s --filter @tasks/mcp-server mcp`, which runs `tsx --env-file=../../.env src/index.ts`. It is a long-lived stdio process for the duration of the chat. **It does not auto-reload on source changes.**
Consequences when iterating on agent-side code:
- Editing any file under `apps/mcp-server/**` or any imported library used by a tool (e.g. `packages/database/src/markdown-backlog/export.ts`, `packages/database/src/schema/**`, `packages/database/src/client.ts`) has **no effect on tool calls** until the server is restarted and Cursor reconnects.
- A new Cursor chat does not always spawn a new server. Cursor may bind to an existing stale process, which means you can end up talking to code committed before that process started. Multiple stale servers from prior sessions can accumulate. Symptom: a tool that should return a new field returns the old shape, or status flips happen in the DB but the markdown file doesn't get re-exported.
### How to recover
```bash
# 1. Find stale MCP servers (each pair is pnpm wrapper + tsx child)
pgrep -fa "apps/mcp-server.*src/index.ts"
# 2. Kill them
pkill -f "apps/mcp-server.*src/index.ts"
# 3. Open a new Cursor chat. Cursor will spawn a fresh server against current source.
```
When debugging "did the tool definitely run against the new code?", a useful check is whether the tool's response shape includes a field that only exists in the new version (e.g. the `export` field on `claim_task` / `complete_task` was added in commit `56b697b`). Missing field = stale server.
### Why we don't run `tsx watch`
A watch-mode restart would drop the stdio JSON-RPC connection mid-call, which the MCP client (Cursor) wouldn't gracefully recover from. A real fix would need a graceful-reload protocol or out-of-process tool execution. Documented gotcha is the cheap, correct first step.
## API surface (target) ## API surface (target)
Lightweight endpoints or jobs (names indicative): Lightweight endpoints or jobs (names indicative):

View file

@ -0,0 +1,308 @@
-- ============================================================================
-- One-shot drift reconciliation #2 against CT 102's `tasks` database.
--
-- Background (read 2026-06-02-db-drift-reconcile.sql first for full history):
-- The CT 102 DB was bootstrapped from `drizzle-kit push`, which writes
-- schema directly and never records ledger rows. The 2026-06-02 reconcile
-- stamped migrations 0000-0007 as applied AND created the two tables
-- that script could see were missing (markdown_backlog_items,
-- cursor_sync_mappings). It did NOT verify every other table from
-- every other migration was present — it trusted the historical
-- `drizzle-kit push` had landed them.
--
-- That assumption broke today (2026-06-05) when SSO via Authentik failed
-- on the OAuth callback with:
-- [auth][cause]: relation "accounts" does not exist
-- `accounts` is defined in migration 0000 (lines 67-80 of
-- 0000_nervous_ogun.sql) but never made it onto CT 102. Credentials
-- login worked all along because it only reads `users`; OAuth providers
-- funnel through `resolveOAuthUser` in apps/web/lib/auth.ts, which
-- writes to `accounts` and `user_email_identities`.
--
-- Because the 2026-06-02 script stamped 0000 as applied, drizzle's
-- migrator will not replay it. The only safe fix is another idempotent
-- one-shot.
--
-- This script (idempotent, safe to run more than once):
-- 1. Creates every auth-adjacent table that *might* be missing from
-- migrations 0000, 0005, 0006, 0007, 0009, at their final post-0009
-- shape. CREATE TABLE IF NOT EXISTS makes already-present tables a
-- no-op.
-- 2. Adds the matching FKs and indexes, each guarded with a DO block so
-- duplicate_object / duplicate_table errors are swallowed.
-- 3. Backfills user_email_identities from existing users (matches the
-- seed step in migration 0005).
-- 4. Does NOT touch drizzle.__drizzle_migrations. The ledger is already
-- consistent — see step 3 of the operator notes at the bottom for
-- the verification query.
--
-- Run as:
-- psql "$DATABASE_URL" -f 2026-06-05-db-drift-reconcile-oauth-tables.sql
--
-- After running, `pnpm db:migrate` stays a clean no-op and Authentik /
-- GitHub / Google sign-in will work.
-- ============================================================================
BEGIN;
-- ----------------------------------------------------------------------------
-- 1. accounts (migration 0000) — the table whose absence is breaking SSO.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"provider_account_id" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255)
);
DO $$ BEGIN
ALTER TABLE "accounts"
ADD CONSTRAINT "accounts_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "accounts_provider_provider_account_id_unique"
ON "accounts" USING btree ("provider", "provider_account_id");
CREATE INDEX IF NOT EXISTS "accounts_user_id_idx"
ON "accounts" USING btree ("user_id");
-- ----------------------------------------------------------------------------
-- 2. sessions + verification_tokens (migration 0000).
-- Not strictly required at runtime since session.strategy = "jwt", but
-- keeping the schema honest avoids the next "wait, that's missing too?"
-- moment. Skipped harmlessly if already present.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_token" varchar(255) NOT NULL,
"user_id" uuid NOT NULL,
"expires" timestamp with time zone NOT NULL,
CONSTRAINT "sessions_session_token_unique" UNIQUE ("session_token")
);
DO $$ BEGIN
ALTER TABLE "sessions"
ADD CONSTRAINT "sessions_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "sessions_user_id_idx"
ON "sessions" USING btree ("user_id");
CREATE TABLE IF NOT EXISTS "verification_tokens" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp with time zone NOT NULL,
CONSTRAINT "verification_tokens_identifier_token_pk"
PRIMARY KEY ("identifier", "token")
);
-- ----------------------------------------------------------------------------
-- 3. user_email_identities (migration 0005).
-- The OAuth path also writes here via ensureUserIdByVerifiedEmail, so
-- its absence would re-break SSO right after we fixed `accounts`.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "user_email_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"email" varchar(255) NOT NULL,
"verified_at" timestamp with time zone,
"source" varchar(30) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone
);
DO $$ BEGIN
ALTER TABLE "user_email_identities"
ADD CONSTRAINT "user_email_identities_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "user_email_identities_user_id_idx"
ON "user_email_identities" USING btree ("user_id");
CREATE INDEX IF NOT EXISTS "user_email_identities_email_idx"
ON "user_email_identities" USING btree ("email");
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_identities_user_id_email_unique"
ON "user_email_identities" USING btree ("user_id", "email");
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_identities_verified_email_unique"
ON "user_email_identities" USING btree ("email")
WHERE "user_email_identities"."verified_at" IS NOT NULL;
-- Backfill (matches the seed in migration 0005). ON CONFLICT makes this
-- safe to re-run.
INSERT INTO "user_email_identities" ("user_id", "email", "verified_at", "source", "created_at")
SELECT "id", lower("email"), "created_at", 'primary', "created_at"
FROM "users"
ON CONFLICT ("user_id", "email") DO NOTHING;
-- ----------------------------------------------------------------------------
-- 4. workspace_invites (migration 0006).
-- Not on the SSO critical path, but the invite-acceptance flow lands in
-- the same auth.ts module and will hit a missing-table error the first
-- time a workspace admin clicks "invite".
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "workspace_invites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"email" varchar(255) NOT NULL,
"role" varchar(20) NOT NULL,
"invited_by_user_id" uuid NOT NULL,
"token" varchar(128) NOT NULL,
"expires_at" timestamp with time zone DEFAULT now() + interval '14 days' NOT NULL,
"accepted_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
DO $$ BEGIN
ALTER TABLE "workspace_invites"
ADD CONSTRAINT "workspace_invites_workspace_id_workspaces_id_fk"
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "workspace_invites"
ADD CONSTRAINT "workspace_invites_invited_by_user_id_users_id_fk"
FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."users"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "workspace_invites_workspace_id_idx"
ON "workspace_invites" USING btree ("workspace_id");
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_invites_token_unique"
ON "workspace_invites" USING btree ("token");
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_invites_open_email_unique"
ON "workspace_invites" USING btree ("workspace_id", "email")
WHERE "workspace_invites"."accepted_at" IS NULL
AND "workspace_invites"."revoked_at" IS NULL;
-- ----------------------------------------------------------------------------
-- 5. audit_log (migration 0007).
-- The 2026-06-02 notes claimed audit_log was added manually, but if a
-- fresh DB ever skipped that step this guards it.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"actor_user_id" uuid,
"action" varchar(100) NOT NULL,
"target_type" varchar(50) NOT NULL,
"target_id" uuid,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
DO $$ BEGIN
ALTER TABLE "audit_log"
ADD CONSTRAINT "audit_log_workspace_id_workspaces_id_fk"
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "audit_log"
ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk"
FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id")
ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "audit_log_workspace_id_created_at_idx"
ON "audit_log" USING btree ("workspace_id", "created_at");
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx"
ON "audit_log" USING btree ("actor_user_id");
CREATE INDEX IF NOT EXISTS "audit_log_action_idx"
ON "audit_log" USING btree ("action");
-- ----------------------------------------------------------------------------
-- 6. agent_runs (migration 0009).
-- Added after the 2026-06-02 reconcile, so it *should* be present from
-- a normal `pnpm db:migrate` run. Guard it anyway since this script is
-- meant to leave the schema in a known state.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "agent_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"backlog_item_id" uuid NOT NULL,
"actor_user_id" uuid,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"outcome" varchar(20),
"error" text,
"tokens_input" integer,
"tokens_output" integer,
"tokens_total" integer,
"notes" text,
"metadata" jsonb
);
DO $$ BEGIN
ALTER TABLE "agent_runs"
ADD CONSTRAINT "agent_runs_workspace_id_workspaces_id_fk"
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "agent_runs"
ADD CONSTRAINT "agent_runs_backlog_item_id_markdown_backlog_items_id_fk"
FOREIGN KEY ("backlog_item_id") REFERENCES "public"."markdown_backlog_items"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "agent_runs"
ADD CONSTRAINT "agent_runs_actor_user_id_users_id_fk"
FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id")
ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "agent_runs_workspace_id_started_at_idx"
ON "agent_runs" USING btree ("workspace_id", "started_at" DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS "agent_runs_backlog_item_id_started_at_idx"
ON "agent_runs" USING btree ("backlog_item_id", "started_at" DESC NULLS LAST);
COMMIT;
-- ============================================================================
-- Operator post-checks (run by hand, not part of the transaction above):
--
-- 1. Confirm every expected table is present:
-- SELECT tablename FROM pg_tables
-- WHERE schemaname = 'public'
-- AND tablename IN ('accounts','sessions','verification_tokens',
-- 'user_email_identities','workspace_invites',
-- 'audit_log','agent_runs')
-- ORDER BY tablename;
-- Expect all 7.
--
-- 2. Sanity-check the OAuth path by signing in via Authentik. The
-- callback at /api/auth/callback/authentik should now write a row
-- to `accounts` and `user_email_identities` instead of 500'ing.
--
-- 3. Confirm the drizzle ledger is still consistent:
-- SELECT hash, created_at
-- FROM drizzle.__drizzle_migrations
-- ORDER BY created_at;
-- Expect 10 rows ending at 0009_loving_rogue's hash
-- (cf. packages/database/migrations/meta/_journal.json). If 0008 or
-- 0009 are missing, run `pnpm db:migrate` from the repo root and
-- they will be applied + stamped normally.
-- ============================================================================

View file

@ -12,3 +12,4 @@ will fail or corrupt a healthy one.
| Date | File | What it fixed | | Date | File | What it fixed |
|------|------|---------------| |------|------|---------------|
| 2026-06-02 | [`2026-06-02-db-drift-reconcile.sql`](./2026-06-02-db-drift-reconcile.sql) | Reconciled CT 102 `tasks` DB after years of `drizzle-kit push` left the migration ledger empty. Created the two tables that had never made it in (`markdown_backlog_items`, `cursor_sync_mappings`) and stamped all 8 migrations as applied. | | 2026-06-02 | [`2026-06-02-db-drift-reconcile.sql`](./2026-06-02-db-drift-reconcile.sql) | Reconciled CT 102 `tasks` DB after years of `drizzle-kit push` left the migration ledger empty. Created the two tables that had never made it in (`markdown_backlog_items`, `cursor_sync_mappings`) and stamped all 8 migrations as applied. |
| 2026-06-05 | [`2026-06-05-db-drift-reconcile-oauth-tables.sql`](./2026-06-05-db-drift-reconcile-oauth-tables.sql) | Authentik / GitHub / Google SSO was 500'ing on the OAuth callback with `relation "accounts" does not exist`. The 2026-06-02 reconcile had stamped 0000 as applied without verifying every 0000 table existed. Idempotently created the auth-adjacent tables that the OAuth path touches (`accounts`, `sessions`, `verification_tokens`, `user_email_identities`, `workspace_invites`, `audit_log`, `agent_runs`) and backfilled `user_email_identities` from `users`. Did not touch the ledger. |

View file

@ -0,0 +1,79 @@
---
kind: task
slug: deep-link-runs-to-task-detail
title: Deep-link agent runs to task detail + document MCP stdio staleness
plan_slug: agent-coordination
epic_slug: task-as-runnable-unit
status: done
priority: P2
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-03"
---
# Task summary
Two small follow-ups from `Task-workflow-prompt-task-detail-ui` so the editor surface is actually reachable, and from `Task-export-db-to-markdown-frontmatter` so the next operator doesn't spend an hour debugging a stale MCP process.
## Description
### (1) Linkify `/settings/runs` rows → task detail page
Today the task title cell in `/settings/runs` is plain text. The route I just shipped lives at `/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]` but you can only reach it by typing the URL. Closing this gap unlocks the dogfood loop: claim a task via MCP → run appears in `/settings/runs` → click through to inspect / edit the workflow prompt.
Concretely:
- `runs.listRecent` and `runs.listForTask` already join `markdown_backlog_items`; add `planSlug`, `epicSlug`, and `slug` to the selects.
- In `app/(app)/[workspaceSlug]/settings/runs/page.tsx`, wrap the task title cell in a `next/link` `<Link>` when the joined row has all three slugs and `taskKind === "task"`. Plans/epics don't have a detail page yet, so render those titles unlinked.
- Stop the link click from also toggling the row's expand/collapse handler (e.g. `e.stopPropagation()`).
### (2) Document MCP stdio process staleness
Symptom: `complete_task` returned without an `export` field and the on-disk `.md` file stayed `status: draft` even though the DB row flipped to `done`. Direct calls to `exportBacklogItemToMarkdown` work fine.
Root cause: the MCP server is launched by Cursor as `tsx --env-file=../../.env src/index.ts` (NOT `tsx watch`) and lives for the duration of the chat session. `tsx` imports the module graph once at startup; subsequent edits to `apps/mcp-server/**/*.ts` or any imported library (`packages/database/src/markdown-backlog/**`) don't take effect until the process is killed and Cursor reconnects. Worse, multiple stale MCP processes can accumulate from prior sessions; if Cursor binds the new session to one of those, you'll be talking to even older code.
This is a process-lifecycle gotcha, not a code bug — but it bit me right after we shipped the export feature, which means the next operator will hit it too.
Document in `config/CursorSync.md` under a new "MCP server lifecycle" section:
- The server is spawned once per Cursor chat session via `.cursor/mcp.json`.
- Changes to `apps/mcp-server/**` or any imported `packages/database/**` library require restarting the Cursor session (or killing the stale `tsx ... src/index.ts` process and starting a new chat).
- How to find and kill stale processes: `pgrep -fa "apps/mcp-server.*src/index.ts"`.
- Why we don't run `tsx watch`: a process restart would drop the stdio JSON-RPC connection mid-call.
Also add a one-liner pointer from `AGENTS.md` (the dev-loop section) so it's discoverable without grepping.
Out of scope for this task: actually fixing the staleness (would need a graceful-reload protocol or a switch to `tsx watch` with reconnection logic in the client). Documenting is the cheap, correct first step.
## Subtasks
- [x] Add `planSlug`/`epicSlug`/`slug` to `runs.listRecent` and `runs.listForTask` selects.
- [x] Wrap the task title cell in `/settings/runs` in a `Link` when all three slugs are present and `taskKind === "task"`.
- [x] Update `config/CursorSync.md` with an "MCP server lifecycle" section.
- [x] Add a one-line pointer in `AGENTS.md`.
## Acceptance criteria
- [x] Clicking the task title in `/settings/runs` navigates to the task detail page (and does NOT also toggle the row's expand/collapse).
- [x] `pnpm lint && pnpm type-check && pnpm test` clean.
- [x] `config/CursorSync.md` explains MCP staleness and how to recover, and `AGENTS.md` points at it.
## Implementation notes
- Selects: added `taskSlug` / `taskPlanSlug` / `taskEpicSlug` to both `runs.listRecent` and `runs.listForTask` (same shape on both for client-side reuse).
- Link wiring: new `buildTaskHref()` helper in the runs page returns `null` for plans/epics, for rows missing any path segment, and during the initial render before `workspaceSlug` is known. The cell renders `row.taskTitle` unlinked in those cases so the table doesn't flicker between linked/unlinked.
- Click stop: `onClick={(e) => e.stopPropagation()}` on the `<Link>` so the surrounding row's expand/collapse handler doesn't fire on link clicks. Verified via the AC.
- One real surprise during verification: `pnpm type-check` failed against a stale `.next/types/...` generated file (Next.js's per-page type validator was tripping on an unrelated pre-existing `export function formatDuration` in the runs page). Removing `apps/web/.next/types` and re-running cleared it. Logged because the next operator may hit it after the dev server has been running across a refactor.
## Follow-ups
- The exported helper `formatDuration` in the runs page should either be moved to a shared util (it's now informally part of the runs-row contract) or have its `export` keyword dropped. Out of scope here.
- Linking plan/epic titles from the runs page once a plans-tree browser exists.
## Links
- Parent surface: `./Task-workflow-prompt-task-detail-ui.md`
- Sync contract: `../../config/CursorSync.md`
- Epic: `./Epic-task-as-runnable-unit.md`

View file

@ -4,12 +4,12 @@ slug: workflow-prompt-task-detail-ui
title: Backlog-item detail panel — render and edit workflow_prompt title: Backlog-item detail panel — render and edit workflow_prompt
plan_slug: agent-coordination plan_slug: agent-coordination
epic_slug: task-as-runnable-unit epic_slug: task-as-runnable-unit
status: draft status: done
priority: P2 priority: P2
tenant_id: global tenant_id: global
owner: unassigned owner: unassigned
cursor_todo_id: null cursor_todo_id: null
updated_at: "2026-06-02" updated_at: "2026-06-03"
--- ---
# Task summary # Task summary
@ -33,16 +33,28 @@ For v1 of THIS task, pick the smallest surface that lets an operator actually us
## Subtasks ## Subtasks
- [ ] Decide on the rendering surface (see options above). - [x] Decide on the rendering surface (see options above).
- [ ] Build the section component with effective-prompt preview + override textarea. - [x] Build the section component with effective-prompt preview + override textarea.
- [ ] Owner/admin gate matches the procedure (the procedure refuses non-managers, but the UI should hide the save button rather than let the click fail). - [x] Owner/admin gate matches the procedure (the procedure refuses non-managers, but the UI should hide the save button rather than let the click fail).
- [ ] Show "clear override" affordance when an override is set. - [x] Show "clear override" affordance when an override is set.
## Acceptance criteria ## Acceptance criteria
- [ ] Saving an override flips the source badge to "From this task." - [x] Saving an override flips the source badge to "From this task."
- [ ] Clearing an override re-shows the inherited source. - [x] Clearing an override re-shows the inherited source.
- [ ] Non-managers see the prompt but cannot edit it. - [x] Non-managers see the prompt but cannot edit it.
## Implementation notes
- Surface picked: dedicated route at `/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]`. There's no plans-tree browser yet — this lets an operator land on a task by typing the URL (or future deep-link from `/settings/runs`) without first shipping a tree view.
- New tRPC procedure: `backlog.getTaskByPath({ planSlug, epicSlug, taskSlug })` — returns task row, parent epic + plan titles (for inheritance badge), and `callerRole` so the UI can pre-hide edit affordances in a single roundtrip. Slugs are re-validated against the importer's `[a-z0-9-]` shape at the procedure boundary.
- New component: `components/backlog/workflow-prompt-section.tsx`. Source badge derives its label from `(source, sourceContext)`; preview is read-only `<pre>`; textarea is the editable knob bound to `ownOverride`. Save calls `backlog.updateWorkflowPrompt`; Clear sends `workflowPrompt: null`. Both invalidate `getWorkflowPrompt` rather than optimistic-mutate, because clearing a task override can re-expose an epic/plan inheritance the client doesn't know about ahead of time.
- Server still gates writes via `ctx.workspace.role`; the UI hide is convenience, not security.
## Follow-ups
- Linking from `/settings/runs` rows to the task detail page (needs `planSlug`/`epicSlug`/`slug` on `runs.listRecent`).
- Plans tree browser at `/[workspaceSlug]/plans` (currently only the leaf route exists).
## Links ## Links