diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2a536d8f5c..22cb697bfa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -651,6 +651,9 @@ export type { IssueWakeDiagnosticWakeFailureClass, IssueWakeDiagnosticWakeRequest, IssueWakeDiagnosticsResponse, + IssueSubtreeDiagnosticNode, + IssueSubtreeDiagnosticEdge, + IssueSubtreeDiagnosticsResponse, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 281ca5ba08..0d27034914 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -298,6 +298,9 @@ export type { IssueWakeDiagnosticWakeFailureClass, IssueWakeDiagnosticWakeRequest, IssueWakeDiagnosticsResponse, + IssueSubtreeDiagnosticNode, + IssueSubtreeDiagnosticEdge, + IssueSubtreeDiagnosticsResponse, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 16981e9299..5bc6010b32 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -307,6 +307,80 @@ export interface IssueWakeDiagnosticsResponse { }; } +export interface IssueSubtreeDiagnosticNode { + issue: IssueBlockerDiagnosticIssueSummary; + parentId: string | null; + depth: number; + diagnosis: string | null; + likelyReason: string | null; + blockers: IssueBlockerDiagnosticNode[]; + blockerReadiness: IssueBlockerDiagnosticsReadiness | null; + omittedUnauthorizedBlockerCount: number | null; + wakeEvents: IssueWakeDiagnosticEvent[]; + wakeRequestCount: number; + activityRecordCount: number; + truncated: boolean; + truncatedSections: { + blockers: boolean; + wakeRequests: boolean; + activityRecords: boolean; + }; +} + +export type IssueSubtreeDiagnosticEdge = + | { + kind: "parent"; + fromIssueId: string; + toIssueId: string; + timestamp: string | null; + } + | { + kind: "blocks"; + fromIssueId: string; + toIssueId: string; + timestamp: string | null; + } + | { + kind: "wake_request"; + issueId: string; + agentId: string | null; + reason: string | null; + status: string; + timestamp: string; + } + | { + kind: "activity"; + issueId: string; + action: string; + timestamp: string; + }; + +export interface IssueSubtreeDiagnosticsResponse { + issue: IssueBlockerDiagnosticIssueSummary; + diagnosis: string | null; + likelyReason: string | null; + nodes: IssueSubtreeDiagnosticNode[]; + edges: IssueSubtreeDiagnosticEdge[]; + nodeCount: number; + omittedUnauthorizedNodeCount: number | null; + truncated: boolean; + truncatedSections: { + nodes: boolean; + depth: boolean; + blockers: boolean; + wakeRequests: boolean; + activityRecords: boolean; + }; + caps: { + maxDepth: number; + maxNodes: number; + maxBlockersPerNode: number; + maxWakeRequestsPerNode: number; + maxActivityRecordsPerNode: number; + lookbackDays: number; + }; +} + export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention"; export type IssueBlockerAttentionReason = diff --git a/server/src/__tests__/issue-subtree-diagnostics-routes.test.ts b/server/src/__tests__/issue-subtree-diagnostics-routes.test.ts new file mode 100644 index 0000000000..7c77ce03ec --- /dev/null +++ b/server/src/__tests__/issue-subtree-diagnostics-routes.test.ts @@ -0,0 +1,429 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentWakeupRequests, + agents, + companies, + createDb, + heartbeatRuns, + issueComments, + issueRelations, + issues, + projects, +} from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres subtree diagnostic route tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +type Db = ReturnType; +type CompanyRow = typeof companies.$inferSelect; +type AgentRow = typeof agents.$inferSelect; +type ProjectRow = typeof projects.$inferSelect; +type IssueRow = typeof issues.$inferSelect; + +function createApp(db: Db, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; +} + +function boardActor(company: CompanyRow): Express.Request["actor"] { + return { + type: "board", + userId: "board-user", + companyIds: [company.id], + memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }], + isInstanceAdmin: true, + source: "local_implicit", + }; +} + +function agentActor(company: CompanyRow, agent: AgentRow, runId: string): Express.Request["actor"] { + return { + type: "agent", + agentId: agent.id, + companyId: company.id, + runId, + source: "agent_jwt", + }; +} + +async function seedCompany(db: Db, label = "Subtree Diagnostics") { + const nonce = randomUUID().slice(0, 8); + const [company] = await db.insert(companies).values({ + name: `${label} ${nonce}`, + issuePrefix: `SD${nonce.slice(0, 4).toUpperCase()}`, + defaultResponsibleUserId: "board-user", + }).returning(); + return company!; +} + +async function seedAgent(db: Db, companyId: string, permissions: Record = {}) { + const [agent] = await db.insert(agents).values({ + companyId, + name: `Agent ${randomUUID().slice(0, 6)}`, + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions, + }).returning(); + return agent!; +} + +async function seedProject(db: Db, companyId: string, name: string) { + const [project] = await db.insert(projects).values({ + companyId, + name, + status: "in_progress", + }).returning(); + return project!; +} + +async function seedIssue( + db: Db, + input: { + companyId: string; + projectId?: string | null; + title: string; + status?: string; + assigneeAgentId?: string | null; + parentId?: string | null; + }, +) { + const [issue] = await db.insert(issues).values({ + companyId: input.companyId, + projectId: input.projectId ?? null, + parentId: input.parentId ?? null, + title: input.title, + status: input.status ?? "todo", + priority: "medium", + assigneeAgentId: input.assigneeAgentId ?? null, + responsibleUserId: "board-user", + }).returning(); + return issue!; +} + +async function blockIssue(db: Db, companyId: string, blockerIssueId: string, blockedIssueId: string) { + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); +} + +async function attachMentionScopedLowTrustRun(db: Db, fixture: { + company: CompanyRow; + ownerAgent: AgentRow; + mentionedAgent: AgentRow; + allowedProject: ProjectRow; + root: IssueRow; +}) { + const executionPolicy = { + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: fixture.company.id, + projectIds: [fixture.allowedProject.id], + issueIds: [], + allowedAgentIds: [], + }, + }, + }; + await db.update(agents).set({ + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: executionPolicy.authorizationPolicy, + }, + }).where(eq(agents.id, fixture.mentionedAgent.id)); + fixture.mentionedAgent.permissions = { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: executionPolicy.authorizationPolicy, + }; + await db.insert(issueComments).values({ + companyId: fixture.company.id, + issueId: fixture.root.id, + authorAgentId: fixture.ownerAgent.id, + body: `[@Mentioned Agent](agent://${fixture.mentionedAgent.id}) please inspect this root.`, + }); + const [run] = await db.insert(heartbeatRuns).values({ + companyId: fixture.company.id, + agentId: fixture.mentionedAgent.id, + status: "running", + contextSnapshot: { + issueId: fixture.root.id, + executionPolicy, + }, + }).returning(); + return run!; +} + +describeEmbeddedPostgres("issue subtree diagnostics route", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-subtree-diagnostics-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(agentWakeupRequests); + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("returns visible subtree nodes, blocker edges, wake edges, and a deterministic stall diagnosis", async () => { + const company = await seedCompany(db); + const agent = await seedAgent(db, company.id); + const project = await seedProject(db, company.id, "Core"); + const root = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Blocked root", + status: "blocked", + assigneeAgentId: agent.id, + }); + const child = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + parentId: root.id, + title: "Unfinished child blocker", + status: "in_progress", + }); + const rawMarker = `RAW-SUBTREE-${randomUUID()}`; + await blockIssue(db, company.id, child.id, root.id); + await db.insert(agentWakeupRequests).values({ + companyId: company.id, + agentId: agent.id, + source: "automation", + reason: "issue_commented", + status: "completed", + payload: { issueId: child.id, privateValue: rawMarker }, + error: `secret ${rawMarker}`, + requestedAt: new Date(Date.now() - 5_000), + finishedAt: new Date(Date.now() - 1_000), + }); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${root.id}/diagnostics/subtree`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + diagnosis: expect.stringContaining("Blocked root appears to be the subtree stall point"), + likelyReason: expect.stringContaining("Blocked root appears to be the subtree stall point"), + nodeCount: 2, + omittedUnauthorizedNodeCount: 0, + truncated: false, + caps: { + maxDepth: 8, + maxNodes: 100, + maxBlockersPerNode: 20, + maxWakeRequestsPerNode: 5, + maxActivityRecordsPerNode: 5, + lookbackDays: 14, + }, + }); + expect(res.body.nodes).toHaveLength(2); + const rootNode = res.body.nodes.find((node: { issue: { id: string } }) => node.issue.id === root.id); + const childNode = res.body.nodes.find((node: { issue: { id: string } }) => node.issue.id === child.id); + expect(rootNode).toMatchObject({ + diagnosis: expect.stringContaining("blocked by Unfinished child blocker"), + blockers: [expect.objectContaining({ id: child.id, status: "in_progress", isUnresolved: true })], + }); + expect(childNode).toMatchObject({ + parentId: root.id, + depth: 1, + wakeRequestCount: 1, + wakeEvents: [expect.objectContaining({ kind: "wake_request", reason: "issue_commented", status: "completed" })], + }); + expect(res.body.edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "parent", fromIssueId: root.id, toIssueId: child.id }), + expect.objectContaining({ kind: "blocks", fromIssueId: child.id, toIssueId: root.id }), + expect.objectContaining({ kind: "wake_request", issueId: child.id, reason: "issue_commented" }), + ])); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain(rawMarker); + expect(serialized).not.toContain("\"payload\""); + expect(serialized).not.toContain("\"details\""); + expect(serialized).not.toContain("\"triggerDetail\""); + expect(serialized).not.toContain("\"error\""); + }); + + it("returns null diagnosis for a quiet unblocked singleton subtree", async () => { + const company = await seedCompany(db); + const project = await seedProject(db, company.id, "Core"); + const issue = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Quiet root", + status: "todo", + }); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${issue.id}/diagnostics/subtree`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.diagnosis).toBeNull(); + expect(res.body.likelyReason).toBeNull(); + expect(res.body.nodes).toHaveLength(1); + expect(res.body.nodes[0]).toMatchObject({ + diagnosis: null, + likelyReason: null, + blockers: [], + wakeEvents: [], + truncated: false, + }); + expect(res.body.edges).toEqual([]); + expect(res.body.truncated).toBe(false); + }); + + it("caps subtree nodes and reports truncation explicitly", async () => { + const company = await seedCompany(db); + const project = await seedProject(db, company.id, "Core"); + const root = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Wide root", + status: "todo", + }); + const childRows = []; + for (let index = 0; index < 100; index += 1) { + childRows.push({ + companyId: company.id, + projectId: project.id, + parentId: root.id, + title: `Child ${String(index).padStart(3, "0")}`, + status: "todo", + priority: "medium", + responsibleUserId: "board-user", + }); + } + await db.insert(issues).values(childRows); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${root.id}/diagnostics/subtree`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.nodes).toHaveLength(100); + expect(res.body.nodeCount).toBe(100); + expect(res.body.truncated).toBe(true); + expect(res.body.truncatedSections).toMatchObject({ nodes: true }); + expect(res.body.omittedUnauthorizedNodeCount).toBeNull(); + expect(res.body.diagnosis).toContain("bounded to depth 8 and 100 nodes"); + }); + + it("omits subtree nodes outside a mention-scoped actor's issue-read grant", async () => { + const company = await seedCompany(db); + const ownerAgent = await seedAgent(db, company.id); + const mentionedAgent = await seedAgent(db, company.id); + const allowedProject = await seedProject(db, company.id, "Allowed"); + const targetProject = await seedProject(db, company.id, "Target"); + const hiddenMarker = `HIDDEN-SUBTREE-${randomUUID()}`; + const root = await seedIssue(db, { + companyId: company.id, + projectId: targetProject.id, + title: "Mention-visible root", + status: "todo", + assigneeAgentId: ownerAgent.id, + }); + const hiddenChild = await seedIssue(db, { + companyId: company.id, + projectId: targetProject.id, + parentId: root.id, + title: hiddenMarker, + status: "blocked", + }); + await db.insert(agentWakeupRequests).values({ + companyId: company.id, + agentId: ownerAgent.id, + source: "automation", + reason: "issue_commented", + status: "failed", + payload: { issueId: hiddenChild.id, privateValue: hiddenMarker }, + error: `secret ${hiddenMarker}`, + requestedAt: new Date(Date.now() - 5_000), + }); + const run = await attachMentionScopedLowTrustRun(db, { + company, + ownerAgent, + mentionedAgent, + allowedProject, + root, + }); + + const res = await request(createApp(db, agentActor(company, mentionedAgent, run.id))) + .get(`/api/issues/${root.id}/diagnostics/subtree`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.nodes).toHaveLength(1); + expect(res.body.nodes[0].issue.id).toBe(root.id); + expect(res.body.omittedUnauthorizedNodeCount).toBe(1); + expect(res.body.diagnosis).toContain("authorization boundary"); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain(hiddenChild.id); + expect(serialized).not.toContain(hiddenMarker); + expect(serialized).not.toContain("\"payload\""); + expect(serialized).not.toContain("\"error\""); + }); + + it("denies cross-company issue reads", async () => { + const companyA = await seedCompany(db, "Company A"); + const companyB = await seedCompany(db, "Company B"); + const agentB = await seedAgent(db, companyB.id); + const projectA = await seedProject(db, companyA.id, "A"); + const issueA = await seedIssue(db, { + companyId: companyA.id, + projectId: projectA.id, + title: "Company A issue", + status: "blocked", + }); + const [runB] = await db.insert(heartbeatRuns).values({ + companyId: companyB.id, + agentId: agentB.id, + status: "running", + contextSnapshot: { issueId: issueA.id }, + }).returning(); + + const res = await request(createApp(db, agentActor(companyB, agentB, runB!.id))) + .get(`/api/issues/${issueA.id}/diagnostics/subtree`); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f0aaf95618..e318974cd5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -69,6 +69,9 @@ import { type IssueBlockerDiagnosticNode, type IssueBlockerDiagnosticsReadiness, type IssueBlockerDiagnosticsResponse, + type IssueSubtreeDiagnosticEdge, + type IssueSubtreeDiagnosticNode, + type IssueSubtreeDiagnosticsResponse, type IssueWakeDiagnosticActivityRecord, type IssueWakeDiagnosticEvent, type IssueWakeDiagnosticWakeFailureClass, @@ -818,6 +821,7 @@ function buildIssueBlockerDiagnosticsResponse(input: { pendingFinalizeBlockerIssueIds: string[]; }; truncated: boolean; + maxBlockers?: number; }): IssueBlockerDiagnosticsResponse { const issue = toIssueBlockerDiagnosticSummary(input.issue); const visibleBlockerIds = new Set(input.visibleBlockers.map((blocker) => blocker.id)); @@ -866,13 +870,14 @@ function buildIssueBlockerDiagnosticsResponse(input: { readiness, omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount, truncated: input.truncated, + maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS, }), readiness, blockers, omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount, truncated: input.truncated, caps: { - maxBlockers: ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS, + maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS, }, }; } @@ -883,10 +888,11 @@ function buildIssueBlockerDiagnosis(input: { readiness: IssueBlockerDiagnosticsReadiness | null; omittedUnauthorizedBlockerCount: number | null; truncated: boolean; + maxBlockers: number; }) { if (input.truncated) { return `Blocker diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated at ${ - ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS + input.maxBlockers } blockers, so readiness is not reported.`; } const omittedUnauthorizedBlockerCount = input.omittedUnauthorizedBlockerCount ?? 0; @@ -1086,12 +1092,15 @@ function buildIssueWakeDiagnosis(input: { events: IssueWakeDiagnosticEvent[]; blockerDiagnostics: IssueBlockerDiagnosticsResponse; truncated: boolean; + maxWakeRequests: number; + maxActivityRecords: number; + lookbackDays: number; }) { if (input.truncated) { return `Wake diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated to ${ - ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS - } wake requests and ${ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS} activity records over ${ - ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS + input.maxWakeRequests + } wake requests and ${input.maxActivityRecords} activity records over ${ + input.lookbackDays } days, so the diagnosis only covers returned records.`; } @@ -1203,6 +1212,9 @@ function buildIssueWakeDiagnosticsResponse(input: { truncatedWakeRequests: boolean; truncatedActivityRecords: boolean; includeInternalIds: boolean; + maxWakeRequests?: number; + maxActivityRecords?: number; + lookbackDays?: number; }): IssueWakeDiagnosticsResponse { const issue = toIssueBlockerDiagnosticSummary(input.issue); const events: IssueWakeDiagnosticEvent[] = [ @@ -1214,11 +1226,17 @@ function buildIssueWakeDiagnosticsResponse(input: { ), ].sort((left, right) => issueWakeDiagnosticEventTimestamp(right) - issueWakeDiagnosticEventTimestamp(left)); const truncated = input.truncatedWakeRequests || input.truncatedActivityRecords; + const maxWakeRequests = input.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS; + const maxActivityRecords = input.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS; + const lookbackDays = input.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS; const diagnosis = buildIssueWakeDiagnosis({ issue, events, blockerDiagnostics: input.blockerDiagnostics, truncated, + maxWakeRequests, + maxActivityRecords, + lookbackDays, }); return { @@ -1234,13 +1252,249 @@ function buildIssueWakeDiagnosticsResponse(input: { activityRecords: input.truncatedActivityRecords, }, caps: { - maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, - maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, - lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, + maxWakeRequests, + maxActivityRecords, + lookbackDays, }, }; } +type IssueSubtreeDiagnosticAuthzNode = IssueBlockerDiagnosticAuthzIssue & { + depth: number; + createdAt: Date | string; + updatedAt: Date | string; +}; + +type IssueSubtreeDiagnosticBlockerAuthzRow = IssueBlockerDiagnosticAuthzIssue & { + blockedIssueId: string; + relationCreatedAt: Date | string; +}; + +type IssueSubtreeDiagnosticWakeRequestRow = { + issueId: string; + agentId: string; + source: string; + reason: string | null; + status: string; + coalescedCount: number; + runId: string | null; + requestedAt: Date | string; + claimedAt: Date | string | null; + finishedAt: Date | string | null; + error: string | null; +}; + +type IssueSubtreeDiagnosticActivityRow = { + issueId: string; + action: string; + entityType: string; + entityId: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date | string; +}; + +function groupByIssueId(rows: T[]) { + const map = new Map(); + for (const row of rows) { + const issueRows = map.get(row.issueId) ?? []; + issueRows.push(row); + map.set(row.issueId, issueRows); + } + return map; +} + +function groupBlockersByBlockedIssueId(rows: IssueSubtreeDiagnosticBlockerAuthzRow[]) { + const map = new Map(); + for (const row of rows) { + const issueRows = map.get(row.blockedIssueId) ?? []; + issueRows.push(row); + map.set(row.blockedIssueId, issueRows); + } + return map; +} + +function issueSubtreeEdgeTimestamp(edge: IssueSubtreeDiagnosticEdge) { + return edge.timestamp ? new Date(edge.timestamp).getTime() : 0; +} + +function buildIssueSubtreeDiagnosis(input: { + issue: IssueBlockerDiagnosticIssueSummary; + nodes: IssueSubtreeDiagnosticNode[]; + omittedUnauthorizedNodeCount: number | null; + truncated: boolean; + caps: IssueSubtreeDiagnosticsResponse["caps"]; +}) { + if (input.truncated) { + return `Subtree diagnostics for ${blockerDiagnosticLabel(input.issue)} are bounded to depth ${ + input.caps.maxDepth + } and ${input.caps.maxNodes} nodes, so the diagnosis only covers returned visible nodes.`; + } + if ((input.omittedUnauthorizedNodeCount ?? 0) > 0) { + return `One or more subtree nodes under ${blockerDiagnosticLabel( + input.issue, + )} are outside this actor's authorization boundary, so this diagnosis only covers visible nodes.`; + } + + const blockedNodeWithDiagnosis = input.nodes.find((node) => node.issue.status === "blocked" && node.diagnosis); + const firstNodeWithDiagnosis = blockedNodeWithDiagnosis ?? input.nodes.find((node) => node.diagnosis); + if (!firstNodeWithDiagnosis?.diagnosis) return null; + + return `${blockerDiagnosticLabel(firstNodeWithDiagnosis.issue)} appears to be the subtree stall point: ${ + firstNodeWithDiagnosis.diagnosis + }`; +} + +function buildIssueSubtreeDiagnosticsResponse(input: { + issue: IssueBlockerDiagnosticReadableIssue; + nodes: IssueSubtreeDiagnosticAuthzNode[]; + visibleNodes: IssueSubtreeDiagnosticAuthzNode[]; + blockersByIssueId: Map; + visibleBlockers: IssueSubtreeDiagnosticBlockerAuthzRow[]; + readinessByIssueId: Map; + wakeRequestsByIssueId: Map; + activityRecordsByIssueId: Map; + truncatedNodes: boolean; + truncatedDepth: boolean; + truncatedBlockerIssueIds: Set; + truncatedWakeIssueIds: Set; + truncatedActivityIssueIds: Set; + includeInternalIds: boolean; + caps: IssueSubtreeDiagnosticsResponse["caps"]; +}): IssueSubtreeDiagnosticsResponse { + const issue = toIssueBlockerDiagnosticSummary(input.issue); + const visibleNodeIds = new Set(input.visibleNodes.map((node) => node.id)); + const visibleBlockerIdsByIssueId = groupBlockersByBlockedIssueId(input.visibleBlockers); + const omittedUnauthorizedNodeCount = input.truncatedNodes || input.truncatedDepth + ? null + : input.nodes.filter((node) => !visibleNodeIds.has(node.id)).length; + const nodeResponses: IssueSubtreeDiagnosticNode[] = []; + const edges: IssueSubtreeDiagnosticEdge[] = []; + + for (const node of input.visibleNodes) { + const rawBlockers = input.blockersByIssueId.get(node.id) ?? []; + const visibleBlockers = visibleBlockerIdsByIssueId.get(node.id) ?? []; + const blockerResponse = buildIssueBlockerDiagnosticsResponse({ + issue: node, + blockers: rawBlockers, + visibleBlockers, + readiness: input.readinessByIssueId.get(node.id) ?? { + allBlockersDone: true, + isDependencyReady: true, + unresolvedBlockerIssueIds: [], + pendingFinalizeBlockerIssueIds: [], + }, + truncated: input.truncatedBlockerIssueIds.has(node.id), + maxBlockers: input.caps.maxBlockersPerNode, + }); + const wakeResponse = buildIssueWakeDiagnosticsResponse({ + issue: node, + wakeRequests: input.wakeRequestsByIssueId.get(node.id) ?? [], + activityRecords: input.activityRecordsByIssueId.get(node.id) ?? [], + blockerDiagnostics: blockerResponse, + truncatedWakeRequests: input.truncatedWakeIssueIds.has(node.id), + truncatedActivityRecords: input.truncatedActivityIssueIds.has(node.id), + includeInternalIds: input.includeInternalIds, + maxWakeRequests: input.caps.maxWakeRequestsPerNode, + maxActivityRecords: input.caps.maxActivityRecordsPerNode, + lookbackDays: input.caps.lookbackDays, + }); + const nodeDiagnosis = wakeResponse.diagnosis ?? blockerResponse.diagnosis; + + if (node.parentId && visibleNodeIds.has(node.parentId)) { + edges.push({ + kind: "parent", + fromIssueId: node.parentId, + toIssueId: node.id, + timestamp: dateToIso(node.createdAt), + }); + } + for (const blocker of visibleBlockers) { + edges.push({ + kind: "blocks", + fromIssueId: blocker.id, + toIssueId: node.id, + timestamp: dateToIso(blocker.relationCreatedAt), + }); + } + for (const event of wakeResponse.events) { + if (event.kind === "wake_request") { + edges.push({ + kind: "wake_request", + issueId: node.id, + agentId: event.agentId, + reason: event.reason, + status: event.status, + timestamp: event.requestedAt, + }); + } else { + edges.push({ + kind: "activity", + issueId: node.id, + action: event.action, + timestamp: event.createdAt, + }); + } + } + + nodeResponses.push({ + issue: toIssueBlockerDiagnosticSummary(node), + parentId: node.parentId && visibleNodeIds.has(node.parentId) ? node.parentId : null, + depth: node.depth, + diagnosis: nodeDiagnosis, + likelyReason: nodeDiagnosis, + blockers: blockerResponse.blockers, + blockerReadiness: blockerResponse.readiness, + omittedUnauthorizedBlockerCount: blockerResponse.omittedUnauthorizedBlockerCount, + wakeEvents: wakeResponse.events, + wakeRequestCount: wakeResponse.wakeRequestCount, + activityRecordCount: wakeResponse.activityRecordCount, + truncated: blockerResponse.truncated || wakeResponse.truncated, + truncatedSections: { + blockers: blockerResponse.truncated, + wakeRequests: wakeResponse.truncatedSections.wakeRequests, + activityRecords: wakeResponse.truncatedSections.activityRecords, + }, + }); + } + + edges.sort((left, right) => issueSubtreeEdgeTimestamp(right) - issueSubtreeEdgeTimestamp(left)); + const truncatedSections = { + nodes: input.truncatedNodes, + depth: input.truncatedDepth, + blockers: input.truncatedBlockerIssueIds.size > 0, + wakeRequests: input.truncatedWakeIssueIds.size > 0, + activityRecords: input.truncatedActivityIssueIds.size > 0, + }; + const truncated = Object.values(truncatedSections).some(Boolean); + const diagnosis = buildIssueSubtreeDiagnosis({ + issue, + nodes: nodeResponses, + omittedUnauthorizedNodeCount, + truncated, + caps: input.caps, + }); + + return { + issue, + diagnosis, + likelyReason: diagnosis, + nodes: nodeResponses, + edges, + nodeCount: nodeResponses.length, + omittedUnauthorizedNodeCount, + truncated, + truncatedSections, + caps: input.caps, + }; +} + const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE = @@ -4188,6 +4442,60 @@ export function issueRoutes( res.json(response); }); + router.get("/issues/:id/diagnostics/subtree", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; + + const [diagnostic, includeInternalIds] = await Promise.all([ + svc.getSubtreeDiagnostics(issue.id), + actorCanReadCompanyScope(req, issue.companyId), + ]); + const allBlockers = [...diagnostic.blockersByIssueId.values()].flat(); + const [visibleNodes, visibleBlockers] = await Promise.all([ + filterIssuesForActor(req, diagnostic.nodes), + filterIssuesForActor(req, allBlockers), + ]); + const response = buildIssueSubtreeDiagnosticsResponse({ + issue, + nodes: diagnostic.nodes, + visibleNodes, + blockersByIssueId: diagnostic.blockersByIssueId, + visibleBlockers, + readinessByIssueId: diagnostic.readinessByIssueId, + wakeRequestsByIssueId: diagnostic.wakeRequestsByIssueId, + activityRecordsByIssueId: diagnostic.activityRecordsByIssueId, + truncatedNodes: diagnostic.truncatedNodes, + truncatedDepth: diagnostic.truncatedDepth, + truncatedBlockerIssueIds: diagnostic.truncatedBlockerIssueIds, + truncatedWakeIssueIds: diagnostic.truncatedWakeIssueIds, + truncatedActivityIssueIds: diagnostic.truncatedActivityIssueIds, + includeInternalIds, + caps: diagnostic.caps, + }); + + logger.info( + { + companyId: issue.companyId, + issueId: issue.id, + actorType: req.actor.type, + nodeCount: response.nodeCount, + omittedUnauthorizedNodeCount: response.omittedUnauthorizedNodeCount, + edgeCount: response.edges.length, + internalIdsIncluded: includeInternalIds, + truncated: response.truncated, + }, + "issue subtree diagnostics read", + ); + + res.json(response); + }); + router.get("/issues/:id", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d7cbf4ddff..4aae3cd950 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4970,6 +4970,13 @@ registerCurrentRoute({ summary: "Get wake diagnostics for an issue", }); +registerCurrentRoute({ + method: "get", + path: "/api/issues/{id}/diagnostics/subtree", + tags: ["issues"], + summary: "Get bounded subtree wake and blocker diagnostics for an issue", +}); + registerCurrentRoute({ method: "get", path: "/api/issues/{id}/recovery-actions", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index fc5ce3081c..7f1bec5013 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -104,6 +104,11 @@ export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100; export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50; export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50; export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14; +export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH = 8; +export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES = 100; +export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE = 20; +export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE = 5; +export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE = 5; const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; @@ -548,6 +553,30 @@ type IssueWakeDiagnosticsActivityRow = { details: Record | null; createdAt: Date; }; +type IssueSubtreeDiagnosticsIssueRow = IssueBlockerDiagnosticsIssueRow & { + depth: number; + createdAt: Date; + updatedAt: Date; +}; +type IssueSubtreeDiagnosticsBlockerRow = IssueBlockerDiagnosticsIssueRow & { + blockedIssueId: string; + relationCreatedAt: Date; +}; +type IssueSubtreeDiagnosticsWakeRequestRow = IssueWakeDiagnosticsWakeRequestRow & { + issueId: string; +}; +type IssueSubtreeDiagnosticsActivityRow = IssueWakeDiagnosticsActivityRow & { + issueId: string; +}; +type IssueSubtreeDiagnosticsBlockerResultRow = IssueSubtreeDiagnosticsBlockerRow & { + rowNumber: number | string; +}; +type IssueSubtreeDiagnosticsWakeRequestResultRow = IssueSubtreeDiagnosticsWakeRequestRow & { + rowNumber: number | string; +}; +type IssueSubtreeDiagnosticsActivityResultRow = IssueSubtreeDiagnosticsActivityRow & { + rowNumber: number | string; +}; export type IssueDependencyReadiness = { issueId: string; blockerIssueIds: string[]; @@ -5052,6 +5081,303 @@ export function issueService(db: Db) { }; }, + getSubtreeDiagnostics: async ( + issueId: string, + opts?: { + maxDepth?: number; + maxNodes?: number; + maxBlockersPerNode?: number; + maxWakeRequestsPerNode?: number; + maxActivityRecordsPerNode?: number; + lookbackDays?: number; + }, + ) => { + const issue = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + + const maxDepth = Math.max( + 0, + Math.min(opts?.maxDepth ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH, ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH), + ); + const maxNodes = Math.max( + 1, + Math.min(opts?.maxNodes ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES, ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES), + ); + const maxBlockersPerNode = Math.max( + 0, + Math.min( + opts?.maxBlockersPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE, + ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE, + ), + ); + const maxWakeRequestsPerNode = Math.max( + 0, + Math.min( + opts?.maxWakeRequestsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE, + ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE, + ), + ); + const maxActivityRecordsPerNode = Math.max( + 0, + Math.min( + opts?.maxActivityRecordsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE, + ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE, + ), + ); + const lookbackDays = Math.max( + 1, + Math.min(opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS), + ); + const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); + const sinceIso = since.toISOString(); + + const rawSubtreeRows = await db.execute(sql` + WITH RECURSIVE issue_tree AS ( + SELECT + id, + company_id, + project_id, + parent_id, + identifier, + title, + status, + priority, + assignee_agent_id, + assignee_user_id, + created_at, + updated_at, + 0 AS depth, + ARRAY[id] AS path + FROM issues + WHERE company_id = ${issue.companyId} + AND id = ${issue.id} + AND hidden_at IS NULL + UNION ALL + SELECT + child.id, + child.company_id, + child.project_id, + child.parent_id, + child.identifier, + child.title, + child.status, + child.priority, + child.assignee_agent_id, + child.assignee_user_id, + child.created_at, + child.updated_at, + issue_tree.depth + 1, + issue_tree.path || child.id + FROM issues child + JOIN issue_tree ON child.parent_id = issue_tree.id + WHERE child.company_id = ${issue.companyId} + AND child.hidden_at IS NULL + AND issue_tree.depth < ${maxDepth + 1} + AND NOT child.id = ANY(issue_tree.path) + ) + SELECT + id, + company_id AS "companyId", + project_id AS "projectId", + parent_id AS "parentId", + identifier, + title, + status, + priority, + assignee_agent_id AS "assigneeAgentId", + assignee_user_id AS "assigneeUserId", + created_at AS "createdAt", + updated_at AS "updatedAt", + depth::int AS depth + FROM issue_tree + ORDER BY depth ASC, created_at ASC, id ASC + LIMIT ${maxNodes + 1} + `); + const subtreeRows = Array.from(rawSubtreeRows) + .map((row) => ({ ...row, depth: Number(row.depth) })); + const rowsWithinDepth = subtreeRows.filter((row) => row.depth <= maxDepth); + const nodes = rowsWithinDepth.slice(0, maxNodes) as IssueSubtreeDiagnosticsIssueRow[]; + const truncatedNodes = rowsWithinDepth.length > maxNodes; + const truncatedDepth = truncatedNodes || subtreeRows.some((row) => row.depth > maxDepth); + const nodeIds = nodes.map((node) => node.id); + + const readiness = nodeIds.length > 0 + ? await listIssueDependencyReadinessMap(db, issue.companyId, nodeIds) + : new Map(); + const blockersByIssueId = new Map(); + const wakeRequestsByIssueId = new Map(); + const activityRecordsByIssueId = new Map(); + const truncatedBlockerIssueIds = new Set(); + const truncatedWakeIssueIds = new Set(); + const truncatedActivityIssueIds = new Set(); + + if (nodeIds.length > 0) { + const nodeIdValues = sql.join(nodeIds.map((id) => sql`${id}`), sql`, `); + const rawBlockerRows = Array.from(await db.execute(sql` + WITH blocker_rows AS ( + SELECT + blocker.id, + blocker.company_id AS "companyId", + blocker.project_id AS "projectId", + blocker.parent_id AS "parentId", + blocker.identifier, + blocker.title, + blocker.status, + blocker.priority, + blocker.assignee_agent_id AS "assigneeAgentId", + blocker.assignee_user_id AS "assigneeUserId", + relation.related_issue_id AS "blockedIssueId", + relation.created_at AS "relationCreatedAt", + row_number() OVER ( + PARTITION BY relation.related_issue_id + ORDER BY blocker.title ASC, blocker.id ASC + )::int AS "rowNumber" + FROM issue_relations relation + INNER JOIN issues blocker ON blocker.id = relation.issue_id + WHERE relation.company_id = ${issue.companyId} + AND relation.type = 'blocks' + AND blocker.company_id = ${issue.companyId} + AND blocker.hidden_at IS NULL + AND relation.related_issue_id::text IN (${nodeIdValues}) + ) + SELECT * + FROM blocker_rows + WHERE "rowNumber" <= ${maxBlockersPerNode + 1} + ORDER BY "blockedIssueId" ASC, "rowNumber" ASC + `)) as IssueSubtreeDiagnosticsBlockerResultRow[]; + for (const row of rawBlockerRows) { + const normalized = { ...row, rowNumber: Number(row.rowNumber) }; + if (normalized.rowNumber > maxBlockersPerNode) { + truncatedBlockerIssueIds.add(normalized.blockedIssueId); + continue; + } + const rows = blockersByIssueId.get(normalized.blockedIssueId) ?? []; + rows.push(normalized); + blockersByIssueId.set(normalized.blockedIssueId, rows); + } + + const wakeTargetIssueIdSql = sql` + coalesce( + wake.payload ->> 'issueId', + wake.payload ->> 'taskId', + wake.payload -> '_paperclipWakeContext' ->> 'issueId', + wake.payload -> '_paperclipWakeContext' ->> 'taskId' + ) + `; + const rawWakeRows = Array.from(await db.execute(sql` + WITH wake_rows AS ( + SELECT + ${wakeTargetIssueIdSql} AS "issueId", + wake.agent_id AS "agentId", + wake.source, + wake.reason, + wake.status, + wake.coalesced_count AS "coalescedCount", + wake.run_id AS "runId", + wake.requested_at AS "requestedAt", + wake.claimed_at AS "claimedAt", + wake.finished_at AS "finishedAt", + wake.error, + row_number() OVER ( + PARTITION BY ${wakeTargetIssueIdSql} + ORDER BY wake.requested_at DESC, wake.created_at DESC + )::int AS "rowNumber" + FROM agent_wakeup_requests wake + WHERE wake.company_id = ${issue.companyId} + AND wake.requested_at >= ${sinceIso}::timestamptz + AND ${wakeTargetIssueIdSql} IN (${nodeIdValues}) + ) + SELECT * + FROM wake_rows + WHERE "rowNumber" <= ${maxWakeRequestsPerNode + 1} + ORDER BY "issueId" ASC, "requestedAt" DESC + `)) as IssueSubtreeDiagnosticsWakeRequestResultRow[]; + for (const row of rawWakeRows) { + const normalized = { ...row, rowNumber: Number(row.rowNumber) }; + if (normalized.rowNumber > maxWakeRequestsPerNode) { + truncatedWakeIssueIds.add(normalized.issueId); + continue; + } + const rows = wakeRequestsByIssueId.get(normalized.issueId) ?? []; + rows.push(normalized); + wakeRequestsByIssueId.set(normalized.issueId, rows); + } + + const activityTargetIssueIdSql = sql` + coalesce( + CASE WHEN activity.entity_type = 'issue' THEN activity.entity_id ELSE NULL END, + activity.details ->> 'issueId', + activity.details ->> 'rootIssueId' + ) + `; + const activityActionValues = sql.join( + ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + ); + const rawActivityRows = Array.from(await db.execute(sql` + WITH activity_rows AS ( + SELECT + ${activityTargetIssueIdSql} AS "issueId", + activity.action, + activity.entity_type AS "entityType", + activity.entity_id AS "entityId", + activity.agent_id AS "agentId", + activity.run_id AS "runId", + activity.details, + activity.created_at AS "createdAt", + row_number() OVER ( + PARTITION BY ${activityTargetIssueIdSql} + ORDER BY activity.created_at DESC, activity.id DESC + )::int AS "rowNumber" + FROM activity_log activity + WHERE activity.company_id = ${issue.companyId} + AND activity.created_at >= ${sinceIso}::timestamptz + AND activity.action IN (${activityActionValues}) + AND ${activityTargetIssueIdSql} IN (${nodeIdValues}) + ) + SELECT * + FROM activity_rows + WHERE "rowNumber" <= ${maxActivityRecordsPerNode + 1} + ORDER BY "issueId" ASC, "createdAt" DESC + `)) as IssueSubtreeDiagnosticsActivityResultRow[]; + for (const row of rawActivityRows) { + const normalized = { ...row, rowNumber: Number(row.rowNumber) }; + if (normalized.rowNumber > maxActivityRecordsPerNode) { + truncatedActivityIssueIds.add(normalized.issueId); + continue; + } + const rows = activityRecordsByIssueId.get(normalized.issueId) ?? []; + rows.push(normalized); + activityRecordsByIssueId.set(normalized.issueId, rows); + } + } + + return { + nodes, + blockersByIssueId, + readinessByIssueId: readiness, + wakeRequestsByIssueId, + activityRecordsByIssueId, + truncatedNodes, + truncatedDepth, + truncatedBlockerIssueIds, + truncatedWakeIssueIds, + truncatedActivityIssueIds, + caps: { + maxDepth, + maxNodes, + maxBlockersPerNode, + maxWakeRequestsPerNode, + maxActivityRecordsPerNode, + lookbackDays, + }, + }; + }, + getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => { const issue = await dbOrTx .select({ id: issues.id, companyId: issues.companyId }) diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 38fcacb538..57ed100dea 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -274,6 +274,54 @@ Security and bounds: - Activity records are limited to wake defer/suppression actions and exact allowlisted fields such as `rootIssueId`, `holdId`, `source`, `requestedReason`, and `previousReason`. - Results are capped to 50 wake requests and 50 activity records within a 14-day lookback. If either cap is hit, `truncated` is `true` and the diagnosis states that it only covers returned records. +### Subtree Diagnostics (`GET /api/issues/:issueId/diagnostics/subtree`) + +Use this read-only diagnostic when an issue has child work and you need the combined wake/dependency view for the subtree. Read top-level `diagnosis` first; `likelyReason` is the same value. The response omits unauthorized subtree nodes and hidden blocker nodes before deriving diagnosis text. + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "nodes": [ + { + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "parentId": null, + "depth": 0, + "diagnosis": "PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 is blocked by PAP-80, which is in_progress.", + "blockers": [ + { "id": "issue-80", "identifier": "PAP-80", "title": "Finish dependency", "status": "in_progress", "priority": "medium", "assigneeAgentId": "agent-2", "assigneeUserId": null, "isUnresolved": true, "isDependencyReady": false, "isPendingFinalize": false, "flags": [] } + ], + "blockerReadiness": { "allBlockersDone": false, "isDependencyReady": false, "unresolvedBlockerCount": 1, "pendingFinalizeBlockerCount": 0 }, + "omittedUnauthorizedBlockerCount": 0, + "wakeEvents": [], + "wakeRequestCount": 0, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "blockers": false, "wakeRequests": false, "activityRecords": false } + } + ], + "edges": [ + { "kind": "blocks", "fromIssueId": "issue-80", "toIssueId": "issue-99", "timestamp": "2026-07-07T00:00:00.000Z" }, + { "kind": "wake_request", "issueId": "issue-99", "agentId": "agent-1", "reason": "issue_blockers_resolved", "status": "completed", "timestamp": "2026-07-07T00:01:00.000Z" } + ], + "nodeCount": 1, + "omittedUnauthorizedNodeCount": 0, + "truncated": false, + "truncatedSections": { "nodes": false, "depth": false, "blockers": false, "wakeRequests": false, "activityRecords": false }, + "caps": { "maxDepth": 8, "maxNodes": 100, "maxBlockersPerNode": 20, "maxWakeRequestsPerNode": 5, "maxActivityRecordsPerNode": 5, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization. Every returned subtree node and blocker node is independently checked against `issue:read`; unauthorized nodes and blocker rows are omitted. +- `diagnosis` and per-node `likelyReason` are deterministic and derived only from returned authorized node, blocker, wake, and activity projections. +- Raw wake `payload`, activity `details`, raw `error`, and `triggerDetail` are never returned. Wake fields use the same coarse projections as wake diagnostics. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for internal wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- The subtree walk is capped to depth 8 and 100 nodes with a cycle guard. Per-node blockers, wake requests, and activity records are also capped. Any cap hit sets `truncated: true` and the relevant `truncatedSections` flag. + ### Execution Policy Fields On An Issue When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`: @@ -1012,6 +1060,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/heartbeat-context` | Compact context for heartbeat: issue state, ancestor summaries, comment cursor | | GET | `/api/issues/:issueId/diagnostics/blockers` | Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags | | GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference | +| GET | `/api/issues/:issueId/diagnostics/subtree` | Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis` | | POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) | | PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) | | POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. |