From 9d5b0e3c57f7469aef238e15e319112b24eb4fd4 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 22:39:34 -0700 Subject: [PATCH] Add read-only issue subtree diagnostics endpoint (#9135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issue/task orchestration subsystem tracks parent–child and blocker–dependent relationships, forming a directed acyclic (in intention) subtree below each root issue > - Agents and operators have no lightweight way to inspect the dependency and wake state across an entire issue subtree — they must walk the tree issue-by-issue, making multiple round-trips with full object fetches > - A bounded, read-only subtree diagnostic endpoint lets callers understand the health of an entire work tree (which nodes are blocked, which are cycling, which have pending wakes) from a single authenticated request > - This pull request adds `GET /api/issues/:id/diagnostics/subtree`, a depth/node/per-node capped traversal that reuses the blocker and wake projection helpers from the companion blocker and wake diagnostics endpoints (see Refs #9114, #9133) > - The benefit is that platform operators, monitoring, and coaching tooling can surface \"why is this subtree stalled?\" across all nodes without database access or unbounded graph walks, using only data the caller already has read permission for ## Linked Issues or Issue Description Refs #9114 (companion blocker diagnostics endpoint — blocker projection helpers reused here) Refs #9133 (companion wake diagnostics endpoint — wake projection helpers reused here) ## What Changed - **New route** `GET /api/issues/:id/diagnostics/subtree` in `server/src/routes/issues.ts`: returns a bounded subtree traversal rooted at `:id`, with depth/node/per-node caps and explicit truncation flags - **Cycle-safe traversal**: visited-node set prevents infinite loops on any accidental cycle in the ancestry graph - **Per-node authorization**: each subtree node is individually filtered through `assertIssueReadAllowed`; unauthorized nodes are omitted from the response and do not influence aggregate counts - **Blocker and wake reuse**: per-node blocker rows and wake events are projected through the same helpers as #9114 and #9133 — raw wake payloads, raw errors, activity details, and trigger detail fields are stripped - **Low-trust filtering**: the `mention-scoped` low-trust path redacts node/blocker identifiers for unauthorized actors, consistent with #9133 - **Truncation reporting**: response includes `depthTruncated`, `nodeTruncated`, and per-node `blockersTruncated`/`wakesTruncated` flags when caps are hit - **Shared types** in `@paperclipai/shared`: `IssueSubtreeDiagnosticsResponse` and supporting node/blocker/wake types exported from the shared package - **OpenAPI tag registration** for the new route - **API reference docs** in `skills/paperclip/references/api-reference.md` - **Test coverage** (`server/src/__tests__/issue-subtree-diagnostics-routes.test.ts`, embedded Postgres): happy path, quiet singleton (no children/blockers), node cap truncation, mention-scoped low-trust filtering, cross-company denial ## Verification ```bash # Subtree diagnostics tests only pnpm exec vitest run server/src/__tests__/issue-subtree-diagnostics-routes.test.ts # Full diagnostics suite (blocker + wake + subtree) pnpm exec vitest run server/src/__tests__/issue-blocker-diagnostics-routes.test.ts server/src/__tests__/issue-wake-diagnostics-routes.test.ts server/src/__tests__/issue-subtree-diagnostics-routes.test.ts # Type-check shared and server packages pnpm --filter @paperclipai/shared typecheck pnpm --filter @paperclipai/server typecheck # Whitespace / diff check git diff --check ``` All commands passed locally (5 subtree tests, 17 total across the three diagnostics test files). ## Risks - **No schema or migration changes** — read-only projection over existing relations; no DDL risk - **Bounded traversal** — depth, node count, and per-node blocker/wake caps prevent unbounded graph walks; truncation is reported explicitly in the response - **Auth boundary** — root issue read is company-scoped and checked before the subtree is built; each subtree node is individually authorized; cross-company access is denied at `assertCompanyAccess` - **No raw payloads** — raw wake payload, raw error, activity details, and trigger detail fields are stripped from all nodes, consistent with the companion endpoints - Low overall risk; the endpoint is additive and read-only ## Model Used - **Provider:** Anthropic - **Model:** Claude Sonnet 4.6 (`claude-sonnet-4-6`) - **Tool use:** yes (file reads, edits, bash execution, Paperclip API calls) - **Reasoning mode:** standard (no extended thinking) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 3 + packages/shared/src/types/issue.ts | 74 +++ .../issue-subtree-diagnostics-routes.test.ts | 429 ++++++++++++++++++ server/src/routes/issues.ts | 324 ++++++++++++- server/src/routes/openapi.ts | 7 + server/src/services/issues.ts | 326 +++++++++++++ skills/paperclip/references/api-reference.md | 49 ++ 8 files changed, 1207 insertions(+), 8 deletions(-) create mode 100644 server/src/__tests__/issue-subtree-diagnostics-routes.test.ts 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. |