mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add covered blocked issue attention state
This commit is contained in:
1 parent
4788e563fd
commit
8f208110b0
14 files changed
+637
-13
No files matched your search
@@ -315,6 +315,9 @@ export type {
|
||||
IssueWorkProductReviewState,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -109,6 +109,9 @@ export type {
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -118,6 +118,23 @@ export interface IssueRelationIssueSummary {
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention";
|
||||
|
||||
export type IssueBlockerAttentionReason =
|
||||
| "active_child"
|
||||
| "active_dependency"
|
||||
| "attention_required"
|
||||
| null;
|
||||
|
||||
export interface IssueBlockerAttention {
|
||||
state: IssueBlockerAttentionState;
|
||||
reason: IssueBlockerAttentionReason;
|
||||
unresolvedBlockerCount: number;
|
||||
coveredBlockerCount: number;
|
||||
attentionBlockerCount: number;
|
||||
sampleBlockerIdentifier: string | null;
|
||||
}
|
||||
|
||||
export interface IssueRelation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -242,6 +259,7 @@ export interface Issue {
|
||||
labels?: IssueLabel[];
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blocks?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
relatedWork?: IssueRelatedWorkSummary;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
planDocument?: IssueDocument | null;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue blocker attention tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-blocker-attention-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function createCompany(prefix = "PBA") {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: `Company ${prefix}`,
|
||||
issuePrefix: prefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: `${prefix} Agent`,
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
});
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function insertIssue(input: {
|
||||
companyId: string;
|
||||
id?: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: string;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
}) {
|
||||
const id = input.id ?? randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
identifier: input.identifier,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
priority: "medium",
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function block(input: { companyId: string; blockerIssueId: string; blockedIssueId: string }) {
|
||||
await db.insert(issueRelations).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.blockerIssueId,
|
||||
relatedIssueId: input.blockedIssueId,
|
||||
type: "blocks",
|
||||
});
|
||||
}
|
||||
|
||||
async function activeRun(input: { companyId: string; agentId: string; issueId: string; status?: string }) {
|
||||
await db.insert(heartbeatRuns).values({
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: input.status ?? "running",
|
||||
contextSnapshot: { issueId: input.issueId },
|
||||
});
|
||||
}
|
||||
|
||||
it("classifies a blocked parent as covered when its child has a running execution path", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBC");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBC-1", title: "Parent", status: "blocked" });
|
||||
const childId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBC-2",
|
||||
title: "Running child",
|
||||
status: "todo",
|
||||
parentId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: childId, blockedIssueId: parentId });
|
||||
await activeRun({ companyId, agentId, issueId: childId });
|
||||
|
||||
const [parent] = await svc.list(companyId, { status: "blocked" });
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PBC-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mixed blockers attention-required when any path lacks active work", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBM");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBM-1", title: "Parent", status: "blocked" });
|
||||
const activeChildId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBM-2",
|
||||
title: "Running child",
|
||||
status: "todo",
|
||||
parentId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const idleBlockerId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBM-3",
|
||||
title: "Idle blocker",
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: activeChildId, blockedIssueId: parentId });
|
||||
await block({ companyId, blockerIssueId: idleBlockerId, blockedIssueId: parentId });
|
||||
await activeRun({ companyId, agentId, issueId: activeChildId });
|
||||
|
||||
const [parent] = await svc.list(companyId, { status: "blocked" });
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 2,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PBM-3",
|
||||
});
|
||||
});
|
||||
|
||||
it("covers recursive blocker chains when the downstream leaf has active work", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBR");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBR-1", title: "Parent", status: "blocked" });
|
||||
const blockerId = await insertIssue({ companyId, identifier: "PBR-2", title: "Blocked dependency", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBR-3",
|
||||
title: "Running leaf",
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: blockerId });
|
||||
await activeRun({ companyId, agentId, issueId: leafId });
|
||||
|
||||
const [parent] = await svc.list(companyId, { status: "blocked" });
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PBR-3",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let another company's active run cover the blocker", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBS");
|
||||
const other = await createCompany("PBT");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBS-1", title: "Parent", status: "blocked" });
|
||||
const blockerId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBS-2",
|
||||
title: "Same-company blocker",
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
|
||||
await activeRun({ companyId: other.companyId, agentId: other.agentId, issueId: blockerId });
|
||||
|
||||
const [parent] = await svc.list(companyId, { status: "blocked" });
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PBS-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1016,6 +1016,7 @@ export function issueRoutes(
|
||||
commentCursor,
|
||||
wakeComment,
|
||||
relations,
|
||||
blockerAttention,
|
||||
attachments,
|
||||
continuationSummary,
|
||||
currentExecutionWorkspace,
|
||||
@@ -1026,6 +1027,7 @@ export function issueRoutes(
|
||||
svc.getCommentCursor(issue.id),
|
||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
||||
svc.listAttachments(issue.id),
|
||||
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
||||
currentExecutionWorkspacePromise,
|
||||
@@ -1038,6 +1040,7 @@ export function issueRoutes(
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
@@ -1107,12 +1110,13 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
@@ -1126,6 +1130,7 @@ export function issueRoutes(
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
@@ -656,6 +657,39 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
|
||||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
|
||||
type IssueBlockerAttentionNode = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
parentId: string | null;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
type IssueBlockerAttentionEdge = {
|
||||
issueId: string;
|
||||
blockerIssueId: string;
|
||||
};
|
||||
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
|
||||
issueId: string | null;
|
||||
blockerIssueId: string;
|
||||
};
|
||||
type IssueBlockerAttentionActivePathRow = {
|
||||
issueId: string | null;
|
||||
};
|
||||
type IssueBlockerAttentionAgentRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
async function activeRunMapForIssues(
|
||||
dbOrTx: any,
|
||||
@@ -694,6 +728,267 @@ async function activeRunMapForIssues(
|
||||
return map;
|
||||
}
|
||||
|
||||
function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {}): IssueBlockerAttention {
|
||||
return {
|
||||
state: input.state ?? "none",
|
||||
reason: input.reason ?? null,
|
||||
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
|
||||
coveredBlockerCount: input.coveredBlockerCount ?? 0,
|
||||
attentionBlockerCount: input.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
|
||||
return node?.identifier ?? node?.id ?? null;
|
||||
}
|
||||
|
||||
function appendBlockerAttentionEdges(
|
||||
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
|
||||
rows: IssueBlockerAttentionEdge[],
|
||||
) {
|
||||
for (const row of rows) {
|
||||
const existing = edgesByIssueId.get(row.issueId) ?? [];
|
||||
if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) {
|
||||
existing.push(row);
|
||||
edgesByIssueId.set(row.issueId, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
issueRows: Array<Pick<IssueBlockerAttentionNode, "id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId">>,
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
|
||||
const attentionMap = new Map<string, IssueBlockerAttention>();
|
||||
for (const row of issueRows) {
|
||||
if (row.status !== "blocked") {
|
||||
attentionMap.set(row.id, createIssueBlockerAttention());
|
||||
}
|
||||
}
|
||||
if (roots.length === 0) return attentionMap;
|
||||
|
||||
const nodesById = new Map<string, IssueBlockerAttentionNode>();
|
||||
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
|
||||
for (const root of roots) nodesById.set(root.id, { ...root });
|
||||
|
||||
let frontier = roots.map((root) => root.id);
|
||||
let truncated = false;
|
||||
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
|
||||
const nextFrontier = new Set<string>();
|
||||
|
||||
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const explicitBlockerRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issues.id,
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, chunk),
|
||||
eq(issues.companyId, companyId),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: issues.parentId,
|
||||
blockerIssueId: issues.id,
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.parentId, chunk),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
const [explicitBlockerRows, childRows] = await Promise.all([
|
||||
explicitBlockerRowsPromise,
|
||||
childRowsPromise,
|
||||
]);
|
||||
|
||||
appendBlockerAttentionEdges(edgesByIssueId, [
|
||||
...explicitBlockerRows
|
||||
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
|
||||
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
|
||||
...childRows
|
||||
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
|
||||
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
|
||||
]);
|
||||
|
||||
for (const row of [...explicitBlockerRows, ...childRows]) {
|
||||
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
|
||||
nodesById.set(row.blockerIssueId, {
|
||||
id: row.blockerIssueId,
|
||||
companyId: row.companyId,
|
||||
parentId: row.parentId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
nextFrontier.add(row.blockerIssueId);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
frontier = [...nextFrontier];
|
||||
}
|
||||
if (frontier.length > 0) truncated = true;
|
||||
|
||||
const nodeIds = [...nodesById.keys()];
|
||||
const activeIssueIds = new Set<string>();
|
||||
const agentIds = new Set<string>();
|
||||
for (const node of nodesById.values()) {
|
||||
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
|
||||
}
|
||||
|
||||
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const runRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, chunk),
|
||||
),
|
||||
);
|
||||
const wakeRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES),
|
||||
sql`${agentWakeupRequests.runId} is null`,
|
||||
inArray(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, chunk),
|
||||
),
|
||||
);
|
||||
const [runRows, wakeRows] = await Promise.all([
|
||||
runRowsPromise,
|
||||
wakeRowsPromise,
|
||||
]);
|
||||
for (const row of [...runRows, ...wakeRows]) {
|
||||
if (row.issueId) activeIssueIds.add(row.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
|
||||
? await dbOrTx
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds])))
|
||||
: [];
|
||||
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
|
||||
type PathClassification = { covered: boolean; sampleBlockerIdentifier: string | null };
|
||||
const classifyPath = (
|
||||
nodeId: string,
|
||||
seen: Set<string>,
|
||||
): PathClassification => {
|
||||
if (truncated || seen.has(nodeId)) return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(nodeId)) };
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node || node.companyId !== companyId) return { covered: false, sampleBlockerIdentifier: nodeId };
|
||||
if (node.status === "done") return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
if (activeIssueIds.has(node.id)) return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
if (node.status === "cancelled") return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
|
||||
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (downstream.length > 0) {
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(nodeId);
|
||||
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
|
||||
const attention = classified.find((result) => !result.covered);
|
||||
if (attention) return attention;
|
||||
return {
|
||||
covered: true,
|
||||
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? blockerSampleIdentifier(node),
|
||||
};
|
||||
}
|
||||
|
||||
if (node.assigneeAgentId) {
|
||||
const assignee = agentsById.get(node.assigneeAgentId);
|
||||
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
|
||||
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
}
|
||||
}
|
||||
|
||||
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (topLevelEdges.length === 0) {
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const classified = topLevelEdges.map((edge) => ({
|
||||
edge,
|
||||
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
|
||||
}));
|
||||
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount;
|
||||
const attentionEntry = classified.find((entry) => !entry.result.covered);
|
||||
const sampleEntry = attentionEntry ?? classified[0] ?? null;
|
||||
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
|
||||
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: attentionBlockerCount === 0 ? "covered" : "needs_attention",
|
||||
reason: attentionBlockerCount === 0
|
||||
? topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency"
|
||||
: "attention_required",
|
||||
unresolvedBlockerCount: topLevelEdges.length,
|
||||
coveredBlockerCount,
|
||||
attentionBlockerCount,
|
||||
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
|
||||
}));
|
||||
}
|
||||
|
||||
return attentionMap;
|
||||
}
|
||||
|
||||
const issueListSelect = {
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
@@ -1514,6 +1809,7 @@ export function issueService(db: Db) {
|
||||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
@@ -1526,6 +1822,7 @@ export function issueService(db: Db) {
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1542,6 +1839,7 @@ export function issueService(db: Db) {
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
@@ -1685,6 +1983,14 @@ export function issueService(db: Db) {
|
||||
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
|
||||
},
|
||||
|
||||
listBlockerAttention: async (
|
||||
companyId: string,
|
||||
issueRows: Array<Pick<IssueBlockerAttentionNode, "id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId">>,
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
|
||||
@@ -150,7 +150,7 @@ export function InboxIssueMetaLeading({
|
||||
<>
|
||||
{showStatus ? (
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
{statusSlot ?? <StatusIcon status={issue.status} />}
|
||||
{statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />}
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentifier ? (
|
||||
|
||||
@@ -44,7 +44,7 @@ export function IssueQuicklookCard({
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && "space-y-1.5")}>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className="mt-0.5 shrink-0" />
|
||||
<RouterDom.Link
|
||||
to={linkTo}
|
||||
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function IssueRow({
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||
@@ -82,7 +82,7 @@ export function IssueRow({
|
||||
{desktopMetaLeading ?? (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} className={selectedStatusClass} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{identifier}
|
||||
|
||||
@@ -1087,7 +1087,7 @@ export function IssuesList({
|
||||
</button>
|
||||
) : (
|
||||
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1111,7 +1111,7 @@ export function IssuesList({
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
statusSlot={(
|
||||
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
describe("StatusIcon", () => {
|
||||
it("renders covered blocked issues with a non-red attention state label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="covered"');
|
||||
expect(html).toContain('aria-label="Blocked, waiting on running sub-issue"');
|
||||
expect(html).toContain("border-cyan-600");
|
||||
expect(html).not.toContain("border-red-600");
|
||||
});
|
||||
|
||||
it("keeps normal blocked issues on the attention-required visual", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-blocker-attention-state="covered"');
|
||||
expect(html).toContain('aria-label="Blocked"');
|
||||
expect(html).toContain("border-red-600");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -12,15 +13,26 @@ function statusLabel(status: string): string {
|
||||
|
||||
interface StatusIconProps {
|
||||
status: string;
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
onChange?: (status: string) => void;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function StatusIcon({ status, onChange, className, showLabel }: StatusIconProps) {
|
||||
function coveredBlockedLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
|
||||
return blockerAttention?.reason === "active_child"
|
||||
? "Blocked, waiting on running sub-issue"
|
||||
: "Blocked, covered by active dependency";
|
||||
}
|
||||
|
||||
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const colorClass = issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isDone = status === "done";
|
||||
const ariaLabel = isCoveredBlocked ? coveredBlockedLabel(blockerAttention) : statusLabel(status);
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
@@ -30,10 +42,16 @@ export function StatusIcon({ status, onChange, className, showLabel }: StatusIco
|
||||
onChange && !showLabel && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
{isDone && (
|
||||
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
|
||||
)}
|
||||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ export function Dashboard() {
|
||||
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
|
||||
{/* Status icon - left column on mobile */}
|
||||
<span className="shrink-0 sm:hidden">
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
|
||||
</span>
|
||||
|
||||
{/* Right column on mobile: title + metadata stacked */}
|
||||
@@ -365,7 +365,7 @@ export function Dashboard() {
|
||||
{issue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function MyIssues() {
|
||||
title={issue.title}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
leading={
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
|
||||
}
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
Reference in new issue
Block a user