Files
paperclip/server/src/routes/issues.ts
2026-03-19 08:39:24 -05:00

1568 lines
52 KiB
TypeScript

import { Router, type Request, type Response } from "express";
import multer from "multer";
import type { Db } from "@paperclipai/db";
import {
addIssueCommentSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
import { validate } from "../middleware/validate.js";
import {
accessService,
agentService,
executionWorkspaceService,
goalService,
heartbeatService,
issueApprovalService,
issueService,
documentService,
logActivity,
projectService,
routineService,
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = issueService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const routinesSvc = routineService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
function withContentPath<T extends { id: string }>(attachment: T) {
return {
...attachment,
contentPath: `/api/attachments/${attachment.id}/content`,
};
}
async function runSingleFileUpload(req: Request, res: Response) {
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
if (err) reject(err);
else resolve();
});
});
}
async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return true;
if (!req.actor.agentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
const actorAgent = await agentsSvc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
res.status(403).json({ error: "Forbidden" });
return false;
}
if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true;
res.status(403).json({ error: "Missing permission to link approvals" });
return false;
}
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
if (agent.role === "ceo") return true;
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function assertCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) throw forbidden("Missing permission: tasks:assign");
return;
}
if (req.actor.type === "agent") {
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
if (allowedByGrant) return;
const actorAgent = await agentsSvc.getById(req.actor.agentId);
if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
throw forbidden("Missing permission: tasks:assign");
}
throw unauthorized();
}
function requireAgentRunId(req: Request, res: Response) {
if (req.actor.type !== "agent") return null;
const runId = req.actor.runId?.trim();
if (runId) return runId;
res.status(401).json({ error: "Agent run id required" });
return null;
}
async function assertAgentRunCheckoutOwnership(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) {
return true;
}
const runId = requireAgentRunId(req, res);
if (!runId) return false;
const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
if (ownership.adoptedFromRunId) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.checkout_lock_adopted",
entityType: "issue",
entityId: issue.id,
details: {
previousCheckoutRunId: ownership.adoptedFromRunId,
checkoutRunId: runId,
reason: "stale_checkout_run",
},
});
}
return true;
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId);
if (issue) {
return issue.id;
}
}
return rawId;
}
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
router.param("id", async (req, res, next, rawId) => {
try {
req.params.id = await normalizeIssueIdentifier(rawId);
next();
} catch (err) {
next(err);
}
});
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
router.param("issueId", async (req, res, next, rawId) => {
try {
req.params.issueId = await normalizeIssueIdentifier(rawId);
next();
} catch (err) {
next(err);
}
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
const assigneeUserId =
assigneeUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: assigneeUserFilterRaw;
const touchedByUserId =
touchedByUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: touchedByUserFilterRaw;
const unreadForUserId =
unreadForUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: unreadForUserFilterRaw;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
return;
}
if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
return;
}
if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
return;
}
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
assigneeUserId,
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
q: req.query.q as string | undefined,
});
res.json(result);
});
router.get("/companies/:companyId/labels", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.listLabels(companyId);
res.json(result);
});
router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const label = await svc.createLabel(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "label.created",
entityType: "label",
entityId: label.id,
details: { name: label.name, color: label.color },
});
res.status(201).json(label);
});
router.delete("/labels/:labelId", async (req, res) => {
const labelId = req.params.labelId as string;
const existing = await svc.getLabelById(labelId);
if (!existing) {
res.status(404).json({ error: "Label not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await svc.deleteLabel(labelId);
if (!removed) {
res.status(404).json({ error: "Label not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: removed.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "label.deleted",
entityType: "label",
entityId: removed.id,
details: { name: removed.name, color: removed.color },
});
res.json(removed);
});
router.get("/issues/:id", 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 [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
const currentExecutionWorkspace = issue.executionWorkspaceId
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
: null;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...documentPayload,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
currentExecutionWorkspace,
workProducts,
});
});
router.get("/issues/:id/heartbeat-context", 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 wakeCommentId =
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
? req.query.wakeCommentId.trim()
: null;
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
]);
res.json({
issue: {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
updatedAt: issue.updatedAt,
},
ancestors: ancestors.map((ancestor) => ({
id: ancestor.id,
identifier: ancestor.identifier,
title: ancestor.title,
status: ancestor.status,
priority: ancestor.priority,
})),
project: project
? {
id: project.id,
name: project.name,
status: project.status,
targetDate: project.targetDate,
}
: null,
goal: goal
? {
id: goal.id,
title: goal.title,
status: goal.status,
level: goal.level,
parentId: goal.parentId,
}
: null,
commentCursor,
wakeComment:
wakeComment && wakeComment.issueId === issue.id
? wakeComment
: null,
});
});
router.get("/issues/:id/work-products", 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 workProducts = await workProductsSvc.listForIssue(issue.id);
res.json(workProducts);
});
router.get("/issues/:id/documents", 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 docs = await documentsSvc.listIssueDocuments(issue.id);
res.json(docs);
});
router.get("/issues/:id/documents/:key", 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
if (!doc) {
res.status(404).json({ error: "Document not found" });
return;
}
res.json(doc);
});
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const actor = getActorInfo(req);
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key: keyParsed.data,
title: req.body.title ?? null,
format: req.body.format,
body: req.body.body,
changeSummary: req.body.changeSummary ?? null,
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
const doc = result.document;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: result.created ? "issue.document_created" : "issue.document_updated",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
title: doc.title,
format: doc.format,
revisionNumber: doc.latestRevisionNumber,
},
});
res.status(result.created ? 201 : 200).json(doc);
});
router.get("/issues/:id/documents/:key/revisions", 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
res.json(revisions);
});
router.delete("/issues/:id/documents/:key", 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 (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
if (!removed) {
res.status(404).json({ error: "Document not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_deleted",
entityType: "issue",
entityId: issue.id,
details: {
key: removed.key,
documentId: removed.id,
title: removed.title,
},
});
res.json({ ok: true });
});
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), 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 product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
});
if (!product) {
res.status(422).json({ error: "Invalid work product payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_created",
entityType: "issue",
entityId: issue.id,
details: { workProductId: product.id, type: product.type, provider: product.provider },
});
res.status(201).json(product);
});
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const product = await workProductsSvc.update(id, req.body);
if (!product) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_updated",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
});
res.json(product);
});
router.delete("/work-products/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await workProductsSvc.remove(id);
if (!removed) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_deleted",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: removed.id, type: removed.type },
});
res.json(removed);
});
router.post("/issues/:id/read", 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 (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.read_marked",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
});
res.json(readState);
});
router.get("/issues/:id/approvals", 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 approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
res.json(approvals);
});
router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), 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;
}
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
const actor = getActorInfo(req);
await issueApprovalsSvc.link(id, req.body.approvalId, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approval_linked",
entityType: "issue",
entityId: issue.id,
details: { approvalId: req.body.approvalId },
});
const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
res.status(201).json(approvals);
});
router.delete("/issues/:id/approvals/:approvalId", async (req, res) => {
const id = req.params.id as string;
const approvalId = req.params.approvalId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
await issueApprovalsSvc.unlink(id, approvalId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approval_unlinked",
entityType: "issue",
entityId: issue.id,
details: { approvalId },
});
res.json({ ok: true });
});
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId);
}
const actor = getActorInfo(req);
const issue = await svc.create(companyId, {
...req.body,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.created",
entityType: "issue",
entityId: issue.id,
details: { title: issue.title, identifier: issue.identifier },
});
if (issue.assigneeAgentId && issue.status !== "backlog") {
void heartbeat
.wakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "create" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.create" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
}
res.status(201).json(issue);
});
router.patch("/issues/:id", validate(updateIssueSchema), 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);
const assigneeWillChange =
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
req.body.assigneeAgentId === null &&
typeof req.body.assigneeUserId === "string" &&
!!existing.createdByUserId &&
req.body.assigneeUserId === existing.createdByUserId;
if (assigneeWillChange) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
let issue;
try {
issue = await svc.update(id, updateFields);
} catch (err) {
if (err instanceof HttpError && err.status === 422) {
logger.warn(
{
issueId: id,
companyId: existing.companyId,
assigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
},
currentAssignee: {
assigneeAgentId: existing.assigneeAgentId,
assigneeUserId: existing.assigneeUserId,
},
error: err.message,
details: err.details,
},
"issue update rejected with 422",
);
}
throw err;
}
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
await routinesSvc.syncRunStatusForIssue(issue.id);
// Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {};
for (const key of Object.keys(updateFields)) {
if (key in existing && (existing as Record<string, unknown>)[key] !== (updateFields as Record<string, unknown>)[key]) {
previous[key] = (existing as Record<string, unknown>)[key];
}
}
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
let comment = null;
if (commentBody) {
comment = await svc.addComment(id, commentBody, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
details: {
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(hasFieldChanges ? { updated: true } : {}),
},
});
}
const assigneeChanged = assigneeWillChange;
const statusChangedFromBacklog =
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
wakeups.set(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "update" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.update" },
});
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
wakeups.set(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
});
}
if (commentBody && comment) {
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: id, commentId: comment.id },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: id,
taskId: id,
commentId: comment.id,
wakeCommentId: comment.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
});
}
}
for (const [agentId, wakeup] of wakeups.entries()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
}
})();
res.json({ ...issue, comment });
});
router.delete("/issues/:id", 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);
const attachments = await svc.listAttachments(id);
const issue = await svc.remove(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
for (const attachment of attachments) {
try {
await storage.deleteObject(attachment.companyId, attachment.objectKey);
} catch (err) {
logger.warn({ err, issueId: id, attachmentId: attachment.id }, "failed to delete attachment object during issue delete");
}
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.deleted",
entityType: "issue",
entityId: issue.id,
});
res.json(issue);
});
router.post("/issues/:id/checkout", validate(checkoutIssueSchema), 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 (issue.projectId) {
const project = await projectsSvc.getById(issue.projectId);
if (project?.pausedAt) {
res.status(409).json({
error:
project.pauseReason === "budget"
? "Project is paused because its budget hard-stop was reached"
: "Project is paused",
});
return;
}
}
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only checkout as itself" });
return;
}
const checkoutRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !checkoutRunId) return;
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.checked_out",
entityType: "issue",
entityId: issue.id,
details: { agentId: req.body.agentId },
});
if (
shouldWakeAssigneeOnCheckout({
actorType: req.actor.type,
actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
checkoutAgentId: req.body.agentId,
checkoutRunId,
})
) {
void heartbeat
.wakeup(req.body.agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_checked_out",
payload: { issueId: issue.id, mutation: "checkout" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
}
res.json(updated);
});
router.post("/issues/:id/release", 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 assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actorRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !actorRunId) return;
const released = await svc.release(
id,
req.actor.type === "agent" ? req.actor.agentId : undefined,
actorRunId,
);
if (!released) {
res.status(404).json({ error: "Issue not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: released.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.released",
entityType: "issue",
entityId: released.id,
});
res.json(released);
});
router.get("/issues/:id/comments", 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 afterCommentId =
typeof req.query.after === "string" && req.query.after.trim().length > 0
? req.query.after.trim()
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
? req.query.afterCommentId.trim()
: null;
const order =
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
? "asc"
: "desc";
const limitRaw =
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
? Number(req.query.limit)
: null;
const limit =
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
: null;
const comments = await svc.listComments(id, {
afterCommentId,
order,
limit,
});
res.json(comments);
});
router.get("/issues/:id/comments/:commentId", async (req, res) => {
const id = req.params.id as string;
const commentId = req.params.commentId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const comment = await svc.getComment(commentId);
if (!comment || comment.issueId !== id) {
res.status(404).json({ error: "Comment not found" });
return;
}
res.json(comment);
});
router.post("/issues/:id/comments", validate(addIssueCommentSchema), 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 assertAgentRunCheckoutOwnership(req, res, issue))) return;
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
const interruptRequested = req.body.interrupt === true;
const isClosed = issue.status === "done" || issue.status === "cancelled";
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
let currentIssue = issue;
if (reopenRequested && isClosed) {
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
reopened = true;
reopenFromStatus = issue.status;
currentIssue = reopenedIssue;
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: currentIssue.id,
details: {
status: "todo",
reopened: true,
reopenedFrom: reopenFromStatus,
source: "comment",
identifier: currentIssue.identifier,
},
});
}
if (interruptRequested) {
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
return;
}
let runToInterrupt = currentIssue.executionRunId
? await heartbeat.getRun(currentIssue.executionRunId)
: null;
if (
(!runToInterrupt || runToInterrupt.status !== "running") &&
currentIssue.assigneeAgentId
) {
const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
runToInterrupt = activeRun;
}
}
if (runToInterrupt && runToInterrupt.status === "running") {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
companyId: cancelled.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id },
});
}
}
}
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_added",
entityType: "issue",
entityId: currentIssue.id,
details: {
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
identifier: currentIssue.identifier,
issueTitle: currentIssue.title,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
const assigneeId = currentIssue.assigneeAgentId;
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const skipWake = selfComment || isClosed;
if (assigneeId && (reopened || !skipWake)) {
if (reopened) {
wakeups.set(assigneeId, {
source: "automation",
triggerDetail: "system",
reason: "issue_reopened_via_comment",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
reopenedFrom: reopenFromStatus,
mutation: "comment",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
} else {
wakeups.set(assigneeId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
}
}
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actorIsAgent && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: id, commentId: comment.id },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: id,
taskId: id,
commentId: comment.id,
wakeCommentId: comment.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
});
}
for (const [agentId, wakeup] of wakeups.entries()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
}
})();
res.status(201).json(comment);
});
router.get("/issues/:id/attachments", async (req, res) => {
const issueId = req.params.id as string;
const issue = await svc.getById(issueId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const attachments = await svc.listAttachments(issueId);
res.json(attachments.map(withContentPath));
});
router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
const companyId = req.params.companyId as string;
const issueId = req.params.issueId as string;
assertCompanyAccess(req, companyId);
const issue = await svc.getById(issueId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
if (issue.companyId !== companyId) {
res.status(422).json({ error: "Issue does not belong to company" });
return;
}
try {
await runSingleFileUpload(req, res);
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
return;
}
throw err;
}
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
if (!file) {
res.status(400).json({ error: "Missing file field 'file'" });
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {
res.status(422).json({ error: "Attachment is empty" });
return;
}
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
if (!parsedMeta.success) {
res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
return;
}
const actor = getActorInfo(req);
const stored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: file.originalname || null,
contentType,
body: file.buffer,
});
const attachment = await svc.createAttachment({
issueId,
issueCommentId: parsedMeta.data.issueCommentId ?? null,
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.attachment_added",
entityType: "issue",
entityId: issueId,
details: {
attachmentId: attachment.id,
originalFilename: attachment.originalFilename,
contentType: attachment.contentType,
byteSize: attachment.byteSize,
},
});
res.status(201).json(withContentPath(attachment));
});
router.get("/attachments/:attachmentId/content", async (req, res, next) => {
const attachmentId = req.params.attachmentId as string;
const attachment = await svc.getAttachmentById(attachmentId);
if (!attachment) {
res.status(404).json({ error: "Attachment not found" });
return;
}
assertCompanyAccess(req, attachment.companyId);
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
res.setHeader("Cache-Control", "private, max-age=60");
const filename = attachment.originalFilename ?? "attachment";
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
object.stream.on("error", (err) => {
next(err);
});
object.stream.pipe(res);
});
router.delete("/attachments/:attachmentId", async (req, res) => {
const attachmentId = req.params.attachmentId as string;
const attachment = await svc.getAttachmentById(attachmentId);
if (!attachment) {
res.status(404).json({ error: "Attachment not found" });
return;
}
assertCompanyAccess(req, attachment.companyId);
try {
await storage.deleteObject(attachment.companyId, attachment.objectKey);
} catch (err) {
logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
}
const removed = await svc.removeAttachment(attachmentId);
if (!removed) {
res.status(404).json({ error: "Attachment not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: removed.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.attachment_removed",
entityType: "issue",
entityId: removed.issueId,
details: {
attachmentId: removed.id,
},
});
res.json({ ok: true });
});
return router;
}