Merge upstream/master into dev (13 commits — includes #5922, #5938, blocked inbox, recovery actions)

This commit is contained in:
2026-05-13 22:35:18 -04:00
180 changed files with 31626 additions and 545 deletions
+8 -4
View File
@@ -42,6 +42,7 @@ import {
heartbeatService,
ISSUE_LIST_DEFAULT_LIMIT,
issueApprovalService,
issueRecoveryActionService,
issueService,
logActivity,
syncInstructionsBundleConfigFromFilePath,
@@ -1742,16 +1743,18 @@ export function agentRoutes(
}
const issuesSvc = issueService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
limit: ISSUE_LIST_DEFAULT_LIMIT,
});
const dependencyReadiness = await issuesSvc.listDependencyReadiness(
req.actor.companyId,
rows.map((issue) => issue.id),
);
const issueIds = rows.map((issue) => issue.id);
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
]);
res.json(
rows.map((issue) => ({
@@ -1765,6 +1768,7 @@ export function agentRoutes(
parentId: issue.parentId,
updatedAt: issue.updatedAt,
activeRun: issue.activeRun,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true,
unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0,
unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [],
+279 -11
View File
@@ -2,9 +2,16 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db";
import {
activityLog,
executionWorkspaces,
issueExecutionDecisions,
issueRelations,
issues as issueRows,
projectWorkspaces,
} from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -18,6 +25,7 @@ import {
createChildIssueSchema,
createIssueSchema,
resolveCreateIssueStatusDefault,
resolveIssueRecoveryActionSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
@@ -37,6 +45,7 @@ import {
type CompanySearchQuery,
type CompanySearchResponse,
type ExecutionWorkspace,
type IssueRelationIssueSummary,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
@@ -53,6 +62,7 @@ import {
goalService,
heartbeatService,
issueApprovalService,
issueRecoveryActionService,
issueThreadInteractionService,
ISSUE_LIST_DEFAULT_LIMIT,
ISSUE_LIST_MAX_LIMIT,
@@ -405,6 +415,47 @@ async function listSuccessfulRunHandoffStates(
return states;
}
type RecoveryActionsLister = {
listActiveForIssues: (
companyId: string,
sourceIssueIds: string[],
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
};
async function relationRecoveryActionMap(
recoveryActionsSvc: RecoveryActionsLister,
companyId: string,
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
): Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>> {
const candidates: IssueRelationIssueSummary[] = [];
const visit = (summary: IssueRelationIssueSummary) => {
candidates.push(summary);
for (const terminal of summary.terminalBlockers ?? []) {
visit(terminal);
}
};
for (const blocker of relations.blockedBy) visit(blocker);
for (const blocking of relations.blocks) visit(blocking);
if (candidates.length === 0) return new Map();
const ids = [...new Set(candidates.map((summary) => summary.id))];
return recoveryActionsSvc.listActiveForIssues(companyId, ids);
}
function withRecoveryActionsOnRelationSummaries(
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
recoveryActionByIssueId: Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>,
) {
const augment = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({
...summary,
activeRecoveryAction: recoveryActionByIssueId.get(summary.id) ?? summary.activeRecoveryAction ?? null,
terminalBlockers: summary.terminalBlockers?.map(augment),
});
return {
blockedBy: relations.blockedBy.map(augment),
blocks: relations.blocks.map(augment),
};
}
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
@@ -787,6 +838,7 @@ export function issueRoutes(
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
@@ -1392,6 +1444,7 @@ export function issueRoutes(
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
? Number.parseInt(rawOffset, 10)
: null;
const attention = req.query.attention as string | undefined;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
@@ -1409,6 +1462,10 @@ export function issueRoutes(
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
return;
}
if (attention !== undefined && attention !== "blocked") {
res.status(400).json({ error: "attention must be 'blocked' when provided" });
return;
}
if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` });
return;
@@ -1420,6 +1477,7 @@ export function issueRoutes(
const offset = parsedOffset ?? 0;
const result = await svc.list(companyId, {
attention: attention === "blocked" ? "blocked" : undefined,
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
participantAgentId: req.query.participantAgentId as string | undefined,
@@ -1443,21 +1501,65 @@ export function issueRoutes(
includePluginOperations:
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
includeBlockedInboxAttention:
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
q: req.query.q as string | undefined,
limit,
offset,
});
const handoffStates = await listSuccessfulRunHandoffStates(
db,
companyId,
result.map((issue) => issue.id),
);
const issueIds = result.map((issue) => issue.id);
const [handoffStates, recoveryActionByIssue] = await Promise.all([
listSuccessfulRunHandoffStates(db, companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
]);
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
})));
});
router.get("/companies/:companyId/issues/count", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const attention = req.query.attention as string | undefined;
if (attention !== "blocked") {
res.status(400).json({ error: "issues/count currently requires attention=blocked" });
return;
}
if (req.query.limit !== undefined || req.query.offset !== undefined) {
res.status(400).json({ error: "issues/count does not accept limit or offset" });
return;
}
const count = await svc.count(companyId, {
attention: "blocked",
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
participantAgentId: req.query.participantAgentId as string | undefined,
assigneeUserId: req.query.assigneeUserId as string | undefined,
projectId: req.query.projectId as string | undefined,
workspaceId: req.query.workspaceId as string | undefined,
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
parentId: req.query.parentId as string | undefined,
descendantOf: req.query.descendantOf as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originKindPrefix: req.query.originKindPrefix as string | undefined,
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
excludeRoutineExecutions:
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
includePluginOperations:
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
includeBlockedBy: true,
includeBlockedInboxAttention: true,
q: req.query.q as string | undefined,
});
res.json({ count });
});
router.get("/companies/:companyId/labels", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -1541,6 +1643,7 @@ export function issueRoutes(
attachments,
continuationSummary,
currentExecutionWorkspace,
activeRecoveryAction,
] =
await Promise.all([
resolveIssueProjectAndGoal(issue),
@@ -1554,7 +1657,17 @@ export function issueRoutes(
svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
currentExecutionWorkspacePromise,
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]);
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
recoveryActionsSvc,
issue.companyId,
relations,
);
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
relations,
recoveryActionsByRelationIssue,
);
res.json({
issue: {
@@ -1567,12 +1680,13 @@ export function issueRoutes(
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
scheduledRetry,
activeRecoveryAction,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
originKind: issue.originKind,
@@ -1649,6 +1763,7 @@ export function issueRoutes(
referenceSummary,
successfulRunHandoffStates,
scheduledRetry,
activeRecoveryAction,
] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
@@ -1660,7 +1775,17 @@ export function issueRoutes(
issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
svc.getCurrentScheduledRetry(issue.id),
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]);
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
recoveryActionsSvc,
issue.companyId,
relations,
);
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
relations,
recoveryActionsByRelationIssue,
);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
@@ -1676,8 +1801,9 @@ export function issueRoutes(
productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
scheduledRetry,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
activeRecoveryAction,
blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
...documentPayload,
@@ -1689,6 +1815,148 @@ export function issueRoutes(
});
});
router.get("/issues/:id/recovery-actions", 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);
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id);
res.json({
active,
actions: active ? [active] : [],
});
});
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") {
assertBoard(req);
}
const actor = getActorInfo(req);
const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {};
await assertAgentInReviewReviewPath({
existing,
updateFields,
actorType: req.actor.type,
});
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
const result = await db.transaction(async (tx) => {
let issue = existing;
if (outcome === "blocked") {
const unresolvedBlockers = await tx
.select({ id: issueRows.id })
.from(issueRelations)
.innerJoin(issueRows, eq(issueRelations.issueId, issueRows.id))
.where(
and(
eq(issueRelations.companyId, existing.companyId),
eq(issueRelations.relatedIssueId, existing.id),
eq(issueRelations.type, "blocks"),
notInArray(issueRows.status, ["done", "cancelled"]),
),
)
.limit(1);
if (unresolvedBlockers.length === 0) {
throw unprocessable("Blocked recovery resolution requires an unresolved first-class blocker on the source issue");
}
}
if (sourceIssueStatus) {
const updatedIssue = await svc.update(
id,
{
status: sourceIssueStatus,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
},
tx,
);
if (!updatedIssue) throw notFound("Issue not found");
issue = updatedIssue;
}
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
{
companyId: existing.companyId,
sourceIssueId: existing.id,
actionId: actionId ?? null,
status: actionStatus,
outcome,
resolutionNote: resolutionNote ?? null,
},
tx,
);
if (!recoveryAction) throw notFound("Active recovery action not found");
return { issue, recoveryAction };
});
await routinesSvc.syncRunStatusForIssue(result.issue.id);
if (sourceIssueStatus && existing.status !== result.issue.status) {
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
status: result.issue.status,
source: "recovery_action_resolution",
recoveryActionId: result.recoveryAction.id,
_previous: {
status: existing.status,
},
},
});
}
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
recoveryActionId: result.recoveryAction.id,
recoveryActionStatus: result.recoveryAction.status,
outcome: result.recoveryAction.outcome,
sourceIssueStatus: sourceIssueStatus ?? null,
resolutionNote: result.recoveryAction.resolutionNote,
},
});
res.json({
issue: {
...result.issue,
activeRecoveryAction: null,
},
recoveryAction: result.recoveryAction,
});
});
router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
+6 -20
View File
@@ -199,9 +199,9 @@ function listBundledPluginExamples(): AvailablePluginExample[] {
*
* Lookup order:
* - UUID-like IDs: getById first, then getByKey.
* - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById.
* - Other non-UUID IDs: try getById first (test/memory registries may allow this),
* then fallback to getByKey. Any UUID parse error from getById is ignored.
* - All non-UUID values: getByKey only, never getById. The persisted plugin
* ID column is a PostgreSQL UUID, so probing it with keys such as
* "acme.plugin" raises a database cast error before a key lookup can happen.
*
* @param registry - The plugin registry service instance
* @param pluginId - Either a database UUID or plugin key (manifest id)
@@ -212,27 +212,13 @@ async function resolvePlugin(
pluginId: string,
) {
const isUuid = UUID_REGEX.test(pluginId);
const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/");
// Scoped package IDs are valid plugin keys but invalid UUIDs.
// Skip getById() entirely to avoid Postgres uuid parse errors.
if (isScopedPackageKey && !isUuid) {
if (!isUuid) {
return registry.getByKey(pluginId);
}
try {
const byId = await registry.getById(pluginId);
if (byId) return byId;
} catch (error) {
const maybeCode =
typeof error === "object" && error !== null && "code" in error
? (error as { code?: unknown }).code
: undefined;
// Ignore invalid UUID cast errors and continue with key lookup.
if (maybeCode !== "22P02") {
throw error;
}
}
const byId = await registry.getById(pluginId);
if (byId) return byId;
return registry.getByKey(pluginId);
}