Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions lib/context/_core/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import "server-only";

import { getDependencyChain, getDownstreamTx } from "@/lib/data/traversal";
import {
buildDepAdjacency,
walkEffectiveDepsBounded,
} from "@/lib/graph/effective-deps";
import {
fetchDependencyTasks,
fetchEdgeNotesBySource,
Expand Down Expand Up @@ -39,13 +42,27 @@ export async function buildAgentContext(
const assignees = task.assignees;
const links = task.links;

const downstream = await getDownstreamTx(tx, taskId, 2);

const [deps, upstreamEdgeNotes] = await Promise.all([
getDependencyChain(taskId, task.projectId, 2, tx),
const [{ adj, taskStatus }, upstreamEdgeNotes] = await Promise.all([
buildDepAdjacency(task.projectId, tx),
fetchEdgeNotesBySource(task.projectId, taskId, tx),
]);

const reverseAdj = new Map<string, string[]>();
for (const [src, targets] of adj) {
for (const t of targets) {
const list = reverseAdj.get(t) ?? [];
list.push(src);
reverseAdj.set(t, list);
}
}

const deps = [
...walkEffectiveDepsBounded(taskId, adj, taskStatus, 2).keys(),
].map((id) => ({ id }));
const downstream = [
...walkEffectiveDepsBounded(taskId, reverseAdj, taskStatus, 2).keys(),
].map((id) => ({ id }));

const prLink = links.find((l) => l.kind === "pull_request");

const headerLines: string[] = [
Expand Down
32 changes: 26 additions & 6 deletions lib/context/_core/planning.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import "server-only";

import { getDependencyChain, getDownstreamTx } from "@/lib/data/traversal";
import {
buildDepAdjacency,
walkEffectiveDepsBounded,
} from "@/lib/graph/effective-deps";
import {
fetchDependencyTasks,
fetchEdgeNotesBySource,
Expand Down Expand Up @@ -31,7 +34,23 @@ export async function buildPlanningContext(
): Promise<string> {
return withUserContext(ctx.userId, async (tx) => {
const task = await getTaskFullTx(tx, taskId);
const downstream = await getDownstreamTx(tx, taskId, 2);

const { adj, taskStatus } = await buildDepAdjacency(task.projectId, tx);
const reverseAdj = new Map<string, string[]>();
for (const [src, targets] of adj) {
for (const t of targets) {
const list = reverseAdj.get(t) ?? [];
list.push(src);
reverseAdj.set(t, list);
}
}
const deps = [
...walkEffectiveDepsBounded(taskId, adj, taskStatus, 2).keys(),
].map((id) => ({ id }));
const downstream = [
...walkEffectiveDepsBounded(taskId, reverseAdj, taskStatus, 2).keys(),
].map((id) => ({ id }));

const project = await getProjectHeader(task.projectId, tx);
if (!project) {
console.error("Task has no joinable project", {
Expand Down Expand Up @@ -78,10 +97,11 @@ export async function buildPlanningContext(
);
}

const [deps, upstreamEdgeNotes] = await Promise.all([
getDependencyChain(taskId, task.projectId, 2, tx),
fetchEdgeNotesBySource(task.projectId, taskId, tx),
]);
const upstreamEdgeNotes = await fetchEdgeNotesBySource(
task.projectId,
taskId,
tx,
);

if (deps.length > 0) {
const prereqLines: string[] = [];
Expand Down
32 changes: 26 additions & 6 deletions lib/context/_core/review.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import "server-only";

import { getDependencyChain, getDownstreamTx } from "@/lib/data/traversal";
import {
buildDepAdjacency,
walkEffectiveDepsBounded,
} from "@/lib/graph/effective-deps";
import {
fetchDependencyTasks,
fetchEdgeNotesBySource,
Expand Down Expand Up @@ -60,7 +63,23 @@ export async function buildReviewContext(
): Promise<string> {
return withUserContext(ctx.userId, async (tx) => {
const task = await getTaskFullTx(tx, taskId);
const downstream = await getDownstreamTx(tx, taskId, 2);

const { adj, taskStatus } = await buildDepAdjacency(task.projectId, tx);
const reverseAdj = new Map<string, string[]>();
for (const [src, targets] of adj) {
for (const t of targets) {
const list = reverseAdj.get(t) ?? [];
list.push(src);
reverseAdj.set(t, list);
}
}
const deps = [
...walkEffectiveDepsBounded(taskId, adj, taskStatus, 2).keys(),
].map((id) => ({ id }));
const downstream = [
...walkEffectiveDepsBounded(taskId, reverseAdj, taskStatus, 2).keys(),
].map((id) => ({ id }));

const project = await getProjectHeader(task.projectId, tx);
if (!project) {
console.error("Task has no joinable project", {
Expand All @@ -76,10 +95,11 @@ export async function buildReviewContext(
const taskRef = task.taskRef;
const links = task.links;

const [deps, upstreamEdgeNotes] = await Promise.all([
getDependencyChain(taskId, task.projectId, 2, tx),
fetchEdgeNotesBySource(task.projectId, taskId, tx),
]);
const upstreamEdgeNotes = await fetchEdgeNotesBySource(
task.projectId,
taskId,
tx,
);

const prLink = links.find((l) => l.kind === "pull_request");

Expand Down
34 changes: 0 additions & 34 deletions lib/data/traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { and, eq, sql } from "drizzle-orm";
import { type Conn } from "@/lib/db/raw";
import { withUserContext, type Tx } from "@/lib/db/rls";
import { tasks, projects, taskEdges } from "@/lib/db/schema";
import { fetchDependencyChain } from "@/lib/db/raw/fetch-dependency-chain";
import { fetchDownstream } from "@/lib/db/raw/fetch-downstream";
import { asIdentifier, composeTaskRef } from "@/lib/graph/identifier";
import { buildEffectiveDepGraph } from "@/lib/graph/effective-deps";
Expand Down Expand Up @@ -47,39 +46,6 @@ export async function getAncestors(
return [{ id: project.id, type: "project", title: project.title }];
}

// ---------------------------------------------------------------------------
// Dependency chain — internal helper (recursive CTE)
// ---------------------------------------------------------------------------

/** A task in a dependency chain with depth. */
type DependencyNode = {
id: string;
depth: number;
};

/**
* Follow `depends_on` edges recursively up to maxDepth. Internal —
* caller asserted task access first.
*
* Defense-in-depth: every step joins `tasks` and filters on `projectId` so
* a stale or hand-crafted cross-project edge cannot pull tasks from another
* project (and therefore another team) into the result.
*
* @param taskId - UUID of the starting task.
* @param projectId - UUID of the project the starting task belongs to.
* @param maxDepth - Maximum traversal depth (default 10).
* @param conn - RLS-scoped {@link Conn} from an active `withUserContext` frame.
* @returns Array of dependency tasks with depth.
*/
export async function getDependencyChain(
taskId: string,
projectId: string,
maxDepth = 10,
conn: Conn,
): Promise<DependencyNode[]> {
return fetchDependencyChain(conn, taskId, projectId, maxDepth);
}

// ---------------------------------------------------------------------------
// Connected tasks — internal helper (1-hop neighbors)
// ---------------------------------------------------------------------------
Expand Down
144 changes: 124 additions & 20 deletions lib/graph/effective-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,31 @@ export type EffectiveDepGraph = {
};

/**
* Build the effective dependency graph for a project.
* Load the raw dependency-traversal substrate for a project: the
* `depends_on` adjacency map, the full task-id → status map (all tasks,
* cancelled included so the walks can pass through them), and the
* active-only task info map (cancelled excluded).
*
* Treats cancelled tasks as transparent: walks through them to find the
* nearest active prerequisite, but excludes them from the result graph.
* Used by getBlockedTasks, getCriticalPath, and deriveTaskStatesSlim (which
* in turn backs getReadyTasks and getPlannableTasks) so they all share
* consistent transitive-aware semantics.
* This is the exact substrate `buildEffectiveDepGraph` needs and the
* depth-bounded bundle walks (`walkEffectiveDepsBounded`) also need, kept
* as one source of truth so the analyze tools and the context bundles
* derive their dependency graphs from identical data.
*
* @param projectId - UUID of the project.
* @param conn - Drizzle client or transaction handle. Callers running under a
* `withUserContext` transaction must pass the active `tx` so the underlying
* reads participate in the same RLS-scoped frame; standalone callers pass
* the bare `db` pool client (data-layer scope only — boundary enforced by
* the lint rule on this directory).
* @returns The effective dependency graph (active-only nodes, transitive edges).
* @param conn - Drizzle client or transaction handle. Callers running under
* a `withUserContext` transaction must pass the active `tx` so the reads
* participate in the same RLS-scoped frame.
* @returns The adjacency map, the all-tasks status map, and the active-task
* info map.
*/
export async function buildEffectiveDepGraph(
export async function buildDepAdjacency(
projectId: string,
conn: Conn,
): Promise<EffectiveDepGraph> {
): Promise<{
adj: Map<string, string[]>;
taskStatus: Map<string, string>;
activeTasks: Map<string, ActiveTaskInfo>;
}> {
const allTasks = await listTasksForGraph(projectId, conn);

const activeTasks = new Map<string, ActiveTaskInfo>();
Expand All @@ -70,24 +75,57 @@ export async function buildEffectiveDepGraph(
}
}

const adj = new Map<string, string[]>();
if (allTasks.length === 0) {
return {
activeTasks,
effectiveDeps: new Map(),
effectiveDependents: new Map(),
};
return { adj, taskStatus, activeTasks };
}

const taskIds = allTasks.map((t) => t.id);
const dependsOnEdges = await listDependsOnEdges(taskIds, conn);

const adj = new Map<string, string[]>();
for (const e of dependsOnEdges) {
const list = adj.get(e.sourceTaskId) ?? [];
list.push(e.targetTaskId);
adj.set(e.sourceTaskId, list);
}

return { adj, taskStatus, activeTasks };
}

/**
* Build the effective dependency graph for a project.
*
* Treats cancelled tasks as transparent: walks through them to find the
* nearest active prerequisite, but excludes them from the result graph.
* Used by getBlockedTasks, getCriticalPath, and deriveTaskStatesSlim (which
* in turn backs getReadyTasks and getPlannableTasks) so they all share
* consistent transitive-aware semantics.
*
* @param projectId - UUID of the project.
* @param conn - Drizzle client or transaction handle. Callers running under a
* `withUserContext` transaction must pass the active `tx` so the underlying
* reads participate in the same RLS-scoped frame; standalone callers pass
* the bare `db` pool client (data-layer scope only — boundary enforced by
* the lint rule on this directory).
* @returns The effective dependency graph (active-only nodes, transitive edges).
*/
export async function buildEffectiveDepGraph(
projectId: string,
conn: Conn,
): Promise<EffectiveDepGraph> {
const { adj, taskStatus, activeTasks } = await buildDepAdjacency(
projectId,
conn,
);

if (taskStatus.size === 0) {
return {
activeTasks,
effectiveDeps: new Map(),
effectiveDependents: new Map(),
};
}

const effectiveDeps = new Map<string, Set<string>>();
for (const activeId of activeTasks.keys()) {
effectiveDeps.set(activeId, walkEffectiveDeps(activeId, adj, taskStatus));
Expand Down Expand Up @@ -145,3 +183,69 @@ function walkEffectiveDeps(

return result;
}

/**
* Walk forward from an active source over the effective dependency graph,
* stopping at maxDepth active "walls". Cancelled tasks are transparent and
* depth-free: the walk passes through them without consuming a depth slot,
* so A -> B(cancelled) -> C(active) yields C at effective depth 1.
*
* BFS by effective depth: the first time an active task is seen is at its
* minimum effective depth, matching the shallowest-depth behaviour of the
* old recursive CTE. The cancelled-frontier is drained at the current
* active depth before BFS advances, so a chain of any number of cancelled
* middles stays depth-free.
*
* @param source - Starting active task id (not included in the result).
* @param adj - source -> targets adjacency map for depends_on edges.
* @param taskStatus - task id -> status map for ALL project tasks.
* @param maxDepth - Maximum number of active hops to include (e.g. 2).
* @returns Map of active-task-id -> effective depth (1..maxDepth).
*/
export function walkEffectiveDepsBounded(
source: string,
adj: Map<string, string[]>,
taskStatus: Map<string, string>,
maxDepth: number,
): Map<string, number> {
const result = new Map<string, number>();
// Seed `visited` with `source` so a cycle back to it never records the
// source itself (the result is the set of deps, source-exclusive).
const visited = new Set<string>([source]);
const visitedCancelled = new Set<string>();
let queue: { id: string; depth: number }[] = [{ id: source, depth: 0 }];

while (queue.length > 0) {
const next: { id: string; depth: number }[] = [];

for (const node of queue) {
// Drain the cancelled frontier at this active depth before advancing,
// so passing through cancelled middles never consumes a depth slot.
const cancelledFrontier: string[] = [node.id];
while (cancelledFrontier.length > 0) {
const cur = cancelledFrontier.pop()!;
for (const target of adj.get(cur) ?? []) {
const status = taskStatus.get(target);
if (status === undefined) continue;
if (status === "cancelled") {
if (visitedCancelled.has(target)) continue;
visitedCancelled.add(target);
cancelledFrontier.push(target);
continue;
}
// Active wall at active depth node.depth + 1.
const wallDepth = node.depth + 1;
if (wallDepth > maxDepth) continue;
if (visited.has(target)) continue;
visited.add(target);
result.set(target, wallDepth);
next.push({ id: target, depth: wallDepth });
}
}
}

queue = next;
}

return result;
}
Loading
Loading