Add ILIKE-based issue search across title, identifier, description, and comments with relevance ranking. Add assigneeUserId filter and allow agents to return issues to creator. Show assigned issue count in sidebar badges. Add minCount param to live-runs endpoint. Add activity charts (run activity, priority, status, success rate) to dashboard. Improve active agents panel with recent run cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1018 lines
33 KiB
TypeScript
1018 lines
33 KiB
TypeScript
import { Router, type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import type { Db } from "@paperclip/db";
|
|
import {
|
|
addIssueCommentSchema,
|
|
createIssueAttachmentMetadataSchema,
|
|
createIssueLabelSchema,
|
|
checkoutIssueSchema,
|
|
createIssueSchema,
|
|
linkIssueApprovalSchema,
|
|
updateIssueSchema,
|
|
} from "@paperclip/shared";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { validate } from "../middleware/validate.js";
|
|
import {
|
|
accessService,
|
|
agentService,
|
|
goalService,
|
|
heartbeatService,
|
|
issueApprovalService,
|
|
issueService,
|
|
logActivity,
|
|
projectService,
|
|
} from "../services/index.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
|
|
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
|
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/webp",
|
|
"image/gif",
|
|
]);
|
|
|
|
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 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);
|
|
}
|
|
});
|
|
|
|
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 assigneeUserId =
|
|
assigneeUserFilterRaw === "me" && req.actor.type === "board"
|
|
? req.actor.userId
|
|
: assigneeUserFilterRaw;
|
|
|
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
|
res.status(403).json({ error: "assigneeUserId=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,
|
|
projectId: req.query.projectId as string | undefined,
|
|
labelId: req.query.labelId as string | undefined,
|
|
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] = await Promise.all([
|
|
svc.getAncestors(issue.id),
|
|
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
|
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
|
|
]);
|
|
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null });
|
|
});
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
// 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);
|
|
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, _previous: Object.keys(previous).length > 0 ? 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,
|
|
},
|
|
});
|
|
|
|
}
|
|
|
|
const assigneeChanged = assigneeWillChange;
|
|
|
|
// 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) {
|
|
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 (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;
|
|
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 (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 },
|
|
});
|
|
|
|
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 comments = await svc.listComments(id);
|
|
res.json(comments);
|
|
});
|
|
|
|
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 isClosed = issue.status === "done" || issue.status === "cancelled";
|
|
let reopened = false;
|
|
let reopenFromStatus: 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,
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
// 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;
|
|
if (assigneeId) {
|
|
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",
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
} else {
|
|
wakeups.set(assigneeId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: currentIssue.id,
|
|
commentId: comment.id,
|
|
mutation: "comment",
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: currentIssue.id,
|
|
taskId: currentIssue.id,
|
|
commentId: comment.id,
|
|
source: "issue.comment",
|
|
wakeReason: "issue_commented",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
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 (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(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;
|
|
}
|