mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
parent
9a8d219949
commit
5a0c1979cf
121 changed files with 9625 additions and 2044 deletions
|
|
@ -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 {
|
||||
|
|
@ -38,7 +39,7 @@ import { redactCurrentUserText } from "../log-redaction.js";
|
|||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
import {
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
|
||||
isVerifiedIssueTreeControlInteractionWake,
|
||||
issueTreeControlService,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} from "./issue-tree-control.js";
|
||||
|
|
@ -82,18 +83,6 @@ function readStringFromRecord(record: unknown, key: string) {
|
|||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readLatestWakeCommentId(record: unknown) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>).wakeCommentIds;
|
||||
if (Array.isArray(value)) {
|
||||
const latest = value
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.at(-1);
|
||||
if (latest) return latest.trim();
|
||||
}
|
||||
return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId");
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
|
|
@ -668,6 +657,46 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
|
|||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
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;
|
||||
executionRunId?: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
type IssueBlockerAttentionInputNode =
|
||||
Pick<
|
||||
IssueBlockerAttentionNode,
|
||||
"id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId"
|
||||
>
|
||||
& { executionRunId?: 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,
|
||||
|
|
@ -706,6 +735,380 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type IssueRelationSummaryRow = {
|
||||
relatedId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async function terminalExplicitBlockersByRoot(
|
||||
companyId: string,
|
||||
roots: IssueRelationIssueSummary[],
|
||||
dbOrTx: DbReader,
|
||||
): Promise<Map<string, IssueRelationIssueSummary[]>> {
|
||||
const rootIds = [...new Set(roots.map((root) => root.id))];
|
||||
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
|
||||
if (rootIds.length === 0) return terminalByRoot;
|
||||
|
||||
const nodesById = new Map<string, IssueRelationIssueSummary>();
|
||||
const edgesByIssueId = new Map<string, string[]>();
|
||||
for (const root of roots) nodesById.set(root.id, root);
|
||||
|
||||
let frontier = rootIds;
|
||||
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 rows = await dbOrTx
|
||||
.select({
|
||||
currentIssueId: issueRelations.relatedIssueId,
|
||||
relatedId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
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"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? [];
|
||||
if (!existingEdges.includes(row.relatedId)) {
|
||||
existingEdges.push(row.relatedId);
|
||||
edgesByIssueId.set(row.currentIssueId, existingEdges);
|
||||
}
|
||||
if (!nodesById.has(row.relatedId)) {
|
||||
nodesById.set(row.relatedId, summarizeIssueRelationRow(row));
|
||||
nextFrontier.add(row.relatedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break;
|
||||
frontier = [...nextFrontier];
|
||||
}
|
||||
|
||||
const collectTerminal = (issueId: string, seen: Set<string>): IssueRelationIssueSummary[] => {
|
||||
if (seen.has(issueId)) return [];
|
||||
const node = nodesById.get(issueId);
|
||||
if (!node || node.status === "done") return [];
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(issueId);
|
||||
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
|
||||
if (downstreamIds.length === 0) return [node];
|
||||
return downstreamIds.flatMap((downstreamId) => collectTerminal(downstreamId, nextSeen));
|
||||
};
|
||||
|
||||
for (const rootId of rootIds) {
|
||||
const deduped = new Map<string, IssueRelationIssueSummary>();
|
||||
for (const blocker of collectTerminal(rootId, new Set())) {
|
||||
if (blocker.id !== rootId) deduped.set(blocker.id, blocker);
|
||||
}
|
||||
if (deduped.size > 0) {
|
||||
terminalByRoot.set(rootId, [...deduped.values()].sort((a, b) => a.title.localeCompare(b.title)));
|
||||
}
|
||||
}
|
||||
|
||||
return terminalByRoot;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
): 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,
|
||||
executionRunId: issues.executionRunId,
|
||||
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,
|
||||
executionRunId: issues.executionRunId,
|
||||
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,
|
||||
executionRunId: row.executionRunId,
|
||||
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>();
|
||||
const issueIdByExecutionRunId = new Map<string, string>();
|
||||
for (const node of nodesById.values()) {
|
||||
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
|
||||
if (node.executionRunId) issueIdByExecutionRunId.set(node.executionRunId, node.id);
|
||||
}
|
||||
|
||||
for (const chunk of chunkList([...issueIdByExecutionRunId.keys()], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const runRows: Array<{ id: string }> = await dbOrTx
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
|
||||
inArray(heartbeatRuns.id, chunk),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of runRows) {
|
||||
const issueId = issueIdByExecutionRunId.get(row.id);
|
||||
if (issueId) activeIssueIds.add(issueId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
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 wakeRows = await wakeRowsPromise;
|
||||
for (const row of 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,
|
||||
|
|
@ -956,18 +1359,25 @@ export function issueService(db: Db) {
|
|||
) {
|
||||
if (!checkoutRunId) return false;
|
||||
const run = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, checkoutRunId), eq(heartbeatRuns.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const wakeReason =
|
||||
readStringFromRecord(run?.contextSnapshot, "wakeReason") ??
|
||||
readStringFromRecord(run?.contextSnapshot, "reason");
|
||||
return Boolean(
|
||||
wakeReason &&
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS.has(wakeReason) &&
|
||||
readLatestWakeCommentId(run?.contextSnapshot),
|
||||
);
|
||||
const issueId = readStringFromRecord(run?.contextSnapshot, "issueId");
|
||||
if (!run || !issueId) return false;
|
||||
return isVerifiedIssueTreeControlInteractionWake(db, {
|
||||
companyId,
|
||||
issueId,
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
wakeupRequestId: run.wakeupRequestId,
|
||||
contextSnapshot: run.contextSnapshot as Record<string, unknown> | null | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAssignableUser(companyId: string, userId: string) {
|
||||
|
|
@ -1118,30 +1528,26 @@ export function issueService(db: Db) {
|
|||
]);
|
||||
|
||||
for (const row of blockedByRows) {
|
||||
empty.get(row.currentIssueId)?.blockedBy.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blockedBy.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
for (const row of blockingRows) {
|
||||
empty.get(row.currentIssueId)?.blocks.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blocks.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
|
||||
const terminalByRoot = await terminalExplicitBlockersByRoot(
|
||||
companyId,
|
||||
[...empty.values()].flatMap((relations) => relations.blockedBy),
|
||||
dbOrTx,
|
||||
);
|
||||
|
||||
for (const relations of empty.values()) {
|
||||
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
for (const blocker of relations.blockedBy) {
|
||||
const terminalBlockers = terminalByRoot.get(blocker.id);
|
||||
if (terminalBlockers && terminalBlockers.length > 0) {
|
||||
blocker.terminalBlockers = terminalBlockers;
|
||||
}
|
||||
}
|
||||
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
|
|
@ -1519,6 +1925,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) => {
|
||||
|
|
@ -1531,6 +1938,7 @@ export function issueService(db: Db) {
|
|||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1547,6 +1955,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,
|
||||
|
|
@ -1690,6 +2099,14 @@ export function issueService(db: Db) {
|
|||
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
|
||||
},
|
||||
|
||||
listBlockerAttention: async (
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue