@paperclipai/server 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/dist/adapters/codex-models.d.ts +4 -0
  2. package/dist/adapters/codex-models.d.ts.map +1 -0
  3. package/dist/adapters/codex-models.js +98 -0
  4. package/dist/adapters/codex-models.js.map +1 -0
  5. package/dist/adapters/http/execute.d.ts +3 -0
  6. package/dist/adapters/http/execute.d.ts.map +1 -0
  7. package/dist/adapters/http/execute.js +39 -0
  8. package/dist/adapters/http/execute.js.map +1 -0
  9. package/dist/adapters/http/index.d.ts +3 -0
  10. package/dist/adapters/http/index.d.ts.map +1 -0
  11. package/dist/adapters/http/index.js +20 -0
  12. package/dist/adapters/http/index.js.map +1 -0
  13. package/dist/adapters/http/test.d.ts +3 -0
  14. package/dist/adapters/http/test.d.ts.map +1 -0
  15. package/dist/adapters/http/test.js +106 -0
  16. package/dist/adapters/http/test.js.map +1 -0
  17. package/dist/adapters/index.d.ts +4 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +3 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/process/execute.d.ts +3 -0
  22. package/dist/adapters/process/execute.d.ts.map +1 -0
  23. package/dist/adapters/process/execute.js +63 -0
  24. package/dist/adapters/process/execute.js.map +1 -0
  25. package/dist/adapters/process/index.d.ts +3 -0
  26. package/dist/adapters/process/index.d.ts.map +1 -0
  27. package/dist/adapters/process/index.js +23 -0
  28. package/dist/adapters/process/index.js.map +1 -0
  29. package/dist/adapters/process/test.d.ts +3 -0
  30. package/dist/adapters/process/test.d.ts.map +1 -0
  31. package/dist/adapters/process/test.js +77 -0
  32. package/dist/adapters/process/test.js.map +1 -0
  33. package/dist/adapters/registry.d.ts +9 -0
  34. package/dist/adapters/registry.d.ts.map +1 -0
  35. package/dist/adapters/registry.js +63 -0
  36. package/dist/adapters/registry.js.map +1 -0
  37. package/dist/adapters/types.d.ts +2 -0
  38. package/dist/adapters/types.d.ts.map +1 -0
  39. package/dist/adapters/types.js +2 -0
  40. package/dist/adapters/types.js.map +1 -0
  41. package/dist/adapters/utils.d.ts +10 -0
  42. package/dist/adapters/utils.d.ts.map +1 -0
  43. package/dist/adapters/utils.js +14 -0
  44. package/dist/adapters/utils.js.map +1 -0
  45. package/dist/agent-auth-jwt.d.ts +14 -0
  46. package/dist/agent-auth-jwt.d.ts.map +1 -0
  47. package/dist/agent-auth-jwt.js +117 -0
  48. package/dist/agent-auth-jwt.js.map +1 -0
  49. package/dist/app.d.ts +20 -0
  50. package/dist/app.d.ts.map +1 -0
  51. package/dist/app.js +127 -0
  52. package/dist/app.js.map +1 -0
  53. package/dist/auth/better-auth.d.ts +23 -0
  54. package/dist/auth/better-auth.d.ts.map +1 -0
  55. package/dist/auth/better-auth.js +80 -0
  56. package/dist/auth/better-auth.js.map +1 -0
  57. package/dist/board-claim.d.ts +23 -0
  58. package/dist/board-claim.d.ts.map +1 -0
  59. package/dist/board-claim.js +115 -0
  60. package/dist/board-claim.js.map +1 -0
  61. package/dist/config-file.d.ts +3 -0
  62. package/dist/config-file.d.ts.map +1 -0
  63. package/dist/config-file.js +16 -0
  64. package/dist/config-file.js.map +1 -0
  65. package/dist/config.d.ts +33 -0
  66. package/dist/config.d.ts.map +1 -0
  67. package/dist/config.js +114 -0
  68. package/dist/config.js.map +1 -0
  69. package/dist/errors.d.ts +12 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +28 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/home-paths.d.ts +11 -0
  74. package/dist/home-paths.d.ts.map +1 -0
  75. package/dist/home-paths.js +54 -0
  76. package/dist/home-paths.js.map +1 -0
  77. package/dist/index.d.ts +2 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +439 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/middleware/auth.d.ts +12 -0
  82. package/dist/middleware/auth.d.ts.map +1 -0
  83. package/dist/middleware/auth.js +124 -0
  84. package/dist/middleware/auth.js.map +1 -0
  85. package/dist/middleware/board-mutation-guard.d.ts +3 -0
  86. package/dist/middleware/board-mutation-guard.d.ts.map +1 -0
  87. package/dist/middleware/board-mutation-guard.js +60 -0
  88. package/dist/middleware/board-mutation-guard.js.map +1 -0
  89. package/dist/middleware/error-handler.d.ts +3 -0
  90. package/dist/middleware/error-handler.d.ts.map +1 -0
  91. package/dist/middleware/error-handler.js +22 -0
  92. package/dist/middleware/error-handler.js.map +1 -0
  93. package/dist/middleware/index.d.ts +4 -0
  94. package/dist/middleware/index.d.ts.map +1 -0
  95. package/dist/middleware/index.js +4 -0
  96. package/dist/middleware/index.js.map +1 -0
  97. package/dist/middleware/logger.d.ts +4 -0
  98. package/dist/middleware/logger.d.ts.map +1 -0
  99. package/dist/middleware/logger.js +37 -0
  100. package/dist/middleware/logger.js.map +1 -0
  101. package/dist/middleware/private-hostname-guard.d.ts +11 -0
  102. package/dist/middleware/private-hostname-guard.d.ts.map +1 -0
  103. package/dist/middleware/private-hostname-guard.js +78 -0
  104. package/dist/middleware/private-hostname-guard.js.map +1 -0
  105. package/dist/middleware/validate.d.ts +4 -0
  106. package/dist/middleware/validate.d.ts.map +1 -0
  107. package/dist/middleware/validate.js +7 -0
  108. package/dist/middleware/validate.js.map +1 -0
  109. package/dist/paths.d.ts +3 -0
  110. package/dist/paths.d.ts.map +1 -0
  111. package/dist/paths.js +31 -0
  112. package/dist/paths.js.map +1 -0
  113. package/dist/realtime/live-events-ws.d.ts +10 -0
  114. package/dist/realtime/live-events-ws.d.ts.map +1 -0
  115. package/dist/realtime/live-events-ws.js +185 -0
  116. package/dist/realtime/live-events-ws.js.map +1 -0
  117. package/dist/redaction.d.ts +4 -0
  118. package/dist/redaction.d.ts.map +1 -0
  119. package/dist/redaction.js +63 -0
  120. package/dist/redaction.js.map +1 -0
  121. package/dist/routes/access.d.ts +9 -0
  122. package/dist/routes/access.d.ts.map +1 -0
  123. package/dist/routes/access.js +887 -0
  124. package/dist/routes/access.js.map +1 -0
  125. package/dist/routes/activity.d.ts +3 -0
  126. package/dist/routes/activity.d.ts.map +1 -0
  127. package/dist/routes/activity.js +87 -0
  128. package/dist/routes/activity.js.map +1 -0
  129. package/dist/routes/agents.d.ts +3 -0
  130. package/dist/routes/agents.d.ts.map +1 -0
  131. package/dist/routes/agents.js +1132 -0
  132. package/dist/routes/agents.js.map +1 -0
  133. package/dist/routes/approvals.d.ts +3 -0
  134. package/dist/routes/approvals.d.ts.map +1 -0
  135. package/dist/routes/approvals.js +271 -0
  136. package/dist/routes/approvals.js.map +1 -0
  137. package/dist/routes/assets.d.ts +4 -0
  138. package/dist/routes/assets.d.ts.map +1 -0
  139. package/dist/routes/assets.js +138 -0
  140. package/dist/routes/assets.js.map +1 -0
  141. package/dist/routes/authz.d.ts +15 -0
  142. package/dist/routes/authz.d.ts.map +1 -0
  143. package/dist/routes/authz.js +40 -0
  144. package/dist/routes/authz.js.map +1 -0
  145. package/dist/routes/companies.d.ts +3 -0
  146. package/dist/routes/companies.d.ts.map +1 -0
  147. package/dist/routes/companies.js +159 -0
  148. package/dist/routes/companies.js.map +1 -0
  149. package/dist/routes/costs.d.ts +3 -0
  150. package/dist/routes/costs.d.ts.map +1 -0
  151. package/dist/routes/costs.js +113 -0
  152. package/dist/routes/costs.js.map +1 -0
  153. package/dist/routes/dashboard.d.ts +3 -0
  154. package/dist/routes/dashboard.d.ts.map +1 -0
  155. package/dist/routes/dashboard.js +15 -0
  156. package/dist/routes/dashboard.js.map +1 -0
  157. package/dist/routes/goals.d.ts +3 -0
  158. package/dist/routes/goals.d.ts.map +1 -0
  159. package/dist/routes/goals.js +95 -0
  160. package/dist/routes/goals.js.map +1 -0
  161. package/dist/routes/health.d.ts +9 -0
  162. package/dist/routes/health.d.ts.map +1 -0
  163. package/dist/routes/health.js +38 -0
  164. package/dist/routes/health.js.map +1 -0
  165. package/dist/routes/index.d.ts +15 -0
  166. package/dist/routes/index.d.ts.map +1 -0
  167. package/dist/routes/index.js +15 -0
  168. package/dist/routes/index.js.map +1 -0
  169. package/dist/routes/issues.d.ts +4 -0
  170. package/dist/routes/issues.d.ts.map +1 -0
  171. package/dist/routes/issues.js +973 -0
  172. package/dist/routes/issues.js.map +1 -0
  173. package/dist/routes/llms.d.ts +3 -0
  174. package/dist/routes/llms.d.ts.map +1 -0
  175. package/dist/routes/llms.js +78 -0
  176. package/dist/routes/llms.js.map +1 -0
  177. package/dist/routes/projects.d.ts +3 -0
  178. package/dist/routes/projects.d.ts.map +1 -0
  179. package/dist/routes/projects.js +253 -0
  180. package/dist/routes/projects.js.map +1 -0
  181. package/dist/routes/secrets.d.ts +3 -0
  182. package/dist/routes/secrets.d.ts.map +1 -0
  183. package/dist/routes/secrets.js +128 -0
  184. package/dist/routes/secrets.js.map +1 -0
  185. package/dist/routes/sidebar-badges.d.ts +3 -0
  186. package/dist/routes/sidebar-badges.d.ts.map +1 -0
  187. package/dist/routes/sidebar-badges.js +47 -0
  188. package/dist/routes/sidebar-badges.js.map +1 -0
  189. package/dist/secrets/external-stub-providers.d.ts +5 -0
  190. package/dist/secrets/external-stub-providers.d.ts.map +1 -0
  191. package/dist/secrets/external-stub-providers.js +21 -0
  192. package/dist/secrets/external-stub-providers.js.map +1 -0
  193. package/dist/secrets/local-encrypted-provider.d.ts +3 -0
  194. package/dist/secrets/local-encrypted-provider.d.ts.map +1 -0
  195. package/dist/secrets/local-encrypted-provider.js +116 -0
  196. package/dist/secrets/local-encrypted-provider.js.map +1 -0
  197. package/dist/secrets/provider-registry.d.ts +5 -0
  198. package/dist/secrets/provider-registry.d.ts.map +1 -0
  199. package/dist/secrets/provider-registry.js +20 -0
  200. package/dist/secrets/provider-registry.js.map +1 -0
  201. package/dist/secrets/types.d.ts +21 -0
  202. package/dist/secrets/types.d.ts.map +1 -0
  203. package/dist/secrets/types.js +2 -0
  204. package/dist/secrets/types.js.map +1 -0
  205. package/dist/services/access.d.ts +81 -0
  206. package/dist/services/access.d.ts.map +1 -0
  207. package/dist/services/access.js +187 -0
  208. package/dist/services/access.js.map +1 -0
  209. package/dist/services/activity-log.d.ts +14 -0
  210. package/dist/services/activity-log.d.ts.map +1 -0
  211. package/dist/services/activity-log.js +32 -0
  212. package/dist/services/activity-log.js.map +1 -0
  213. package/dist/services/activity.d.ts +764 -0
  214. package/dist/services/activity.d.ts.map +1 -0
  215. package/dist/services/activity.js +105 -0
  216. package/dist/services/activity.js.map +1 -0
  217. package/dist/services/agent-permissions.d.ts +6 -0
  218. package/dist/services/agent-permissions.d.ts.map +1 -0
  219. package/dist/services/agent-permissions.js +18 -0
  220. package/dist/services/agent-permissions.js.map +1 -0
  221. package/dist/services/agents.d.ts +1494 -0
  222. package/dist/services/agents.d.ts.map +1 -0
  223. package/dist/services/agents.js +454 -0
  224. package/dist/services/agents.js.map +1 -0
  225. package/dist/services/approvals.d.ts +540 -0
  226. package/dist/services/approvals.d.ts.map +1 -0
  227. package/dist/services/approvals.js +173 -0
  228. package/dist/services/approvals.js.map +1 -0
  229. package/dist/services/assets.d.ts +33 -0
  230. package/dist/services/assets.d.ts.map +1 -0
  231. package/dist/services/assets.js +17 -0
  232. package/dist/services/assets.js.map +1 -0
  233. package/dist/services/companies.d.ts +503 -0
  234. package/dist/services/companies.d.ts.map +1 -0
  235. package/dist/services/companies.js +120 -0
  236. package/dist/services/companies.js.map +1 -0
  237. package/dist/services/company-portability.d.ts +8 -0
  238. package/dist/services/company-portability.d.ts.map +1 -0
  239. package/dist/services/company-portability.js +851 -0
  240. package/dist/services/company-portability.js.map +1 -0
  241. package/dist/services/costs.d.ts +50 -0
  242. package/dist/services/costs.d.ts.map +1 -0
  243. package/dist/services/costs.js +166 -0
  244. package/dist/services/costs.js.map +1 -0
  245. package/dist/services/dashboard.d.ts +21 -0
  246. package/dist/services/dashboard.d.ts.map +1 -0
  247. package/dist/services/dashboard.js +96 -0
  248. package/dist/services/dashboard.js.map +1 -0
  249. package/dist/services/goals.d.ts +407 -0
  250. package/dist/services/goals.d.ts.map +1 -0
  251. package/dist/services/goals.js +29 -0
  252. package/dist/services/goals.js.map +1 -0
  253. package/dist/services/heartbeat.d.ts +1666 -0
  254. package/dist/services/heartbeat.d.ts.map +1 -0
  255. package/dist/services/heartbeat.js +1752 -0
  256. package/dist/services/heartbeat.js.map +1 -0
  257. package/dist/services/index.d.ts +20 -0
  258. package/dist/services/index.d.ts.map +1 -0
  259. package/dist/services/index.js +20 -0
  260. package/dist/services/index.js.map +1 -0
  261. package/dist/services/issue-approvals.d.ts +56 -0
  262. package/dist/services/issue-approvals.d.ts.map +1 -0
  263. package/dist/services/issue-approvals.js +153 -0
  264. package/dist/services/issue-approvals.js.map +1 -0
  265. package/dist/services/issues.d.ts +756 -0
  266. package/dist/services/issues.d.ts.map +1 -0
  267. package/dist/services/issues.js +917 -0
  268. package/dist/services/issues.js.map +1 -0
  269. package/dist/services/live-events.d.ts +12 -0
  270. package/dist/services/live-events.d.ts.map +1 -0
  271. package/dist/services/live-events.js +24 -0
  272. package/dist/services/live-events.js.map +1 -0
  273. package/dist/services/projects.d.ts +66 -0
  274. package/dist/services/projects.d.ts.map +1 -0
  275. package/dist/services/projects.js +472 -0
  276. package/dist/services/projects.js.map +1 -0
  277. package/dist/services/run-log-store.d.ts +34 -0
  278. package/dist/services/run-log-store.d.ts.map +1 -0
  279. package/dist/services/run-log-store.js +112 -0
  280. package/dist/services/run-log-store.js.map +1 -0
  281. package/dist/services/secrets.d.ts +506 -0
  282. package/dist/services/secrets.d.ts.map +1 -0
  283. package/dist/services/secrets.js +284 -0
  284. package/dist/services/secrets.js.map +1 -0
  285. package/dist/services/sidebar-badges.d.ts +9 -0
  286. package/dist/services/sidebar-badges.d.ts.map +1 -0
  287. package/dist/services/sidebar-badges.js +33 -0
  288. package/dist/services/sidebar-badges.js.map +1 -0
  289. package/dist/startup-banner.d.ts +27 -0
  290. package/dist/startup-banner.d.ts.map +1 -0
  291. package/dist/startup-banner.js +112 -0
  292. package/dist/startup-banner.js.map +1 -0
  293. package/dist/storage/index.d.ts +6 -0
  294. package/dist/storage/index.d.ts.map +1 -0
  295. package/dist/storage/index.js +29 -0
  296. package/dist/storage/index.js.map +1 -0
  297. package/dist/storage/local-disk-provider.d.ts +3 -0
  298. package/dist/storage/local-disk-provider.d.ts.map +1 -0
  299. package/dist/storage/local-disk-provider.js +79 -0
  300. package/dist/storage/local-disk-provider.js.map +1 -0
  301. package/dist/storage/provider-registry.d.ts +4 -0
  302. package/dist/storage/provider-registry.d.ts.map +1 -0
  303. package/dist/storage/provider-registry.js +15 -0
  304. package/dist/storage/provider-registry.js.map +1 -0
  305. package/dist/storage/s3-provider.d.ts +11 -0
  306. package/dist/storage/s3-provider.d.ts.map +1 -0
  307. package/dist/storage/s3-provider.js +123 -0
  308. package/dist/storage/s3-provider.js.map +1 -0
  309. package/dist/storage/service.d.ts +3 -0
  310. package/dist/storage/service.d.ts.map +1 -0
  311. package/dist/storage/service.js +120 -0
  312. package/dist/storage/service.js.map +1 -0
  313. package/dist/storage/types.d.ts +55 -0
  314. package/dist/storage/types.d.ts.map +1 -0
  315. package/dist/storage/types.js +2 -0
  316. package/dist/storage/types.js.map +1 -0
  317. package/package.json +62 -0
