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
1 change: 1 addition & 0 deletions lib/data/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,7 @@ export async function listTasksForGraph(projectId: string, conn: Conn) {
status: tasks.status,
sequenceNumber: tasks.sequenceNumber,
tags: tasks.tags,
priority: tasks.priority,
})
.from(tasks)
.where(eq(tasks.projectId, projectId));
Expand Down
110 changes: 90 additions & 20 deletions lib/data/traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,46 @@ import { withUserContext, type Tx } from "@/lib/db/rls";
import { tasks, projects, taskEdges } from "@/lib/db/schema";
import { fetchDownstream } from "@/lib/db/raw/fetch-downstream";
import { asIdentifier, composeTaskRef } from "@/lib/graph/identifier";
import { buildEffectiveDepGraph } from "@/lib/graph/effective-deps";
import {
buildEffectiveDepGraph,
type ActiveTaskInfo,
} from "@/lib/graph/effective-deps";
import { hasCriteriaExpr, deriveTaskStatesSlim } from "@/lib/data/task";
import type { AuthContext } from "@/lib/auth/context";
import type { Priority } from "@/lib/types";
import {
assertProjectAccessTx,
assertTaskAccessTx,
} from "@/lib/auth/authorization";

// ---------------------------------------------------------------------------
// Priority weighting for the critical-path DP
// ---------------------------------------------------------------------------

/**
* Doubling ladder so a single `urgent` node (8) dominates a chain of three
* `normal` nodes (6); a 2-chain of two `urgent` (16) outranks a 3-chain of
* three `backlog` (3). See MYMR-208.
*/
const PRIORITY_WEIGHTS = {
urgent: 8,
core: 4,
normal: 2,
backlog: 1,
} as const satisfies Record<Priority, number>;

const DEFAULT_PRIORITY_WEIGHT = PRIORITY_WEIGHTS.normal;

/**
* Lookup the DP weight for a task's priority. Null or any string outside the
* recognized alphabet falls back to the `normal` weight (2) so the DP never
* sees `undefined`/`NaN`.
*/
function priorityWeight(p: ActiveTaskInfo["priority"]): number {
if (p === null) return DEFAULT_PRIORITY_WEIGHT;
return PRIORITY_WEIGHTS[p] ?? DEFAULT_PRIORITY_WEIGHT;
}

// ---------------------------------------------------------------------------
// Ancestor traversal — internal helper
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -426,22 +458,30 @@ export type CriticalPathTask = {
};

