1568 lines
52 KiB
TypeScript
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;
|
|
}
|