@@ -0,0 +1,973 @@
1
+ import { Router } from "express";
2
+ import multer from "multer";
3
+ import { addIssueCommentSchema, createIssueAttachmentMetadataSchema, createIssueLabelSchema, checkoutIssueSchema, createIssueSchema, linkIssueApprovalSchema, updateIssueSchema, } from "@paperclipai/shared";
4
+ import { validate } from "../middleware/validate.js";
5
+ import { accessService, agentService, goalService, heartbeatService, issueApprovalService, issueService, logActivity, projectService, } from "../services/index.js";
6
+ import { logger } from "../middleware/logger.js";
7
+ import { forbidden, HttpError, unauthorized } from "../errors.js";
8
+ import { assertCompanyAccess, getActorInfo } from "./authz.js";
9
+ const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
10
+ const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
11
+ "image/png",
12
+ "image/jpeg",
13
+ "image/jpg",
14
+ "image/webp",
15
+ "image/gif",
16
+ ]);
17
+ export function issueRoutes(db, storage) {
18
+ const router = Router();
19
+ const svc = issueService(db);
20
+ const access = accessService(db);
21
+ const heartbeat = heartbeatService(db);
22
+ const agentsSvc = agentService(db);
23
+ const projectsSvc = projectService(db);
24
+ const goalsSvc = goalService(db);
25
+ const issueApprovalsSvc = issueApprovalService(db);
26
+ const upload = multer({
27
+ storage: multer.memoryStorage(),
28
+ limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
29
+ });
30
+ function withContentPath(attachment) {
31
+ return {
32
+ ...attachment,
33
+ contentPath: `/api/attachments/${attachment.id}/content`,
34
+ };
35
+ }
36
+ async function runSingleFileUpload(req, res) {
37
+ await new Promise((resolve, reject) => {
38
+ upload.single("file")(req, res, (err) => {
39
+ if (err)
40
+ reject(err);
41
+ else
42
+ resolve();
43
+ });
44
+ });
45
+ }
46
+ async function assertCanManageIssueApprovalLinks(req, res, companyId) {
47
+ assertCompanyAccess(req, companyId);
48
+ if (req.actor.type === "board")
49
+ return true;
50
+ if (!req.actor.agentId) {
51
+ res.status(403).json({ error: "Agent authentication required" });
52
+ return false;
53
+ }
54
+ const actorAgent = await agentsSvc.getById(req.actor.agentId);
55
+ if (!actorAgent || actorAgent.companyId !== companyId) {
56
+ res.status(403).json({ error: "Forbidden" });
57
+ return false;
58
+ }
59
+ if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents))
60
+ return true;
61
+ res.status(403).json({ error: "Missing permission to link approvals" });
62
+ return false;
63
+ }
64
+ function canCreateAgentsLegacy(agent) {
65
+ if (agent.role === "ceo")
66
+ return true;
67
+ if (!agent.permissions || typeof agent.permissions !== "object")
68
+ return false;
69
+ return Boolean(agent.permissions.canCreateAgents);
70
+ }
71
+ async function assertCanAssignTasks(req, companyId) {
72
+ assertCompanyAccess(req, companyId);
73
+ if (req.actor.type === "board") {
74
+ if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
75
+ return;
76
+ const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
77
+ if (!allowed)
78
+ throw forbidden("Missing permission: tasks:assign");
79
+ return;
80
+ }
81
+ if (req.actor.type === "agent") {
82
+ if (!req.actor.agentId)
83
+ throw forbidden("Agent authentication required");
84
+ const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
85
+ if (allowedByGrant)
86
+ return;
87
+ const actorAgent = await agentsSvc.getById(req.actor.agentId);
88
+ if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent))
89
+ return;
90
+ throw forbidden("Missing permission: tasks:assign");
91
+ }
92
+ throw unauthorized();
93
+ }
94
+ function requireAgentRunId(req, res) {
95
+ if (req.actor.type !== "agent")
96
+ return null;
97
+ const runId = req.actor.runId?.trim();
98
+ if (runId)
99
+ return runId;
100
+ res.status(401).json({ error: "Agent run id required" });
101
+ return null;
102
+ }
103
+ async function assertAgentRunCheckoutOwnership(req, res, issue) {
104
+ if (req.actor.type !== "agent")
105
+ return true;
106
+ const actorAgentId = req.actor.agentId;
107
+ if (!actorAgentId) {
108
+ res.status(403).json({ error: "Agent authentication required" });
109
+ return false;
110
+ }
111
+ if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) {
112
+ return true;
113
+ }
114
+ const runId = requireAgentRunId(req, res);
115
+ if (!runId)
116
+ return false;
117
+ const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
118
+ if (ownership.adoptedFromRunId) {
119
+ const actor = getActorInfo(req);
120
+ await logActivity(db, {
121
+ companyId: issue.companyId,
122
+ actorType: actor.actorType,
123
+ actorId: actor.actorId,
124
+ agentId: actor.agentId,
125
+ runId: actor.runId,
126
+ action: "issue.checkout_lock_adopted",
127
+ entityType: "issue",
128
+ entityId: issue.id,
129
+ details: {
130
+ previousCheckoutRunId: ownership.adoptedFromRunId,
131
+ checkoutRunId: runId,
132
+ reason: "stale_checkout_run",
133
+ },
134
+ });
135
+ }
136
+ return true;
137
+ }
138
+ async function normalizeIssueIdentifier(rawId) {
139
+ if (/^[A-Z]+-\d+$/i.test(rawId)) {
140
+ const issue = await svc.getByIdentifier(rawId);
141
+ if (issue) {
142
+ return issue.id;
143
+ }
144
+ }
145
+ return rawId;
146
+ }
147
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
148
+ router.param("id", async (req, res, next, rawId) => {
149
+ try {
150
+ req.params.id = await normalizeIssueIdentifier(rawId);
151
+ next();
152
+ }
153
+ catch (err) {
154
+ next(err);
155
+ }
156
+ });
157
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
158
+ router.param("issueId", async (req, res, next, rawId) => {
159
+ try {
160
+ req.params.issueId = await normalizeIssueIdentifier(rawId);
161
+ next();
162
+ }
163
+ catch (err) {
164
+ next(err);
165
+ }
166
+ });
167
+ router.get("/companies/:companyId/issues", async (req, res) => {
168
+ const companyId = req.params.companyId;
169
+ assertCompanyAccess(req, companyId);
170
+ const assigneeUserFilterRaw = req.query.assigneeUserId;
171
+ const assigneeUserId = assigneeUserFilterRaw === "me" && req.actor.type === "board"
172
+ ? req.actor.userId
173
+ : assigneeUserFilterRaw;
174
+ if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
175
+ res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
176
+ return;
177
+ }
178
+ const result = await svc.list(companyId, {
179
+ status: req.query.status,
180
+ assigneeAgentId: req.query.assigneeAgentId,
181
+ assigneeUserId,
182
+ projectId: req.query.projectId,
183
+ labelId: req.query.labelId,
184
+ q: req.query.q,
185
+ });
186
+ res.json(result);
187
+ });
188
+ router.get("/companies/:companyId/labels", async (req, res) => {
189
+ const companyId = req.params.companyId;
190
+ assertCompanyAccess(req, companyId);
191
+ const result = await svc.listLabels(companyId);
192
+ res.json(result);
193
+ });
194
+ router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => {
195
+ const companyId = req.params.companyId;
196
+ assertCompanyAccess(req, companyId);
197
+ const label = await svc.createLabel(companyId, req.body);
198
+ const actor = getActorInfo(req);
199
+ await logActivity(db, {
200
+ companyId,
201
+ actorType: actor.actorType,
202
+ actorId: actor.actorId,
203
+ agentId: actor.agentId,
204
+ runId: actor.runId,
205
+ action: "label.created",
206
+ entityType: "label",
207
+ entityId: label.id,
208
+ details: { name: label.name, color: label.color },
209
+ });
210
+ res.status(201).json(label);
211
+ });
212
+ router.delete("/labels/:labelId", async (req, res) => {
213
+ const labelId = req.params.labelId;
214
+ const existing = await svc.getLabelById(labelId);
215
+ if (!existing) {
216
+ res.status(404).json({ error: "Label not found" });
217
+ return;
218
+ }
219
+ assertCompanyAccess(req, existing.companyId);
220
+ const removed = await svc.deleteLabel(labelId);
221
+ if (!removed) {
222
+ res.status(404).json({ error: "Label not found" });
223
+ return;
224
+ }
225
+ const actor = getActorInfo(req);
226
+ await logActivity(db, {
227
+ companyId: removed.companyId,
228
+ actorType: actor.actorType,
229
+ actorId: actor.actorId,
230
+ agentId: actor.agentId,
231
+ runId: actor.runId,
232
+ action: "label.deleted",
233
+ entityType: "label",
234
+ entityId: removed.id,
235
+ details: { name: removed.name, color: removed.color },
236
+ });
237
+ res.json(removed);
238
+ });
239
+ router.get("/issues/:id", async (req, res) => {
240
+ const id = req.params.id;
241
+ const issue = await svc.getById(id);
242
+ if (!issue) {
243
+ res.status(404).json({ error: "Issue not found" });
244
+ return;
245
+ }
246
+ assertCompanyAccess(req, issue.companyId);
247
+ const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
248
+ svc.getAncestors(issue.id),
249
+ issue.projectId ? projectsSvc.getById(issue.projectId) : null,
250
+ issue.goalId ? goalsSvc.getById(issue.goalId) : null,
251
+ svc.findMentionedProjectIds(issue.id),
252
+ ]);
253
+ const mentionedProjects = mentionedProjectIds.length > 0
254
+ ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
255
+ : [];
256
+ res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
257
+ });
258
+ router.get("/issues/:id/approvals", async (req, res) => {
259
+ const id = req.params.id;
260
+ const issue = await svc.getById(id);
261
+ if (!issue) {
262
+ res.status(404).json({ error: "Issue not found" });
263
+ return;
264
+ }
265
+ assertCompanyAccess(req, issue.companyId);
266
+ const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
267
+ res.json(approvals);
268
+ });
269
+ router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => {
270
+ const id = req.params.id;
271
+ const issue = await svc.getById(id);
272
+ if (!issue) {
273
+ res.status(404).json({ error: "Issue not found" });
274
+ return;
275
+ }
276
+ if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId)))
277
+ return;
278
+ const actor = getActorInfo(req);
279
+ await issueApprovalsSvc.link(id, req.body.approvalId, {
280
+ agentId: actor.agentId,
281
+ userId: actor.actorType === "user" ? actor.actorId : null,
282
+ });
283
+ await logActivity(db, {
284
+ companyId: issue.companyId,
285
+ actorType: actor.actorType,
286
+ actorId: actor.actorId,
287
+ agentId: actor.agentId,
288
+ runId: actor.runId,
289
+ action: "issue.approval_linked",
290
+ entityType: "issue",
291
+ entityId: issue.id,
292
+ details: { approvalId: req.body.approvalId },
293
+ });
294
+ const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
295
+ res.status(201).json(approvals);
296
+ });
297
+ router.delete("/issues/:id/approvals/:approvalId", async (req, res) => {
298
+ const id = req.params.id;
299
+ const approvalId = req.params.approvalId;
300
+ const issue = await svc.getById(id);
301
+ if (!issue) {
302
+ res.status(404).json({ error: "Issue not found" });
303
+ return;
304
+ }
305
+ if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId)))
306
+ return;
307
+ await issueApprovalsSvc.unlink(id, approvalId);
308
+ const actor = getActorInfo(req);
309
+ await logActivity(db, {
310
+ companyId: issue.companyId,
311
+ actorType: actor.actorType,
312
+ actorId: actor.actorId,
313
+ agentId: actor.agentId,
314
+ runId: actor.runId,
315
+ action: "issue.approval_unlinked",
316
+ entityType: "issue",
317
+ entityId: issue.id,
318
+ details: { approvalId },
319
+ });
320
+ res.json({ ok: true });
321
+ });
322
+ router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
323
+ const companyId = req.params.companyId;
324
+ assertCompanyAccess(req, companyId);
325
+ if (req.body.assigneeAgentId || req.body.assigneeUserId) {
326
+ await assertCanAssignTasks(req, companyId);
327
+ }
328
+ const actor = getActorInfo(req);
329
+ const issue = await svc.create(companyId, {
330
+ ...req.body,
331
+ createdByAgentId: actor.agentId,
332
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
333
+ });
334
+ await logActivity(db, {
335
+ companyId,
336
+ actorType: actor.actorType,
337
+ actorId: actor.actorId,
338
+ agentId: actor.agentId,
339
+ runId: actor.runId,
340
+ action: "issue.created",
341
+ entityType: "issue",
342
+ entityId: issue.id,
343
+ details: { title: issue.title, identifier: issue.identifier },
344
+ });
345
+ if (issue.assigneeAgentId) {
346
+ void heartbeat
347
+ .wakeup(issue.assigneeAgentId, {
348
+ source: "assignment",
349
+ triggerDetail: "system",
350
+ reason: "issue_assigned",
351
+ payload: { issueId: issue.id, mutation: "create" },
352
+ requestedByActorType: actor.actorType,
353
+ requestedByActorId: actor.actorId,
354
+ contextSnapshot: { issueId: issue.id, source: "issue.create" },
355
+ })
356
+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
357
+ }
358
+ res.status(201).json(issue);
359
+ });
360
+ router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => {
361
+ const id = req.params.id;
362
+ const existing = await svc.getById(id);
363
+ if (!existing) {
364
+ res.status(404).json({ error: "Issue not found" });
365
+ return;
366
+ }
367
+ assertCompanyAccess(req, existing.companyId);
368
+ const assigneeWillChange = (req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
369
+ (req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
370
+ const isAgentReturningIssueToCreator = req.actor.type === "agent" &&
371
+ !!req.actor.agentId &&
372
+ existing.assigneeAgentId === req.actor.agentId &&
373
+ req.body.assigneeAgentId === null &&
374
+ typeof req.body.assigneeUserId === "string" &&
375
+ !!existing.createdByUserId &&
376
+ req.body.assigneeUserId === existing.createdByUserId;
377
+ if (assigneeWillChange) {
378
+ if (!isAgentReturningIssueToCreator) {
379
+ await assertCanAssignTasks(req, existing.companyId);
380
+ }
381
+ }
382
+ if (!(await assertAgentRunCheckoutOwnership(req, res, existing)))
383
+ return;
384
+ const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
385
+ if (hiddenAtRaw !== undefined) {
386
+ updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
387
+ }
388
+ let issue;
389
+ try {
390
+ issue = await svc.update(id, updateFields);
391
+ }
392
+ catch (err) {
393
+ if (err instanceof HttpError && err.status === 422) {
394
+ logger.warn({
395
+ issueId: id,
396
+ companyId: existing.companyId,
397
+ assigneePatch: {
398
+ assigneeAgentId: req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
399
+ assigneeUserId: req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
400
+ },
401
+ currentAssignee: {
402
+ assigneeAgentId: existing.assigneeAgentId,
403
+ assigneeUserId: existing.assigneeUserId,
404
+ },
405
+ error: err.message,
406
+ details: err.details,
407
+ }, "issue update rejected with 422");
408
+ }
409
+ throw err;
410
+ }
411
+ if (!issue) {
412
+ res.status(404).json({ error: "Issue not found" });
413
+ return;
414
+ }
415
+ // Build activity details with previous values for changed fields
416
+ const previous = {};
417
+ for (const key of Object.keys(updateFields)) {
418
+ if (key in existing && existing[key] !== updateFields[key]) {
419
+ previous[key] = existing[key];
420
+ }
421
+ }
422
+ const actor = getActorInfo(req);
423
+ await logActivity(db, {
424
+ companyId: issue.companyId,
425
+ actorType: actor.actorType,
426
+ actorId: actor.actorId,
427
+ agentId: actor.agentId,
428
+ runId: actor.runId,
429
+ action: "issue.updated",
430
+ entityType: "issue",
431
+ entityId: issue.id,
432
+ details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
433
+ });
434
+ let comment = null;
435
+ if (commentBody) {
436
+ comment = await svc.addComment(id, commentBody, {
437
+ agentId: actor.agentId ?? undefined,
438
+ userId: actor.actorType === "user" ? actor.actorId : undefined,
439
+ });
440
+ await logActivity(db, {
441
+ companyId: issue.companyId,
442
+ actorType: actor.actorType,
443
+ actorId: actor.actorId,
444
+ agentId: actor.agentId,
445
+ runId: actor.runId,
446
+ action: "issue.comment_added",
447
+ entityType: "issue",
448
+ entityId: issue.id,
449
+ details: {
450
+ commentId: comment.id,
451
+ bodySnippet: comment.body.slice(0, 120),
452
+ identifier: issue.identifier,
453
+ issueTitle: issue.title,
454
+ },
455
+ });
456
+ }
457
+ const assigneeChanged = assigneeWillChange;
458
+ // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
459
+ void (async () => {
460
+ const wakeups = new Map();
461
+ if (assigneeChanged && issue.assigneeAgentId) {
462
+ wakeups.set(issue.assigneeAgentId, {
463
+ source: "assignment",
464
+ triggerDetail: "system",
465
+ reason: "issue_assigned",
466
+ payload: { issueId: issue.id, mutation: "update" },
467
+ requestedByActorType: actor.actorType,
468
+ requestedByActorId: actor.actorId,
469
+ contextSnapshot: { issueId: issue.id, source: "issue.update" },
470
+ });
471
+ }
472
+ if (commentBody && comment) {
473
+ let mentionedIds = [];
474
+ try {
475
+ mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
476
+ }
477
+ catch (err) {
478
+ logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
479
+ }
480
+ for (const mentionedId of mentionedIds) {
481
+ if (wakeups.has(mentionedId))
482
+ continue;
483
+ wakeups.set(mentionedId, {
484
+ source: "automation",
485
+ triggerDetail: "system",
486
+ reason: "issue_comment_mentioned",
487
+ payload: { issueId: id, commentId: comment.id },
488
+ requestedByActorType: actor.actorType,
489
+ requestedByActorId: actor.actorId,
490
+ contextSnapshot: {
491
+ issueId: id,
492
+ taskId: id,
493
+ commentId: comment.id,
494
+ wakeCommentId: comment.id,
495
+ wakeReason: "issue_comment_mentioned",
496
+ source: "comment.mention",
497
+ },
498
+ });
499
+ }
500
+ }
501
+ for (const [agentId, wakeup] of wakeups.entries()) {
502
+ heartbeat
503
+ .wakeup(agentId, wakeup)
504
+ .catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
505
+ }
506
+ })();
507
+ res.json({ ...issue, comment });
508
+ });
509
+ router.delete("/issues/:id", async (req, res) => {
510
+ const id = req.params.id;
511
+ const existing = await svc.getById(id);
512
+ if (!existing) {
513
+ res.status(404).json({ error: "Issue not found" });
514
+ return;
515
+ }
516
+ assertCompanyAccess(req, existing.companyId);
517
+ const attachments = await svc.listAttachments(id);
518
+ const issue = await svc.remove(id);
519
+ if (!issue) {
520
+ res.status(404).json({ error: "Issue not found" });
521
+ return;
522
+ }
523
+ for (const attachment of attachments) {
524
+ try {
525
+ await storage.deleteObject(attachment.companyId, attachment.objectKey);
526
+ }
527
+ catch (err) {
528
+ logger.warn({ err, issueId: id, attachmentId: attachment.id }, "failed to delete attachment object during issue delete");
529
+ }
530
+ }
531
+ const actor = getActorInfo(req);
532
+ await logActivity(db, {
533
+ companyId: issue.companyId,
534
+ actorType: actor.actorType,
535
+ actorId: actor.actorId,
536
+ agentId: actor.agentId,
537
+ runId: actor.runId,
538
+ action: "issue.deleted",
539
+ entityType: "issue",
540
+ entityId: issue.id,
541
+ });
542
+ res.json(issue);
543
+ });
544
+ router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => {
545
+ const id = req.params.id;
546
+ const issue = await svc.getById(id);
547
+ if (!issue) {
548
+ res.status(404).json({ error: "Issue not found" });
549
+ return;
550
+ }
551
+ assertCompanyAccess(req, issue.companyId);
552
+ if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
553
+ res.status(403).json({ error: "Agent can only checkout as itself" });
554
+ return;
555
+ }
556
+ const checkoutRunId = requireAgentRunId(req, res);
557
+ if (req.actor.type === "agent" && !checkoutRunId)
558
+ return;
559
+ const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
560
+ const actor = getActorInfo(req);
561
+ await logActivity(db, {
562
+ companyId: issue.companyId,
563
+ actorType: actor.actorType,
564
+ actorId: actor.actorId,
565
+ agentId: actor.agentId,
566
+ runId: actor.runId,
567
+ action: "issue.checked_out",
568
+ entityType: "issue",
569
+ entityId: issue.id,
570
+ details: { agentId: req.body.agentId },
571
+ });
572
+ void heartbeat
573
+ .wakeup(req.body.agentId, {
574
+ source: "assignment",
575
+ triggerDetail: "system",
576
+ reason: "issue_checked_out",
577
+ payload: { issueId: issue.id, mutation: "checkout" },
578
+ requestedByActorType: actor.actorType,
579
+ requestedByActorId: actor.actorId,
580
+ contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
581
+ })
582
+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
583
+ res.json(updated);
584
+ });
585
+ router.post("/issues/:id/release", async (req, res) => {
586
+ const id = req.params.id;
587
+ const existing = await svc.getById(id);
588
+ if (!existing) {
589
+ res.status(404).json({ error: "Issue not found" });
590
+ return;
591
+ }
592
+ assertCompanyAccess(req, existing.companyId);
593
+ if (!(await assertAgentRunCheckoutOwnership(req, res, existing)))
594
+ return;
595
+ const actorRunId = requireAgentRunId(req, res);
596
+ if (req.actor.type === "agent" && !actorRunId)
597
+ return;
598
+ const released = await svc.release(id, req.actor.type === "agent" ? req.actor.agentId : undefined, actorRunId);
599
+ if (!released) {
600
+ res.status(404).json({ error: "Issue not found" });
601
+ return;
602
+ }
603
+ const actor = getActorInfo(req);
604
+ await logActivity(db, {
605
+ companyId: released.companyId,
606
+ actorType: actor.actorType,
607
+ actorId: actor.actorId,
608
+ agentId: actor.agentId,
609
+ runId: actor.runId,
610
+ action: "issue.released",
611
+ entityType: "issue",
612
+ entityId: released.id,
613
+ });
614
+ res.json(released);
615
+ });
616
+ router.get("/issues/:id/comments", async (req, res) => {
617
+ const id = req.params.id;
618
+ const issue = await svc.getById(id);
619
+ if (!issue) {
620
+ res.status(404).json({ error: "Issue not found" });
621
+ return;
622
+ }
623
+ assertCompanyAccess(req, issue.companyId);
624
+ const comments = await svc.listComments(id);
625
+ res.json(comments);
626
+ });
627
+ router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
628
+ const id = req.params.id;
629
+ const issue = await svc.getById(id);
630
+ if (!issue) {
631
+ res.status(404).json({ error: "Issue not found" });
632
+ return;
633
+ }
634
+ assertCompanyAccess(req, issue.companyId);
635
+ if (!(await assertAgentRunCheckoutOwnership(req, res, issue)))
636
+ return;
637
+ const actor = getActorInfo(req);
638
+ const reopenRequested = req.body.reopen === true;
639
+ const interruptRequested = req.body.interrupt === true;
640
+ const isClosed = issue.status === "done" || issue.status === "cancelled";
641
+ let reopened = false;
642
+ let reopenFromStatus = null;
643
+ let interruptedRunId = null;
644
+ let currentIssue = issue;
645
+ if (reopenRequested && isClosed) {
646
+ const reopenedIssue = await svc.update(id, { status: "todo" });
647
+ if (!reopenedIssue) {
648
+ res.status(404).json({ error: "Issue not found" });
649
+ return;
650
+ }
651
+ reopened = true;
652
+ reopenFromStatus = issue.status;
653
+ currentIssue = reopenedIssue;
654
+ await logActivity(db, {
655
+ companyId: currentIssue.companyId,
656
+ actorType: actor.actorType,
657
+ actorId: actor.actorId,
658
+ agentId: actor.agentId,
659
+ runId: actor.runId,
660
+ action: "issue.updated",
661
+ entityType: "issue",
662
+ entityId: currentIssue.id,
663
+ details: {
664
+ status: "todo",
665
+ reopened: true,
666
+ reopenedFrom: reopenFromStatus,
667
+ source: "comment",
668
+ identifier: currentIssue.identifier,
669
+ },
670
+ });
671
+ }
672
+ if (interruptRequested) {
673
+ if (req.actor.type !== "board") {
674
+ res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
675
+ return;
676
+ }
677
+ let runToInterrupt = currentIssue.executionRunId
678
+ ? await heartbeat.getRun(currentIssue.executionRunId)
679
+ : null;
680
+ if ((!runToInterrupt || runToInterrupt.status !== "running") &&
681
+ currentIssue.assigneeAgentId) {
682
+ const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
683
+ const activeIssueId = activeRun &&
684
+ activeRun.contextSnapshot &&
685
+ typeof activeRun.contextSnapshot === "object" &&
686
+ typeof activeRun.contextSnapshot.issueId === "string"
687
+ ? activeRun.contextSnapshot.issueId
688
+ : null;
689
+ if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
690
+ runToInterrupt = activeRun;
691
+ }
692
+ }
693
+ if (runToInterrupt && runToInterrupt.status === "running") {
694
+ const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
695
+ if (cancelled) {
696
+ interruptedRunId = cancelled.id;
697
+ await logActivity(db, {
698
+ companyId: cancelled.companyId,
699
+ actorType: actor.actorType,
700
+ actorId: actor.actorId,
701
+ agentId: actor.agentId,
702
+ runId: actor.runId,
703
+ action: "heartbeat.cancelled",
704
+ entityType: "heartbeat_run",
705
+ entityId: cancelled.id,
706
+ details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id },
707
+ });
708
+ }
709
+ }
710
+ }
711
+ const comment = await svc.addComment(id, req.body.body, {
712
+ agentId: actor.agentId ?? undefined,
713
+ userId: actor.actorType === "user" ? actor.actorId : undefined,
714
+ });
715
+ await logActivity(db, {
716
+ companyId: currentIssue.companyId,
717
+ actorType: actor.actorType,
718
+ actorId: actor.actorId,
719
+ agentId: actor.agentId,
720
+ runId: actor.runId,
721
+ action: "issue.comment_added",
722
+ entityType: "issue",
723
+ entityId: currentIssue.id,
724
+ details: {
725
+ commentId: comment.id,
726
+ bodySnippet: comment.body.slice(0, 120),
727
+ identifier: currentIssue.identifier,
728
+ issueTitle: currentIssue.title,
729
+ ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
730
+ ...(interruptedRunId ? { interruptedRunId } : {}),
731
+ },
732
+ });
733
+ // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
734
+ void (async () => {
735
+ const wakeups = new Map();
736
+ const assigneeId = currentIssue.assigneeAgentId;
737
+ if (assigneeId) {
738
+ if (reopened) {
739
+ wakeups.set(assigneeId, {
740
+ source: "automation",
741
+ triggerDetail: "system",
742
+ reason: "issue_reopened_via_comment",
743
+ payload: {
744
+ issueId: currentIssue.id,
745
+ commentId: comment.id,
746
+ reopenedFrom: reopenFromStatus,
747
+ mutation: "comment",
748
+ ...(interruptedRunId ? { interruptedRunId } : {}),
749
+ },
750
+ requestedByActorType: actor.actorType,
751
+ requestedByActorId: actor.actorId,
752
+ contextSnapshot: {
753
+ issueId: currentIssue.id,
754
+ taskId: currentIssue.id,
755
+ commentId: comment.id,
756
+ source: "issue.comment.reopen",
757
+ wakeReason: "issue_reopened_via_comment",
758
+ reopenedFrom: reopenFromStatus,
759
+ ...(interruptedRunId ? { interruptedRunId } : {}),
760
+ },
761
+ });
762
+ }
763
+ else {
764
+ wakeups.set(assigneeId, {
765
+ source: "automation",
766
+ triggerDetail: "system",
767
+ reason: "issue_commented",
768
+ payload: {
769
+ issueId: currentIssue.id,
770
+ commentId: comment.id,
771
+ mutation: "comment",
772
+ ...(interruptedRunId ? { interruptedRunId } : {}),
773
+ },
774
+ requestedByActorType: actor.actorType,
775
+ requestedByActorId: actor.actorId,
776
+ contextSnapshot: {
777
+ issueId: currentIssue.id,
778
+ taskId: currentIssue.id,
779
+ commentId: comment.id,
780
+ source: "issue.comment",
781
+ wakeReason: "issue_commented",
782
+ ...(interruptedRunId ? { interruptedRunId } : {}),
783
+ },
784
+ });
785
+ }
786
+ }
787
+ let mentionedIds = [];
788
+ try {
789
+ mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body);
790
+ }
791
+ catch (err) {
792
+ logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
793
+ }
794
+ for (const mentionedId of mentionedIds) {
795
+ if (wakeups.has(mentionedId))
796
+ continue;
797
+ wakeups.set(mentionedId, {
798
+ source: "automation",
799
+ triggerDetail: "system",
800
+ reason: "issue_comment_mentioned",
801
+ payload: { issueId: id, commentId: comment.id },
802
+ requestedByActorType: actor.actorType,
803
+ requestedByActorId: actor.actorId,
804
+ contextSnapshot: {
805
+ issueId: id,
806
+ taskId: id,
807
+ commentId: comment.id,
808
+ wakeCommentId: comment.id,
809
+ wakeReason: "issue_comment_mentioned",
810
+ source: "comment.mention",
811
+ },
812
+ });
813
+ }
814
+ for (const [agentId, wakeup] of wakeups.entries()) {
815
+ heartbeat
816
+ .wakeup(agentId, wakeup)
817
+ .catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
818
+ }
819
+ })();
820
+ res.status(201).json(comment);
821
+ });
822
+ router.get("/issues/:id/attachments", async (req, res) => {
823
+ const issueId = req.params.id;
824
+ const issue = await svc.getById(issueId);
825
+ if (!issue) {
826
+ res.status(404).json({ error: "Issue not found" });
827
+ return;
828
+ }
829
+ assertCompanyAccess(req, issue.companyId);
830
+ const attachments = await svc.listAttachments(issueId);
831
+ res.json(attachments.map(withContentPath));
832
+ });
833
+ router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
834
+ const companyId = req.params.companyId;
835
+ const issueId = req.params.issueId;
836
+ assertCompanyAccess(req, companyId);
837
+ const issue = await svc.getById(issueId);
838
+ if (!issue) {
839
+ res.status(404).json({ error: "Issue not found" });
840
+ return;
841
+ }
842
+ if (issue.companyId !== companyId) {
843
+ res.status(422).json({ error: "Issue does not belong to company" });
844
+ return;
845
+ }
846
+ try {
847
+ await runSingleFileUpload(req, res);
848
+ }
849
+ catch (err) {
850
+ if (err instanceof multer.MulterError) {
851
+ if (err.code === "LIMIT_FILE_SIZE") {
852
+ res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
853
+ return;
854
+ }
855
+ res.status(400).json({ error: err.message });
856
+ return;
857
+ }
858
+ throw err;
859
+ }
860
+ const file = req.file;
861
+ if (!file) {
862
+ res.status(400).json({ error: "Missing file field 'file'" });
863
+ return;
864
+ }
865
+ const contentType = (file.mimetype || "").toLowerCase();
866
+ if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
867
+ res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
868
+ return;
869
+ }
870
+ if (file.buffer.length <= 0) {
871
+ res.status(422).json({ error: "Attachment is empty" });
872
+ return;
873
+ }
874
+ const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
875
+ if (!parsedMeta.success) {
876
+ res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
877
+ return;
878
+ }
879
+ const actor = getActorInfo(req);
880
+ const stored = await storage.putFile({
881
+ companyId,
882
+ namespace: `issues/${issueId}`,
883
+ originalFilename: file.originalname || null,
884
+ contentType,
885
+ body: file.buffer,
886
+ });
887
+ const attachment = await svc.createAttachment({
888
+ issueId,
889
+ issueCommentId: parsedMeta.data.issueCommentId ?? null,
890
+ provider: stored.provider,
891
+ objectKey: stored.objectKey,
892
+ contentType: stored.contentType,
893
+ byteSize: stored.byteSize,
894
+ sha256: stored.sha256,
895
+ originalFilename: stored.originalFilename,
896
+ createdByAgentId: actor.agentId,
897
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
898
+ });
899
+ await logActivity(db, {
900
+ companyId,
901
+ actorType: actor.actorType,
902
+ actorId: actor.actorId,
903
+ agentId: actor.agentId,
904
+ runId: actor.runId,
905
+ action: "issue.attachment_added",
906
+ entityType: "issue",
907
+ entityId: issueId,
908
+ details: {
909
+ attachmentId: attachment.id,
910
+ originalFilename: attachment.originalFilename,
911
+ contentType: attachment.contentType,
912
+ byteSize: attachment.byteSize,
913
+ },
914
+ });
915
+ res.status(201).json(withContentPath(attachment));
916
+ });
917
+ router.get("/attachments/:attachmentId/content", async (req, res, next) => {
918
+ const attachmentId = req.params.attachmentId;
919
+ const attachment = await svc.getAttachmentById(attachmentId);
920
+ if (!attachment) {
921
+ res.status(404).json({ error: "Attachment not found" });
922
+ return;
923
+ }
924
+ assertCompanyAccess(req, attachment.companyId);
925
+ const object = await storage.getObject(attachment.companyId, attachment.objectKey);
926
+ res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
927
+ res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
928
+ res.setHeader("Cache-Control", "private, max-age=60");
929
+ const filename = attachment.originalFilename ?? "attachment";
930
+ res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
931
+ object.stream.on("error", (err) => {
932
+ next(err);
933
+ });
934
+ object.stream.pipe(res);
935
+ });
936
+ router.delete("/attachments/:attachmentId", async (req, res) => {
937
+ const attachmentId = req.params.attachmentId;
938
+ const attachment = await svc.getAttachmentById(attachmentId);
939
+ if (!attachment) {
940
+ res.status(404).json({ error: "Attachment not found" });
941
+ return;
942
+ }
943
+ assertCompanyAccess(req, attachment.companyId);
944
+ try {
945
+ await storage.deleteObject(attachment.companyId, attachment.objectKey);
946
+ }
947
+ catch (err) {
948
+ logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
949
+ }
950
+ const removed = await svc.removeAttachment(attachmentId);
951
+ if (!removed) {
952
+ res.status(404).json({ error: "Attachment not found" });
953
+ return;
954
+ }
955
+ const actor = getActorInfo(req);
956
+ await logActivity(db, {
957
+ companyId: removed.companyId,
958
+ actorType: actor.actorType,
959
+ actorId: actor.actorId,
960
+ agentId: actor.agentId,
961
+ runId: actor.runId,
962
+ action: "issue.attachment_removed",
963
+ entityType: "issue",
964
+ entityId: removed.issueId,
965
+ details: {
966
+ attachmentId: removed.id,
967
+ },
968
+ });
969
+ res.json({ ok: true });
970
+ });
971
+ return router;
972
+ }
973
+ //# sourceMappingURL=issues.js.map