Skip to content
Merged
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
8 changes: 6 additions & 2 deletions apps/web/app/api/agent/projects/[projectId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type RouteContext = {
* summaryUpdatedAt: "2026-05-11T08:40:00.000Z"
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* archivedAt: null
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
Expand Down Expand Up @@ -113,13 +117,13 @@ export async function GET(request: NextRequest, { params }: RouteContext) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/api/agent/projects/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { prisma } from "@/lib/prisma"
* summaryUpdatedAt: "2026-05-11T08:40:00.000Z"
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* archivedAt: null
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
Expand Down Expand Up @@ -112,13 +116,13 @@ export async function GET(request: NextRequest) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down
16 changes: 12 additions & 4 deletions apps/web/app/api/agent/tasks/[taskId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type RouteContext = {
* summaryUpdatedAt: "2026-05-11T08:40:00.000Z"
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
* taskUpdatedByName: "Build Agent"
Expand Down Expand Up @@ -100,13 +104,13 @@ export async function GET(request: NextRequest, { params }: RouteContext) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down Expand Up @@ -195,6 +199,10 @@ export async function GET(request: NextRequest, { params }: RouteContext) {
* summaryUpdatedAt: "2026-05-11T08:40:00.000Z"
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
* taskUpdatedByName: "Build Agent"
Expand Down Expand Up @@ -389,13 +397,13 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down
16 changes: 12 additions & 4 deletions apps/web/app/api/agent/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const statuses = Object.values(Status)
* summaryUpdatedAt: null
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* archivedAt: null
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
Expand Down Expand Up @@ -113,13 +117,13 @@ export async function GET(request: NextRequest) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down Expand Up @@ -209,6 +213,10 @@ export async function GET(request: NextRequest) {
* summaryUpdatedAt: "2026-05-11T08:40:00.000Z"
* readBy: []
* blockingReason: null
* dependencyIds: []
* dependencies: []
* unblocks: []
* isDependencyReady: false
* taskUpdatedAt: "2026-05-11T08:40:00.000Z"
* taskUpdatedById: "550e8400-e29b-41d4-a716-446655440000"
* taskUpdatedByName: "Build Agent"
Expand Down Expand Up @@ -346,13 +354,13 @@ export async function POST(request: NextRequest) {
archivedAt: true,
blockedByDependencies: {
select: {
dependencyTask: { select: { id: true, name: true, status: true } },
dependencyTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
unblocksDependencies: {
select: {
blockedTask: { select: { id: true, name: true, status: true } },
blockedTask: { select: { id: true, name: true, status: true, archivedAt: true } },
},
orderBy: { createdAt: "asc" },
},
Expand Down
13 changes: 11 additions & 2 deletions apps/web/app/api/openapi/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,25 @@ const openApiDocument = swaggerJsdoc({
dependencyIds: {
type: "array",
items: { type: "string", format: "uuid" },
description: "Database task ids for tasks this task depends on.",
description:
"Database task ids for active, non-archived tasks this task depends on. Archived dependency tasks are omitted from Agent API task payloads.",
},
dependencies: {
type: "array",
items: { $ref: "#/components/schemas/TaskDependencySummary" },
description:
"Active, non-archived dependency task summaries only. Archived dependency tasks are filtered out and dependency summaries expose id, name, and status only.",
},
unblocks: {
type: "array",
items: { $ref: "#/components/schemas/TaskDependencySummary" },
description:
"Active, non-archived task summaries that this task unblocks. Archived unblocked tasks are filtered out and summaries expose id, name, and status only.",
},
isDependencyReady: {
type: "boolean",
description: "True when the task has dependencies and every dependency is done.",
description:
"True when the task has at least one active, non-archived dependency and every active dependency is done. Archived dependencies do not affect readiness.",
},
archivedAt: { type: "string", format: "date-time", nullable: true },
taskUpdatedAt: { type: "string", format: "date-time" },
Expand Down Expand Up @@ -194,6 +200,9 @@ const openApiDocument = swaggerJsdoc({
},
TaskDependencySummary: {
type: "object",
description:
"Compact active task summary used by dependencies and unblocks. Archived tasks are omitted before serialization, and archivedAt is intentionally not exposed here.",
additionalProperties: false,
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
Expand Down
25 changes: 23 additions & 2 deletions apps/web/lib/api/task-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ type DependencyTask = {
id: string
name: string
status: Status
archivedAt?: Date | null
}

type SerializedDependencyTask = Omit<DependencyTask, "archivedAt">

type DependencyEdge = {
dependencyTask: DependencyTask
}
Expand All @@ -21,8 +24,14 @@ export type TaskDependencyPayload = {

export function serializeTaskDependencies<T extends TaskDependencyPayload>(task: T) {
const { blockedByDependencies, unblocksDependencies, ...rest } = task
const dependencies = blockedByDependencies?.map((dependency) => dependency.dependencyTask) ?? []
const unblocks = unblocksDependencies?.map((dependency) => dependency.blockedTask) ?? []
const dependencies =
blockedByDependencies
?.map((dependency) => serializeDependencyTask(dependency.dependencyTask))
.filter((dependency): dependency is SerializedDependencyTask => dependency !== null) ?? []
const unblocks =
unblocksDependencies
?.map((dependency) => serializeDependencyTask(dependency.blockedTask))
.filter((dependency): dependency is SerializedDependencyTask => dependency !== null) ?? []

return {
...rest,
Expand All @@ -33,6 +42,18 @@ export function serializeTaskDependencies<T extends TaskDependencyPayload>(task:
}
}

function serializeDependencyTask(task: DependencyTask): SerializedDependencyTask | null {
if (task.archivedAt) {
return null
}

return {
id: task.id,
name: task.name,
status: task.status,
}
}

export function hasDependencyCycle(
edges: Array<{ blockedTaskId: string; dependencyTaskId: string }>,
blockedTaskId: string,
Expand Down
112 changes: 112 additions & 0 deletions apps/web/lib/api/task-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { Status } from "@/generated/prisma/enums"

import { serializeTaskDependencies } from "./task-dependencies"

describe("Agent API task visibility", () => {
it("omits archived dependency and unblocks tasks from serialized task payloads", () => {
const activeDependency = {
id: "dependency-active",
name: "Active dependency",
status: Status.done,
archivedAt: null,
}
const archivedDependency = {
id: "dependency-archived",
name: "Archived dependency",
status: Status.done,
archivedAt: new Date("2026-05-16T07:00:00.000Z"),
}
const activeBlockedTask = {
id: "blocked-active",
name: "Active blocked task",
status: Status.todo,
archivedAt: null,
}
const archivedBlockedTask = {
id: "blocked-archived",
name: "Archived blocked task",
status: Status.todo,
archivedAt: new Date("2026-05-16T07:00:00.000Z"),
}

const result = serializeTaskDependencies({
id: "task-1",
blockedByDependencies: [
{ dependencyTask: activeDependency },
{ dependencyTask: archivedDependency },
],
unblocksDependencies: [
{ blockedTask: activeBlockedTask },
{ blockedTask: archivedBlockedTask },
],
})

assert.deepEqual(result.dependencies, [
{ id: activeDependency.id, name: activeDependency.name, status: activeDependency.status },
])
assert.deepEqual(result.dependencyIds, [activeDependency.id])
assert.deepEqual(result.unblocks, [
{ id: activeBlockedTask.id, name: activeBlockedTask.name, status: activeBlockedTask.status },
])
assert.equal(result.isDependencyReady, true)
})

it("omits archivedAt from serialized active dependency and unblocks tasks", () => {
const result = serializeTaskDependencies({
id: "task-1",
blockedByDependencies: [
{
dependencyTask: {
id: "dependency-active",
name: "Active dependency",
status: Status.done,
archivedAt: null,
},
},
],
unblocksDependencies: [
{
blockedTask: {
id: "blocked-active",
name: "Active blocked task",
status: Status.todo,
archivedAt: null,
},
},
],
})

assert.deepEqual(result.dependencies, [
{ id: "dependency-active", name: "Active dependency", status: Status.done },
])
assert.deepEqual(result.unblocks, [
{ id: "blocked-active", name: "Active blocked task", status: Status.todo },
])
assert.equal("archivedAt" in result.dependencies[0], false)
assert.equal("archivedAt" in result.unblocks[0], false)
})

it("does not mark dependency readiness from archived dependencies", () => {
const result = serializeTaskDependencies({
id: "task-1",
blockedByDependencies: [
{
dependencyTask: {
id: "dependency-archived",
name: "Archived dependency",
status: Status.done,
archivedAt: new Date("2026-05-16T07:00:00.000Z"),
},
},
],
unblocksDependencies: [],
})

assert.deepEqual(result.dependencies, [])
assert.deepEqual(result.dependencyIds, [])
assert.equal(result.isDependencyReady, false)
})
})
Loading