/**
* Find the longest chain of effective `depends_on` edges across active tasks.
* Find the most important remaining chain of effective `depends_on` edges
* across active, non-done tasks.
*
* Operates on the effective dependency graph — cancelled tasks are transparent,
* so a chain `A → B → C` where B is cancelled is treated as the active chain
* `A → C` (and contributes length 2, not 3). This avoids the orphan-bug where
* tasks above a cancelled middle would be excluded from the chain entirely.
* Operates on the effective dependency graph: cancelled tasks are transparent
* (passed through inside the shared graph substrate) and done tasks are
* locally transparent here (filtered out of the DP node set), so a chain
* `A(done) → B(planned) → C(draft)` reports as `B → C`. Each DP node
* contributes weight by its `priority` (urgent=8, core=4, normal=2,
* backlog=1; null or unrecognized → 2) so a chain's score reflects priority
* mass, not raw length. A single `urgent` task (8) outranks a chain of three
* `normal` tasks (6); a 2-chain of two `urgent` tasks (16) outranks a
* 3-chain of three `backlog` tasks (3).
*
* Algorithm: Kahn's topological sort over active tasks (deps first) followed
* by DP `longest[node] = 1 + max(longest[dep])`, then backtrack from the
* highest-`longest` node to recover the chain in root-first order.
* Algorithm: Kahn's topological sort over not-done active tasks (deps first)
* followed by DP `longest[node] = max(longest[dep]) + priorityWeight(node)`,
* then backtrack from the highest-`longest` node to recover the chain in
* root-first order. Returns empty when no not-done active tasks exist or a
* cycle is detected.
*
* @param ctx - Resolved auth context.
* @param projectId - UUID of the project.
* @returns Ordered array of active tasks forming the longest effective chain
* (foundational task first, topmost dependent last). Empty when no active
* tasks exist or a cycle is detected.
* @returns Ordered array of active, not-done tasks forming the highest-weight
* effective chain (foundational task first, topmost dependent last). Empty
* when no not-done active tasks exist or a cycle is detected.
*/
export async function getCriticalPath(
ctx: AuthContext,
Expand All @@ -454,9 +494,39 @@ export async function getCriticalPath(
const graph = await buildEffectiveDepGraph(projectId, tx);
if (graph.activeTasks.size === 0) return [];

// Done-transparency, scoped locally: build a DP node set that excludes
// done tasks, then rebuild deps/dependents adjacency over that set. The
// shared graph substrate keeps done tasks (other analyzers depend on
// them); the filter happens here only.
const dpNodes = new Map<string, ActiveTaskInfo>();
for (const info of graph.activeTasks.values()) {
if (info.status === "done") continue;
dpNodes.set(info.id, info);
}
if (dpNodes.size === 0) return [];

const dpDeps = new Map<string, Set<string>>();
for (const id of dpNodes.keys()) {
const fullDeps = graph.effectiveDeps.get(id) ?? new Set<string>();
const filtered = new Set<string>();
for (const dep of fullDeps) {
if (dpNodes.has(dep)) filtered.add(dep);
}
dpDeps.set(id, filtered);
}

const dpDependents = new Map<string, Set<string>>();
for (const [src, deps] of dpDeps) {
for (const dep of deps) {
const set = dpDependents.get(dep) ?? new Set<string>();
set.add(src);
dpDependents.set(dep, set);
}
}

const remaining = new Map<string, number>();
for (const id of graph.activeTasks.keys()) {
remaining.set(id, graph.effectiveDeps.get(id)?.size ?? 0);
for (const id of dpNodes.keys()) {
remaining.set(id, dpDeps.get(id)?.size ?? 0);
}

const topoOrder: string[] = [];
Expand All @@ -467,21 +537,20 @@ export async function getCriticalPath(
while (queue.length > 0) {
const cur = queue.shift()!;
topoOrder.push(cur);
const dependents =
graph.effectiveDependents.get(cur) ?? new Set<string>();
const dependents = dpDependents.get(cur) ?? new Set<string>();
for (const dependent of dependents) {
const newCount = (remaining.get(dependent) ?? 0) - 1;
remaining.set(dependent, newCount);
if (newCount === 0) queue.push(dependent);
}
}

if (topoOrder.length < graph.activeTasks.size) return [];
if (topoOrder.length < dpNodes.size) return [];

const longestTo = new Map<string, number>();
const parent = new Map<string, string | null>();
for (const node of topoOrder) {
const deps = graph.effectiveDeps.get(node) ?? new Set<string>();
const deps = dpDeps.get(node) ?? new Set<string>();
let bestParent: string | null = null;
let bestParentLen = 0;
for (const dep of deps) {
Expand All @@ -491,7 +560,8 @@ export async function getCriticalPath(
bestParent = dep;
}
}
longestTo.set(node, bestParentLen + 1);
const info = dpNodes.get(node)!;
longestTo.set(node, bestParentLen + priorityWeight(info.priority));
parent.set(node, bestParent);
}

Expand All @@ -514,7 +584,7 @@ export async function getCriticalPath(
chain.reverse();

return chain.map((id) => {
const info = graph.activeTasks.get(id)!;
const info = dpNodes.get(id)!;
return {
id: info.id,
taskRef: composeTaskRef(identifier, info.sequenceNumber),
Expand Down
70 changes: 70 additions & 0 deletions lib/db/raw/fetch-effective-dep-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { sql } from "drizzle-orm";
import { tasks, taskEdges } from "@/lib/db/schema";
import { executeRaw, type Conn } from "@/lib/db/raw";

/** A task in an effective `depends_on` chain with its effective depth. */
export type EffectiveDepRow = { id: string; depth: number };

/**
* Walk forward `depends_on` edges from `taskId`, treating cancelled tasks
* as transparent: a chain `A → B(cancelled) → C(active)` returns C at
* effective depth 1. Cancelled middles do not consume a depth slot.
*
* Recursive CTE bounded by `effective_depth < maxDepth` on the active
* wall. The `CYCLE` clause terminates recursion on cycles, including
* cancelled-only loops. Joins `tasks` at every step and filters on
* `projectId` so a stale or hand-crafted cross-project edge cannot leak
* into the result. The source task is excluded from the result.
*
* @param conn - Drizzle client or transaction handle.
* @param taskId - UUID of the starting task (excluded from the result).
* @param projectId - UUID of the project the starting task belongs to.
* @param maxDepth - Maximum effective hops to include.
* @returns Distinct active task ids reachable from `taskId` within
* `maxDepth` effective hops, ordered by minimum effective depth ascending.
*/
export async function fetchEffectiveDepChain(
conn: Conn,
taskId: string,
projectId: string,
maxDepth: number,
): Promise<EffectiveDepRow[]> {
const rows = await executeRaw<{ id: string; depth: number | string }>(
conn,
sql`
WITH RECURSIVE walk AS (
SELECT
e.target_task_id AS id,
t.status AS status,
CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END AS effective_depth
FROM ${taskEdges} e
INNER JOIN ${tasks} t ON t.id = e.target_task_id
WHERE e.source_task_id = ${taskId}
AND e.edge_type = 'depends_on'
AND t.project_id = ${projectId}

UNION ALL

SELECT
e.target_task_id,
t.status,
w.effective_depth + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END
FROM ${taskEdges} e
INNER JOIN walk w ON e.source_task_id = w.id
INNER JOIN ${tasks} t ON t.id = e.target_task_id
WHERE e.edge_type = 'depends_on'
AND t.project_id = ${projectId}
AND w.effective_depth < ${maxDepth}
) CYCLE id SET is_cycle USING path
SELECT id, MIN(effective_depth) AS depth
FROM walk
WHERE NOT is_cycle
AND status <> 'cancelled'
AND effective_depth <= ${maxDepth}
AND id <> ${taskId}
GROUP BY id
ORDER BY depth ASC
`,
);
return rows.map((r) => ({ id: r.id, depth: Number(r.depth) }));
}
70 changes: 70 additions & 0 deletions lib/db/raw/fetch-effective-downstream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { sql } from "drizzle-orm";
import { tasks, taskEdges } from "@/lib/db/schema";
import { executeRaw, type Conn } from "@/lib/db/raw";

/** A task in an effective downstream-dependents chain with its effective depth. */
export type EffectiveDownstreamRow = { id: string; depth: number };

/**
* Walk backward `depends_on` edges from `taskId` (find tasks that depend
* on it), treating cancelled tasks as transparent: a chain
* `C(active) → B(cancelled) → A` returns A at effective depth 1 from C's
* perspective.
*
* Mirror of {@link fetchEffectiveDepChain} with source / target swapped
* on the join. Same `CYCLE`, `projectId` filter, and source-exclusion
* semantics.
*
* @param conn - Drizzle client or transaction handle.
* @param taskId - UUID of the starting task (excluded from the result).
* @param projectId - UUID of the project the starting task belongs to.
* @param maxDepth - Maximum effective hops to include.
* @returns Distinct active task ids that effectively depend on `taskId`
* within `maxDepth` effective hops, ordered by minimum effective depth
* ascending.
*/
export async function fetchEffectiveDownstream(
conn: Conn,
taskId: string,
projectId: string,
maxDepth: number,
): Promise<EffectiveDownstreamRow[]> {
const rows = await executeRaw<{ id: string; depth: number | string }>(
conn,
sql`
WITH RECURSIVE walk AS (
SELECT
e.source_task_id AS id,
t.status AS status,
CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END AS effective_depth
FROM ${taskEdges} e
INNER JOIN ${tasks} t ON t.id = e.source_task_id
WHERE e.target_task_id = ${taskId}
AND e.edge_type = 'depends_on'
AND t.project_id = ${projectId}

UNION ALL

SELECT
e.source_task_id,
t.status,
w.effective_depth + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END
FROM ${taskEdges} e
INNER JOIN walk w ON e.target_task_id = w.id
INNER JOIN ${tasks} t ON t.id = e.source_task_id
WHERE e.edge_type = 'depends_on'
AND t.project_id = ${projectId}
AND w.effective_depth < ${maxDepth}
) CYCLE id SET is_cycle USING path
SELECT id, MIN(effective_depth) AS depth
FROM walk
WHERE NOT is_cycle
AND status <> 'cancelled'
AND effective_depth <= ${maxDepth}
AND id <> ${taskId}
GROUP BY id
ORDER BY depth ASC
`,
);
return rows.map((r) => ({ id: r.id, depth: Number(r.depth) }));
}
Loading
Loading