diff --git a/app/api/task/[taskId]/context/route.ts b/app/api/task/[taskId]/context/route.ts index 8c589a3a..a54c6382 100644 --- a/app/api/task/[taskId]/context/route.ts +++ b/app/api/task/[taskId]/context/route.ts @@ -1,13 +1,15 @@ import { getAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; -import { getTaskFull } from "@/lib/data/task"; +import { getTaskProjectId } from "@/lib/data/task"; import { getProjectMaxUpdatedAt } from "@/lib/data/project"; -import { buildAgentContext } from "@/lib/context/_core/agent"; -import { buildPlanningContext } from "@/lib/context/_core/planning"; +import { resolveContextBundle } from "@/lib/context/_core/bundle"; +import { buildAgentContextFrom } from "@/lib/context/_core/agent"; +import { buildPlanningContextFrom } from "@/lib/context/_core/planning"; import { - buildWorkingContext, + buildWorkingContextFrom, formatWorkingContext, } from "@/lib/context/_core/working"; +import { withUserContext } from "@/lib/db/rls"; import { conditionalRespond, etagMatches } from "@/lib/api/conditional"; import { internalError } from "@/lib/api/error"; import { error } from "@/lib/api/response"; @@ -19,6 +21,11 @@ import { error } from "@/lib/api/response"; * access first (a missing or cross-team task surfaces as 404) and the URL * task id is the authoritative scope. * + * The validator path reads only the slim `projectId` gate, so HEAD/304 + * never pay for a full task. On a cache miss the task row and dependency + * traversal are resolved once via {@link resolveContextBundle} and fed to + * the three pure context cores. + * * `Last-Modified` is the project-max validator (see * {@link getProjectMaxUpdatedAt}) — over-conservative but trivial to * compute, and bundle bytes are small enough that occasional false-positive @@ -37,18 +44,24 @@ async function handle(req: Request, taskId: string): Promise { } try { - const task = await getTaskFull(ctx, taskId); - const max = await getProjectMaxUpdatedAt(ctx, task.projectId); + const projectId = await getTaskProjectId(ctx, taskId); + const max = await getProjectMaxUpdatedAt(ctx, projectId); if (req.method === "HEAD" || etagMatches(req, max)) { return conditionalRespond(req, null, max); } - const [agent, planning, workingRaw] = await Promise.all([ - buildAgentContext(ctx, taskId), - buildPlanningContext(ctx, taskId), - buildWorkingContext(ctx, taskId), - ]); + const { agent, planning, workingRaw } = await withUserContext( + ctx.userId, + async (tx) => { + const bundle = await resolveContextBundle(tx, taskId); + return { + agent: buildAgentContextFrom(bundle), + planning: buildPlanningContextFrom(bundle), + workingRaw: buildWorkingContextFrom(bundle), + }; + }, + ); const working = await formatWorkingContext(workingRaw); return conditionalRespond(req, { agent, planning, working }, max); } catch (err) { diff --git a/app/globals.css b/app/globals.css index f2502a61..7210c8e1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -713,6 +713,64 @@ html.light .progress-shimmer::after { animation: loading-fade-in 220ms ease-out both; } +/* =========================================== + SKELETON LOADING + =========================================== + Placeholder bars for body content while a detail fetch resolves. + A single accent-tinted sheen sweeps each bar; `--skeleton-delay` + staggers both the entrance and the sweep so the wave travels down + the panel instead of twinkling at random. The sheen rests off-canvas + between passes, so the reduced-motion clamp freezes bars flat. */ +:root { + --skeleton-base: rgba(255, 255, 255, 0.05); + --skeleton-sheen: rgba(165, 180, 252, 0.09); +} +html.light { + --skeleton-base: rgba(15, 17, 26, 0.06); + --skeleton-sheen: rgba(67, 56, 202, 0.08); +} + +@keyframes skeleton-wave { + 0% { + background-position: 135% 0; + } + 55%, + 100% { + background-position: -65% 0; + } +} + +.skeleton-bar { + border-radius: var(--skeleton-radius, 4px); + background-color: var(--skeleton-base); + background-image: linear-gradient( + 100deg, + transparent 36%, + var(--skeleton-sheen) 50%, + transparent 64% + ); + background-repeat: no-repeat; + background-size: 220% 100%; + background-position: 135% 0; + animation: skeleton-wave 2.2s ease-in-out infinite; + animation-delay: var(--skeleton-delay, 0ms); +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.rise-in { + animation: rise-in 280ms ease-out both; + animation-delay: var(--skeleton-delay, 0ms); +} + /* =========================================== REDUCED MOTION =========================================== */ diff --git a/app/page.tsx b/app/page.tsx index 18e51e88..dae0becb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,8 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { listProjectsSlim } from "@/lib/graph/queries"; -import { listUserTeamsAction } from "@/lib/actions/team-list"; +import { + loadSidebarProjects, + loadUserTeams, +} from "@/lib/server/request-loaders"; import { requireMembership } from "@/lib/auth/membership"; import { TopBar } from "@/components/layout/TopBar"; import { AppShell } from "@/components/layout/AppShell"; @@ -24,8 +26,8 @@ export default async function HomePage() { await requireMembership(); const [projects, teamsResult] = await Promise.all([ - listProjectsSlim(), - listUserTeamsAction(), + loadSidebarProjects(), + loadUserTeams(), ]); const teams = teamsResult.ok ? teamsResult.data : []; diff --git a/app/project/[projectId]/_components/WorkspaceClient.tsx b/app/project/[projectId]/_components/WorkspaceClient.tsx index 6ffe13ff..3e56bda3 100644 --- a/app/project/[projectId]/_components/WorkspaceClient.tsx +++ b/app/project/[projectId]/_components/WorkspaceClient.tsx @@ -17,12 +17,14 @@ import { PropRail } from "@/components/workspace/detail/PropRail"; import { PropRailDrawer } from "@/components/workspace/detail/PropRailDrawer"; import { WorkspaceGraphView } from "@/components/workspace/graph/WorkspaceGraphView"; import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { useSkeletonVisibility } from "@/hooks/useSkeletonVisibility"; import { DeferredLoadingSpinner } from "@/components/shared/DeferredLoadingSpinner"; import { projectKeys, taskKeys } from "@/lib/query/keys"; import { fetchProjectGraph, fetchTaskBody } from "@/lib/query/queries"; import type { ProjectGraphSlim, TaskEdgeRef, + TaskFullWithEdges, TaskGraphSlim, } from "@/lib/data/views"; @@ -345,14 +347,52 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) { const qc = useQueryClient(); const taskId = taskSlim.id; - const { data: selectedTaskFull } = useQuery({ + const { data: selectedTaskFull, isPlaceholderData } = useQuery({ queryKey: taskKeys.detail(projectId, taskId), queryFn: fetchTaskBody(qc, projectId, taskId), + placeholderData: (): TaskFullWithEdges | undefined => { + const cached = qc.getQueryData( + projectKeys.graph(projectId), + ); + const slim = cached?.tasks.find((t) => t.id === taskId); + if (!slim) return undefined; + // Fields the slim projection lacks (description, sequenceNumber, + // createdAt, files, assignees, ...) are fabricated empties below. + // Anything that renders or mutates them must stay gated on + // `isPlaceholderData` until the real detail fetch resolves. + return { + id: slim.id, + projectId, + title: slim.title, + sequenceNumber: 0, + description: "", + status: slim.status, + order: slim.order, + category: slim.category ?? null, + implementationPlan: null, + executionRecord: null, + tags: slim.tags ?? [], + priority: slim.priority ?? null, + estimate: slim.estimate ?? null, + files: [], + history: [], + createdAt: new Date(), + updatedAt: slim.updatedAt, + taskRef: slim.taskRef, + assignees: [], + acceptanceCriteria: [], + decisions: [], + links: [], + edges: [], + }; + }, }); + const showBodySkeleton = useSkeletonVisibility(isPlaceholderData, taskId); + const taskFullMatches = selectedTaskFull && selectedTaskFull.id === taskId; const taskEdges: TaskEdgeRef[] = - taskFullMatches && selectedTaskFull + taskFullMatches && selectedTaskFull && !isPlaceholderData ? selectedTaskFull.edges : graph.edges .filter((e) => e.sourceTaskId === taskId || e.targetTaskId === taskId) @@ -382,6 +422,8 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) { onTogglePropRail={ showPropRailToggle ? () => setPropRailOpen((v) => !v) : undefined } + isBodyLoading={isPlaceholderData} + showBodySkeleton={showBodySkeleton} /> ) : ( @@ -408,6 +450,7 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) { projectName={graph.project.title} onSelectNode={handleSelectNode} onGraphChange={refreshAll} + isBodyLoading={isPlaceholderData} /> ) : null; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a274660b..ed2b78e6 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,7 +3,7 @@ import { TopBar } from "@/components/layout/TopBar"; import { AppShell } from "@/components/layout/AppShell"; import { getSession } from "@/lib/auth/session"; import { listOAuthSessionsAction } from "@/lib/actions/oauth-session"; -import { listUserTeamsAction } from "@/lib/actions/team-list"; +import { loadUserTeams } from "@/lib/server/request-loaders"; import { SettingsView } from "./_components/SettingsView"; /** Force dynamic rendering — this page reads the session and DB. */ @@ -23,7 +23,7 @@ export default async function SettingsPage() { const [sessionsResult, teamsResult] = await Promise.all([ listOAuthSessionsAction(), - listUserTeamsAction(), + loadUserTeams(), ]); const initialSessions = sessionsResult.ok ? sessionsResult.data : []; diff --git a/components/layout/AppShell.tsx b/components/layout/AppShell.tsx index e47ca0a2..3ec8d2cc 100644 --- a/components/layout/AppShell.tsx +++ b/components/layout/AppShell.tsx @@ -2,8 +2,10 @@ import { type ReactNode } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth/session"; -import { listProjectsSlim } from "@/lib/graph/queries"; -import { listUserTeamsAction } from "@/lib/actions/team-list"; +import { + loadSidebarProjects, + loadUserTeams, +} from "@/lib/server/request-loaders"; import { Sidebar, type SidebarProject, @@ -40,8 +42,8 @@ export async function AppShell({ children }: AppShellProps) { if (!session) redirect("/sign-in"); const [projects, teamsResult, cookieStore] = await Promise.all([ - listProjectsSlim(), - listUserTeamsAction(), + loadSidebarProjects(), + loadUserTeams(), cookies(), ]); const initialSidebarCollapsed = diff --git a/components/workspace/DetailPanel.tsx b/components/workspace/DetailPanel.tsx index ef6bffe3..bf607a5f 100644 --- a/components/workspace/DetailPanel.tsx +++ b/components/workspace/DetailPanel.tsx @@ -43,6 +43,17 @@ interface DetailPanelProps { propRailOpen?: boolean; /** Toggle the properties rail open/closed; when omitted the toggle is hidden. */ onTogglePropRail?: () => void; + /** + * When true the header is rendered from placeholder data and the body + * withholds its content until the full detail fetch resolves. + */ + isBodyLoading?: boolean; + /** + * When true the body renders skeleton blocks. Derived from + * `isBodyLoading` via `useSkeletonVisibility` (show delay + minimum + * visible hold) so fast fetches never flash a skeleton. + */ + showBodySkeleton?: boolean; /** Additional CSS classes. */ className?: string; } @@ -73,6 +84,8 @@ export function DetailPanel({ onToggleNavigator, propRailOpen, onTogglePropRail, + isBodyLoading = false, + showBodySkeleton = false, className = "", }: DetailPanelProps) { return ( @@ -95,6 +108,8 @@ export function DetailPanel({ onToggleNavigator={onToggleNavigator} propRailOpen={propRailOpen} onTogglePropRail={onTogglePropRail} + isBodyLoading={isBodyLoading} + showBodySkeleton={showBodySkeleton} /> ); diff --git a/components/workspace/detail/DetailView.tsx b/components/workspace/detail/DetailView.tsx index 35bc2dd9..ef6a9b48 100644 --- a/components/workspace/detail/DetailView.tsx +++ b/components/workspace/detail/DetailView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, type CSSProperties } from "react"; import type { TaskEdgeRef, TaskFull, @@ -61,6 +61,19 @@ interface DetailViewProps { propRailOpen?: boolean; /** Toggle the properties rail open/closed; when omitted the toggle is hidden. */ onTogglePropRail?: () => void; + /** + * When true the header is rendered from seeded placeholder data and the + * body withholds its content while the full task fetch resolves. Set + * from `isPlaceholderData` on the detail `useQuery`. + */ + isBodyLoading?: boolean; + /** + * When true the body renders skeleton blocks. Lags `isBodyLoading` via + * `useSkeletonVisibility`: fast fetches resolve before the show delay + * and swap straight to content; slow fetches hold the skeleton for a + * minimum beat so it never flash-swaps mid-entrance. + */ + showBodySkeleton?: boolean; } /** @@ -89,6 +102,8 @@ export function DetailView({ onToggleNavigator, propRailOpen, onTogglePropRail, + isBodyLoading = false, + showBodySkeleton = false, }: DetailViewProps) { // Read the server-derived `state` for this task off the slim payload — // the same projection the canvas, rail, and structure list see. Falls @@ -133,73 +148,79 @@ export function DetailView({ />
-
- - - - -
- - } + {showBodySkeleton ? ( + + ) : isBodyLoading ? null : ( +
+ - -
- +
+ + } + /> + +
- + - + - + - -
+ + + +
+ )} ); @@ -358,4 +379,139 @@ function buildDownstream( return out; } +/** + * Build an inline style from skeleton CSS custom properties + * (`--skeleton-delay`, `--skeleton-radius`, `--skeleton-base`). + * + * @param vars - Custom-property map applied to a skeleton element. + * @returns The map typed as a React inline style. + */ +function skeletonVars( + vars: Record<`--skeleton-${string}`, string>, +): CSSProperties { + return vars as CSSProperties; +} + +/** + * The five bundle-preview section tints (spec / prerequisites / neighbors / + * decisions / files), previewed by the bundle skeleton bars. + */ +const BUNDLE_SKELETON_BARS: { tint: string; width: string }[] = [ + { tint: "var(--color-accent)", width: "w-full" }, + { tint: "var(--color-planned)", width: "w-5/6" }, + { tint: "var(--color-relates)", width: "w-2/3" }, + { tint: "var(--color-progress)", width: "w-1/2" }, + { tint: "var(--color-accent-2)", width: "w-2/5" }, +]; + +/** + * Skeleton placeholder for the detail body while `isPlaceholderData` is + * true. Mirrors the anatomy of the real body — description lines, criteria + * checklist rows, the color-coded bundle-preview card, decisions, and + * relationship chips — so the layout stays stable when the full fetch + * resolves. Sections rise in staggered (fade + 4px y-slide) and a + * shared sheen wave travels down the bars via `--skeleton-delay`. + * + * @returns Skeleton element rendered inside the scrollable body area. + */ +function DetailBodySkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ {BUNDLE_SKELETON_BARS.map((bar, i) => ( +
+ ))} +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ ); +} + export default DetailView; diff --git a/components/workspace/detail/PropRail.tsx b/components/workspace/detail/PropRail.tsx index 8176f39b..c97edd7a 100644 --- a/components/workspace/detail/PropRail.tsx +++ b/components/workspace/detail/PropRail.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -104,6 +111,14 @@ interface PropRailProps { onSelectNode: (taskId: string) => void; /** Refresh the graph after a mutation. */ onGraphChange?: () => void; + /** + * True while the detail query is serving seeded placeholder data. The + * `assignees` and `files` props are empty placeholders in this window, so + * their rows render as skeletons and the assignee picker is not mounted — + * preventing a mutation that would read the empty placeholder and drop the + * task's real assignees. + */ + isBodyLoading?: boolean; } /** @@ -133,6 +148,7 @@ export function PropRail({ projectName, onSelectNode, onGraphChange, + isBodyLoading = false, }: PropRailProps) { // Walk `edges` once and produce both directions plus the pre-mapped // DepGroup items. Filtering twice per render — once per direction — was @@ -456,11 +472,15 @@ export function PropRail({ } label="Assignees"> - + {isBodyLoading ? ( + + ) : ( + + )} } label="Category"> @@ -528,9 +548,17 @@ export function PropRail({ 0 ? files.length : undefined} + count={!isBodyLoading && files.length > 0 ? files.length : undefined} > - {files.length > 0 ? ( + {isBodyLoading ? ( +
+
+
+
+ ) : files.length > 0 ? (
    {files.map((path) => ( diff --git a/eslint.config.mjs b/eslint.config.mjs index 843255e7..e542d48e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,8 +23,16 @@ const eslintConfig = [ { // Generated outputs: wrangler env types and OpenNext / Wrangler build // artifacts. Linting these adds no value and surfaces noise from - // generated code. - ignores: ["cloudflare-env.d.ts", ".open-next/**", ".wrangler/**"], + // generated code. `.claude/**` excludes nested git worktrees: they are + // separate checkouts that lint themselves, and the root-relative exempt + // globs below (e.g. `lib/data/account.ts`) do not match their prefixed + // paths, so scanning them re-flags already-audited RLS bypass sites. + ignores: [ + "cloudflare-env.d.ts", + ".open-next/**", + ".wrangler/**", + ".claude/**", + ], }, { files: ["**/*.{ts,tsx}"], diff --git a/hooks/useSkeletonVisibility.ts b/hooks/useSkeletonVisibility.ts new file mode 100644 index 00000000..19f2ae23 --- /dev/null +++ b/hooks/useSkeletonVisibility.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * Debounced skeleton visibility for perceived-performance alignment. + * + * Translates a raw loading flag into "should a skeleton render" using two + * thresholds: + * + * - Show delay: loading that resolves within `showDelayMs` never surfaces + * a skeleton, so fast fetches swap straight to content instead of + * flashing placeholder chrome for a few frames. + * - Minimum visible time: once shown, the skeleton stays for at least + * `minVisibleMs` so it cannot flash-swap away mid-entrance when content + * lands just after the delay threshold. + * + * A `resetKey` change (e.g. the selected task id) drops visibility in the + * same render, so a skeleton held for one subject never lingers over the + * next subject's already-available content; the show delay re-arms from + * zero for the new subject. + * + * @param loading - Raw loading flag (e.g. `isPlaceholderData`). + * @param resetKey - Identity of the loading subject; a change resets state. + * @param showDelayMs - Delay before the skeleton may appear. + * @param minVisibleMs - Minimum time the skeleton stays visible once shown. + * @returns True while the skeleton should render. + */ +export function useSkeletonVisibility( + loading: boolean, + resetKey?: unknown, + showDelayMs = 200, + minVisibleMs = 400, +): boolean { + const [visible, setVisible] = useState(false); + const [prevKey, setPrevKey] = useState(resetKey); + const shownAtRef = useRef(0); + + if (prevKey !== resetKey) { + setPrevKey(resetKey); + setVisible(false); + } + + useEffect(() => { + if (loading && !visible) { + const timer = setTimeout(() => { + shownAtRef.current = Date.now(); + setVisible(true); + }, showDelayMs); + return () => clearTimeout(timer); + } + if (!loading && visible) { + const remaining = Math.max( + 0, + minVisibleMs - (Date.now() - shownAtRef.current), + ); + if (remaining === 0) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(false), remaining); + return () => clearTimeout(timer); + } + }, [loading, visible, resetKey, showDelayMs, minVisibleMs]); + + return visible; +} diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts index abaeefe6..e9c22928 100644 --- a/lib/auth/authorization.ts +++ b/lib/auth/authorization.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { Task } from "@/lib/db/schema"; +import { cache } from "react"; import type { AuthContext } from "@/lib/auth/context"; import type { Tx } from "@/lib/db/rls"; import { @@ -9,6 +9,7 @@ import { findTaskAccess, findTaskAccessTx, type ProjectAccessRow, + type TaskAccessGate, } from "@/lib/data/access"; import { roleHasProjectPermission, @@ -132,6 +133,31 @@ export async function assertProjectAccess( return access; } +/** + * Membership-gated project access row, memoized for the lifetime of one RSC + * request. Keyed on `(userId, projectId)` strings — not the per-call + * `AuthContext` object — so the workspace layout and page share a single + * project-access read per navigation. Same access boundary as + * {@link assertProjectAccess}: RLS scopes the read and the membership + * JOIN gates it; missing or cross-team projects raise + * {@link ForbiddenError} (anti-enumeration 404). + * + * @param userId - Verified user id from the request's auth context. + * @param projectId - UUID of the project to authorize. + * @returns The access row with the project, caller role, and owning team. + * @throws ForbiddenError on malformed, missing, or cross-team project. + */ +export const loadProjectAccess = cache( + async (userId: string, projectId: string): Promise => { + if (!isUuid(projectId)) { + throw new ForbiddenError("Forbidden", "project", projectId); + } + const access = await findProjectAccess(userId, projectId); + if (!access) throw new ForbiddenError("Forbidden", "project", projectId); + return access; + }, +); + /** * {@link assertProjectAccess} on a caller-supplied tx so the access * check shares one `withUserContext` frame with the protected work. @@ -163,20 +189,20 @@ export async function assertProjectAccessTx( } /** - * Verify the caller can access the task and return its full row. Joins + * Verify the caller can access the task and return its gate row. Joins * through the parent project to confirm the caller is a member of the * project's organization. The active organization is not part of the gate * — membership in the resource's team is the boundary. * * @param taskId - UUID of the task to authorize. * @param ctx - Resolved auth context. - * @returns The full task row. + * @returns The slim gate row for the task. * @throws ForbiddenError on missing task or cross-team access. */ export async function assertTaskAccess( taskId: string, ctx: AuthContext, -): Promise { +): Promise { if (!isUuid(taskId)) { throw new ForbiddenError("Forbidden", "task", taskId); } @@ -190,13 +216,13 @@ export async function assertTaskAccess( * * @param tx - Active RLS transaction handle. * @param taskId - UUID of the task to authorize. - * @returns The full task row. + * @returns The slim gate row for the task. * @throws ForbiddenError on missing task or cross-team access. */ export async function assertTaskAccessTx( tx: Tx, taskId: string, -): Promise { +): Promise { if (!isUuid(taskId)) { throw new ForbiddenError("Forbidden", "task", taskId); } diff --git a/lib/context/_core/agent.ts b/lib/context/_core/agent.ts index aa16d66e..11f4c8bb 100644 --- a/lib/context/_core/agent.ts +++ b/lib/context/_core/agent.ts @@ -1,190 +1,174 @@ import "server-only"; -import { loadBundleDeps } from "@/lib/graph/effective-deps"; -import { - fetchDependencyTasks, - fetchEdgeNotesBySource, - fetchEdgeNotesByTarget, - fetchTaskSummaries, - getTaskFullTx, -} from "@/lib/data/task"; import { section, formatCriteria, formatDecisions } from "@/lib/context/format"; import type { AuthContext } from "@/lib/auth/context"; import { withUserContext } from "@/lib/db/rls"; +import { + resolveDependencyClosure, + type AgentContextData, +} from "@/lib/context/_core/bundle"; /** - * Build lean, position-optimized context for external coding agents. + * Assemble the lean agent context string from pre-resolved dependency data. * * Sections ordered by U-shaped attention: start/end get highest recall, middle - * lowest. No token budget — controlled content is compact, implPlan is critical - * and never truncated. + * lowest. No token budget; controlled content is compact and the implPlan is + * critical and never truncated. Pure: reads only its argument, issues no + * queries. * - * @param ctx - Resolved auth context. - * @param taskId - UUID of the task. + * @param data Resolved dependency-closure data. * @returns Formatted context string. */ -export async function buildAgentContext( - ctx: AuthContext, - taskId: string, -): Promise { - return withUserContext(ctx.userId, async (tx) => { - const task = await getTaskFullTx(tx, taskId); - - const taskRef = task.taskRef; - const tags = (task.tags as string[] | null) ?? []; - const files = (task.files as string[] | null) ?? []; - const status = task.status as string; - const priority = task.priority as string | null; - const estimate = task.estimate as number | null; - const assignees = task.assignees; - const links = task.links; - - const [{ deps, downstream }, upstreamEdgeNotes] = await Promise.all([ - loadBundleDeps(task.projectId, taskId, 2, tx), - fetchEdgeNotesBySource(task.projectId, taskId, tx), - ]); - - const prLink = links.find((l) => l.kind === "pull_request"); - - const headerLines: string[] = [ - `# ${taskRef ? `\`${taskRef}\` ` : ""}${task.title}`, - ]; - if (tags.length > 0) { - headerLines.push(`Tags: ${tags.map((t) => `\`${t}\``).join(", ")}`); +export function buildAgentContextFrom(data: AgentContextData): string { + const { task, deps, downstream, upstreamEdgeNotes, depTasks } = data; + + const taskRef = task.taskRef; + const tags = (task.tags as string[] | null) ?? []; + const files = (task.files as string[] | null) ?? []; + const status = task.status as string; + const priority = task.priority as string | null; + const estimate = task.estimate as number | null; + const assignees = task.assignees; + const links = task.links; + + const prLink = links.find((l) => l.kind === "pull_request"); + + const headerLines: string[] = [ + `# ${taskRef ? `\`${taskRef}\` ` : ""}${task.title}`, + ]; + if (tags.length > 0) { + headerLines.push(`Tags: ${tags.map((t) => `\`${t}\``).join(", ")}`); + } + if (priority) headerLines.push(`Priority: \`${priority}\``); + if (estimate) headerLines.push(`Estimate: ${estimate} pts`); + if (prLink) headerLines.push(`PR: ${prLink.url}`); + headerLines.push(""); + headerLines.push(task.description); + + const parts: string[] = [headerLines.join("\n")]; + + if (task.implementationPlan && status !== "done" && status !== "cancelled") { + parts.push(section("Implementation Plan") + "\n" + task.implementationPlan); + } + + if (deps.length > 0) { + const prereqLines: string[] = []; + const execLines: string[] = []; + + const depMap = new Map(depTasks.map((dt) => [dt.id, dt])); + + for (const dep of deps) { + const info = depMap.get(dep.id); + if (!info) continue; + const note = upstreamEdgeNotes.get(dep.id); + let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; + if (note) line += ` — ${note}`; + prereqLines.push(line); + + if (info.executionRecord) { + execLines.push(`### \`${info.taskRef}\` ${info.title}`); + execLines.push(info.executionRecord); + } } - if (priority) headerLines.push(`Priority: \`${priority}\``); - if (estimate) headerLines.push(`Estimate: ${estimate} pts`); - if (prLink) headerLines.push(`PR: ${prLink.url}`); - headerLines.push(""); - headerLines.push(task.description); - - const parts: string[] = [headerLines.join("\n")]; - - if ( - task.implementationPlan && - status !== "done" && - status !== "cancelled" - ) { + + if (prereqLines.length > 0) { parts.push( - section("Implementation Plan") + "\n" + task.implementationPlan, + section("Prerequisites (context only — do NOT implement these)") + + "\n" + + prereqLines.join("\n"), ); } - if (deps.length > 0) { - const prereqLines: string[] = []; - const execLines: string[] = []; - - const depTasks = await fetchDependencyTasks( - task.projectId, - deps.map((d) => d.id), - tx, + if (execLines.length > 0) { + parts.push( + section("Upstream Execution Records") + "\n" + execLines.join("\n"), ); - const depMap = new Map(depTasks.map((dt) => [dt.id, dt])); - - for (const dep of deps) { - const info = depMap.get(dep.id); - if (!info) continue; - const note = upstreamEdgeNotes.get(dep.id); - let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; - if (note) line += ` — ${note}`; - prereqLines.push(line); - - if (info.executionRecord) { - execLines.push(`### \`${info.taskRef}\` ${info.title}`); - execLines.push(info.executionRecord); - } - } + } + } - if (prereqLines.length > 0) { - parts.push( - section("Prerequisites (context only — do NOT implement these)") + - "\n" + - prereqLines.join("\n"), - ); - } + if (task.decisions.length > 0) { + parts.push(section("Constraints") + "\n" + formatDecisions(task.decisions)); + } - if (execLines.length > 0) { - parts.push( - section("Upstream Execution Records") + "\n" + execLines.join("\n"), - ); - } - } + parts.push( + section("Done Means") + "\n" + formatCriteria(task.acceptanceCriteria), + ); - if (task.decisions.length > 0) { - parts.push( - section("Constraints") + "\n" + formatDecisions(task.decisions), - ); - } + if (files.length > 0) { + parts.push(section("Files") + "\n" + files.map((f) => `- ${f}`).join("\n")); + } + if (assignees.length > 0) { parts.push( - section("Done Means") + "\n" + formatCriteria(task.acceptanceCriteria), + section("Assignees") + + "\n" + + assignees.map((a) => `- ${a.name} <${a.email}>`).join("\n"), ); - - if (files.length > 0) { - parts.push( - section("Files") + "\n" + files.map((f) => `- ${f}`).join("\n"), - ); + } + + if (links.length > 0) { + const linkLines = links.map((l) => { + let host = ""; + try { + host = new URL(l.url).host; + } catch { + host = l.url; + } + const display = l.label ?? host; + return `- [${l.kind}] ${display} (${l.url})`; + }); + parts.push(section("Links") + "\n" + linkLines.join("\n")); + } + + if ( + task.executionRecord && + (status === "done" || status === "cancelled" || status === "in_review") + ) { + parts.push(section("Execution Record") + "\n" + task.executionRecord); + } + + if (downstream.length > 0) { + const summaryMap = new Map(data.downstreamSummaries.map((s) => [s.id, s])); + const downLines: string[] = []; + + for (const d of downstream) { + const info = summaryMap.get(d.id); + if (!info) continue; + const note = data.downstreamEdgeNotes.get(d.id); + let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; + if (note) line += ` — ${note}`; + downLines.push(line); } - if (assignees.length > 0) { + if (downLines.length > 0) { parts.push( - section("Assignees") + + section("Downstream (what depends on this task's output)") + "\n" + - assignees.map((a) => `- ${a.name} <${a.email}>`).join("\n"), + downLines.join("\n"), ); } + } - if (links.length > 0) { - const linkLines = links.map((l) => { - let host = ""; - try { - host = new URL(l.url).host; - } catch { - host = l.url; - } - const display = l.label ?? host; - return `- [${l.kind}] ${display} (${l.url})`; - }); - parts.push(section("Links") + "\n" + linkLines.join("\n")); - } - - if ( - task.executionRecord && - (status === "done" || status === "cancelled" || status === "in_review") - ) { - parts.push(section("Execution Record") + "\n" + task.executionRecord); - } - - if (downstream.length > 0) { - const [downstreamEdgeNotes, downstreamSummaries] = await Promise.all([ - fetchEdgeNotesByTarget(task.projectId, taskId, tx), - fetchTaskSummaries( - task.projectId, - downstream.map((d) => d.id), - tx, - ), - ]); - const summaryMap = new Map(downstreamSummaries.map((s) => [s.id, s])); - const downLines: string[] = []; - - for (const d of downstream) { - const info = summaryMap.get(d.id); - if (!info) continue; - const note = downstreamEdgeNotes.get(d.id); - let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; - if (note) line += ` — ${note}`; - downLines.push(line); - } - - if (downLines.length > 0) { - parts.push( - section("Downstream (what depends on this task's output)") + - "\n" + - downLines.join("\n"), - ); - } - } + return parts.join("\n\n"); +} - return parts.join("\n\n"); +/** + * Build lean, position-optimized context for external coding agents. + * + * The MCP `mymir_context` entry point. Resolves only the dependency-closure + * data this depth renders, then delegates to the pure + * {@link buildAgentContextFrom} assembler. + * + * @param ctx Resolved auth context. + * @param taskId UUID of the task. + * @returns Formatted context string. + */ +export async function buildAgentContext( + ctx: AuthContext, + taskId: string, +): Promise { + return withUserContext(ctx.userId, async (tx) => { + const data = await resolveDependencyClosure(tx, taskId, "agent"); + return buildAgentContextFrom(data); }); } diff --git a/lib/context/_core/bundle.ts b/lib/context/_core/bundle.ts new file mode 100644 index 00000000..6e870200 --- /dev/null +++ b/lib/context/_core/bundle.ts @@ -0,0 +1,251 @@ +import "server-only"; + +import { loadBundleDeps } from "@/lib/graph/effective-deps"; +import { + fetchDependencyTasks, + fetchEdgeNotesBySource, + fetchEdgeNotesByTarget, + fetchTaskSummaries, + getTaskForDepthTx, + type DependencyTaskInfo, +} from "@/lib/data/task"; +import { getProjectHeader, type ProjectHeader } from "@/lib/data/project"; +import { getAncestors } from "@/lib/data/traversal"; +import { getTaskEdgesDetailedTx, type DetailedEdge } from "@/lib/data/edge"; +import type { TaskFull } from "@/lib/data/views"; +import type { TaskFetchDepth } from "@/lib/db/raw/fetch-task-full"; +import type { Tx } from "@/lib/db/rls"; + +/** Downstream-task summary projection shared by the agent + planning cores. */ +type DownstreamSummary = { + id: string; + taskRef: string; + title: string; + status: string; + description: string; +}; + +/** Ancestor node surfaced by the working core. */ +type Ancestor = { id: string; type: "project"; title: string }; + +/** + * The shared 2-hop effective dependency closure and every secondary lookup + * keyed off it. Read by both the agent and planning cores. + */ +export type DependencyClosureData = { + /** Full task row. */ + task: TaskFull; + /** Active prerequisites within 2 effective hops. */ + deps: { id: string }[]; + /** Active dependents within 2 effective hops. */ + downstream: { id: string }[]; + /** Outgoing depends_on edge notes, keyed by prerequisite id. */ + upstreamEdgeNotes: Map; + /** Dependency-task summaries (taskRef, title, status, executionRecord). */ + depTasks: DependencyTaskInfo[]; + /** Incoming depends_on edge notes, keyed by dependent id. */ + downstreamEdgeNotes: Map; + /** Downstream-task summaries (taskRef, title, status, description). */ + downstreamSummaries: DownstreamSummary[]; +}; + +/** Exactly what {@link buildAgentContextFrom} reads. */ +export type AgentContextData = DependencyClosureData; + +/** Exactly what {@link buildPlanningContextFrom} reads. */ +export type PlanningContextData = DependencyClosureData & { + /** Parent project header, or null when the project is unjoinable. */ + project: ProjectHeader | null; +}; + +/** Exactly what {@link buildWorkingContextFrom} reads. */ +export type WorkingContextData = { + /** Full task row. */ + task: TaskFull; + /** Connected 1-hop edges of every type with connected-task detail. */ + detailedEdges: DetailedEdge[]; + /** Ancestor chain (always the parent project). */ + ancestors: Ancestor[]; +}; + +/** + * The complete data union the three context cores read. Resolving this once + * lets the route feed all three pure cores from a single task read and a + * single dependency traversal. A wider object than any single core needs, so + * it is structurally assignable to each narrower core parameter. + */ +export type ContextBundle = PlanningContextData & WorkingContextData; + +/** + * Resolve the shared dependency closure for a task in one task read and one + * dependency/downstream traversal. The secondary closure lookups run in + * parallel off that shared substrate. `getTaskForDepthTx` asserts access, so + * callers need no prior gate. + * + * `depth` scopes ONLY the main task-row column projection — the agent core + * fetches at `agent`, the planning core at `planning`. The traversal and every + * secondary lookup (dep-task execution records, edge notes, downstream + * summaries) are depth-independent and identical across both cores. + * + * @param tx Active RLS transaction handle from a `withUserContext` frame. + * @param taskId UUID of the task. + * @param depth Column projection for the main task-row fetch. + * @returns The resolved closure feeding the agent and planning cores. + * @throws ForbiddenError When the caller cannot access the task. + */ +export async function resolveDependencyClosure( + tx: Tx, + taskId: string, + depth: TaskFetchDepth, +): Promise { + const task = await getTaskForDepthTx(tx, taskId, depth); + const { projectId } = task; + + const [{ deps, downstream }, upstreamEdgeNotes] = await Promise.all([ + loadBundleDeps(projectId, taskId, 2, tx), + fetchEdgeNotesBySource(projectId, taskId, tx), + ]); + + const [depTasks, downstreamEdgeNotes, downstreamSummaries] = + await resolveClosureSecondaries(tx, projectId, taskId, deps, downstream); + + return { + task, + deps, + downstream, + upstreamEdgeNotes, + depTasks, + downstreamEdgeNotes, + downstreamSummaries, + }; +} + +/** + * Resolve the dependency closure plus the parent project header, the planning + * core's full input. One task read and one traversal. + * + * @param tx Active RLS transaction handle from a `withUserContext` frame. + * @param taskId UUID of the task. + * @returns The closure plus project header. + * @throws ForbiddenError When the caller cannot access the task. + */ +export async function resolvePlanningData( + tx: Tx, + taskId: string, +): Promise { + const closure = await resolveDependencyClosure(tx, taskId, "planning"); + const project = await getProjectHeader(closure.task.projectId, tx); + return { ...closure, project }; +} + +/** + * Resolve the working core's input: the full task row plus its 1-hop edges and + * ancestor chain. One task read, no dependency closure. + * + * @param tx Active RLS transaction handle from a `withUserContext` frame. + * @param taskId UUID of the task. + * @returns The task row, detailed edges, and ancestors. + * @throws ForbiddenError When the caller cannot access the task. + */ +export async function resolveWorkingData( + tx: Tx, + taskId: string, +): Promise { + const task = await getTaskForDepthTx(tx, taskId, "working"); + const [detailedEdges, ancestors] = await Promise.all([ + getTaskEdgesDetailedTx(tx, taskId), + getAncestors(taskId, tx), + ]); + return { task, detailedEdges, ancestors }; +} + +/** + * Resolve the full {@link ContextBundle} for a task in one task read and one + * dependency traversal, sharing every lookup across the three cores. Used by + * the route, which feeds all three cores from this single bundle. Fetches at + * `agent` depth, the column superset of the agent, planning, and working + * cores, so one read serves all three. + * + * @param tx Active RLS transaction handle from a `withUserContext` frame. + * @param taskId UUID of the task. + * @returns The resolved bundle feeding all three context cores. + * @throws ForbiddenError When the caller cannot access the task. + */ +export async function resolveContextBundle( + tx: Tx, + taskId: string, +): Promise { + const task = await getTaskForDepthTx(tx, taskId, "agent"); + const { projectId } = task; + + const [ + { deps, downstream }, + upstreamEdgeNotes, + project, + detailedEdges, + ancestors, + ] = await Promise.all([ + loadBundleDeps(projectId, taskId, 2, tx), + fetchEdgeNotesBySource(projectId, taskId, tx), + getProjectHeader(projectId, tx), + getTaskEdgesDetailedTx(tx, taskId), + getAncestors(taskId, tx), + ]); + + const [depTasks, downstreamEdgeNotes, downstreamSummaries] = + await resolveClosureSecondaries(tx, projectId, taskId, deps, downstream); + + return { + task, + deps, + downstream, + upstreamEdgeNotes, + depTasks, + downstreamEdgeNotes, + downstreamSummaries, + project, + detailedEdges, + ancestors, + }; +} + +/** + * Resolve the closure-derived secondary lookups: dependency-task summaries, + * incoming edge notes, and downstream summaries. Each guards on a non-empty + * id set so an isolated task issues no superfluous query, matching the + * per-depth fetch shape the cores assume. + * + * @param tx Active RLS transaction handle from a `withUserContext` frame. + * @param projectId UUID of the project the task belongs to. + * @param taskId UUID of the task. + * @param deps Active prerequisite ids from the closure. + * @param downstream Active dependent ids from the closure. + * @returns Dep-task summaries, downstream edge notes, and downstream summaries. + */ +async function resolveClosureSecondaries( + tx: Tx, + projectId: string, + taskId: string, + deps: { id: string }[], + downstream: { id: string }[], +): Promise<[DependencyTaskInfo[], Map, DownstreamSummary[]]> { + return Promise.all([ + deps.length > 0 + ? fetchDependencyTasks( + projectId, + deps.map((d) => d.id), + tx, + ) + : Promise.resolve([] as DependencyTaskInfo[]), + downstream.length > 0 + ? fetchEdgeNotesByTarget(projectId, taskId, tx) + : Promise.resolve(new Map()), + downstream.length > 0 + ? fetchTaskSummaries( + projectId, + downstream.map((d) => d.id), + tx, + ) + : Promise.resolve([] as DownstreamSummary[]), + ]); +} diff --git a/lib/context/_core/overview.ts b/lib/context/_core/overview.ts index c3684910..d4a9cb57 100644 --- a/lib/context/_core/overview.ts +++ b/lib/context/_core/overview.ts @@ -8,7 +8,7 @@ import { } from "@/lib/graph/identifier"; import { getProjectTagsTx } from "@/lib/data/project"; import { - listProjectTasks, + listProjectTasksForOverview, fetchAssigneesByProjectUnchecked, } from "@/lib/data/task"; import { fetchEdgesForTaskIds } from "@/lib/data/edge"; @@ -73,7 +73,7 @@ export async function buildProjectOverview( return withUserContext(ctx.userId, async (tx) => { const { project } = await assertProjectAccessTx(tx, projectId); const projectTags = await getProjectTagsTx(tx, projectId); - const allTasks = await listProjectTasks(projectId, tx); + const allTasks = await listProjectTasksForOverview(projectId, tx); const identifier = asIdentifier(project.identifier); const assigneesByTask = await fetchAssigneesByProjectUnchecked( diff --git a/lib/context/_core/planning.ts b/lib/context/_core/planning.ts index 6894dbb0..ae2cf511 100644 --- a/lib/context/_core/planning.ts +++ b/lib/context/_core/planning.ts @@ -1,165 +1,155 @@ import "server-only"; -import { loadBundleDeps } from "@/lib/graph/effective-deps"; -import { - fetchDependencyTasks, - fetchEdgeNotesBySource, - fetchEdgeNotesByTarget, - fetchTaskSummaries, - getTaskFullTx, -} from "@/lib/data/task"; -import { getProjectHeader } from "@/lib/data/project"; import { section, formatCriteria, formatDecisions } from "@/lib/context/format"; import type { AuthContext } from "@/lib/auth/context"; import { withUserContext } from "@/lib/db/rls"; +import { + resolvePlanningData, + type PlanningContextData, +} from "@/lib/context/_core/bundle"; /** - * Build planning-optimized context for a task. + * Assemble the planning context string from pre-resolved planning data. * * Supplies the project-level breadth a planner can't derive from reading code * alone: project description, upstream execution records, and downstream task - * specs. Sections ordered by U-shaped attention. No token budget — all content - * included as-is. + * specs. Sections ordered by U-shaped attention. Pure: reads only its + * argument, issues no queries. * - * @param ctx - Resolved auth context. - * @param taskId - UUID of the task. + * @param data Resolved planning data (dependency closure plus project header). * @returns Formatted planning context string. */ -export async function buildPlanningContext( - ctx: AuthContext, - taskId: string, -): Promise { - return withUserContext(ctx.userId, async (tx) => { - const task = await getTaskFullTx(tx, taskId); - - const [{ deps, downstream }, upstreamEdgeNotes] = await Promise.all([ - loadBundleDeps(task.projectId, taskId, 2, tx), - fetchEdgeNotesBySource(task.projectId, taskId, tx), - ]); - - const project = await getProjectHeader(task.projectId, tx); - if (!project) { - console.error("Task has no joinable project", { - taskId: task.id, - projectId: task.projectId, - }); - } - const tags = (task.tags as string[] | null) ?? []; - const priority = task.priority as string | null; - const estimate = task.estimate as number | null; - const taskRef = task.taskRef; - - const headerLines: string[] = [ - `# ${taskRef ? `\`${taskRef}\` ` : ""}${task.title}`, - ]; - if (tags.length > 0) { - headerLines.push(`Tags: ${tags.map((t) => `\`${t}\``).join(", ")}`); +export function buildPlanningContextFrom(data: PlanningContextData): string { + const { task, deps, downstream, upstreamEdgeNotes, depTasks, project } = data; + + if (!project) { + console.error("Task has no joinable project", { + taskId: task.id, + projectId: task.projectId, + }); + } + + const tags = (task.tags as string[] | null) ?? []; + const priority = task.priority as string | null; + const estimate = task.estimate as number | null; + const taskRef = task.taskRef; + + const headerLines: string[] = [ + `# ${taskRef ? `\`${taskRef}\` ` : ""}${task.title}`, + ]; + if (tags.length > 0) { + headerLines.push(`Tags: ${tags.map((t) => `\`${t}\``).join(", ")}`); + } + if (priority) headerLines.push(`Priority: \`${priority}\``); + if (estimate) headerLines.push(`Estimate: ${estimate} pts`); + + const parts: string[] = [headerLines.join("\n")]; + + if (project) { + const projectLines = [`Project: ${project.title}`]; + if (project.description) { + projectLines.push(project.description); } - if (priority) headerLines.push(`Priority: \`${priority}\``); - if (estimate) headerLines.push(`Estimate: ${estimate} pts`); + parts.push(section("Project Context") + "\n" + projectLines.join("\n")); + } - const parts: string[] = [headerLines.join("\n")]; - - if (project) { - const projectLines = [`Project: ${project.title}`]; - if (project.description) { - projectLines.push(project.description); - } - parts.push(section("Project Context") + "\n" + projectLines.join("\n")); - } + parts.push(section("Description") + "\n" + task.description); + parts.push( + section("Acceptance Criteria") + + "\n" + + formatCriteria(task.acceptanceCriteria), + ); - parts.push(section("Description") + "\n" + task.description); + if (task.implementationPlan) { parts.push( - section("Acceptance Criteria") + - "\n" + - formatCriteria(task.acceptanceCriteria), + section("Existing Implementation Plan") + "\n" + task.implementationPlan, ); + } - if (task.implementationPlan) { - parts.push( - section("Existing Implementation Plan") + - "\n" + - task.implementationPlan, - ); - } + if (deps.length > 0) { + const prereqLines: string[] = []; + const execLines: string[] = []; - if (deps.length > 0) { - const prereqLines: string[] = []; - const execLines: string[] = []; + const depMap = new Map(depTasks.map((dt) => [dt.id, dt])); - const depTasks = await fetchDependencyTasks( - task.projectId, - deps.map((d) => d.id), - tx, - ); - const depMap = new Map(depTasks.map((dt) => [dt.id, dt])); - - for (const dep of deps) { - const info = depMap.get(dep.id); - if (!info) continue; - const note = upstreamEdgeNotes.get(dep.id); - let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; - if (note) line += ` — ${note}`; - prereqLines.push(line); - - if (info.status === "done" && info.executionRecord) { - execLines.push(`### \`${info.taskRef}\` ${info.title}`); - execLines.push(info.executionRecord); - } - } + for (const dep of deps) { + const info = depMap.get(dep.id); + if (!info) continue; + const note = upstreamEdgeNotes.get(dep.id); + let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; + if (note) line += ` — ${note}`; + prereqLines.push(line); - if (prereqLines.length > 0) { - parts.push( - section("Prerequisites (context only — do NOT implement these)") + - "\n" + - prereqLines.join("\n"), - ); - } - - if (execLines.length > 0) { - parts.push( - section("What's Been Built (from done prerequisites)") + - "\n" + - execLines.join("\n"), - ); + if (info.status === "done" && info.executionRecord) { + execLines.push(`### \`${info.taskRef}\` ${info.title}`); + execLines.push(info.executionRecord); } } - if (task.decisions.length > 0) { - parts.push(section("Decisions") + "\n" + formatDecisions(task.decisions)); + if (prereqLines.length > 0) { + parts.push( + section("Prerequisites (context only — do NOT implement these)") + + "\n" + + prereqLines.join("\n"), + ); } - if (downstream.length > 0) { - const [downstreamEdgeNotes, downstreamSummaries] = await Promise.all([ - fetchEdgeNotesByTarget(task.projectId, taskId, tx), - fetchTaskSummaries( - task.projectId, - downstream.map((d) => d.id), - tx, - ), - ]); - const summaryMap = new Map(downstreamSummaries.map((s) => [s.id, s])); - const downLines: string[] = []; - - for (const d of downstream) { - const info = summaryMap.get(d.id); - if (!info) continue; - const note = downstreamEdgeNotes.get(d.id); - let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; - if (note) line += ` — ${note}`; - if (info.description) line += `\n ${info.description}`; - downLines.push(line); - } + if (execLines.length > 0) { + parts.push( + section("What's Been Built (from done prerequisites)") + + "\n" + + execLines.join("\n"), + ); + } + } + + if (task.decisions.length > 0) { + parts.push(section("Decisions") + "\n" + formatDecisions(task.decisions)); + } + + if (downstream.length > 0) { + const summaryMap = new Map(data.downstreamSummaries.map((s) => [s.id, s])); + const downLines: string[] = []; + + for (const d of downstream) { + const info = summaryMap.get(d.id); + if (!info) continue; + const note = data.downstreamEdgeNotes.get(d.id); + let line = `- \`${info.taskRef}\` **${info.title}** [${info.status}]`; + if (note) line += ` — ${note}`; + if (info.description) line += `\n ${info.description}`; + downLines.push(line); + } - if (downLines.length > 0) { - parts.push( - section("Downstream (tasks that depend on this task's output)") + - "\n" + - downLines.join("\n"), - ); - } + if (downLines.length > 0) { + parts.push( + section("Downstream (tasks that depend on this task's output)") + + "\n" + + downLines.join("\n"), + ); } + } + + return parts.join("\n\n"); +} - return parts.join("\n\n"); +/** + * Build planning-optimized context for a task. + * + * The MCP `mymir_context` entry point. Resolves only the planning data this + * depth renders, then delegates to the pure {@link buildPlanningContextFrom} + * assembler. + * + * @param ctx Resolved auth context. + * @param taskId UUID of the task. + * @returns Formatted planning context string. + */ +export async function buildPlanningContext( + ctx: AuthContext, + taskId: string, +): Promise { + return withUserContext(ctx.userId, async (tx) => { + const data = await resolvePlanningData(tx, taskId); + return buildPlanningContextFrom(data); }); } diff --git a/lib/context/_core/review.ts b/lib/context/_core/review.ts index 14ec5188..72ad37fd 100644 --- a/lib/context/_core/review.ts +++ b/lib/context/_core/review.ts @@ -6,7 +6,7 @@ import { fetchEdgeNotesBySource, fetchEdgeNotesByTarget, fetchTaskSummaries, - getTaskFullTx, + getTaskForDepthTx, } from "@/lib/data/task"; import { getProjectHeader } from "@/lib/data/project"; import { section, formatCriteria, formatDecisions } from "@/lib/context/format"; @@ -59,7 +59,7 @@ export async function buildReviewContext( taskId: string, ): Promise { return withUserContext(ctx.userId, async (tx) => { - const task = await getTaskFullTx(tx, taskId); + const task = await getTaskForDepthTx(tx, taskId, "review"); const [{ deps, downstream }, upstreamEdgeNotes] = await Promise.all([ loadBundleDeps(task.projectId, taskId, 2, tx), diff --git a/lib/context/_core/summary.ts b/lib/context/_core/summary.ts index f088c794..efc3f1d4 100644 --- a/lib/context/_core/summary.ts +++ b/lib/context/_core/summary.ts @@ -2,7 +2,7 @@ import "server-only"; import type { EdgeType, Priority, Estimate } from "@/lib/types"; import { getTaskEdgesDetailedTx } from "@/lib/data/edge"; -import { getTaskFullTx } from "@/lib/data/task"; +import { getTaskForDepthTx } from "@/lib/data/task"; import type { TaskLinkRef } from "@/lib/data/views"; import { getProjectHeader } from "@/lib/data/project"; import type { AuthContext } from "@/lib/auth/context"; @@ -51,7 +51,7 @@ export async function buildSummaryContext( taskId: string, ): Promise { return withUserContext(ctx.userId, async (tx) => { - const task = await getTaskFullTx(tx, taskId); + const task = await getTaskForDepthTx(tx, taskId, "summary"); const detailedEdges = await getTaskEdgesDetailedTx(tx, taskId); const project = await getProjectHeader(task.projectId, tx); if (!project) { diff --git a/lib/context/_core/working.ts b/lib/context/_core/working.ts index bf467486..b9b487a0 100644 --- a/lib/context/_core/working.ts +++ b/lib/context/_core/working.ts @@ -1,13 +1,14 @@ import "server-only"; import type { AcceptanceCriterion } from "@/lib/types"; -import { getAncestors } from "@/lib/data/traversal"; -import { getTaskEdgesDetailedTx } from "@/lib/data/edge"; -import { getTaskFullTx } from "@/lib/data/task"; import type { AssigneeRef, TaskLinkRef } from "@/lib/data/views"; import { section, formatCriteria } from "@/lib/context/format"; import type { AuthContext } from "@/lib/auth/context"; import { withUserContext } from "@/lib/db/rls"; +import { + resolveWorkingData, + type WorkingContextData, +} from "@/lib/context/_core/bundle"; /** Full working context for AI assistant (1-hop). */ type WorkingContext = { @@ -27,15 +28,47 @@ type WorkingContext = { links: TaskLinkRef[]; }; +/** + * Assemble the working context from pre-resolved working data. 1-hop edges plus + * the ancestor chain. Pure: reads only its argument, issues no queries. + * + * @param data Resolved working data (task row, detailed edges, ancestors). + * @returns Working context with task data, ancestors, and edges. + */ +export function buildWorkingContextFrom( + data: WorkingContextData, +): WorkingContext { + const { task, detailedEdges, ancestors } = data; + + const edges = detailedEdges.map((e) => ({ + id: e.connectedTask.id, + taskRef: e.connectedTask.taskRef, + edgeType: e.edgeType as string, + direction: e.direction, + title: e.connectedTask.title, + status: e.connectedTask.status, + note: e.note, + })); + + return { + node: task as unknown as Record, + taskRef: task.taskRef, + ancestors, + edges, + assignees: task.assignees, + links: task.links, + }; +} + /** * Build full working context for a task. 1-hop traversal. * - * Sections ordered by U-shaped attention: header + description + criteria at - * start, edges in middle. No token budget — all content included as-is. Used - * by MCP for `mymir_context depth='working'`. + * Resolves only the working data this depth renders (no dependency closure or + * project header), then delegates to the pure {@link buildWorkingContextFrom} + * assembler. Used by MCP for `mymir_context depth='working'`. * - * @param ctx - Resolved auth context. - * @param taskId - UUID of the task. + * @param ctx Resolved auth context. + * @param taskId UUID of the task. * @returns Working context with task data, ancestors, and edges. */ export async function buildWorkingContext( @@ -43,30 +76,8 @@ export async function buildWorkingContext( taskId: string, ): Promise { return withUserContext(ctx.userId, async (tx) => { - const task = await getTaskFullTx(tx, taskId); - const [detailedEdges, ancestors] = await Promise.all([ - getTaskEdgesDetailedTx(tx, taskId), - getAncestors(taskId, tx), - ]); - - const edges = detailedEdges.map((e) => ({ - id: e.connectedTask.id, - taskRef: e.connectedTask.taskRef, - edgeType: e.edgeType as string, - direction: e.direction, - title: e.connectedTask.title, - status: e.connectedTask.status, - note: e.note, - })); - - return { - node: task as unknown as Record, - taskRef: task.taskRef, - ancestors, - edges, - assignees: task.assignees, - links: task.links, - }; + const data = await resolveWorkingData(tx, taskId); + return buildWorkingContextFrom(data); }); } diff --git a/lib/data/access.ts b/lib/data/access.ts index bbfc7978..2c8def14 100644 --- a/lib/data/access.ts +++ b/lib/data/access.ts @@ -13,10 +13,23 @@ import { executeRaw } from "@/lib/db/raw"; import { withUserContext, type Tx } from "@/lib/db/rls"; import type { ProjectListOrganization } from "@/lib/data/views"; +/** Slim task row returned by the membership gate. Only the columns callers read. */ +export type TaskAccessGate = Pick< + Task, + "id" | "projectId" | "title" | "status" | "files" | "updatedAt" +>; + +/** + * Project columns the access check returns. Omits `history` and `createdAt` + * to reduce DB egress; callers only read id, organizationId, identifier, + * title, status, description, categories, updatedAt. + */ +export type ProjectAccessProject = Omit; + /** Resolved project access returned when a caller can read a project. */ export type ProjectAccessRow = { - /** The authorized project row. */ - project: Project; + /** The authorized project row — only the 8 columns callers read. */ + project: ProjectAccessProject; /** Caller's `member.role` string from the same JOIN. */ memberRole: string; /** Owning team — projected from the same lookup to save a round-trip. */ @@ -49,7 +62,16 @@ export async function findProjectAccessTx( projectId: string, ): Promise { const [projectRow] = await tx - .select() + .select({ + id: projects.id, + organizationId: projects.organizationId, + title: projects.title, + identifier: projects.identifier, + description: projects.description, + status: projects.status, + categories: projects.categories, + updatedAt: projects.updatedAt, + }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); @@ -80,12 +102,12 @@ export async function findProjectAccessTx( * * @param userId - Verified user id. * @param taskId - UUID of the task. - * @returns Task row when accessible, null otherwise. + * @returns Gate row with only the columns callers read, or null when inaccessible. */ export async function findTaskAccess( userId: string, taskId: string, -): Promise { +): Promise { return withUserContext(userId, (tx) => findTaskAccessTx(tx, taskId)); } @@ -94,14 +116,21 @@ export async function findTaskAccess( * * @param tx - Active RLS transaction handle. * @param taskId - UUID of the task. - * @returns Task row when accessible, null otherwise. + * @returns Gate row with only the columns callers read, or null when inaccessible. */ export async function findTaskAccessTx( tx: Tx, taskId: string, -): Promise { +): Promise { const [row] = await tx - .select() + .select({ + id: tasks.id, + projectId: tasks.projectId, + title: tasks.title, + status: tasks.status, + files: tasks.files, + updatedAt: tasks.updatedAt, + }) .from(tasks) .where(eq(tasks.id, taskId)) .limit(1); diff --git a/lib/data/project.ts b/lib/data/project.ts index 53dd4042..dd27a40c 100644 --- a/lib/data/project.ts +++ b/lib/data/project.ts @@ -1,6 +1,6 @@ import "server-only"; -import { and, asc, desc, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import { serviceRoleDb } from "@/lib/db"; import { executeRaw, type Conn } from "@/lib/db/raw"; import { withUserContext, type Tx } from "@/lib/db/rls"; @@ -48,6 +48,7 @@ import { assertProjectAccess, assertProjectAccessTx, isUuid, + type ProjectAccess, } from "@/lib/auth/authorization"; import { emitProjectDeleted, @@ -75,6 +76,26 @@ function makeHistoryEntry( // Single-entity queries // --------------------------------------------------------------------------- +/** + * Guard a caller-supplied pre-resolved access row against the project the + * read is scoped to. A mismatch is a programmer error — fail loudly rather + * than serve one project's data under another project's authorization. + * + * @param access - Pre-resolved access row, when the caller supplied one. + * @param projectId - UUID of the project being read. + * @throws Error when the access row belongs to a different project. + */ +function assertAccessMatchesProject( + access: ProjectAccess | undefined, + projectId: string, +): void { + if (access && access.project.id !== projectId) { + throw new Error( + `pre-resolved access row is for project ${access.project.id}, not ${projectId}`, + ); + } +} + /** * Slim graph payload for the workspace canvas + task list. Drops the heavy * task fields (description, plan, decisions, criteria, executionRecord) @@ -89,15 +110,22 @@ function makeHistoryEntry( * * @param ctx - Resolved auth context. * @param projectId - UUID of the project. + * @param access - Optional pre-resolved access row so the workspace render + * reuses one project-access read across the layout and page instead of + * reading the row again here. Must have been resolved for this same + * `projectId`; a mismatch throws. Omit to resolve it in-frame. * @returns Slim project metadata + slim tasks + slim edges. * @throws ForbiddenError on missing or cross-team project. + * @throws Error when `access` was resolved for a different project. */ export async function getProjectGraphSlim( ctx: AuthContext, projectId: string, + access?: ProjectAccess, ): Promise { + assertAccessMatchesProject(access, projectId); return withUserContext(ctx.userId, async (tx) => { - const { project } = await assertProjectAccessTx(tx, projectId); + const { project } = access ?? (await assertProjectAccessTx(tx, projectId)); const tasksQ = tx .select({ @@ -183,19 +211,26 @@ export async function getProjectGraphSlim( * * @param ctx - Resolved auth context. * @param projectId - UUID of the project. + * @param access - Optional pre-resolved access row so the workspace render + * reuses one project-access read across the layout and page instead of + * reading the row again here. Must have been resolved for this same + * `projectId`; a mismatch throws. Omit to resolve it in-frame. * @returns Chrome view of the project. * @throws ForbiddenError on missing or cross-team project. + * @throws Error when `access` was resolved for a different project. */ export async function getProjectChrome( ctx: AuthContext, projectId: string, + access?: ProjectAccess, ): Promise { + assertAccessMatchesProject(access, projectId); return withUserContext(ctx.userId, async (tx) => { const { project, memberRole, organization: org, - } = await assertProjectAccessTx(tx, projectId); + } = access ?? (await assertProjectAccessTx(tx, projectId)); const [{ count }] = await tx .select({ count: sql`count(*)::int` }) @@ -616,7 +651,15 @@ export async function listProjectsSlim( sql`SELECT org_id, name, slug, member_role FROM public.current_user_orgs()`, ), tx - .select(getTableColumns(projects)) + .select({ + id: projects.id, + organizationId: projects.organizationId, + title: projects.title, + identifier: projects.identifier, + description: projects.description, + status: projects.status, + updatedAt: projects.updatedAt, + }) .from(projects) .where(cursorClause) .orderBy(desc(projects.updatedAt), desc(projects.id)) diff --git a/lib/data/task.ts b/lib/data/task.ts index 5315c63b..ed1ca20b 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -16,7 +16,12 @@ import { type TaskLink, } from "@/lib/db/schema"; import { acquireProjectLock } from "@/lib/db/raw/acquire-project-lock"; -import { fetchTaskFull } from "@/lib/db/raw/fetch-task-full"; +import { + fetchTaskFull, + fetchTaskForDepth, + type TaskFetchDepth, + type TaskFullRawRow, +} from "@/lib/db/raw/fetch-task-full"; import { fetchTaskChildren } from "@/lib/db/raw/fetch-task-children"; import type { AcceptanceCriterion, @@ -434,6 +439,28 @@ export async function getTaskFull( return withUserContext(ctx.userId, (tx) => getTaskFullTx(tx, taskId)); } +/** + * Slim membership-gated lookup of a task's `projectId`. Routes through the + * {@link assertTaskAccessTx} gate, which both authorizes the caller and + * returns `projectId` in one slim row, with no full-task read. Used by the + * conditional-GET validator on the context bundle endpoint so the HEAD/304 + * path never pays for the full task. + * + * @param ctx - Resolved auth context. + * @param taskId - UUID of the task. + * @returns The task's `projectId`. + * @throws ForbiddenError when the caller cannot access the task. + */ +export async function getTaskProjectId( + ctx: AuthContext, + taskId: string, +): Promise { + return withUserContext(ctx.userId, async (tx) => { + const gate = await assertTaskAccessTx(tx, taskId); + return gate.projectId; + }); +} + /** * {@link getTaskFull} on a caller-supplied tx. * @@ -451,7 +478,48 @@ export async function getTaskFullTx(tx: Tx, taskId: string): Promise { `getTaskFull: task ${taskId} disappeared after access check`, ); } - const r = rows[0]; + return mapTaskFullRow(rows[0]); +} + +/** + * Membership-gated single-round-trip task fetch narrowed to the columns the + * supplied {@link TaskFetchDepth} renders. Mirrors {@link getTaskFullTx} but + * routes through {@link fetchTaskForDepth}, so each MCP context builder pays + * egress only for the task-row columns and child aggregates its formatter + * reads. Columns the depth omits arrive as type-stable empty values, keeping + * the returned {@link TaskFull} shape identical across depths. + * + * @param tx - Active RLS transaction handle. + * @param taskId - UUID of the task. + * @param depth - Context depth selecting the column projection. + * @returns Task row (depth-projected) with composed `taskRef` and aggregates. + * @throws ForbiddenError when the caller is not a member of the task's team. + */ +export async function getTaskForDepthTx( + tx: Tx, + taskId: string, + depth: TaskFetchDepth, +): Promise { + await assertTaskAccessTx(tx, taskId); + const rows = await fetchTaskForDepth(tx, taskId, depth); + if (rows.length === 0) { + throw new Error( + `getTaskForDepth: task ${taskId} disappeared after access check`, + ); + } + return mapTaskFullRow(rows[0]); +} + +/** + * Map a {@link TaskFullRawRow} to the camelCase {@link TaskFull} shape, + * composing `taskRef` and narrowing the decision `source` union. Shared by + * {@link getTaskFullTx} and {@link getTaskForDepthTx} so both surfaces map + * identically. + * + * @param r - Raw row from `fetchTaskFull` or `fetchTaskForDepth`. + * @returns The mapped {@link TaskFull}. + */ +function mapTaskFullRow(r: TaskFullRawRow): TaskFull { const taskRef = composeTaskRef( asIdentifier(r.project_identifier), r.sequence_number, @@ -771,30 +839,34 @@ export async function getTaskSlim( // --------------------------------------------------------------------------- /** - * Fetch all tasks for a project, ordered by `order`. - * @param ctx - Resolved auth context. - * @param projectId - UUID of the project. - * @returns Ordered array of tasks. - */ -export async function getProjectTasks(ctx: AuthContext, projectId: string) { - return withUserContext(ctx.userId, async (tx) => { - await assertProjectAccessTx(tx, projectId); - return listProjectTasks(projectId, tx); - }); -} - -/** - * Fetch all tasks for a project, ordered by `order`. Internal helper — - * caller must assert project access before invoking. Used by context - * assemblers that have already authorized the parent project. + * Fetch a slim task projection for project overview assembly, ordered by + * `order`. Description is capped at 101 characters in SQL to reduce egress + * while preserving the caller's `compress(_, 100)` behavior byte-for-byte: + * any source description longer than 100 chars stays longer than 100 after + * the cap, so the downstream ellipsis truncation is unaffected. Internal + * helper -- caller must assert project access before invoking. * * @param projectId - UUID of the project. * @param conn - RLS-scoped {@link Conn} from an active `withUserContext` frame. - * @returns Ordered array of tasks. + * @returns Ordered array of slim task rows with a 101-char description cap. */ -export async function listProjectTasks(projectId: string, conn: Conn) { +export async function listProjectTasksForOverview( + projectId: string, + conn: Conn, +) { return conn - .select() + .select({ + id: tasks.id, + title: tasks.title, + status: tasks.status, + sequenceNumber: tasks.sequenceNumber, + order: tasks.order, + tags: tasks.tags, + category: tasks.category, + priority: tasks.priority, + estimate: tasks.estimate, + description: sql`substring(${tasks.description} from 1 for 101)`, + }) .from(tasks) .where(eq(tasks.projectId, projectId)) .orderBy(asc(tasks.order)); diff --git a/lib/data/views.ts b/lib/data/views.ts index 3e0706c6..16cda445 100644 --- a/lib/data/views.ts +++ b/lib/data/views.ts @@ -51,8 +51,22 @@ export type ProjectTaskStats = { cancelled: number; }; -/** Project entry returned by `listProjectsSlim`. */ -export type ProjectListEntry = Project & { +/** + * Project entry returned by `listProjectsSlim`. Carries only the columns the + * home grid and sidebar render (id, organizationId, title, identifier, + * description, status, updatedAt); history, categories, and createdAt are + * omitted to keep the wire payload slim. + */ +export type ProjectListEntry = Pick< + Project, + | "id" + | "organizationId" + | "title" + | "identifier" + | "description" + | "status" + | "updatedAt" +> & { organization: ProjectListOrganization; memberRole: string; taskStats: ProjectTaskStats; diff --git a/lib/db/raw/fetch-task-full.ts b/lib/db/raw/fetch-task-full.ts index dd6df53d..ece128f5 100644 --- a/lib/db/raw/fetch-task-full.ts +++ b/lib/db/raw/fetch-task-full.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { sql, type SQL } from "drizzle-orm"; import { executeRaw, type Conn } from "@/lib/db/raw"; /** @@ -41,6 +41,210 @@ export type TaskFullRawRow = { | null; }; +/** + * Context depth identifying which task-row columns and child aggregates a + * single MCP context builder reads. Drives {@link fetchTaskForDepth} so each + * depth pays egress only for the columns its formatter renders. + */ +export type TaskFetchDepth = + | "summary" + | "working" + | "planning" + | "agent" + | "review"; + +/** + * Per-depth projection plan. Each flag gates one droppable `tasks` column or + * child aggregate; omitted columns fall back to a type-stable empty literal in + * {@link fetchTaskForDepth} so the {@link TaskFullRawRow} shape never changes. + * Columns every depth renders (id, title, description, status, priority, + * estimate, ...) are always selected and carry no flag. + */ +type DepthProjection = { + tags: boolean; + implementationPlan: boolean; + executionRecord: boolean; + files: boolean; + assignees: boolean; + acceptanceCriteria: boolean; + decisions: boolean; + links: boolean; +}; + +/** + * The exact column set each depth's formatter reads. `category` and `history` + * are omitted at every depth (no formatter reads them). `implementationPlan` + * is true for `summary` because `buildSummaryContext` reads its presence + * (`hasImplementationPlan`) even though it never renders the plan text. + * + * Invariant: `agent` must keep every flag `planning` and `working` keep — + * `resolveContextBundle` fetches once at `agent` depth and feeds all three + * cores. Exported so the invariant test can pin this. + */ +export const DEPTH_PROJECTIONS: Record = { + summary: { + tags: false, + implementationPlan: true, + executionRecord: false, + files: false, + assignees: true, + acceptanceCriteria: true, + decisions: true, + links: true, + }, + working: { + tags: true, + implementationPlan: false, + executionRecord: false, + files: false, + assignees: true, + acceptanceCriteria: true, + decisions: true, + links: true, + }, + planning: { + tags: true, + implementationPlan: true, + executionRecord: false, + files: false, + assignees: false, + acceptanceCriteria: true, + decisions: true, + links: false, + }, + agent: { + tags: true, + implementationPlan: true, + executionRecord: true, + files: true, + assignees: true, + acceptanceCriteria: true, + decisions: true, + links: true, + }, + review: { + tags: true, + implementationPlan: true, + executionRecord: true, + files: true, + assignees: false, + acceptanceCriteria: true, + decisions: true, + links: true, + }, +}; + +/** Correlated assignee aggregate, identical to {@link fetchTaskFull}. */ +const ASSIGNEES_AGG = sql`(SELECT json_agg(json_build_object('userId', a.user_id, 'name', a.name, 'email', a.email) ORDER BY a.name) + FROM public.task_assignees_visible(t.id) a)`; + +/** Correlated acceptance-criteria aggregate, identical to {@link fetchTaskFull}. */ +const CRITERIA_AGG = sql`(SELECT json_agg(json_build_object('id', c.id, 'text', c.text, 'checked', c.checked) ORDER BY c.position, c.id) + FROM task_acceptance_criteria c + WHERE c.task_id = t.id)`; + +/** Correlated decisions aggregate, identical to {@link fetchTaskFull}. */ +const DECISIONS_AGG = sql`(SELECT json_agg(json_build_object('id', d.id, 'text', d.text, 'source', d.source, 'date', d.decision_date) ORDER BY d.position, d.id) + FROM task_decisions d + WHERE d.task_id = t.id)`; + +/** Correlated links aggregate, identical to {@link fetchTaskFull}. */ +const LINKS_AGG = sql`(SELECT json_agg(json_build_object('id', l.id, 'kind', l.kind, 'url', l.url, 'label', l.label, 'createdAt', l.created_at) ORDER BY l.created_at) + FROM task_links l + WHERE l.task_id = t.id)`; + +/** + * Select a `tasks` column when the depth reads it, else a typed `NULL` + * literal aliased to the same name so {@link TaskFullRawRow} stays stable. + * + * @param keep - Whether the depth reads the column. + * @param column - Bare `tasks` column name (already quoted as `t.`). + * @param alias - Output column alias. + * @param nullCast - Postgres cast applied to the `NULL` fallback. Restricted + * to known cast literals because the value reaches `sql.raw`. + * @returns SQL fragment for the SELECT list. + */ +function depthColumn( + keep: boolean, + column: SQL, + alias: string, + nullCast: "text" | "jsonb", +): SQL { + const aliasId = sql.identifier(alias); + return keep + ? sql`${column} AS ${aliasId}` + : sql`NULL::${sql.raw(nullCast)} AS ${aliasId}`; +} + +/** + * Select a child aggregate when the depth reads it, else a `NULL` literal so + * the caller's `?? []` fallback yields the empty projection. + * + * @param keep - Whether the depth reads the aggregate. + * @param agg - Correlated aggregate subquery. + * @param alias - Output column alias. + * @returns SQL fragment for the SELECT list. + */ +function depthAggregate(keep: boolean, agg: SQL, alias: string): SQL { + const aliasId = sql.identifier(alias); + return keep ? sql`${agg} AS ${aliasId}` : sql`NULL AS ${aliasId}`; +} + +/** + * Fetch the raw projection backing `getTaskForDepth` in a single round-trip, + * narrowed to the columns and child aggregates the supplied {@link + * TaskFetchDepth} renders. Columns no depth reads (`category`, `history`) and + * columns this depth omits are returned as type-stable `NULL` literals so the + * {@link TaskFullRawRow} shape is identical across depths. + * + * UNCHECKED: this helper performs NO authorization. The caller must assert + * task access (`assertTaskAccess`) before invoking. Depth-aware sibling of + * {@link fetchTaskFull}, which the web detail path uses. + * + * @param conn - Drizzle client or transaction handle. + * @param taskId - UUID of the task. + * @param depth - Context depth selecting the column projection. + * @returns Zero or one rows; callers handle the missing case. + */ +export async function fetchTaskForDepth( + conn: Conn, + taskId: string, + depth: TaskFetchDepth, +): Promise { + const p = DEPTH_PROJECTIONS[depth]; + return executeRaw( + conn, + sql` + SELECT + t.id, + t.project_id, + t.title, + t.sequence_number, + t.description, + t.status, + t."order", + NULL::text AS category, + ${depthColumn(p.implementationPlan, sql`t.implementation_plan`, "implementation_plan", "text")}, + ${depthColumn(p.executionRecord, sql`t.execution_record`, "execution_record", "text")}, + ${depthColumn(p.tags, sql`t.tags`, "tags", "jsonb")}, + t.priority, + t.estimate, + ${depthColumn(p.files, sql`t.files`, "files", "jsonb")}, + '[]'::jsonb AS history, + t.created_at, + t.updated_at, + p.identifier AS project_identifier, + ${depthAggregate(p.assignees, ASSIGNEES_AGG, "assignees")}, + ${depthAggregate(p.acceptanceCriteria, CRITERIA_AGG, "acceptance_criteria")}, + ${depthAggregate(p.decisions, DECISIONS_AGG, "decisions")}, + ${depthAggregate(p.links, LINKS_AGG, "links")} + FROM tasks t + JOIN projects p ON p.id = t.project_id + WHERE t.id = ${taskId} + `, + ); +} + /** * Fetch the raw projection backing `getTaskFull` in a single round-trip. * Joins `tasks` to `projects` and folds `task_assignees`, diff --git a/lib/graph/queries.ts b/lib/graph/queries.ts index 404daaab..231367a2 100644 --- a/lib/graph/queries.ts +++ b/lib/graph/queries.ts @@ -13,6 +13,7 @@ import { listMyTasks as coreListMyTasks, type CrossProjectSearchResult, } from "@/lib/data/task"; +import { loadProjectAccess } from "@/lib/auth/authorization"; import type { MyTask } from "@/lib/data/views"; export type { @@ -61,7 +62,8 @@ export type MyTasksListResultPayload = */ export async function getProjectChrome(projectId: string) { const ctx = await getAuthContext(); - return coreGetProjectChrome(ctx, projectId); + const access = await loadProjectAccess(ctx.userId, projectId); + return coreGetProjectChrome(ctx, projectId, access); } /** @@ -86,7 +88,8 @@ export async function listProjectsSlim() { */ export async function getProjectGraphSlim(projectId: string) { const ctx = await getAuthContext(); - return coreGetProjectGraphSlim(ctx, projectId); + const access = await loadProjectAccess(ctx.userId, projectId); + return coreGetProjectGraphSlim(ctx, projectId, access); } /** diff --git a/lib/server/request-loaders.ts b/lib/server/request-loaders.ts new file mode 100644 index 00000000..26d65cd3 --- /dev/null +++ b/lib/server/request-loaders.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { cache } from "react"; +import { listProjectsSlim } from "@/lib/graph/queries"; +import { listUserTeamsAction } from "@/lib/actions/team-list"; + +/** + * Sidebar project list, memoized for the lifetime of one RSC request. + * + * @returns Array of slim project rows for the current user. + */ +export const loadSidebarProjects = cache(listProjectsSlim); + +/** + * User team memberships, memoized for the lifetime of one RSC request. + * + * @returns Discriminated result containing the team list. + */ +export const loadUserTeams = cache(listUserTeamsAction); diff --git a/tests/api/__snapshots__/task-context.test.ts.snap b/tests/api/__snapshots__/task-context.test.ts.snap new file mode 100644 index 00000000..5b84c25e --- /dev/null +++ b/tests/api/__snapshots__/task-context.test.ts.snap @@ -0,0 +1,147 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`GET /api/task/[id]/context — golden bundle for a fully-populated task 1`] = ` +{ + "agent": +"# \`PRJ-2\` Central task +Tags: \`alpha\`, \`beta\` +Priority: \`high\` +Estimate: 3 pts +PR: https://example.test/pr/1 + +Central description + + +## Implementation Plan + +Step one then step two + + +## Prerequisites (context only — do NOT implement these) + +- \`PRJ-1\` **Prereq task** [done] — needs prereq output + + +## Upstream Execution Records + +### \`PRJ-1\` Prereq task +Prereq execution record + + +## Constraints + +- [explicit] Use approach X + + +## Done Means + +- [ ] It works + + +## Files + +- lib/a.ts +- lib/b.ts + + +## Assignees + +- User ctx-golden + + +## Links + +- [pull_request] PR 1 (https://example.test/pr/1) + + +## Execution Record + +Built the thing + + +## Downstream (what depends on this task's output) + +- \`PRJ-3\` **Downstream task** [draft] — consumes central" +, + "planning": +"# \`PRJ-2\` Central task +Tags: \`alpha\`, \`beta\` +Priority: \`high\` +Estimate: 3 pts + + +## Project Context + +Project: Project ctx-golden + + +## Description + +Central description + + +## Acceptance Criteria + +- [ ] It works + + +## Existing Implementation Plan + +Step one then step two + + +## Prerequisites (context only — do NOT implement these) + +- \`PRJ-1\` **Prereq task** [done] — needs prereq output + + +## What's Been Built (from done prerequisites) + +### \`PRJ-1\` Prereq task +Prereq execution record + + +## Decisions + +- [explicit] Use approach X + + +## Downstream (tasks that depend on this task's output) + +- \`PRJ-3\` **Downstream task** [draft] — consumes central + Downstream description" +, + "working": +"# \`PRJ-2\` "Central task" (in_review) + +## Description +Central description + +## Meta +- Priority: \`high\` +- Estimate: 3 pts +- Assignees: User ctx-golden +- PR: https://example.test/pr/1 + +## Tags +\`alpha\`, \`beta\` + +## Acceptance Criteria + +- [ ] It works + +## Hierarchy +project: "Project ctx-golden" > task: "Central task" + +## Decisions +- [explicit] Use approach X (2026-05-16) + +## Connected Tasks +- depends_on → \`PRJ-1\` "Prereq task" (done) — needs prereq output +- depends_on ← \`PRJ-3\` "Downstream task" (draft) — consumes central + +## Links +- [pull_request] PR 1 — https://example.test/pr/1" +, +} +`; diff --git a/tests/api/projects.test.ts b/tests/api/projects.test.ts index c7e2d464..f020482d 100644 --- a/tests/api/projects.test.ts +++ b/tests/api/projects.test.ts @@ -30,6 +30,20 @@ test("GET /api/projects — 200 with body and ETag for an authenticated caller", expect(body.some((p) => p.id === f.projectId)).toBe(true); }); +test("GET /api/projects — list entry has no history, categories, or createdAt keys", async () => { + const f = await seedUserOrgProject("projlist-keys"); + setSession({ user: { id: f.userId } }); + + const res = await GET(new Request("http://test/api/projects")); + expect(res.status).toBe(200); + const body = (await res.json()) as Array>; + const entry = body.find((p) => p.id === f.projectId); + expect(entry).toBeDefined(); + expect(Object.keys(entry!)).not.toContain("history"); + expect(Object.keys(entry!)).not.toContain("categories"); + expect(Object.keys(entry!)).not.toContain("createdAt"); +}); + test("GET /api/projects — 304 when If-None-Match matches the current ETag", async () => { const f = await seedUserOrgProject("projlist-304"); setSession({ user: { id: f.userId } }); diff --git a/tests/api/task-context.test.ts b/tests/api/task-context.test.ts index a42f43b2..4a7aaf68 100644 --- a/tests/api/task-context.test.ts +++ b/tests/api/task-context.test.ts @@ -1,8 +1,19 @@ -import { test, expect, afterEach } from "bun:test"; +import { test, expect, afterEach, spyOn } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject } from "@/tests/setup/seed"; +import { + seedRichContextTask, + normalizeContextGolden, +} from "@/tests/context/fixtures"; import { superuserPool } from "@/tests/setup/global"; import { GET } from "@/app/api/task/[taskId]/context/route"; +import * as taskData from "@/lib/data/task"; +import * as effectiveDeps from "@/lib/graph/effective-deps"; +import * as projectData from "@/lib/data/project"; +import { makeAuthContext } from "@/lib/auth/context"; +import { buildAgentContext } from "@/lib/context/_core/agent"; +import { buildPlanningContext } from "@/lib/context/_core/planning"; +import { buildWorkingContext } from "@/lib/context/_core/working"; const setSession = ( globalThis as unknown as { @@ -29,6 +40,30 @@ async function addTask(projectId: string, suffix: string): Promise { } } +/** + * Fetch the `{ agent, planning, working }` payload for a seeded task as the + * given owner. + * + * @param taskId - UUID of the task. + * @param userId - Owner user id. + * @returns The parsed bundle payload. + */ +async function fetchBundle( + taskId: string, + userId: string, +): Promise<{ agent: string; planning: string; working: string }> { + setSession({ user: { id: userId } }); + const res = await GET(new Request(`http://test/api/task/${taskId}/context`), { + params: Promise.resolve({ taskId }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { + agent: string; + planning: string; + working: string; + }; +} + test("GET /api/task/[id]/context — 401 when unauthenticated", async () => { const res = await GET( new Request( @@ -100,3 +135,120 @@ test("GET /api/task/[id]/context — 304 when If-None-Match matches", async () = expect(conditional.headers.get("ETag")).toBe(etag); expect(await conditional.text()).toBe(""); }); + +test("GET /api/task/[id]/context — golden bundle for a fully-populated task", async () => { + const fx = await seedRichContextTask("ctx-golden"); + const body = await fetchBundle(fx.taskId, fx.userId); + + expect({ + agent: normalizeContextGolden(body.agent, "ctx-golden"), + planning: normalizeContextGolden(body.planning, "ctx-golden"), + working: normalizeContextGolden(body.working, "ctx-golden"), + }).toMatchSnapshot(); +}); + +test("GET /api/task/[id]/context — one full-task read and one traversal", async () => { + const fx = await seedRichContextTask("ctx-counts"); + + const fetchSpy = spyOn(taskData, "getTaskForDepthTx"); + const traversalSpy = spyOn(effectiveDeps, "loadBundleDeps"); + + try { + await fetchBundle(fx.taskId, fx.userId); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(traversalSpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + traversalSpy.mockRestore(); + } +}); + +test("GET /api/task/[id]/context — validator path skips the full-task read", async () => { + const fx = await seedRichContextTask("ctx-validator"); + + setSession({ user: { id: fx.userId } }); + const first = await GET( + new Request(`http://test/api/task/${fx.taskId}/context`), + { params: Promise.resolve({ taskId: fx.taskId }) }, + ); + const etag = first.headers.get("ETag"); + expect(etag).toBeTruthy(); + + const fetchSpy = spyOn(taskData, "getTaskForDepthTx"); + const traversalSpy = spyOn(effectiveDeps, "loadBundleDeps"); + + try { + const conditional = await GET( + new Request(`http://test/api/task/${fx.taskId}/context`, { + headers: { "If-None-Match": etag! }, + }), + { params: Promise.resolve({ taskId: fx.taskId }) }, + ); + expect(conditional.status).toBe(304); + expect(fetchSpy).toHaveBeenCalledTimes(0); + expect(traversalSpy).toHaveBeenCalledTimes(0); + } finally { + fetchSpy.mockRestore(); + traversalSpy.mockRestore(); + } +}); + +test("MCP buildWorkingContext fetches per depth: no dependency closure", async () => { + const fx = await seedRichContextTask("ctx-mcp-working"); + const ctx = makeAuthContext(fx.userId); + + const fetchSpy = spyOn(taskData, "getTaskForDepthTx"); + const traversalSpy = spyOn(effectiveDeps, "loadBundleDeps"); + const projectSpy = spyOn(projectData, "getProjectHeader"); + + try { + await buildWorkingContext(ctx, fx.taskId); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(traversalSpy).toHaveBeenCalledTimes(0); + expect(projectSpy).toHaveBeenCalledTimes(0); + } finally { + fetchSpy.mockRestore(); + traversalSpy.mockRestore(); + projectSpy.mockRestore(); + } +}); + +test("MCP buildAgentContext fetches per depth: closure but no project header", async () => { + const fx = await seedRichContextTask("ctx-mcp-agent"); + const ctx = makeAuthContext(fx.userId); + + const fetchSpy = spyOn(taskData, "getTaskForDepthTx"); + const traversalSpy = spyOn(effectiveDeps, "loadBundleDeps"); + const projectSpy = spyOn(projectData, "getProjectHeader"); + + try { + await buildAgentContext(ctx, fx.taskId); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(traversalSpy).toHaveBeenCalledTimes(1); + expect(projectSpy).toHaveBeenCalledTimes(0); + } finally { + fetchSpy.mockRestore(); + traversalSpy.mockRestore(); + projectSpy.mockRestore(); + } +}); + +test("MCP buildPlanningContext fetches per depth: closure plus project header", async () => { + const fx = await seedRichContextTask("ctx-mcp-planning"); + const ctx = makeAuthContext(fx.userId); + + const fetchSpy = spyOn(taskData, "getTaskForDepthTx"); + const traversalSpy = spyOn(effectiveDeps, "loadBundleDeps"); + const projectSpy = spyOn(projectData, "getProjectHeader"); + + try { + await buildPlanningContext(ctx, fx.taskId); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(traversalSpy).toHaveBeenCalledTimes(1); + expect(projectSpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + traversalSpy.mockRestore(); + projectSpy.mockRestore(); + } +}); diff --git a/tests/context/__snapshots__/agent.test.ts.snap b/tests/context/__snapshots__/agent.test.ts.snap new file mode 100644 index 00000000..e6665205 --- /dev/null +++ b/tests/context/__snapshots__/agent.test.ts.snap @@ -0,0 +1,63 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`buildAgentContext under app_user golden: fully-populated task renders byte-identical agent context 1`] = ` +"# \`PRJ-2\` Central task +Tags: \`alpha\`, \`beta\` +Priority: \`high\` +Estimate: 3 pts +PR: https://example.test/pr/1 + +Central description + + +## Implementation Plan + +Step one then step two + + +## Prerequisites (context only — do NOT implement these) + +- \`PRJ-1\` **Prereq task** [done] — needs prereq output + + +## Upstream Execution Records + +### \`PRJ-1\` Prereq task +Prereq execution record + + +## Constraints + +- [explicit] Use approach X + + +## Done Means + +- [ ] It works + + +## Files + +- lib/a.ts +- lib/b.ts + + +## Assignees + +- User agent-ctx-golden + + +## Links + +- [pull_request] PR 1 (https://example.test/pr/1) + + +## Execution Record + +Built the thing + + +## Downstream (what depends on this task's output) + +- \`PRJ-3\` **Downstream task** [draft] — consumes central" +`; diff --git a/tests/context/__snapshots__/planning.test.ts.snap b/tests/context/__snapshots__/planning.test.ts.snap new file mode 100644 index 00000000..4942c45a --- /dev/null +++ b/tests/context/__snapshots__/planning.test.ts.snap @@ -0,0 +1,50 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`buildPlanningContext under app_user golden: fully-populated task renders byte-identical planning context 1`] = ` +"# \`PRJ-2\` Central task +Tags: \`alpha\`, \`beta\` +Priority: \`high\` +Estimate: 3 pts + + +## Project Context + +Project: Project planning-ctx-golden + + +## Description + +Central description + + +## Acceptance Criteria + +- [ ] It works + + +## Existing Implementation Plan + +Step one then step two + + +## Prerequisites (context only — do NOT implement these) + +- \`PRJ-1\` **Prereq task** [done] — needs prereq output + + +## What's Been Built (from done prerequisites) + +### \`PRJ-1\` Prereq task +Prereq execution record + + +## Decisions + +- [explicit] Use approach X + + +## Downstream (tasks that depend on this task's output) + +- \`PRJ-3\` **Downstream task** [draft] — consumes central + Downstream description" +`; diff --git a/tests/context/__snapshots__/review.test.ts.snap b/tests/context/__snapshots__/review.test.ts.snap new file mode 100644 index 00000000..bf758244 --- /dev/null +++ b/tests/context/__snapshots__/review.test.ts.snap @@ -0,0 +1,83 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`buildReviewContext under app_user golden: fully-populated task renders byte-identical review context 1`] = ` +"# \`PRJ-2\` Central task +Tags: \`alpha\`, \`beta\` +Priority: \`high\` +Estimate: 3 pts +Status: \`in_review\` +PR: https://example.test/pr/1 + + +## Project Context + +Project: Project review-ctx-golden + + +## Description + +Central description + + +## Acceptance Criteria (as evaluated by implementer) + +- [ ] It works + + +## Implementation Plan (as planned) + +Step one then step two + + +## Execution Record (as built) + +Built the thing + + +## Files + +- lib/a.ts +- lib/b.ts + + +## Plan-vs-Files Drift + +Skipped: no path-like tokens extracted from plan. + + +## Decisions + +- [explicit] Use approach X + + +## Links + +- [pull_request] PR 1 (https://example.test/pr/1) + + +## Prerequisites + +- \`PRJ-1\` **Prereq task** [done] — needs prereq output + + +## Upstream Execution Records + +### \`PRJ-1\` Prereq task +Prereq execution record + + +## Downstream Impact (edges to refresh after merge) + +- \`PRJ-3\` **Downstream task** [draft] — consumes central + + +## Review Lens Prompts + +When producing the structured verdict, address each lens against the diff and the executionRecord above. Cite real file paths and line numbers; \`no findings\` is a valid answer. + +- **Security**: trust-boundary input validation, authn / authz on new endpoints, secret handling, SQL or command injection surfaces, deserialization of untrusted data. +- **Performance**: N+1 query patterns, unbounded memory growth, synchronous I/O on hot paths, missing indexes implied by new query shapes. +- **Reliability**: failure modes the plan listed vs the diff's handling, silent error swallowing, idempotency on retry-eligible paths, transactional boundaries. +- **Observability**: logs / metrics / traces consistent with the rest of the codebase, no high-cardinality dimensions that blow the metrics backend. +- **Codebase standards**: project conventions from \`CLAUDE.md\` and the patterns upstream executionRecord entries cite. Lint and formatting belong to the toolchain; flag substantive deviations only." +`; diff --git a/tests/context/__snapshots__/summary.test.ts.snap b/tests/context/__snapshots__/summary.test.ts.snap new file mode 100644 index 00000000..8083ed11 --- /dev/null +++ b/tests/context/__snapshots__/summary.test.ts.snap @@ -0,0 +1,15 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`buildSummaryContext under app_user golden: fully-populated task renders byte-identical summary (has plan) 1`] = ` +"# \`PRJ-2\` "Central task" [in_review] +Project: "Project summary-ctx-golden" + +Central description + +2 depends_on | 1 criteria | 1 decisions | priority: high | 3pts | 1 assigned | has plan +PR: https://example.test/pr/1 + +## Edges +- depends_on → \`PRJ-1\` "Prereq task" [done] \`\` — needs prereq output +- depends_on ← \`PRJ-3\` "Downstream task" [draft] \`\` — consumes central" +`; diff --git a/tests/context/__snapshots__/working.test.ts.snap b/tests/context/__snapshots__/working.test.ts.snap new file mode 100644 index 00000000..c4279d78 --- /dev/null +++ b/tests/context/__snapshots__/working.test.ts.snap @@ -0,0 +1,34 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`buildWorkingContext under app_user golden: fully-populated task renders byte-identical working context 1`] = ` +"# \`PRJ-2\` "Central task" (in_review) + +## Description +Central description + +## Meta +- Priority: \`high\` +- Estimate: 3 pts +- Assignees: User working-ctx-golden +- PR: https://example.test/pr/1 + +## Tags +\`alpha\`, \`beta\` + +## Acceptance Criteria + +- [ ] It works + +## Hierarchy +project: "Project working-ctx-golden" > task: "Central task" + +## Decisions +- [explicit] Use approach X (2026-05-16) + +## Connected Tasks +- depends_on → \`PRJ-1\` "Prereq task" (done) — needs prereq output +- depends_on ← \`PRJ-3\` "Downstream task" (draft) — consumes central + +## Links +- [pull_request] PR 1 — https://example.test/pr/1" +`; diff --git a/tests/context/agent.test.ts b/tests/context/agent.test.ts index edcea554..8db86c8e 100644 --- a/tests/context/agent.test.ts +++ b/tests/context/agent.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { seedRichContextTask, normalizeContextGolden } from "./fixtures"; import { buildAgentContext } from "@/lib/context/_core/agent"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -74,6 +75,15 @@ describe("buildAgentContext under app_user", () => { expect(result).not.toContain("Cancelled middle B"); }); + test("golden: fully-populated task renders byte-identical agent context", async () => { + const fx = await seedRichContextTask("agent-ctx-golden"); + const ctx = makeAuthContext(fx.userId); + const result = await buildAgentContext(ctx, fx.taskId); + expect( + normalizeContextGolden(result, "agent-ctx-golden"), + ).toMatchSnapshot(); + }); + test("rejects cross-team callers (RLS isolation)", async () => { const fxA = await seedUserOrgProject("agent-ctx-a"); const fxB = await seedUserOrgProject("agent-ctx-b"); diff --git a/tests/context/fixtures.ts b/tests/context/fixtures.ts new file mode 100644 index 00000000..cb631cb6 --- /dev/null +++ b/tests/context/fixtures.ts @@ -0,0 +1,88 @@ +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; + +/** A rich context fixture: the central task id plus its owner user id. */ +export type RichContextFixture = { taskId: string; userId: string }; + +/** + * Seed a fully-populated central task plus one done upstream dependency (with + * an execution record) and one downstream dependent. The central task carries + * every droppable column the depth-aware fetch gates: implementation_plan, + * execution_record, files, history, category, tags, priority, estimate, an + * acceptance criterion, a decision, a pull_request link, and an assignee. The + * golden snapshots built on this fixture are the byte-identity contract for + * the per-depth column projection. + * + * @param suffix - Slug/email/identifier suffix so fixtures don't collide. + * @returns The central task id and the owner user id. + */ +export async function seedRichContextTask( + suffix: string, +): Promise { + const fx = await seedUserOrgProject(suffix); + const sr = serviceRoleConnect(); + try { + const [main] = await sr<{ id: string }[]>` + INSERT INTO tasks + ("project_id", "title", "sequence_number", "description", "status", + "implementation_plan", "execution_record", "files", "tags", "priority", + "estimate", "category", "history") + VALUES + (${fx.projectId}, 'Central task', 2, 'Central description', 'in_review', + 'Step one then step two', 'Built the thing', + '["lib/a.ts", "lib/b.ts"]'::jsonb, '["alpha", "beta"]'::jsonb, + 'high', 3, 'feature', + '[{"id": "h1", "date": "2026-05-16T00:00:00.000Z", "action": "created"}]'::jsonb) + RETURNING id`; + const [prereq] = await sr<{ id: string }[]>` + INSERT INTO tasks + ("project_id", "title", "sequence_number", "description", "status", + "execution_record") + VALUES + (${fx.projectId}, 'Prereq task', 1, 'Prereq description', 'done', + 'Prereq execution record') + RETURNING id`; + const [downstream] = await sr<{ id: string }[]>` + INSERT INTO tasks + ("project_id", "title", "sequence_number", "description") + VALUES (${fx.projectId}, 'Downstream task', 3, 'Downstream description') + RETURNING id`; + await sr` + INSERT INTO task_edges (source_task_id, target_task_id, edge_type, note) + VALUES (${main.id}, ${prereq.id}, 'depends_on', 'needs prereq output')`; + await sr` + INSERT INTO task_edges (source_task_id, target_task_id, edge_type, note) + VALUES (${downstream.id}, ${main.id}, 'depends_on', 'consumes central')`; + await sr` + INSERT INTO task_acceptance_criteria (id, task_id, position, text, checked) + VALUES (gen_random_uuid(), ${main.id}, 0, 'It works', false)`; + await sr` + INSERT INTO task_decisions (id, task_id, position, text, source, decision_date) + VALUES (gen_random_uuid(), ${main.id}, 0, 'Use approach X', 'explicit', '2026-05-16')`; + await sr` + INSERT INTO task_links (task_id, url, kind, label) + VALUES (${main.id}, 'https://example.test/pr/1', 'pull_request', 'PR 1')`; + await sr` + INSERT INTO task_assignees (task_id, user_id) + VALUES (${main.id}, ${fx.userId})`; + return { taskId: main.id, userId: fx.userId }; + } finally { + await sr.end({ timeout: 5 }); + } +} + +/** Matches any v4-shaped UUID for golden normalization. */ +const UUID_PATTERN = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g; + +/** + * Normalize a rendered context golden so per-run-random identifiers don't + * defeat the snapshot. Collapses the seeded project's sequence-suffixed + * identifier and any embedded UUIDs to stable placeholders. + * + * @param s - Rendered context string. + * @param suffix - The fixture suffix used in the project identifier. + * @returns The normalized string. + */ +export function normalizeContextGolden(s: string, suffix: string): string { + return s.replaceAll(`PRJ${suffix}`, "PRJ").replace(UUID_PATTERN, ""); +} diff --git a/tests/context/overview.test.ts b/tests/context/overview.test.ts index a40262c9..9629a4a3 100644 --- a/tests/context/overview.test.ts +++ b/tests/context/overview.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; import { buildProjectOverview } from "@/lib/context/_core/overview"; +import { compress } from "@/lib/context/format"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -40,6 +41,45 @@ describe("buildProjectOverview under app_user", () => { expect(result.edges[0].edgeType).toBe("depends_on"); }); + test("truncates description byte-identically to compress and preserves titles, counts, edges", async () => { + const fx = await seedUserOrgProject("overview-ctx-trunc"); + const longDesc = "x".repeat(4000); + const astralDesc = "😀".repeat(2000); + const sr = serviceRoleConnect(); + try { + const [a] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number, description) + VALUES (${fx.projectId}, 'Long', 1, ${longDesc}) + RETURNING id`; + const [b] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'Short', 2) + RETURNING id`; + await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number, description) + VALUES (${fx.projectId}, 'Astral', 3, ${astralDesc}) + RETURNING id`; + await sr`INSERT INTO task_edges (source_task_id, target_task_id, edge_type) + VALUES (${b.id}, ${a.id}, 'depends_on')`; + } finally { + await sr.end({ timeout: 5 }); + } + + const ctx = makeAuthContext(fx.userId); + const result = await buildProjectOverview(ctx, fx.projectId); + expect(result.totalTasks).toBe(3); + expect(result.tasks.map((t) => t.title).sort()).toEqual([ + "Astral", + "Long", + "Short", + ]); + expect(result.edges.length).toBe(1); + const taskLong = result.tasks.find((t) => t.title === "Long"); + expect(taskLong?.description).toBe(compress(longDesc, 100)); + const taskAstral = result.tasks.find((t) => t.title === "Astral"); + expect(taskAstral?.description).toBe(compress(astralDesc, 100)); + }); + test("rejects cross-team callers (RLS isolation under app_user)", async () => { const fxA = await seedUserOrgProject("overview-ctx-a"); const fxB = await seedUserOrgProject("overview-ctx-b"); diff --git a/tests/context/planning.test.ts b/tests/context/planning.test.ts index 55b6b1c2..36c14a1d 100644 --- a/tests/context/planning.test.ts +++ b/tests/context/planning.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { seedRichContextTask, normalizeContextGolden } from "./fixtures"; import { buildPlanningContext } from "@/lib/context/_core/planning"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -70,6 +71,15 @@ describe("buildPlanningContext under app_user", () => { expect(result).not.toContain("Cancelled middle B"); }); + test("golden: fully-populated task renders byte-identical planning context", async () => { + const fx = await seedRichContextTask("planning-ctx-golden"); + const ctx = makeAuthContext(fx.userId); + const result = await buildPlanningContext(ctx, fx.taskId); + expect( + normalizeContextGolden(result, "planning-ctx-golden"), + ).toMatchSnapshot(); + }); + test("rejects cross-team callers (RLS isolation under app_user)", async () => { const fxA = await seedUserOrgProject("planning-ctx-a"); const fxB = await seedUserOrgProject("planning-ctx-b"); diff --git a/tests/context/review.test.ts b/tests/context/review.test.ts index 5f796401..4231eda9 100644 --- a/tests/context/review.test.ts +++ b/tests/context/review.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { seedRichContextTask, normalizeContextGolden } from "./fixtures"; import { buildReviewContext } from "@/lib/context/_core/review"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -72,6 +73,15 @@ describe("buildReviewContext under app_user", () => { expect(result).not.toContain("Cancelled middle B"); }); + test("golden: fully-populated task renders byte-identical review context", async () => { + const fx = await seedRichContextTask("review-ctx-golden"); + const ctx = makeAuthContext(fx.userId); + const result = await buildReviewContext(ctx, fx.taskId); + expect( + normalizeContextGolden(result, "review-ctx-golden"), + ).toMatchSnapshot(); + }); + test("rejects cross-team callers (RLS isolation under app_user)", async () => { const fxA = await seedUserOrgProject("review-ctx-a"); const fxB = await seedUserOrgProject("review-ctx-b"); diff --git a/tests/context/summary.test.ts b/tests/context/summary.test.ts index cec830e4..9a80b012 100644 --- a/tests/context/summary.test.ts +++ b/tests/context/summary.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { seedRichContextTask, normalizeContextGolden } from "./fixtures"; import { buildSummaryContext } from "@/lib/context/_core/summary"; +import { formatSummary } from "@/lib/graph/format-responses"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -41,6 +43,16 @@ describe("buildSummaryContext under app_user", () => { expect(result.edges[0].connectedTaskTitle).toBe("Other task"); }); + test("golden: fully-populated task renders byte-identical summary (has plan)", async () => { + const fx = await seedRichContextTask("summary-ctx-golden"); + const ctx = makeAuthContext(fx.userId); + const result = formatSummary(await buildSummaryContext(ctx, fx.taskId)); + expect(result).toContain("has plan"); + expect( + normalizeContextGolden(result, "summary-ctx-golden"), + ).toMatchSnapshot(); + }); + test("rejects cross-team callers (RLS isolation under app_user)", async () => { const fxA = await seedUserOrgProject("summary-ctx-a"); const fxB = await seedUserOrgProject("summary-ctx-b"); diff --git a/tests/context/working.test.ts b/tests/context/working.test.ts index b5b03b7a..2b4e3878 100644 --- a/tests/context/working.test.ts +++ b/tests/context/working.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; -import { buildWorkingContext } from "@/lib/context/_core/working"; +import { seedRichContextTask, normalizeContextGolden } from "./fixtures"; +import { + buildWorkingContext, + formatWorkingContext, +} from "@/lib/context/_core/working"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -64,6 +68,16 @@ describe("buildWorkingContext under app_user", () => { expect(result.edges[0].note).toBe("shares the same auth surface"); }); + test("golden: fully-populated task renders byte-identical working context", async () => { + const fx = await seedRichContextTask("working-ctx-golden"); + const ctx = makeAuthContext(fx.userId); + const raw = await buildWorkingContext(ctx, fx.taskId); + const result = await formatWorkingContext(raw); + expect( + normalizeContextGolden(result, "working-ctx-golden"), + ).toMatchSnapshot(); + }); + test("rejects cross-team callers (RLS isolation under app_user)", async () => { const fxA = await seedUserOrgProject("working-ctx-a"); const fxB = await seedUserOrgProject("working-ctx-b"); diff --git a/tests/data/project.test.ts b/tests/data/project.test.ts index e6a90e72..0763efb9 100644 --- a/tests/data/project.test.ts +++ b/tests/data/project.test.ts @@ -12,6 +12,7 @@ import { listProjectsSlim, listProjectsForMcp, } from "@/lib/data/project"; +import { findProjectAccess } from "@/lib/data/access"; import { makeAuthContext } from "@/lib/auth/context"; afterEach(async () => { @@ -455,3 +456,49 @@ test("listProjectsForMcp skips teams with zero projects", async () => { rows.find((r) => r.organization.name === "Empty Team Mcp"), ).toBeUndefined(); }); + +test("findProjectAccess access row omits history and createdAt", async () => { + const f = await seedUserOrgProject("access-projection"); + const sqlc = superuserPool(); + try { + await sqlc` + UPDATE projects + SET history = ${'[{"kind":"status_change","from":"brainstorming","to":"active","at":"2025-01-01T00:00:00Z"}]'}::jsonb + WHERE id = ${f.projectId} + `; + } finally { + await sqlc.end({ timeout: 5 }); + } + + const access = await findProjectAccess(f.userId, f.projectId); + + expect(access).not.toBeNull(); + const keys = Object.keys(access!.project); + expect(keys).not.toContain("history"); + expect(keys).not.toContain("createdAt"); + expect(keys.sort()).toEqual([ + "categories", + "description", + "id", + "identifier", + "organizationId", + "status", + "title", + "updatedAt", + ]); +}); + +test("graph and chrome reads reject a pre-resolved access row for another project", async () => { + const a = await seedUserOrgProject("access-mismatch-a"); + const b = await seedUserOrgProject("access-mismatch-b"); + const access = await findProjectAccess(a.userId, a.projectId); + expect(access).not.toBeNull(); + + const ctx = makeAuthContext(a.userId); + await expect(getProjectGraphSlim(ctx, b.projectId, access!)).rejects.toThrow( + "pre-resolved access row", + ); + await expect(getProjectChrome(ctx, b.projectId, access!)).rejects.toThrow( + "pre-resolved access row", + ); +}); diff --git a/tests/data/task.test.ts b/tests/data/task.test.ts index 4ced33d9..591ce083 100644 --- a/tests/data/task.test.ts +++ b/tests/data/task.test.ts @@ -1,7 +1,7 @@ import { test, expect, afterEach } from "bun:test"; import { truncateAll } from "@/tests/setup/schema"; import { superuserPool } from "@/tests/setup/global"; -import { seedUserOrgProject } from "@/tests/setup/seed"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; import { createTask, deleteTask, @@ -14,6 +14,7 @@ import { getTaskFull, getTaskFullWithEdges, } from "@/lib/data/task"; +import { findTaskAccess } from "@/lib/data/access"; import { getProjectMaxUpdatedAt } from "@/lib/data/project"; import { makeAuthContext } from "@/lib/auth/context"; import { ForbiddenError } from "@/lib/auth/authorization"; @@ -1414,3 +1415,22 @@ test("foreign key rejects orphan decision insert", async () => { await sqlc.end({ timeout: 5 }); } }); + +test("findTaskAccess returns only the gate columns", async () => { + const fx = await seedUserOrgProject("access-gate"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number, description, implementation_plan) + VALUES (${fx.projectId}, 'T', 1, repeat('x', 4000), repeat('y', 4000)) + RETURNING id`; + taskId = t.id; + } finally { + await sr.end({ timeout: 5 }); + } + const row = await findTaskAccess(fx.userId, taskId); + expect(Object.keys(row ?? {}).sort()).toEqual( + ["files", "id", "projectId", "status", "title", "updatedAt"].sort(), + ); +}); diff --git a/tests/db/fetch-task-full.test.ts b/tests/db/fetch-task-full.test.ts new file mode 100644 index 00000000..71f457db --- /dev/null +++ b/tests/db/fetch-task-full.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { DEPTH_PROJECTIONS } from "@/lib/db/raw/fetch-task-full"; + +test("agent depth keeps every column planning and working read", () => { + const agent = DEPTH_PROJECTIONS.agent; + const flags = Object.keys(agent) as (keyof typeof agent)[]; + + for (const depth of ["planning", "working"] as const) { + for (const flag of flags) { + if (DEPTH_PROJECTIONS[depth][flag]) { + expect({ depth, flag, agentKeeps: agent[flag] }).toEqual({ + depth, + flag, + agentKeeps: true, + }); + } + } + } +});