corporateai 0.0.1

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 (704) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +3 -0
  3. package/.github/workflows/publish-cli.yml +49 -0
  4. package/.mailmap +1 -0
  5. package/AGENTS.md +148 -0
  6. package/CONTRIBUTING.md +75 -0
  7. package/Dockerfile +59 -0
  8. package/Dockerfile.onboard-smoke +42 -0
  9. package/LICENSE +21 -0
  10. package/README.md +93 -0
  11. package/cli/esbuild.config.mjs +11 -0
  12. package/cli/package.json +24 -0
  13. package/cli/scripts/build-cli.mjs +5 -0
  14. package/cli/src/index.ts +27 -0
  15. package/docker-compose.quickstart.yml +18 -0
  16. package/docker-compose.untrusted-review.yml +33 -0
  17. package/docker-compose.yml +38 -0
  18. package/package.json +56 -0
  19. package/patches/embedded-postgres@18.1.0-beta.16.patch +0 -0
  20. package/pnpm-workspace.yaml +4 -0
  21. package/releases/.gitkeep +0 -0
  22. package/releases/v0.0.1.md +36 -0
  23. package/releases/v0.2.7.md +15 -0
  24. package/releases/v0.3.0.md +54 -0
  25. package/releases/v0.3.1.md +55 -0
  26. package/releases/v2026.318.0.md +66 -0
  27. package/releases/v2026.325.0.md +78 -0
  28. package/report/2026-03-13-08-46-token-optimization-implementation.md +48 -0
  29. package/scripts/backup-db.sh +17 -0
  30. package/scripts/build-npm.sh +80 -0
  31. package/scripts/check-forbidden-tokens.mjs +115 -0
  32. package/scripts/clean-onboard-git.sh +14 -0
  33. package/scripts/clean-onboard-npm.sh +13 -0
  34. package/scripts/clean-onboard-ref.sh +86 -0
  35. package/scripts/create-github-release.sh +99 -0
  36. package/scripts/dev-runner-paths.mjs +38 -0
  37. package/scripts/dev-runner.mjs +606 -0
  38. package/scripts/docker-onboard-smoke.sh +306 -0
  39. package/scripts/ensure-plugin-build-deps.mjs +47 -0
  40. package/scripts/generate-company-assets.ts +365 -0
  41. package/scripts/generate-npm-package-json.mjs +113 -0
  42. package/scripts/generate-org-chart-images.ts +694 -0
  43. package/scripts/generate-org-chart-satori-comparison.ts +225 -0
  44. package/scripts/generate-ui-package-json.mjs +31 -0
  45. package/scripts/kill-dev.sh +71 -0
  46. package/scripts/migrate-inline-env-secrets.ts +126 -0
  47. package/scripts/prepare-server-ui-dist.sh +22 -0
  48. package/scripts/provision-worktree.sh +333 -0
  49. package/scripts/release-lib.sh +306 -0
  50. package/scripts/release-package-map.mjs +169 -0
  51. package/scripts/release.sh +312 -0
  52. package/scripts/rollback-latest.sh +111 -0
  53. package/scripts/smoke/openclaw-docker-ui.sh +329 -0
  54. package/scripts/smoke/openclaw-gateway-e2e.sh +954 -0
  55. package/scripts/smoke/openclaw-join.sh +295 -0
  56. package/scripts/smoke/openclaw-sse-standalone.sh +146 -0
  57. package/scripts/workspace-compat.mjs +60 -0
  58. package/server/CHANGELOG.md +130 -0
  59. package/server/package.json +96 -0
  60. package/server/scripts/copy-onboarding-assets.mjs +10 -0
  61. package/server/scripts/dev-watch.ts +33 -0
  62. package/server/src/__tests__/activity-routes.test.ts +70 -0
  63. package/server/src/__tests__/adapter-models.test.ts +105 -0
  64. package/server/src/__tests__/adapter-session-codecs.test.ts +194 -0
  65. package/server/src/__tests__/agent-auth-jwt.test.ts +79 -0
  66. package/server/src/__tests__/agent-instructions-routes.test.ts +318 -0
  67. package/server/src/__tests__/agent-instructions-service.test.ts +361 -0
  68. package/server/src/__tests__/agent-permissions-routes.test.ts +275 -0
  69. package/server/src/__tests__/agent-shortname-collision.test.ts +69 -0
  70. package/server/src/__tests__/agent-skill-contract.test.ts +50 -0
  71. package/server/src/__tests__/agent-skills-routes.test.ts +462 -0
  72. package/server/src/__tests__/app-hmr-port.test.ts +19 -0
  73. package/server/src/__tests__/approval-routes-idempotency.test.ts +110 -0
  74. package/server/src/__tests__/approvals-service.test.ts +107 -0
  75. package/server/src/__tests__/assets.test.ts +250 -0
  76. package/server/src/__tests__/attachment-types.test.ts +97 -0
  77. package/server/src/__tests__/board-mutation-guard.test.ts +105 -0
  78. package/server/src/__tests__/budgets-service.test.ts +311 -0
  79. package/server/src/__tests__/claude-local-adapter-environment.test.ts +92 -0
  80. package/server/src/__tests__/claude-local-adapter.test.ts +31 -0
  81. package/server/src/__tests__/claude-local-skill-sync.test.ts +111 -0
  82. package/server/src/__tests__/cli-auth-routes.test.ts +230 -0
  83. package/server/src/__tests__/codex-local-adapter-environment.test.ts +143 -0
  84. package/server/src/__tests__/codex-local-adapter.test.ts +253 -0
  85. package/server/src/__tests__/codex-local-execute.test.ts +391 -0
  86. package/server/src/__tests__/codex-local-skill-injection.test.ts +175 -0
  87. package/server/src/__tests__/codex-local-skill-sync.test.ts +123 -0
  88. package/server/src/__tests__/companies-route-path-guard.test.ts +56 -0
  89. package/server/src/__tests__/company-branding-route.test.ts +196 -0
  90. package/server/src/__tests__/company-portability-routes.test.ts +175 -0
  91. package/server/src/__tests__/company-portability.test.ts +2186 -0
  92. package/server/src/__tests__/company-skills-routes.test.ts +113 -0
  93. package/server/src/__tests__/company-skills.test.ts +229 -0
  94. package/server/src/__tests__/costs-service.test.ts +226 -0
  95. package/server/src/__tests__/cursor-local-adapter-environment.test.ts +196 -0
  96. package/server/src/__tests__/cursor-local-adapter.test.ts +406 -0
  97. package/server/src/__tests__/cursor-local-execute.test.ts +263 -0
  98. package/server/src/__tests__/cursor-local-skill-injection.test.ts +104 -0
  99. package/server/src/__tests__/cursor-local-skill-sync.test.ts +145 -0
  100. package/server/src/__tests__/dev-runner-paths.test.ts +25 -0
  101. package/server/src/__tests__/dev-server-status.test.ts +66 -0
  102. package/server/src/__tests__/dev-watch-ignore.test.ts +42 -0
  103. package/server/src/__tests__/documents.test.ts +29 -0
  104. package/server/src/__tests__/error-handler.test.ts +53 -0
  105. package/server/src/__tests__/execution-workspace-policy.test.ts +170 -0
  106. package/server/src/__tests__/forbidden-tokens.test.ts +77 -0
  107. package/server/src/__tests__/gemini-local-adapter-environment.test.ts +135 -0
  108. package/server/src/__tests__/gemini-local-adapter.test.ts +190 -0
  109. package/server/src/__tests__/gemini-local-execute.test.ts +172 -0
  110. package/server/src/__tests__/gemini-local-skill-sync.test.ts +90 -0
  111. package/server/src/__tests__/health.test.ts +16 -0
  112. package/server/src/__tests__/heartbeat-process-recovery.test.ts +256 -0
  113. package/server/src/__tests__/heartbeat-run-summary.test.ts +33 -0
  114. package/server/src/__tests__/heartbeat-workspace-session.test.ts +334 -0
  115. package/server/src/__tests__/helpers/embedded-postgres.ts +7 -0
  116. package/server/src/__tests__/hire-hook.test.ts +181 -0
  117. package/server/src/__tests__/instance-settings-routes.test.ts +156 -0
  118. package/server/src/__tests__/invite-accept-gateway-defaults.test.ts +119 -0
  119. package/server/src/__tests__/invite-accept-replay.test.ts +92 -0
  120. package/server/src/__tests__/invite-expiry.test.ts +10 -0
  121. package/server/src/__tests__/invite-join-grants.test.ts +57 -0
  122. package/server/src/__tests__/invite-join-manager.test.ts +33 -0
  123. package/server/src/__tests__/invite-onboarding-text.test.ts +116 -0
  124. package/server/src/__tests__/issue-comment-reopen-routes.test.ts +146 -0
  125. package/server/src/__tests__/issue-goal-fallback.test.ts +99 -0
  126. package/server/src/__tests__/issues-checkout-wakeup.test.ts +48 -0
  127. package/server/src/__tests__/issues-goal-context-routes.test.ts +187 -0
  128. package/server/src/__tests__/issues-service.test.ts +317 -0
  129. package/server/src/__tests__/issues-user-context.test.ts +113 -0
  130. package/server/src/__tests__/log-redaction.test.ts +74 -0
  131. package/server/src/__tests__/monthly-spend-service.test.ts +90 -0
  132. package/server/src/__tests__/normalize-agent-mention-token.test.ts +41 -0
  133. package/server/src/__tests__/openclaw-gateway-adapter.test.ts +626 -0
  134. package/server/src/__tests__/openclaw-invite-prompt-route.test.ts +192 -0
  135. package/server/src/__tests__/opencode-local-adapter-environment.test.ts +97 -0
  136. package/server/src/__tests__/opencode-local-adapter.test.ts +226 -0
  137. package/server/src/__tests__/opencode-local-skill-sync.test.ts +91 -0
  138. package/server/src/__tests__/paperclip-env.test.ts +58 -0
  139. package/server/src/__tests__/paperclip-skill-utils.test.ts +63 -0
  140. package/server/src/__tests__/pi-local-adapter-environment.test.ts +102 -0
  141. package/server/src/__tests__/pi-local-skill-sync.test.ts +95 -0
  142. package/server/src/__tests__/plugin-dev-watcher.test.ts +68 -0
  143. package/server/src/__tests__/plugin-worker-manager.test.ts +43 -0
  144. package/server/src/__tests__/private-hostname-guard.test.ts +56 -0
  145. package/server/src/__tests__/project-shortname-resolution.test.ts +45 -0
  146. package/server/src/__tests__/quota-windows-service.test.ts +56 -0
  147. package/server/src/__tests__/quota-windows.test.ts +1109 -0
  148. package/server/src/__tests__/redaction.test.ts +66 -0
  149. package/server/src/__tests__/routines-e2e.test.ts +276 -0
  150. package/server/src/__tests__/routines-routes.test.ts +271 -0
  151. package/server/src/__tests__/routines-service.test.ts +424 -0
  152. package/server/src/__tests__/storage-local-provider.test.ts +78 -0
  153. package/server/src/__tests__/ui-branding.test.ts +82 -0
  154. package/server/src/__tests__/work-products.test.ts +95 -0
  155. package/server/src/__tests__/workspace-runtime.test.ts +1131 -0
  156. package/server/src/__tests__/worktree-config.test.ts +426 -0
  157. package/server/src/adapters/codex-models.ts +105 -0
  158. package/server/src/adapters/cursor-models.ts +171 -0
  159. package/server/src/adapters/http/execute.ts +42 -0
  160. package/server/src/adapters/http/index.ts +21 -0
  161. package/server/src/adapters/http/test.ts +116 -0
  162. package/server/src/adapters/index.ts +18 -0
  163. package/server/src/adapters/process/execute.ts +77 -0
  164. package/server/src/adapters/process/index.ts +24 -0
  165. package/server/src/adapters/process/test.ts +89 -0
  166. package/server/src/adapters/registry.ts +225 -0
  167. package/server/src/adapters/server-utils-compat.ts +57 -0
  168. package/server/src/adapters/types.ts +30 -0
  169. package/server/src/adapters/utils.ts +48 -0
  170. package/server/src/agent-auth-jwt.ts +141 -0
  171. package/server/src/app.ts +321 -0
  172. package/server/src/attachment-types.ts +74 -0
  173. package/server/src/auth/better-auth.ts +148 -0
  174. package/server/src/board-claim.ts +150 -0
  175. package/server/src/config-file.ts +17 -0
  176. package/server/src/config.ts +260 -0
  177. package/server/src/dev-server-status.ts +103 -0
  178. package/server/src/dev-watch-ignore.ts +36 -0
  179. package/server/src/errors.ts +34 -0
  180. package/server/src/home-paths.ts +95 -0
  181. package/server/src/index.ts +799 -0
  182. package/server/src/log-redaction.ts +146 -0
  183. package/server/src/middleware/auth.ts +178 -0
  184. package/server/src/middleware/board-mutation-guard.ts +66 -0
  185. package/server/src/middleware/error-handler.ts +71 -0
  186. package/server/src/middleware/index.ts +3 -0
  187. package/server/src/middleware/logger.ts +90 -0
  188. package/server/src/middleware/private-hostname-guard.ts +92 -0
  189. package/server/src/middleware/validate.ts +9 -0
  190. package/server/src/onboarding-assets/ceo/AGENTS.md +54 -0
  191. package/server/src/onboarding-assets/ceo/HEARTBEAT.md +72 -0
  192. package/server/src/onboarding-assets/ceo/SOUL.md +33 -0
  193. package/server/src/onboarding-assets/ceo/TOOLS.md +3 -0
  194. package/server/src/onboarding-assets/default/AGENTS.md +3 -0
  195. package/server/src/paths.ts +34 -0
  196. package/server/src/realtime/live-events-ws.ts +274 -0
  197. package/server/src/redaction.ts +59 -0
  198. package/server/src/routes/access.ts +2888 -0
  199. package/server/src/routes/activity.ts +89 -0
  200. package/server/src/routes/agents.ts +2313 -0
  201. package/server/src/routes/approvals.ts +346 -0
  202. package/server/src/routes/assets.ts +341 -0
  203. package/server/src/routes/authz.ts +52 -0
  204. package/server/src/routes/companies.ts +343 -0
  205. package/server/src/routes/company-skills.ts +300 -0
  206. package/server/src/routes/costs.ts +335 -0
  207. package/server/src/routes/dashboard.ts +19 -0
  208. package/server/src/routes/execution-workspaces.ts +182 -0
  209. package/server/src/routes/goals.ts +107 -0
  210. package/server/src/routes/health.ts +94 -0
  211. package/server/src/routes/index.ts +17 -0
  212. package/server/src/routes/instance-settings.ts +95 -0
  213. package/server/src/routes/issues-checkout-wakeup.ts +14 -0
  214. package/server/src/routes/issues.ts +1680 -0
  215. package/server/src/routes/llms.ts +86 -0
  216. package/server/src/routes/org-chart-svg.ts +777 -0
  217. package/server/src/routes/plugin-ui-static.ts +497 -0
  218. package/server/src/routes/plugins.ts +2220 -0
  219. package/server/src/routes/projects.ts +295 -0
  220. package/server/src/routes/routines.ts +300 -0
  221. package/server/src/routes/secrets.ts +166 -0
  222. package/server/src/routes/sidebar-badges.ts +52 -0
  223. package/server/src/secrets/external-stub-providers.ts +32 -0
  224. package/server/src/secrets/local-encrypted-provider.ts +135 -0
  225. package/server/src/secrets/provider-registry.ts +31 -0
  226. package/server/src/secrets/types.ts +23 -0
  227. package/server/src/services/access.ts +381 -0
  228. package/server/src/services/activity-log.ts +95 -0
  229. package/server/src/services/activity.ts +164 -0
  230. package/server/src/services/agent-instructions.ts +735 -0
  231. package/server/src/services/agent-permissions.ts +27 -0
  232. package/server/src/services/agents.ts +694 -0
  233. package/server/src/services/approvals.ts +273 -0
  234. package/server/src/services/assets.ts +23 -0
  235. package/server/src/services/board-auth.ts +355 -0
  236. package/server/src/services/budgets.ts +959 -0
  237. package/server/src/services/companies.ts +313 -0
  238. package/server/src/services/company-export-readme.ts +173 -0
  239. package/server/src/services/company-portability.ts +4263 -0
  240. package/server/src/services/company-skills.ts +2356 -0
  241. package/server/src/services/costs.ts +365 -0
  242. package/server/src/services/cron.ts +373 -0
  243. package/server/src/services/dashboard.ts +110 -0
  244. package/server/src/services/default-agent-instructions.ts +27 -0
  245. package/server/src/services/documents.ts +434 -0
  246. package/server/src/services/execution-workspace-policy.ts +210 -0
  247. package/server/src/services/execution-workspaces.ts +100 -0
  248. package/server/src/services/finance.ts +135 -0
  249. package/server/src/services/goals.ts +81 -0
  250. package/server/src/services/heartbeat-run-summary.ts +35 -0
  251. package/server/src/services/heartbeat.ts +3863 -0
  252. package/server/src/services/hire-hook.ts +114 -0
  253. package/server/src/services/index.ts +32 -0
  254. package/server/src/services/instance-settings.ts +138 -0
  255. package/server/src/services/issue-approvals.ts +175 -0
  256. package/server/src/services/issue-assignment-wakeup.ts +48 -0
  257. package/server/src/services/issue-goal-fallback.ts +56 -0
  258. package/server/src/services/issues.ts +1828 -0
  259. package/server/src/services/live-events.ts +55 -0
  260. package/server/src/services/plugin-capability-validator.ts +450 -0
  261. package/server/src/services/plugin-config-validator.ts +55 -0
  262. package/server/src/services/plugin-dev-watcher.ts +339 -0
  263. package/server/src/services/plugin-event-bus.ts +413 -0
  264. package/server/src/services/plugin-host-service-cleanup.ts +59 -0
  265. package/server/src/services/plugin-host-services.ts +1132 -0
  266. package/server/src/services/plugin-job-coordinator.ts +261 -0
  267. package/server/src/services/plugin-job-scheduler.ts +753 -0
  268. package/server/src/services/plugin-job-store.ts +466 -0
  269. package/server/src/services/plugin-lifecycle.ts +822 -0
  270. package/server/src/services/plugin-loader.ts +1955 -0
  271. package/server/src/services/plugin-log-retention.ts +87 -0
  272. package/server/src/services/plugin-manifest-validator.ts +164 -0
  273. package/server/src/services/plugin-registry.ts +683 -0
  274. package/server/src/services/plugin-runtime-sandbox.ts +222 -0
  275. package/server/src/services/plugin-secrets-handler.ts +355 -0
  276. package/server/src/services/plugin-state-store.ts +238 -0
  277. package/server/src/services/plugin-stream-bus.ts +81 -0
  278. package/server/src/services/plugin-tool-dispatcher.ts +449 -0
  279. package/server/src/services/plugin-tool-registry.ts +450 -0
  280. package/server/src/services/plugin-worker-manager.ts +1343 -0
  281. package/server/src/services/projects.ts +860 -0
  282. package/server/src/services/quota-windows.ts +65 -0
  283. package/server/src/services/routines.ts +1269 -0
  284. package/server/src/services/run-log-store.ts +156 -0
  285. package/server/src/services/secrets.ts +370 -0
  286. package/server/src/services/sidebar-badges.ts +56 -0
  287. package/server/src/services/work-products.ts +124 -0
  288. package/server/src/services/workspace-operation-log-store.ts +156 -0
  289. package/server/src/services/workspace-operations.ts +262 -0
  290. package/server/src/services/workspace-runtime.ts +1565 -0
  291. package/server/src/startup-banner.ts +176 -0
  292. package/server/src/storage/index.ts +35 -0
  293. package/server/src/storage/local-disk-provider.ts +89 -0
  294. package/server/src/storage/provider-registry.ts +18 -0
  295. package/server/src/storage/s3-provider.ts +153 -0
  296. package/server/src/storage/service.ts +131 -0
  297. package/server/src/storage/types.ts +63 -0
  298. package/server/src/ui-branding.ts +217 -0
  299. package/server/src/version.ts +10 -0
  300. package/server/src/worktree-config.ts +468 -0
  301. package/server/tsconfig.json +9 -0
  302. package/server/vitest.config.ts +7 -0
  303. package/skills/paperclip/SKILL.md +365 -0
  304. package/skills/paperclip/references/api-reference.md +647 -0
  305. package/skills/paperclip/references/company-skills.md +193 -0
  306. package/skills/paperclip-create-agent/SKILL.md +142 -0
  307. package/skills/paperclip-create-agent/references/api-reference.md +105 -0
  308. package/skills/paperclip-create-plugin/SKILL.md +102 -0
  309. package/skills/para-memory-files/SKILL.md +104 -0
  310. package/skills/para-memory-files/references/schemas.md +35 -0
  311. package/tests/e2e/onboarding.spec.ts +142 -0
  312. package/tests/e2e/playwright.config.ts +35 -0
  313. package/tests/release-smoke/docker-auth-onboarding.spec.ts +141 -0
  314. package/tests/release-smoke/playwright.config.ts +28 -0
  315. package/tsconfig.base.json +18 -0
  316. package/tsconfig.json +18 -0
  317. package/ui/README.md +12 -0
  318. package/ui/components.json +21 -0
  319. package/ui/index.html +47 -0
  320. package/ui/package.json +73 -0
  321. package/ui/public/android-chrome-192x192.png +0 -0
  322. package/ui/public/android-chrome-512x512.png +0 -0
  323. package/ui/public/apple-touch-icon.png +0 -0
  324. package/ui/public/brands/opencode-logo-dark-square.svg +18 -0
  325. package/ui/public/brands/opencode-logo-light-square.svg +18 -0
  326. package/ui/public/favicon-16x16.png +0 -0
  327. package/ui/public/favicon-32x32.png +0 -0
  328. package/ui/public/favicon.ico +0 -0
  329. package/ui/public/favicon.svg +9 -0
  330. package/ui/public/site.webmanifest +30 -0
  331. package/ui/public/sprites/1-D-1.png +0 -0
  332. package/ui/public/sprites/1-D-2.png +0 -0
  333. package/ui/public/sprites/1-D-3.png +0 -0
  334. package/ui/public/sprites/1-L-1.png +0 -0
  335. package/ui/public/sprites/1-R-1.png +0 -0
  336. package/ui/public/sprites/10-D-1.png +0 -0
  337. package/ui/public/sprites/10-D-2.png +0 -0
  338. package/ui/public/sprites/10-D-3.png +0 -0
  339. package/ui/public/sprites/10-L-1.png +0 -0
  340. package/ui/public/sprites/10-R-1.png +0 -0
  341. package/ui/public/sprites/11-D-1.png +0 -0
  342. package/ui/public/sprites/11-D-2.png +0 -0
  343. package/ui/public/sprites/11-D-3.png +0 -0
  344. package/ui/public/sprites/11-L-1.png +0 -0
  345. package/ui/public/sprites/11-R-1.png +0 -0
  346. package/ui/public/sprites/12-D-1.png +0 -0
  347. package/ui/public/sprites/12-D-2.png +0 -0
  348. package/ui/public/sprites/12-D-3.png +0 -0
  349. package/ui/public/sprites/12-L-1.png +0 -0
  350. package/ui/public/sprites/12-R-1.png +0 -0
  351. package/ui/public/sprites/13-D-1.png +0 -0
  352. package/ui/public/sprites/13-D-2.png +0 -0
  353. package/ui/public/sprites/13-D-3.png +0 -0
  354. package/ui/public/sprites/13-L-1.png +0 -0
  355. package/ui/public/sprites/13-R-1.png +0 -0
  356. package/ui/public/sprites/14-D-1.png +0 -0
  357. package/ui/public/sprites/14-D-2.png +0 -0
  358. package/ui/public/sprites/14-D-3.png +0 -0
  359. package/ui/public/sprites/14-L-1.png +0 -0
  360. package/ui/public/sprites/14-R-1.png +0 -0
  361. package/ui/public/sprites/2-D-1.png +0 -0
  362. package/ui/public/sprites/2-D-2.png +0 -0
  363. package/ui/public/sprites/2-D-3.png +0 -0
  364. package/ui/public/sprites/2-L-1.png +0 -0
  365. package/ui/public/sprites/2-R-1.png +0 -0
  366. package/ui/public/sprites/3-D-1.png +0 -0
  367. package/ui/public/sprites/3-D-2.png +0 -0
  368. package/ui/public/sprites/3-D-3.png +0 -0
  369. package/ui/public/sprites/3-L-1.png +0 -0
  370. package/ui/public/sprites/3-R-1.png +0 -0
  371. package/ui/public/sprites/4-D-1.png +0 -0
  372. package/ui/public/sprites/4-D-2.png +0 -0
  373. package/ui/public/sprites/4-D-3.png +0 -0
  374. package/ui/public/sprites/4-L-1.png +0 -0
  375. package/ui/public/sprites/4-R-1.png +0 -0
  376. package/ui/public/sprites/5-D-1.png +0 -0
  377. package/ui/public/sprites/5-D-2.png +0 -0
  378. package/ui/public/sprites/5-D-3.png +0 -0
  379. package/ui/public/sprites/5-L-1.png +0 -0
  380. package/ui/public/sprites/5-R-1.png +0 -0
  381. package/ui/public/sprites/6-D-1.png +0 -0
  382. package/ui/public/sprites/6-D-2.png +0 -0
  383. package/ui/public/sprites/6-D-3.png +0 -0
  384. package/ui/public/sprites/6-L-1.png +0 -0
  385. package/ui/public/sprites/6-R-1.png +0 -0
  386. package/ui/public/sprites/7-D-1.png +0 -0
  387. package/ui/public/sprites/7-D-2.png +0 -0
  388. package/ui/public/sprites/7-D-3.png +0 -0
  389. package/ui/public/sprites/7-L-1.png +0 -0
  390. package/ui/public/sprites/7-R-1.png +0 -0
  391. package/ui/public/sprites/8-D-1.png +0 -0
  392. package/ui/public/sprites/8-D-2.png +0 -0
  393. package/ui/public/sprites/8-D-3.png +0 -0
  394. package/ui/public/sprites/8-L-1.png +0 -0
  395. package/ui/public/sprites/8-R-1.png +0 -0
  396. package/ui/public/sprites/9-D-1.png +0 -0
  397. package/ui/public/sprites/9-D-2.png +0 -0
  398. package/ui/public/sprites/9-D-3.png +0 -0
  399. package/ui/public/sprites/9-L-1.png +0 -0
  400. package/ui/public/sprites/9-R-1.png +0 -0
  401. package/ui/public/sprites/ceo-lobster.png +0 -0
  402. package/ui/public/sw.js +42 -0
  403. package/ui/public/worktree-favicon-16x16.png +0 -0
  404. package/ui/public/worktree-favicon-32x32.png +0 -0
  405. package/ui/public/worktree-favicon.ico +0 -0
  406. package/ui/public/worktree-favicon.svg +9 -0
  407. package/ui/src/App.tsx +354 -0
  408. package/ui/src/adapters/claude-local/config-fields.tsx +138 -0
  409. package/ui/src/adapters/claude-local/index.ts +13 -0
  410. package/ui/src/adapters/codex-local/config-fields.tsx +104 -0
  411. package/ui/src/adapters/codex-local/index.ts +13 -0
  412. package/ui/src/adapters/cursor/config-fields.tsx +49 -0
  413. package/ui/src/adapters/cursor/index.ts +13 -0
  414. package/ui/src/adapters/gemini-local/config-fields.tsx +51 -0
  415. package/ui/src/adapters/gemini-local/index.ts +13 -0
  416. package/ui/src/adapters/http/build-config.ts +9 -0
  417. package/ui/src/adapters/http/config-fields.tsx +38 -0
  418. package/ui/src/adapters/http/index.ts +12 -0
  419. package/ui/src/adapters/http/parse-stdout.ts +5 -0
  420. package/ui/src/adapters/index.ts +9 -0
  421. package/ui/src/adapters/local-workspace-runtime-fields.tsx +5 -0
  422. package/ui/src/adapters/openclaw-gateway/config-fields.tsx +237 -0
  423. package/ui/src/adapters/openclaw-gateway/index.ts +13 -0
  424. package/ui/src/adapters/opencode-local/config-fields.tsx +72 -0
  425. package/ui/src/adapters/opencode-local/index.ts +13 -0
  426. package/ui/src/adapters/pi-local/config-fields.tsx +49 -0
  427. package/ui/src/adapters/pi-local/index.ts +13 -0
  428. package/ui/src/adapters/process/build-config.ts +18 -0
  429. package/ui/src/adapters/process/config-fields.tsx +77 -0
  430. package/ui/src/adapters/process/index.ts +12 -0
  431. package/ui/src/adapters/process/parse-stdout.ts +5 -0
  432. package/ui/src/adapters/registry.ts +34 -0
  433. package/ui/src/adapters/runtime-json-fields.tsx +122 -0
  434. package/ui/src/adapters/transcript.test.ts +30 -0
  435. package/ui/src/adapters/transcript.ts +62 -0
  436. package/ui/src/adapters/types.ts +34 -0
  437. package/ui/src/api/access.ts +160 -0
  438. package/ui/src/api/activity.ts +37 -0
  439. package/ui/src/api/agents.ts +194 -0
  440. package/ui/src/api/approvals.ts +25 -0
  441. package/ui/src/api/assets.ts +30 -0
  442. package/ui/src/api/auth.ts +74 -0
  443. package/ui/src/api/budgets.ts +21 -0
  444. package/ui/src/api/client.ts +50 -0
  445. package/ui/src/api/companies.ts +59 -0
  446. package/ui/src/api/companySkills.ts +55 -0
  447. package/ui/src/api/costs.ts +60 -0
  448. package/ui/src/api/dashboard.ts +7 -0
  449. package/ui/src/api/execution-workspaces.ts +27 -0
  450. package/ui/src/api/goals.ts +12 -0
  451. package/ui/src/api/health.ts +41 -0
  452. package/ui/src/api/heartbeats.ts +62 -0
  453. package/ui/src/api/index.ts +18 -0
  454. package/ui/src/api/instanceSettings.ts +19 -0
  455. package/ui/src/api/issues.ts +115 -0
  456. package/ui/src/api/plugins.ts +424 -0
  457. package/ui/src/api/projects.ts +34 -0
  458. package/ui/src/api/routines.ts +59 -0
  459. package/ui/src/api/secrets.ts +26 -0
  460. package/ui/src/api/sidebarBadges.ts +7 -0
  461. package/ui/src/components/AccountingModelCard.tsx +69 -0
  462. package/ui/src/components/ActiveAgentsPanel.tsx +157 -0
  463. package/ui/src/components/ActivityCharts.tsx +264 -0
  464. package/ui/src/components/ActivityRow.tsx +147 -0
  465. package/ui/src/components/AgentActionButtons.tsx +51 -0
  466. package/ui/src/components/AgentConfigForm.tsx +1468 -0
  467. package/ui/src/components/AgentIconPicker.tsx +81 -0
  468. package/ui/src/components/AgentProperties.tsx +107 -0
  469. package/ui/src/components/ApprovalCard.tsx +107 -0
  470. package/ui/src/components/ApprovalPayload.tsx +134 -0
  471. package/ui/src/components/AsciiArtAnimation.tsx +355 -0
  472. package/ui/src/components/BillerSpendCard.tsx +146 -0
  473. package/ui/src/components/BreadcrumbBar.tsx +113 -0
  474. package/ui/src/components/BudgetIncidentCard.tsx +101 -0
  475. package/ui/src/components/BudgetPolicyCard.tsx +220 -0
  476. package/ui/src/components/BudgetSidebarMarker.tsx +13 -0
  477. package/ui/src/components/ClaudeSubscriptionPanel.tsx +141 -0
  478. package/ui/src/components/CodexSubscriptionPanel.tsx +158 -0
  479. package/ui/src/components/CommandPalette.tsx +239 -0
  480. package/ui/src/components/CommentThread.tsx +503 -0
  481. package/ui/src/components/CompanyPatternIcon.tsx +212 -0
  482. package/ui/src/components/CompanyRail.tsx +329 -0
  483. package/ui/src/components/CompanySwitcher.tsx +81 -0
  484. package/ui/src/components/CopyText.tsx +56 -0
  485. package/ui/src/components/DevRestartBanner.tsx +89 -0
  486. package/ui/src/components/EmptyState.tsx +27 -0
  487. package/ui/src/components/EntityRow.tsx +69 -0
  488. package/ui/src/components/FilterBar.tsx +39 -0
  489. package/ui/src/components/FinanceBillerCard.tsx +45 -0
  490. package/ui/src/components/FinanceKindCard.tsx +44 -0
  491. package/ui/src/components/FinanceTimelineCard.tsx +72 -0
  492. package/ui/src/components/GoalProperties.tsx +165 -0
  493. package/ui/src/components/GoalTree.tsx +118 -0
  494. package/ui/src/components/Identity.tsx +39 -0
  495. package/ui/src/components/InlineEditor.tsx +248 -0
  496. package/ui/src/components/InlineEntitySelector.tsx +206 -0
  497. package/ui/src/components/InstanceSidebar.tsx +53 -0
  498. package/ui/src/components/IssueDocumentsSection.tsx +892 -0
  499. package/ui/src/components/IssueProperties.tsx +621 -0
  500. package/ui/src/components/IssueRow.tsx +149 -0
  501. package/ui/src/components/IssueWorkspaceCard.tsx +404 -0
  502. package/ui/src/components/IssuesList.tsx +889 -0
  503. package/ui/src/components/JsonSchemaForm.tsx +1048 -0
  504. package/ui/src/components/KanbanBoard.tsx +275 -0
  505. package/ui/src/components/Layout.tsx +441 -0
  506. package/ui/src/components/LiveRunWidget.tsx +160 -0
  507. package/ui/src/components/MarkdownBody.test.tsx +50 -0
  508. package/ui/src/components/MarkdownBody.tsx +152 -0
  509. package/ui/src/components/MarkdownEditor.tsx +622 -0
  510. package/ui/src/components/MetricCard.tsx +53 -0
  511. package/ui/src/components/MobileBottomNav.tsx +123 -0
  512. package/ui/src/components/NewAgentDialog.tsx +223 -0
  513. package/ui/src/components/NewGoalDialog.tsx +283 -0
  514. package/ui/src/components/NewIssueDialog.tsx +1473 -0
  515. package/ui/src/components/NewProjectDialog.tsx +451 -0
  516. package/ui/src/components/OnboardingWizard.tsx +1392 -0
  517. package/ui/src/components/OpenCodeLogoIcon.tsx +22 -0
  518. package/ui/src/components/PackageFileTree.tsx +318 -0
  519. package/ui/src/components/PageSkeleton.tsx +180 -0
  520. package/ui/src/components/PageTabBar.tsx +45 -0
  521. package/ui/src/components/PathInstructionsModal.tsx +143 -0
  522. package/ui/src/components/PriorityIcon.tsx +77 -0
  523. package/ui/src/components/ProjectProperties.tsx +1127 -0
  524. package/ui/src/components/PropertiesPanel.tsx +29 -0
  525. package/ui/src/components/ProviderQuotaCard.tsx +417 -0
  526. package/ui/src/components/QuotaBar.tsx +65 -0
  527. package/ui/src/components/ReportsToPicker.tsx +127 -0
  528. package/ui/src/components/ScheduleEditor.tsx +344 -0
  529. package/ui/src/components/ScrollToBottom.tsx +79 -0
  530. package/ui/src/components/Sidebar.tsx +130 -0
  531. package/ui/src/components/SidebarAgents.tsx +146 -0
  532. package/ui/src/components/SidebarNavItem.tsx +92 -0
  533. package/ui/src/components/SidebarProjects.tsx +234 -0
  534. package/ui/src/components/SidebarSection.tsx +17 -0
  535. package/ui/src/components/StatusBadge.tsx +15 -0
  536. package/ui/src/components/StatusIcon.tsx +71 -0
  537. package/ui/src/components/SwipeToArchive.tsx +152 -0
  538. package/ui/src/components/ToastViewport.tsx +99 -0
  539. package/ui/src/components/WorktreeBanner.tsx +25 -0
  540. package/ui/src/components/agent-config-defaults.ts +31 -0
  541. package/ui/src/components/agent-config-primitives.tsx +476 -0
  542. package/ui/src/components/transcript/RunTranscriptView.test.tsx +84 -0
  543. package/ui/src/components/transcript/RunTranscriptView.tsx +1015 -0
  544. package/ui/src/components/transcript/useLiveRunTranscripts.ts +297 -0
  545. package/ui/src/components/ui/avatar.tsx +107 -0
  546. package/ui/src/components/ui/badge.tsx +48 -0
  547. package/ui/src/components/ui/breadcrumb.tsx +109 -0
  548. package/ui/src/components/ui/button.tsx +64 -0
  549. package/ui/src/components/ui/card.tsx +92 -0
  550. package/ui/src/components/ui/checkbox.tsx +32 -0
  551. package/ui/src/components/ui/collapsible.tsx +33 -0
  552. package/ui/src/components/ui/command.tsx +194 -0
  553. package/ui/src/components/ui/dialog.tsx +156 -0
  554. package/ui/src/components/ui/dropdown-menu.tsx +257 -0
  555. package/ui/src/components/ui/input.tsx +21 -0
  556. package/ui/src/components/ui/label.tsx +22 -0
  557. package/ui/src/components/ui/popover.tsx +88 -0
  558. package/ui/src/components/ui/scroll-area.tsx +56 -0
  559. package/ui/src/components/ui/select.tsx +188 -0
  560. package/ui/src/components/ui/separator.tsx +28 -0
  561. package/ui/src/components/ui/sheet.tsx +143 -0
  562. package/ui/src/components/ui/skeleton.tsx +13 -0
  563. package/ui/src/components/ui/tabs.tsx +89 -0
  564. package/ui/src/components/ui/textarea.tsx +18 -0
  565. package/ui/src/components/ui/tooltip.tsx +57 -0
  566. package/ui/src/components/visual-office/AgentAvatar.tsx +99 -0
  567. package/ui/src/components/visual-office/OfficeViewExact.tsx +417 -0
  568. package/ui/src/components/visual-office/i18n.ts +52 -0
  569. package/ui/src/components/visual-office/office-view/CliUsagePanel.tsx +240 -0
  570. package/ui/src/components/visual-office/office-view/VirtualPadOverlay.tsx +104 -0
  571. package/ui/src/components/visual-office/office-view/buildScene-break-room.ts +248 -0
  572. package/ui/src/components/visual-office/office-view/buildScene-ceo-hallway.ts +345 -0
  573. package/ui/src/components/visual-office/office-view/buildScene-department-agent.ts +242 -0
  574. package/ui/src/components/visual-office/office-view/buildScene-departments.ts +360 -0
  575. package/ui/src/components/visual-office/office-view/buildScene-final-layers.ts +113 -0
  576. package/ui/src/components/visual-office/office-view/buildScene-types.ts +91 -0
  577. package/ui/src/components/visual-office/office-view/buildScene.ts +232 -0
  578. package/ui/src/components/visual-office/office-view/drawing-core.ts +374 -0
  579. package/ui/src/components/visual-office/office-view/drawing-furniture-a.ts +338 -0
  580. package/ui/src/components/visual-office/office-view/drawing-furniture-b.ts +241 -0
  581. package/ui/src/components/visual-office/office-view/model.ts +301 -0
  582. package/ui/src/components/visual-office/office-view/officeTicker.ts +455 -0
  583. package/ui/src/components/visual-office/office-view/officeTickerRoomAndDelivery.ts +133 -0
  584. package/ui/src/components/visual-office/office-view/themes-locale.ts +460 -0
  585. package/ui/src/components/visual-office/office-view/useCliUsage.ts +37 -0
  586. package/ui/src/components/visual-office/office-view/useOfficeDeliveryEffects.ts +465 -0
  587. package/ui/src/components/visual-office/office-view/useOfficePixiRuntime.ts +282 -0
  588. package/ui/src/components/visual-office/types.ts +123 -0
  589. package/ui/src/context/BreadcrumbContext.tsx +44 -0
  590. package/ui/src/context/CompanyContext.tsx +151 -0
  591. package/ui/src/context/DialogContext.tsx +135 -0
  592. package/ui/src/context/LiveUpdatesProvider.test.ts +119 -0
  593. package/ui/src/context/LiveUpdatesProvider.tsx +760 -0
  594. package/ui/src/context/PanelContext.tsx +73 -0
  595. package/ui/src/context/SidebarContext.tsx +43 -0
  596. package/ui/src/context/ThemeContext.tsx +83 -0
  597. package/ui/src/context/ToastContext.tsx +172 -0
  598. package/ui/src/fixtures/runTranscriptFixtures.ts +226 -0
  599. package/ui/src/hooks/useAgentOrder.ts +105 -0
  600. package/ui/src/hooks/useAutosaveIndicator.ts +72 -0
  601. package/ui/src/hooks/useCompanyPageMemory.test.ts +90 -0
  602. package/ui/src/hooks/useCompanyPageMemory.ts +79 -0
  603. package/ui/src/hooks/useDateRange.ts +120 -0
  604. package/ui/src/hooks/useInboxBadge.ts +132 -0
  605. package/ui/src/hooks/useKeyboardShortcuts.ts +40 -0
  606. package/ui/src/hooks/useProjectOrder.ts +106 -0
  607. package/ui/src/index.css +770 -0
  608. package/ui/src/lib/agent-icons.ts +99 -0
  609. package/ui/src/lib/agent-order.ts +107 -0
  610. package/ui/src/lib/agent-skills-state.test.ts +90 -0
  611. package/ui/src/lib/agent-skills-state.ts +41 -0
  612. package/ui/src/lib/assignees.test.ts +92 -0
  613. package/ui/src/lib/assignees.ts +82 -0
  614. package/ui/src/lib/color-contrast.ts +107 -0
  615. package/ui/src/lib/company-export-selection.test.ts +41 -0
  616. package/ui/src/lib/company-export-selection.ts +57 -0
  617. package/ui/src/lib/company-page-memory.ts +65 -0
  618. package/ui/src/lib/company-portability-sidebar.test.ts +101 -0
  619. package/ui/src/lib/company-portability-sidebar.ts +62 -0
  620. package/ui/src/lib/company-routes.ts +88 -0
  621. package/ui/src/lib/company-selection.test.ts +34 -0
  622. package/ui/src/lib/company-selection.ts +18 -0
  623. package/ui/src/lib/groupBy.ts +11 -0
  624. package/ui/src/lib/inbox.test.ts +404 -0
  625. package/ui/src/lib/inbox.ts +292 -0
  626. package/ui/src/lib/instance-settings.test.ts +26 -0
  627. package/ui/src/lib/instance-settings.ts +25 -0
  628. package/ui/src/lib/issueDetailBreadcrumb.ts +24 -0
  629. package/ui/src/lib/legacy-agent-config.test.ts +40 -0
  630. package/ui/src/lib/legacy-agent-config.ts +17 -0
  631. package/ui/src/lib/mention-aware-link-node.test.ts +50 -0
  632. package/ui/src/lib/mention-aware-link-node.ts +67 -0
  633. package/ui/src/lib/mention-chips.ts +168 -0
  634. package/ui/src/lib/mention-deletion.test.ts +87 -0
  635. package/ui/src/lib/mention-deletion.ts +143 -0
  636. package/ui/src/lib/model-utils.ts +16 -0
  637. package/ui/src/lib/onboarding-goal.test.ts +22 -0
  638. package/ui/src/lib/onboarding-goal.ts +18 -0
  639. package/ui/src/lib/onboarding-launch.test.ts +131 -0
  640. package/ui/src/lib/onboarding-launch.ts +54 -0
  641. package/ui/src/lib/onboarding-route.test.ts +80 -0
  642. package/ui/src/lib/onboarding-route.ts +51 -0
  643. package/ui/src/lib/portable-files.ts +42 -0
  644. package/ui/src/lib/project-order.ts +71 -0
  645. package/ui/src/lib/queryKeys.ts +140 -0
  646. package/ui/src/lib/recent-assignees.ts +36 -0
  647. package/ui/src/lib/router.tsx +76 -0
  648. package/ui/src/lib/routine-trigger-patch.test.ts +72 -0
  649. package/ui/src/lib/routine-trigger-patch.ts +31 -0
  650. package/ui/src/lib/status-colors.ts +108 -0
  651. package/ui/src/lib/timeAgo.ts +31 -0
  652. package/ui/src/lib/utils.ts +168 -0
  653. package/ui/src/lib/worktree-branding.ts +65 -0
  654. package/ui/src/lib/zip.test.ts +289 -0
  655. package/ui/src/lib/zip.ts +284 -0
  656. package/ui/src/main.tsx +67 -0
  657. package/ui/src/pages/Activity.tsx +141 -0
  658. package/ui/src/pages/AgentDetail.tsx +4053 -0
  659. package/ui/src/pages/Agents.tsx +415 -0
  660. package/ui/src/pages/ApprovalDetail.tsx +369 -0
  661. package/ui/src/pages/Approvals.tsx +132 -0
  662. package/ui/src/pages/Auth.tsx +180 -0
  663. package/ui/src/pages/BoardClaim.tsx +125 -0
  664. package/ui/src/pages/CliAuth.tsx +184 -0
  665. package/ui/src/pages/Companies.tsx +297 -0
  666. package/ui/src/pages/CompanyExport.tsx +1019 -0
  667. package/ui/src/pages/CompanyImport.tsx +1355 -0
  668. package/ui/src/pages/CompanySettings.tsx +661 -0
  669. package/ui/src/pages/CompanySkills.tsx +1171 -0
  670. package/ui/src/pages/Costs.tsx +1103 -0
  671. package/ui/src/pages/Dashboard.tsx +388 -0
  672. package/ui/src/pages/DesignGuide.tsx +1330 -0
  673. package/ui/src/pages/ExecutionWorkspaceDetail.tsx +82 -0
  674. package/ui/src/pages/GoalDetail.tsx +197 -0
  675. package/ui/src/pages/Goals.tsx +63 -0
  676. package/ui/src/pages/Inbox.tsx +1291 -0
  677. package/ui/src/pages/InstanceExperimentalSettings.tsx +139 -0
  678. package/ui/src/pages/InstanceGeneralSettings.tsx +104 -0
  679. package/ui/src/pages/InstanceSettings.tsx +284 -0
  680. package/ui/src/pages/InviteLanding.tsx +320 -0
  681. package/ui/src/pages/IssueDetail.tsx +1201 -0
  682. package/ui/src/pages/Issues.tsx +116 -0
  683. package/ui/src/pages/MyIssues.tsx +72 -0
  684. package/ui/src/pages/NewAgent.tsx +353 -0
  685. package/ui/src/pages/NotFound.tsx +66 -0
  686. package/ui/src/pages/Org.tsx +132 -0
  687. package/ui/src/pages/OrgChart.tsx +447 -0
  688. package/ui/src/pages/PluginManager.tsx +510 -0
  689. package/ui/src/pages/PluginPage.tsx +156 -0
  690. package/ui/src/pages/PluginSettings.tsx +836 -0
  691. package/ui/src/pages/ProjectDetail.tsx +633 -0
  692. package/ui/src/pages/Projects.tsx +87 -0
  693. package/ui/src/pages/RoutineDetail.tsx +1022 -0
  694. package/ui/src/pages/Routines.tsx +661 -0
  695. package/ui/src/pages/RunTranscriptUxLab.tsx +334 -0
  696. package/ui/src/pages/VisualOffice.tsx +243 -0
  697. package/ui/src/plugins/bridge-init.ts +69 -0
  698. package/ui/src/plugins/bridge.ts +476 -0
  699. package/ui/src/plugins/launchers.tsx +834 -0
  700. package/ui/src/plugins/slots.tsx +855 -0
  701. package/ui/tsconfig.json +21 -0
  702. package/ui/vite.config.ts +23 -0
  703. package/ui/vitest.config.ts +14 -0
  704. package/vitest.config.ts +11 -0
@@ -0,0 +1,1680 @@
1
+ import { Router, type Request, type Response } from "express";
2
+ import multer from "multer";
3
+ import type { Db } from "@corporateai/db";
4
+ import {
5
+ addIssueCommentSchema,
6
+ createIssueAttachmentMetadataSchema,
7
+ createIssueWorkProductSchema,
8
+ createIssueLabelSchema,
9
+ checkoutIssueSchema,
10
+ createIssueSchema,
11
+ linkIssueApprovalSchema,
12
+ issueDocumentKeySchema,
13
+ updateIssueWorkProductSchema,
14
+ upsertIssueDocumentSchema,
15
+ updateIssueSchema,
16
+ } from "@corporateai/shared";
17
+ import type { StorageService } from "../storage/types.js";
18
+ import { validate } from "../middleware/validate.js";
19
+ import {
20
+ accessService,
21
+ agentService,
22
+ executionWorkspaceService,
23
+ goalService,
24
+ heartbeatService,
25
+ issueApprovalService,
26
+ issueService,
27
+ documentService,
28
+ logActivity,
29
+ projectService,
30
+ routineService,
31
+ workProductService,
32
+ } from "../services/index.js";
33
+ import { logger } from "../middleware/logger.js";
34
+ import { forbidden, HttpError, unauthorized } from "../errors.js";
35
+ import { assertCompanyAccess, getActorInfo } from "./authz.js";
36
+ import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
37
+ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
38
+ import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
39
+
40
+ const MAX_ISSUE_COMMENT_LIMIT = 500;
41
+
42
+ export function issueRoutes(db: Db, storage: StorageService) {
43
+ const router = Router();
44
+ const svc = issueService(db);
45
+ const access = accessService(db);
46
+ const heartbeat = heartbeatService(db);
47
+ const agentsSvc = agentService(db);
48
+ const projectsSvc = projectService(db);
49
+ const goalsSvc = goalService(db);
50
+ const issueApprovalsSvc = issueApprovalService(db);
51
+ const executionWorkspacesSvc = executionWorkspaceService(db);
52
+ const workProductsSvc = workProductService(db);
53
+ const documentsSvc = documentService(db);
54
+ const routinesSvc = routineService(db);
55
+ const upload = multer({
56
+ storage: multer.memoryStorage(),
57
+ limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
58
+ });
59
+
60
+ function withContentPath<T extends { id: string }>(attachment: T) {
61
+ return {
62
+ ...attachment,
63
+ contentPath: `/api/attachments/${attachment.id}/content`,
64
+ };
65
+ }
66
+
67
+ async function runSingleFileUpload(req: Request, res: Response) {
68
+ await new Promise<void>((resolve, reject) => {
69
+ upload.single("file")(req, res, (err: unknown) => {
70
+ if (err) reject(err);
71
+ else resolve();
72
+ });
73
+ });
74
+ }
75
+
76
+ async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
77
+ assertCompanyAccess(req, companyId);
78
+ if (req.actor.type === "board") return true;
79
+ if (!req.actor.agentId) {
80
+ res.status(403).json({ error: "Agent authentication required" });
81
+ return false;
82
+ }
83
+ const actorAgent = await agentsSvc.getById(req.actor.agentId);
84
+ if (!actorAgent || actorAgent.companyId !== companyId) {
85
+ res.status(403).json({ error: "Forbidden" });
86
+ return false;
87
+ }
88
+ if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true;
89
+ res.status(403).json({ error: "Missing permission to link approvals" });
90
+ return false;
91
+ }
92
+
93
+ function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
94
+ if (agent.role === "ceo") return true;
95
+ if (!agent.permissions || typeof agent.permissions !== "object") return false;
96
+ return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
97
+ }
98
+
99
+ async function assertCanAssignTasks(req: Request, companyId: string) {
100
+ assertCompanyAccess(req, companyId);
101
+ if (req.actor.type === "board") {
102
+ if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
103
+ const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
104
+ if (!allowed) throw forbidden("Missing permission: tasks:assign");
105
+ return;
106
+ }
107
+ if (req.actor.type === "agent") {
108
+ if (!req.actor.agentId) throw forbidden("Agent authentication required");
109
+ const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
110
+ if (allowedByGrant) return;
111
+ const actorAgent = await agentsSvc.getById(req.actor.agentId);
112
+ if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
113
+ throw forbidden("Missing permission: tasks:assign");
114
+ }
115
+ throw unauthorized();
116
+ }
117
+
118
+ function requireAgentRunId(req: Request, res: Response) {
119
+ if (req.actor.type !== "agent") return null;
120
+ const runId = req.actor.runId?.trim();
121
+ if (runId) return runId;
122
+ res.status(401).json({ error: "Agent run id required" });
123
+ return null;
124
+ }
125
+
126
+ async function assertAgentRunCheckoutOwnership(
127
+ req: Request,
128
+ res: Response,
129
+ issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
130
+ ) {
131
+ if (req.actor.type !== "agent") return true;
132
+ const actorAgentId = req.actor.agentId;
133
+ if (!actorAgentId) {
134
+ res.status(403).json({ error: "Agent authentication required" });
135
+ return false;
136
+ }
137
+ if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) {
138
+ return true;
139
+ }
140
+ const runId = requireAgentRunId(req, res);
141
+ if (!runId) return false;
142
+ const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
143
+ if (ownership.adoptedFromRunId) {
144
+ const actor = getActorInfo(req);
145
+ await logActivity(db, {
146
+ companyId: issue.companyId,
147
+ actorType: actor.actorType,
148
+ actorId: actor.actorId,
149
+ agentId: actor.agentId,
150
+ runId: actor.runId,
151
+ action: "issue.checkout_lock_adopted",
152
+ entityType: "issue",
153
+ entityId: issue.id,
154
+ details: {
155
+ previousCheckoutRunId: ownership.adoptedFromRunId,
156
+ checkoutRunId: runId,
157
+ reason: "stale_checkout_run",
158
+ },
159
+ });
160
+ }
161
+ return true;
162
+ }
163
+
164
+ async function normalizeIssueIdentifier(rawId: string): Promise<string> {
165
+ if (/^[A-Z]+-\d+$/i.test(rawId)) {
166
+ const issue = await svc.getByIdentifier(rawId);
167
+ if (issue) {
168
+ return issue.id;
169
+ }
170
+ }
171
+ return rawId;
172
+ }
173
+
174
+ async function resolveIssueProjectAndGoal(issue: {
175
+ companyId: string;
176
+ projectId: string | null;
177
+ goalId: string | null;
178
+ }) {
179
+ const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null);
180
+ const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null);
181
+ const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]);
182
+
183
+ if (directGoal) {
184
+ return { project, goal: directGoal };
185
+ }
186
+
187
+ const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null;
188
+ if (projectGoalId) {
189
+ const projectGoal = await goalsSvc.getById(projectGoalId);
190
+ return { project, goal: projectGoal };
191
+ }
192
+
193
+ if (!issue.projectId) {
194
+ const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId);
195
+ return { project, goal: defaultGoal };
196
+ }
197
+
198
+ return { project, goal: null };
199
+ }
200
+
201
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
202
+ router.param("id", async (req, res, next, rawId) => {
203
+ try {
204
+ req.params.id = await normalizeIssueIdentifier(rawId);
205
+ next();
206
+ } catch (err) {
207
+ next(err);
208
+ }
209
+ });
210
+
211
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
212
+ router.param("issueId", async (req, res, next, rawId) => {
213
+ try {
214
+ req.params.issueId = await normalizeIssueIdentifier(rawId);
215
+ next();
216
+ } catch (err) {
217
+ next(err);
218
+ }
219
+ });
220
+
221
+ // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
222
+ router.get("/issues", (_req, res) => {
223
+ res.status(400).json({
224
+ error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
225
+ });
226
+ });
227
+
228
+ router.get("/companies/:companyId/issues", async (req, res) => {
229
+ const companyId = req.params.companyId as string;
230
+ assertCompanyAccess(req, companyId);
231
+ const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
232
+ const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
233
+ const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined;
234
+ const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
235
+ const assigneeUserId =
236
+ assigneeUserFilterRaw === "me" && req.actor.type === "board"
237
+ ? req.actor.userId
238
+ : assigneeUserFilterRaw;
239
+ const touchedByUserId =
240
+ touchedByUserFilterRaw === "me" && req.actor.type === "board"
241
+ ? req.actor.userId
242
+ : touchedByUserFilterRaw;
243
+ const inboxArchivedByUserId =
244
+ inboxArchivedByUserFilterRaw === "me" && req.actor.type === "board"
245
+ ? req.actor.userId
246
+ : inboxArchivedByUserFilterRaw;
247
+ const unreadForUserId =
248
+ unreadForUserFilterRaw === "me" && req.actor.type === "board"
249
+ ? req.actor.userId
250
+ : unreadForUserFilterRaw;
251
+
252
+ if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
253
+ res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
254
+ return;
255
+ }
256
+ if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) {
257
+ res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
258
+ return;
259
+ }
260
+ if (inboxArchivedByUserFilterRaw === "me" && (!inboxArchivedByUserId || req.actor.type !== "board")) {
261
+ res.status(403).json({ error: "inboxArchivedByUserId=me requires board authentication" });
262
+ return;
263
+ }
264
+ if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
265
+ res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
266
+ return;
267
+ }
268
+
269
+ const result = await svc.list(companyId, {
270
+ status: req.query.status as string | undefined,
271
+ assigneeAgentId: req.query.assigneeAgentId as string | undefined,
272
+ participantAgentId: req.query.participantAgentId as string | undefined,
273
+ assigneeUserId,
274
+ touchedByUserId,
275
+ inboxArchivedByUserId,
276
+ unreadForUserId,
277
+ projectId: req.query.projectId as string | undefined,
278
+ parentId: req.query.parentId as string | undefined,
279
+ labelId: req.query.labelId as string | undefined,
280
+ originKind: req.query.originKind as string | undefined,
281
+ originId: req.query.originId as string | undefined,
282
+ includeRoutineExecutions:
283
+ req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
284
+ q: req.query.q as string | undefined,
285
+ });
286
+ res.json(result);
287
+ });
288
+
289
+ router.get("/companies/:companyId/labels", async (req, res) => {
290
+ const companyId = req.params.companyId as string;
291
+ assertCompanyAccess(req, companyId);
292
+ const result = await svc.listLabels(companyId);
293
+ res.json(result);
294
+ });
295
+
296
+ router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => {
297
+ const companyId = req.params.companyId as string;
298
+ assertCompanyAccess(req, companyId);
299
+ const label = await svc.createLabel(companyId, req.body);
300
+ const actor = getActorInfo(req);
301
+ await logActivity(db, {
302
+ companyId,
303
+ actorType: actor.actorType,
304
+ actorId: actor.actorId,
305
+ agentId: actor.agentId,
306
+ runId: actor.runId,
307
+ action: "label.created",
308
+ entityType: "label",
309
+ entityId: label.id,
310
+ details: { name: label.name, color: label.color },
311
+ });
312
+ res.status(201).json(label);
313
+ });
314
+
315
+ router.delete("/labels/:labelId", async (req, res) => {
316
+ const labelId = req.params.labelId as string;
317
+ const existing = await svc.getLabelById(labelId);
318
+ if (!existing) {
319
+ res.status(404).json({ error: "Label not found" });
320
+ return;
321
+ }
322
+ assertCompanyAccess(req, existing.companyId);
323
+ const removed = await svc.deleteLabel(labelId);
324
+ if (!removed) {
325
+ res.status(404).json({ error: "Label not found" });
326
+ return;
327
+ }
328
+ const actor = getActorInfo(req);
329
+ await logActivity(db, {
330
+ companyId: removed.companyId,
331
+ actorType: actor.actorType,
332
+ actorId: actor.actorId,
333
+ agentId: actor.agentId,
334
+ runId: actor.runId,
335
+ action: "label.deleted",
336
+ entityType: "label",
337
+ entityId: removed.id,
338
+ details: { name: removed.name, color: removed.color },
339
+ });
340
+ res.json(removed);
341
+ });
342
+
343
+ router.get("/issues/:id", async (req, res) => {
344
+ const id = req.params.id as string;
345
+ const issue = await svc.getById(id);
346
+ if (!issue) {
347
+ res.status(404).json({ error: "Issue not found" });
348
+ return;
349
+ }
350
+ assertCompanyAccess(req, issue.companyId);
351
+ const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
352
+ resolveIssueProjectAndGoal(issue),
353
+ svc.getAncestors(issue.id),
354
+ svc.findMentionedProjectIds(issue.id),
355
+ documentsSvc.getIssueDocumentPayload(issue),
356
+ ]);
357
+ const mentionedProjects = mentionedProjectIds.length > 0
358
+ ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
359
+ : [];
360
+ const currentExecutionWorkspace = issue.executionWorkspaceId
361
+ ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
362
+ : null;
363
+ const workProducts = await workProductsSvc.listForIssue(issue.id);
364
+ res.json({
365
+ ...issue,
366
+ goalId: goal?.id ?? issue.goalId,
367
+ ancestors,
368
+ ...documentPayload,
369
+ project: project ?? null,
370
+ goal: goal ?? null,
371
+ mentionedProjects,
372
+ currentExecutionWorkspace,
373
+ workProducts,
374
+ });
375
+ });
376
+
377
+ router.get("/issues/:id/heartbeat-context", async (req, res) => {
378
+ const id = req.params.id as string;
379
+ const issue = await svc.getById(id);
380
+ if (!issue) {
381
+ res.status(404).json({ error: "Issue not found" });
382
+ return;
383
+ }
384
+ assertCompanyAccess(req, issue.companyId);
385
+
386
+ const wakeCommentId =
387
+ typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
388
+ ? req.query.wakeCommentId.trim()
389
+ : null;
390
+
391
+ const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([
392
+ resolveIssueProjectAndGoal(issue),
393
+ svc.getAncestors(issue.id),
394
+ svc.getCommentCursor(issue.id),
395
+ wakeCommentId ? svc.getComment(wakeCommentId) : null,
396
+ ]);
397
+
398
+ res.json({
399
+ issue: {
400
+ id: issue.id,
401
+ identifier: issue.identifier,
402
+ title: issue.title,
403
+ description: issue.description,
404
+ status: issue.status,
405
+ priority: issue.priority,
406
+ projectId: issue.projectId,
407
+ goalId: goal?.id ?? issue.goalId,
408
+ parentId: issue.parentId,
409
+ assigneeAgentId: issue.assigneeAgentId,
410
+ assigneeUserId: issue.assigneeUserId,
411
+ updatedAt: issue.updatedAt,
412
+ },
413
+ ancestors: ancestors.map((ancestor) => ({
414
+ id: ancestor.id,
415
+ identifier: ancestor.identifier,
416
+ title: ancestor.title,
417
+ status: ancestor.status,
418
+ priority: ancestor.priority,
419
+ })),
420
+ project: project
421
+ ? {
422
+ id: project.id,
423
+ name: project.name,
424
+ status: project.status,
425
+ targetDate: project.targetDate,
426
+ }
427
+ : null,
428
+ goal: goal
429
+ ? {
430
+ id: goal.id,
431
+ title: goal.title,
432
+ status: goal.status,
433
+ level: goal.level,
434
+ parentId: goal.parentId,
435
+ }
436
+ : null,
437
+ commentCursor,
438
+ wakeComment:
439
+ wakeComment && wakeComment.issueId === issue.id
440
+ ? wakeComment
441
+ : null,
442
+ });
443
+ });
444
+
445
+ router.get("/issues/:id/work-products", async (req, res) => {
446
+ const id = req.params.id as string;
447
+ const issue = await svc.getById(id);
448
+ if (!issue) {
449
+ res.status(404).json({ error: "Issue not found" });
450
+ return;
451
+ }
452
+ assertCompanyAccess(req, issue.companyId);
453
+ const workProducts = await workProductsSvc.listForIssue(issue.id);
454
+ res.json(workProducts);
455
+ });
456
+
457
+ router.get("/issues/:id/documents", async (req, res) => {
458
+ const id = req.params.id as string;
459
+ const issue = await svc.getById(id);
460
+ if (!issue) {
461
+ res.status(404).json({ error: "Issue not found" });
462
+ return;
463
+ }
464
+ assertCompanyAccess(req, issue.companyId);
465
+ const docs = await documentsSvc.listIssueDocuments(issue.id);
466
+ res.json(docs);
467
+ });
468
+
469
+ router.get("/issues/:id/documents/:key", async (req, res) => {
470
+ const id = req.params.id as string;
471
+ const issue = await svc.getById(id);
472
+ if (!issue) {
473
+ res.status(404).json({ error: "Issue not found" });
474
+ return;
475
+ }
476
+ assertCompanyAccess(req, issue.companyId);
477
+ const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
478
+ if (!keyParsed.success) {
479
+ res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
480
+ return;
481
+ }
482
+ const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
483
+ if (!doc) {
484
+ res.status(404).json({ error: "Document not found" });
485
+ return;
486
+ }
487
+ res.json(doc);
488
+ });
489
+
490
+ router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
491
+ const id = req.params.id as string;
492
+ const issue = await svc.getById(id);
493
+ if (!issue) {
494
+ res.status(404).json({ error: "Issue not found" });
495
+ return;
496
+ }
497
+ assertCompanyAccess(req, issue.companyId);
498
+ const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
499
+ if (!keyParsed.success) {
500
+ res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
501
+ return;
502
+ }
503
+
504
+ const actor = getActorInfo(req);
505
+ const result = await documentsSvc.upsertIssueDocument({
506
+ issueId: issue.id,
507
+ key: keyParsed.data,
508
+ title: req.body.title ?? null,
509
+ format: req.body.format,
510
+ body: req.body.body,
511
+ changeSummary: req.body.changeSummary ?? null,
512
+ baseRevisionId: req.body.baseRevisionId ?? null,
513
+ createdByAgentId: actor.agentId ?? null,
514
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
515
+ });
516
+ const doc = result.document;
517
+
518
+ await logActivity(db, {
519
+ companyId: issue.companyId,
520
+ actorType: actor.actorType,
521
+ actorId: actor.actorId,
522
+ agentId: actor.agentId,
523
+ runId: actor.runId,
524
+ action: result.created ? "issue.document_created" : "issue.document_updated",
525
+ entityType: "issue",
526
+ entityId: issue.id,
527
+ details: {
528
+ key: doc.key,
529
+ documentId: doc.id,
530
+ title: doc.title,
531
+ format: doc.format,
532
+ revisionNumber: doc.latestRevisionNumber,
533
+ },
534
+ });
535
+
536
+ res.status(result.created ? 201 : 200).json(doc);
537
+ });
538
+
539
+ router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
540
+ const id = req.params.id as string;
541
+ const issue = await svc.getById(id);
542
+ if (!issue) {
543
+ res.status(404).json({ error: "Issue not found" });
544
+ return;
545
+ }
546
+ assertCompanyAccess(req, issue.companyId);
547
+ const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
548
+ if (!keyParsed.success) {
549
+ res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
550
+ return;
551
+ }
552
+ const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
553
+ res.json(revisions);
554
+ });
555
+
556
+ router.delete("/issues/:id/documents/:key", async (req, res) => {
557
+ const id = req.params.id as string;
558
+ const issue = await svc.getById(id);
559
+ if (!issue) {
560
+ res.status(404).json({ error: "Issue not found" });
561
+ return;
562
+ }
563
+ assertCompanyAccess(req, issue.companyId);
564
+ if (req.actor.type !== "board") {
565
+ res.status(403).json({ error: "Board authentication required" });
566
+ return;
567
+ }
568
+ const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
569
+ if (!keyParsed.success) {
570
+ res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
571
+ return;
572
+ }
573
+ const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
574
+ if (!removed) {
575
+ res.status(404).json({ error: "Document not found" });
576
+ return;
577
+ }
578
+ const actor = getActorInfo(req);
579
+ await logActivity(db, {
580
+ companyId: issue.companyId,
581
+ actorType: actor.actorType,
582
+ actorId: actor.actorId,
583
+ agentId: actor.agentId,
584
+ runId: actor.runId,
585
+ action: "issue.document_deleted",
586
+ entityType: "issue",
587
+ entityId: issue.id,
588
+ details: {
589
+ key: removed.key,
590
+ documentId: removed.id,
591
+ title: removed.title,
592
+ },
593
+ });
594
+ res.json({ ok: true });
595
+ });
596
+
597
+ router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => {
598
+ const id = req.params.id as string;
599
+ const issue = await svc.getById(id);
600
+ if (!issue) {
601
+ res.status(404).json({ error: "Issue not found" });
602
+ return;
603
+ }
604
+ assertCompanyAccess(req, issue.companyId);
605
+ const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
606
+ ...req.body,
607
+ projectId: req.body.projectId ?? issue.projectId ?? null,
608
+ });
609
+ if (!product) {
610
+ res.status(422).json({ error: "Invalid work product payload" });
611
+ return;
612
+ }
613
+ const actor = getActorInfo(req);
614
+ await logActivity(db, {
615
+ companyId: issue.companyId,
616
+ actorType: actor.actorType,
617
+ actorId: actor.actorId,
618
+ agentId: actor.agentId,
619
+ runId: actor.runId,
620
+ action: "issue.work_product_created",
621
+ entityType: "issue",
622
+ entityId: issue.id,
623
+ details: { workProductId: product.id, type: product.type, provider: product.provider },
624
+ });
625
+ res.status(201).json(product);
626
+ });
627
+
628
+ router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
629
+ const id = req.params.id as string;
630
+ const existing = await workProductsSvc.getById(id);
631
+ if (!existing) {
632
+ res.status(404).json({ error: "Work product not found" });
633
+ return;
634
+ }
635
+ assertCompanyAccess(req, existing.companyId);
636
+ const product = await workProductsSvc.update(id, req.body);
637
+ if (!product) {
638
+ res.status(404).json({ error: "Work product not found" });
639
+ return;
640
+ }
641
+ const actor = getActorInfo(req);
642
+ await logActivity(db, {
643
+ companyId: existing.companyId,
644
+ actorType: actor.actorType,
645
+ actorId: actor.actorId,
646
+ agentId: actor.agentId,
647
+ runId: actor.runId,
648
+ action: "issue.work_product_updated",
649
+ entityType: "issue",
650
+ entityId: existing.issueId,
651
+ details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
652
+ });
653
+ res.json(product);
654
+ });
655
+
656
+ router.delete("/work-products/:id", async (req, res) => {
657
+ const id = req.params.id as string;
658
+ const existing = await workProductsSvc.getById(id);
659
+ if (!existing) {
660
+ res.status(404).json({ error: "Work product not found" });
661
+ return;
662
+ }
663
+ assertCompanyAccess(req, existing.companyId);
664
+ const removed = await workProductsSvc.remove(id);
665
+ if (!removed) {
666
+ res.status(404).json({ error: "Work product not found" });
667
+ return;
668
+ }
669
+ const actor = getActorInfo(req);
670
+ await logActivity(db, {
671
+ companyId: existing.companyId,
672
+ actorType: actor.actorType,
673
+ actorId: actor.actorId,
674
+ agentId: actor.agentId,
675
+ runId: actor.runId,
676
+ action: "issue.work_product_deleted",
677
+ entityType: "issue",
678
+ entityId: existing.issueId,
679
+ details: { workProductId: removed.id, type: removed.type },
680
+ });
681
+ res.json(removed);
682
+ });
683
+
684
+ router.post("/issues/:id/read", async (req, res) => {
685
+ const id = req.params.id as string;
686
+ const issue = await svc.getById(id);
687
+ if (!issue) {
688
+ res.status(404).json({ error: "Issue not found" });
689
+ return;
690
+ }
691
+ assertCompanyAccess(req, issue.companyId);
692
+ if (req.actor.type !== "board") {
693
+ res.status(403).json({ error: "Board authentication required" });
694
+ return;
695
+ }
696
+ if (!req.actor.userId) {
697
+ res.status(403).json({ error: "Board user context required" });
698
+ return;
699
+ }
700
+ const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
701
+ const actor = getActorInfo(req);
702
+ await logActivity(db, {
703
+ companyId: issue.companyId,
704
+ actorType: actor.actorType,
705
+ actorId: actor.actorId,
706
+ agentId: actor.agentId,
707
+ runId: actor.runId,
708
+ action: "issue.read_marked",
709
+ entityType: "issue",
710
+ entityId: issue.id,
711
+ details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
712
+ });
713
+ res.json(readState);
714
+ });
715
+
716
+ router.post("/issues/:id/inbox-archive", async (req, res) => {
717
+ const id = req.params.id as string;
718
+ const issue = await svc.getById(id);
719
+ if (!issue) {
720
+ res.status(404).json({ error: "Issue not found" });
721
+ return;
722
+ }
723
+ assertCompanyAccess(req, issue.companyId);
724
+ if (req.actor.type !== "board") {
725
+ res.status(403).json({ error: "Board authentication required" });
726
+ return;
727
+ }
728
+ if (!req.actor.userId) {
729
+ res.status(403).json({ error: "Board user context required" });
730
+ return;
731
+ }
732
+ const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date());
733
+ const actor = getActorInfo(req);
734
+ await logActivity(db, {
735
+ companyId: issue.companyId,
736
+ actorType: actor.actorType,
737
+ actorId: actor.actorId,
738
+ agentId: actor.agentId,
739
+ runId: actor.runId,
740
+ action: "issue.inbox_archived",
741
+ entityType: "issue",
742
+ entityId: issue.id,
743
+ details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt },
744
+ });
745
+ res.json(archiveState);
746
+ });
747
+
748
+ router.delete("/issues/:id/inbox-archive", async (req, res) => {
749
+ const id = req.params.id as string;
750
+ const issue = await svc.getById(id);
751
+ if (!issue) {
752
+ res.status(404).json({ error: "Issue not found" });
753
+ return;
754
+ }
755
+ assertCompanyAccess(req, issue.companyId);
756
+ if (req.actor.type !== "board") {
757
+ res.status(403).json({ error: "Board authentication required" });
758
+ return;
759
+ }
760
+ if (!req.actor.userId) {
761
+ res.status(403).json({ error: "Board user context required" });
762
+ return;
763
+ }
764
+ const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId);
765
+ const actor = getActorInfo(req);
766
+ await logActivity(db, {
767
+ companyId: issue.companyId,
768
+ actorType: actor.actorType,
769
+ actorId: actor.actorId,
770
+ agentId: actor.agentId,
771
+ runId: actor.runId,
772
+ action: "issue.inbox_unarchived",
773
+ entityType: "issue",
774
+ entityId: issue.id,
775
+ details: { userId: req.actor.userId },
776
+ });
777
+ res.json(removed ?? { ok: true });
778
+ });
779
+
780
+ router.get("/issues/:id/approvals", async (req, res) => {
781
+ const id = req.params.id as string;
782
+ const issue = await svc.getById(id);
783
+ if (!issue) {
784
+ res.status(404).json({ error: "Issue not found" });
785
+ return;
786
+ }
787
+ assertCompanyAccess(req, issue.companyId);
788
+ const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
789
+ res.json(approvals);
790
+ });
791
+
792
+ router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => {
793
+ const id = req.params.id as string;
794
+ const issue = await svc.getById(id);
795
+ if (!issue) {
796
+ res.status(404).json({ error: "Issue not found" });
797
+ return;
798
+ }
799
+ if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
800
+
801
+ const actor = getActorInfo(req);
802
+ await issueApprovalsSvc.link(id, req.body.approvalId, {
803
+ agentId: actor.agentId,
804
+ userId: actor.actorType === "user" ? actor.actorId : null,
805
+ });
806
+
807
+ await logActivity(db, {
808
+ companyId: issue.companyId,
809
+ actorType: actor.actorType,
810
+ actorId: actor.actorId,
811
+ agentId: actor.agentId,
812
+ runId: actor.runId,
813
+ action: "issue.approval_linked",
814
+ entityType: "issue",
815
+ entityId: issue.id,
816
+ details: { approvalId: req.body.approvalId },
817
+ });
818
+
819
+ const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
820
+ res.status(201).json(approvals);
821
+ });
822
+
823
+ router.delete("/issues/:id/approvals/:approvalId", async (req, res) => {
824
+ const id = req.params.id as string;
825
+ const approvalId = req.params.approvalId as string;
826
+ const issue = await svc.getById(id);
827
+ if (!issue) {
828
+ res.status(404).json({ error: "Issue not found" });
829
+ return;
830
+ }
831
+ if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
832
+
833
+ await issueApprovalsSvc.unlink(id, approvalId);
834
+
835
+ const actor = getActorInfo(req);
836
+ await logActivity(db, {
837
+ companyId: issue.companyId,
838
+ actorType: actor.actorType,
839
+ actorId: actor.actorId,
840
+ agentId: actor.agentId,
841
+ runId: actor.runId,
842
+ action: "issue.approval_unlinked",
843
+ entityType: "issue",
844
+ entityId: issue.id,
845
+ details: { approvalId },
846
+ });
847
+
848
+ res.json({ ok: true });
849
+ });
850
+
851
+ router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
852
+ const companyId = req.params.companyId as string;
853
+ assertCompanyAccess(req, companyId);
854
+ if (req.body.assigneeAgentId || req.body.assigneeUserId) {
855
+ await assertCanAssignTasks(req, companyId);
856
+ }
857
+
858
+ const actor = getActorInfo(req);
859
+ const issue = await svc.create(companyId, {
860
+ ...req.body,
861
+ createdByAgentId: actor.agentId,
862
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
863
+ });
864
+
865
+ await logActivity(db, {
866
+ companyId,
867
+ actorType: actor.actorType,
868
+ actorId: actor.actorId,
869
+ agentId: actor.agentId,
870
+ runId: actor.runId,
871
+ action: "issue.created",
872
+ entityType: "issue",
873
+ entityId: issue.id,
874
+ details: { title: issue.title, identifier: issue.identifier },
875
+ });
876
+
877
+ void queueIssueAssignmentWakeup({
878
+ heartbeat,
879
+ issue,
880
+ reason: "issue_assigned",
881
+ mutation: "create",
882
+ contextSource: "issue.create",
883
+ requestedByActorType: actor.actorType,
884
+ requestedByActorId: actor.actorId,
885
+ });
886
+
887
+ res.status(201).json(issue);
888
+ });
889
+
890
+ router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => {
891
+ const id = req.params.id as string;
892
+ const existing = await svc.getById(id);
893
+ if (!existing) {
894
+ res.status(404).json({ error: "Issue not found" });
895
+ return;
896
+ }
897
+ assertCompanyAccess(req, existing.companyId);
898
+ const assigneeWillChange =
899
+ (req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
900
+ (req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
901
+
902
+ const isAgentReturningIssueToCreator =
903
+ req.actor.type === "agent" &&
904
+ !!req.actor.agentId &&
905
+ existing.assigneeAgentId === req.actor.agentId &&
906
+ req.body.assigneeAgentId === null &&
907
+ typeof req.body.assigneeUserId === "string" &&
908
+ !!existing.createdByUserId &&
909
+ req.body.assigneeUserId === existing.createdByUserId;
910
+
911
+ if (assigneeWillChange) {
912
+ if (!isAgentReturningIssueToCreator) {
913
+ await assertCanAssignTasks(req, existing.companyId);
914
+ }
915
+ }
916
+ if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
917
+
918
+ const actor = getActorInfo(req);
919
+ const isClosed = existing.status === "done" || existing.status === "cancelled";
920
+ const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
921
+ if (hiddenAtRaw !== undefined) {
922
+ updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
923
+ }
924
+ if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
925
+ updateFields.status = "todo";
926
+ }
927
+ let issue;
928
+ try {
929
+ issue = await svc.update(id, updateFields);
930
+ } catch (err) {
931
+ if (err instanceof HttpError && err.status === 422) {
932
+ logger.warn(
933
+ {
934
+ issueId: id,
935
+ companyId: existing.companyId,
936
+ assigneePatch: {
937
+ assigneeAgentId:
938
+ req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
939
+ assigneeUserId:
940
+ req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
941
+ },
942
+ currentAssignee: {
943
+ assigneeAgentId: existing.assigneeAgentId,
944
+ assigneeUserId: existing.assigneeUserId,
945
+ },
946
+ error: err.message,
947
+ details: err.details,
948
+ },
949
+ "issue update rejected with 422",
950
+ );
951
+ }
952
+ throw err;
953
+ }
954
+ if (!issue) {
955
+ res.status(404).json({ error: "Issue not found" });
956
+ return;
957
+ }
958
+ await routinesSvc.syncRunStatusForIssue(issue.id);
959
+
960
+ if (actor.runId) {
961
+ await heartbeat.reportRunActivity(actor.runId).catch((err) =>
962
+ logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity"));
963
+ }
964
+
965
+ // Build activity details with previous values for changed fields
966
+ const previous: Record<string, unknown> = {};
967
+ for (const key of Object.keys(updateFields)) {
968
+ if (key in existing && (existing as Record<string, unknown>)[key] !== (updateFields as Record<string, unknown>)[key]) {
969
+ previous[key] = (existing as Record<string, unknown>)[key];
970
+ }
971
+ }
972
+
973
+ const hasFieldChanges = Object.keys(previous).length > 0;
974
+ const reopened =
975
+ commentBody &&
976
+ reopenRequested === true &&
977
+ isClosed &&
978
+ previous.status !== undefined &&
979
+ issue.status === "todo";
980
+ const reopenFromStatus = reopened ? existing.status : null;
981
+ await logActivity(db, {
982
+ companyId: issue.companyId,
983
+ actorType: actor.actorType,
984
+ actorId: actor.actorId,
985
+ agentId: actor.agentId,
986
+ runId: actor.runId,
987
+ action: "issue.updated",
988
+ entityType: "issue",
989
+ entityId: issue.id,
990
+ details: {
991
+ ...updateFields,
992
+ identifier: issue.identifier,
993
+ ...(commentBody ? { source: "comment" } : {}),
994
+ ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
995
+ _previous: hasFieldChanges ? previous : undefined,
996
+ },
997
+ });
998
+
999
+ let comment = null;
1000
+ if (commentBody) {
1001
+ comment = await svc.addComment(id, commentBody, {
1002
+ agentId: actor.agentId ?? undefined,
1003
+ userId: actor.actorType === "user" ? actor.actorId : undefined,
1004
+ });
1005
+
1006
+ await logActivity(db, {
1007
+ companyId: issue.companyId,
1008
+ actorType: actor.actorType,
1009
+ actorId: actor.actorId,
1010
+ agentId: actor.agentId,
1011
+ runId: actor.runId,
1012
+ action: "issue.comment_added",
1013
+ entityType: "issue",
1014
+ entityId: issue.id,
1015
+ details: {
1016
+ commentId: comment.id,
1017
+ bodySnippet: comment.body.slice(0, 120),
1018
+ identifier: issue.identifier,
1019
+ issueTitle: issue.title,
1020
+ ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
1021
+ ...(hasFieldChanges ? { updated: true } : {}),
1022
+ },
1023
+ });
1024
+
1025
+ }
1026
+
1027
+ const assigneeChanged = assigneeWillChange;
1028
+ const statusChangedFromBacklog =
1029
+ existing.status === "backlog" &&
1030
+ issue.status !== "backlog" &&
1031
+ req.body.status !== undefined;
1032
+
1033
+ // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
1034
+ void (async () => {
1035
+ const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
1036
+
1037
+ if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
1038
+ wakeups.set(issue.assigneeAgentId, {
1039
+ source: "assignment",
1040
+ triggerDetail: "system",
1041
+ reason: "issue_assigned",
1042
+ payload: { issueId: issue.id, mutation: "update" },
1043
+ requestedByActorType: actor.actorType,
1044
+ requestedByActorId: actor.actorId,
1045
+ contextSnapshot: { issueId: issue.id, source: "issue.update" },
1046
+ });
1047
+ }
1048
+
1049
+ if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
1050
+ wakeups.set(issue.assigneeAgentId, {
1051
+ source: "automation",
1052
+ triggerDetail: "system",
1053
+ reason: "issue_status_changed",
1054
+ payload: { issueId: issue.id, mutation: "update" },
1055
+ requestedByActorType: actor.actorType,
1056
+ requestedByActorId: actor.actorId,
1057
+ contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
1058
+ });
1059
+ }
1060
+
1061
+ if (commentBody && comment) {
1062
+ let mentionedIds: string[] = [];
1063
+ try {
1064
+ mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
1065
+ } catch (err) {
1066
+ logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
1067
+ }
1068
+
1069
+ for (const mentionedId of mentionedIds) {
1070
+ if (wakeups.has(mentionedId)) continue;
1071
+ if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
1072
+ wakeups.set(mentionedId, {
1073
+ source: "automation",
1074
+ triggerDetail: "system",
1075
+ reason: "issue_comment_mentioned",
1076
+ payload: { issueId: id, commentId: comment.id },
1077
+ requestedByActorType: actor.actorType,
1078
+ requestedByActorId: actor.actorId,
1079
+ contextSnapshot: {
1080
+ issueId: id,
1081
+ taskId: id,
1082
+ commentId: comment.id,
1083
+ wakeCommentId: comment.id,
1084
+ wakeReason: "issue_comment_mentioned",
1085
+ source: "comment.mention",
1086
+ },
1087
+ });
1088
+ }
1089
+ }
1090
+
1091
+ for (const [agentId, wakeup] of wakeups.entries()) {
1092
+ heartbeat
1093
+ .wakeup(agentId, wakeup)
1094
+ .catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
1095
+ }
1096
+ })();
1097
+
1098
+ res.json({ ...issue, comment });
1099
+ });
1100
+
1101
+ router.delete("/issues/:id", async (req, res) => {
1102
+ const id = req.params.id as string;
1103
+ const existing = await svc.getById(id);
1104
+ if (!existing) {
1105
+ res.status(404).json({ error: "Issue not found" });
1106
+ return;
1107
+ }
1108
+ assertCompanyAccess(req, existing.companyId);
1109
+ const attachments = await svc.listAttachments(id);
1110
+
1111
+ const issue = await svc.remove(id);
1112
+ if (!issue) {
1113
+ res.status(404).json({ error: "Issue not found" });
1114
+ return;
1115
+ }
1116
+
1117
+ for (const attachment of attachments) {
1118
+ try {
1119
+ await storage.deleteObject(attachment.companyId, attachment.objectKey);
1120
+ } catch (err) {
1121
+ logger.warn({ err, issueId: id, attachmentId: attachment.id }, "failed to delete attachment object during issue delete");
1122
+ }
1123
+ }
1124
+
1125
+ const actor = getActorInfo(req);
1126
+ await logActivity(db, {
1127
+ companyId: issue.companyId,
1128
+ actorType: actor.actorType,
1129
+ actorId: actor.actorId,
1130
+ agentId: actor.agentId,
1131
+ runId: actor.runId,
1132
+ action: "issue.deleted",
1133
+ entityType: "issue",
1134
+ entityId: issue.id,
1135
+ });
1136
+
1137
+ res.json(issue);
1138
+ });
1139
+
1140
+ router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => {
1141
+ const id = req.params.id as string;
1142
+ const issue = await svc.getById(id);
1143
+ if (!issue) {
1144
+ res.status(404).json({ error: "Issue not found" });
1145
+ return;
1146
+ }
1147
+ assertCompanyAccess(req, issue.companyId);
1148
+
1149
+ if (issue.projectId) {
1150
+ const project = await projectsSvc.getById(issue.projectId);
1151
+ if (project?.pausedAt) {
1152
+ res.status(409).json({
1153
+ error:
1154
+ project.pauseReason === "budget"
1155
+ ? "Project is paused because its budget hard-stop was reached"
1156
+ : "Project is paused",
1157
+ });
1158
+ return;
1159
+ }
1160
+ }
1161
+
1162
+ if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
1163
+ res.status(403).json({ error: "Agent can only checkout as itself" });
1164
+ return;
1165
+ }
1166
+
1167
+ const checkoutRunId = requireAgentRunId(req, res);
1168
+ if (req.actor.type === "agent" && !checkoutRunId) return;
1169
+ const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
1170
+ const actor = getActorInfo(req);
1171
+
1172
+ await logActivity(db, {
1173
+ companyId: issue.companyId,
1174
+ actorType: actor.actorType,
1175
+ actorId: actor.actorId,
1176
+ agentId: actor.agentId,
1177
+ runId: actor.runId,
1178
+ action: "issue.checked_out",
1179
+ entityType: "issue",
1180
+ entityId: issue.id,
1181
+ details: { agentId: req.body.agentId },
1182
+ });
1183
+
1184
+ if (
1185
+ shouldWakeAssigneeOnCheckout({
1186
+ actorType: req.actor.type,
1187
+ actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
1188
+ checkoutAgentId: req.body.agentId,
1189
+ checkoutRunId,
1190
+ })
1191
+ ) {
1192
+ void heartbeat
1193
+ .wakeup(req.body.agentId, {
1194
+ source: "assignment",
1195
+ triggerDetail: "system",
1196
+ reason: "issue_checked_out",
1197
+ payload: { issueId: issue.id, mutation: "checkout" },
1198
+ requestedByActorType: actor.actorType,
1199
+ requestedByActorId: actor.actorId,
1200
+ contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
1201
+ })
1202
+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
1203
+ }
1204
+
1205
+ res.json(updated);
1206
+ });
1207
+
1208
+ router.post("/issues/:id/release", async (req, res) => {
1209
+ const id = req.params.id as string;
1210
+ const existing = await svc.getById(id);
1211
+ if (!existing) {
1212
+ res.status(404).json({ error: "Issue not found" });
1213
+ return;
1214
+ }
1215
+ assertCompanyAccess(req, existing.companyId);
1216
+ if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
1217
+ const actorRunId = requireAgentRunId(req, res);
1218
+ if (req.actor.type === "agent" && !actorRunId) return;
1219
+
1220
+ const released = await svc.release(
1221
+ id,
1222
+ req.actor.type === "agent" ? req.actor.agentId : undefined,
1223
+ actorRunId,
1224
+ );
1225
+ if (!released) {
1226
+ res.status(404).json({ error: "Issue not found" });
1227
+ return;
1228
+ }
1229
+
1230
+ const actor = getActorInfo(req);
1231
+ await logActivity(db, {
1232
+ companyId: released.companyId,
1233
+ actorType: actor.actorType,
1234
+ actorId: actor.actorId,
1235
+ agentId: actor.agentId,
1236
+ runId: actor.runId,
1237
+ action: "issue.released",
1238
+ entityType: "issue",
1239
+ entityId: released.id,
1240
+ });
1241
+
1242
+ res.json(released);
1243
+ });
1244
+
1245
+ router.get("/issues/:id/comments", async (req, res) => {
1246
+ const id = req.params.id as string;
1247
+ const issue = await svc.getById(id);
1248
+ if (!issue) {
1249
+ res.status(404).json({ error: "Issue not found" });
1250
+ return;
1251
+ }
1252
+ assertCompanyAccess(req, issue.companyId);
1253
+ const afterCommentId =
1254
+ typeof req.query.after === "string" && req.query.after.trim().length > 0
1255
+ ? req.query.after.trim()
1256
+ : typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
1257
+ ? req.query.afterCommentId.trim()
1258
+ : null;
1259
+ const order =
1260
+ typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
1261
+ ? "asc"
1262
+ : "desc";
1263
+ const limitRaw =
1264
+ typeof req.query.limit === "string" && req.query.limit.trim().length > 0
1265
+ ? Number(req.query.limit)
1266
+ : null;
1267
+ const limit =
1268
+ limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
1269
+ ? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
1270
+ : null;
1271
+ const comments = await svc.listComments(id, {
1272
+ afterCommentId,
1273
+ order,
1274
+ limit,
1275
+ });
1276
+ res.json(comments);
1277
+ });
1278
+
1279
+ router.get("/issues/:id/comments/:commentId", async (req, res) => {
1280
+ const id = req.params.id as string;
1281
+ const commentId = req.params.commentId as string;
1282
+ const issue = await svc.getById(id);
1283
+ if (!issue) {
1284
+ res.status(404).json({ error: "Issue not found" });
1285
+ return;
1286
+ }
1287
+ assertCompanyAccess(req, issue.companyId);
1288
+ const comment = await svc.getComment(commentId);
1289
+ if (!comment || comment.issueId !== id) {
1290
+ res.status(404).json({ error: "Comment not found" });
1291
+ return;
1292
+ }
1293
+ res.json(comment);
1294
+ });
1295
+
1296
+ router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
1297
+ const id = req.params.id as string;
1298
+ const issue = await svc.getById(id);
1299
+ if (!issue) {
1300
+ res.status(404).json({ error: "Issue not found" });
1301
+ return;
1302
+ }
1303
+ assertCompanyAccess(req, issue.companyId);
1304
+ if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
1305
+
1306
+ const actor = getActorInfo(req);
1307
+ const reopenRequested = req.body.reopen === true;
1308
+ const interruptRequested = req.body.interrupt === true;
1309
+ const isClosed = issue.status === "done" || issue.status === "cancelled";
1310
+ let reopened = false;
1311
+ let reopenFromStatus: string | null = null;
1312
+ let interruptedRunId: string | null = null;
1313
+ let currentIssue = issue;
1314
+
1315
+ if (reopenRequested && isClosed) {
1316
+ const reopenedIssue = await svc.update(id, { status: "todo" });
1317
+ if (!reopenedIssue) {
1318
+ res.status(404).json({ error: "Issue not found" });
1319
+ return;
1320
+ }
1321
+ reopened = true;
1322
+ reopenFromStatus = issue.status;
1323
+ currentIssue = reopenedIssue;
1324
+
1325
+ await logActivity(db, {
1326
+ companyId: currentIssue.companyId,
1327
+ actorType: actor.actorType,
1328
+ actorId: actor.actorId,
1329
+ agentId: actor.agentId,
1330
+ runId: actor.runId,
1331
+ action: "issue.updated",
1332
+ entityType: "issue",
1333
+ entityId: currentIssue.id,
1334
+ details: {
1335
+ status: "todo",
1336
+ reopened: true,
1337
+ reopenedFrom: reopenFromStatus,
1338
+ source: "comment",
1339
+ identifier: currentIssue.identifier,
1340
+ },
1341
+ });
1342
+ }
1343
+
1344
+ if (interruptRequested) {
1345
+ if (req.actor.type !== "board") {
1346
+ res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
1347
+ return;
1348
+ }
1349
+
1350
+ let runToInterrupt = currentIssue.executionRunId
1351
+ ? await heartbeat.getRun(currentIssue.executionRunId)
1352
+ : null;
1353
+
1354
+ if (
1355
+ (!runToInterrupt || runToInterrupt.status !== "running") &&
1356
+ currentIssue.assigneeAgentId
1357
+ ) {
1358
+ const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
1359
+ const activeIssueId =
1360
+ activeRun &&
1361
+ activeRun.contextSnapshot &&
1362
+ typeof activeRun.contextSnapshot === "object" &&
1363
+ typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
1364
+ ? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
1365
+ : null;
1366
+ if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
1367
+ runToInterrupt = activeRun;
1368
+ }
1369
+ }
1370
+
1371
+ if (runToInterrupt && runToInterrupt.status === "running") {
1372
+ const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
1373
+ if (cancelled) {
1374
+ interruptedRunId = cancelled.id;
1375
+ await logActivity(db, {
1376
+ companyId: cancelled.companyId,
1377
+ actorType: actor.actorType,
1378
+ actorId: actor.actorId,
1379
+ agentId: actor.agentId,
1380
+ runId: actor.runId,
1381
+ action: "heartbeat.cancelled",
1382
+ entityType: "heartbeat_run",
1383
+ entityId: cancelled.id,
1384
+ details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id },
1385
+ });
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ const comment = await svc.addComment(id, req.body.body, {
1391
+ agentId: actor.agentId ?? undefined,
1392
+ userId: actor.actorType === "user" ? actor.actorId : undefined,
1393
+ });
1394
+
1395
+ if (actor.runId) {
1396
+ await heartbeat.reportRunActivity(actor.runId).catch((err) =>
1397
+ logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment"));
1398
+ }
1399
+
1400
+ await logActivity(db, {
1401
+ companyId: currentIssue.companyId,
1402
+ actorType: actor.actorType,
1403
+ actorId: actor.actorId,
1404
+ agentId: actor.agentId,
1405
+ runId: actor.runId,
1406
+ action: "issue.comment_added",
1407
+ entityType: "issue",
1408
+ entityId: currentIssue.id,
1409
+ details: {
1410
+ commentId: comment.id,
1411
+ bodySnippet: comment.body.slice(0, 120),
1412
+ identifier: currentIssue.identifier,
1413
+ issueTitle: currentIssue.title,
1414
+ ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
1415
+ ...(interruptedRunId ? { interruptedRunId } : {}),
1416
+ },
1417
+ });
1418
+
1419
+ // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
1420
+ void (async () => {
1421
+ const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
1422
+ const assigneeId = currentIssue.assigneeAgentId;
1423
+ const actorIsAgent = actor.actorType === "agent";
1424
+ const selfComment = actorIsAgent && actor.actorId === assigneeId;
1425
+ const skipWake = selfComment || isClosed;
1426
+ if (assigneeId && (reopened || !skipWake)) {
1427
+ if (reopened) {
1428
+ wakeups.set(assigneeId, {
1429
+ source: "automation",
1430
+ triggerDetail: "system",
1431
+ reason: "issue_reopened_via_comment",
1432
+ payload: {
1433
+ issueId: currentIssue.id,
1434
+ commentId: comment.id,
1435
+ reopenedFrom: reopenFromStatus,
1436
+ mutation: "comment",
1437
+ ...(interruptedRunId ? { interruptedRunId } : {}),
1438
+ },
1439
+ requestedByActorType: actor.actorType,
1440
+ requestedByActorId: actor.actorId,
1441
+ contextSnapshot: {
1442
+ issueId: currentIssue.id,
1443
+ taskId: currentIssue.id,
1444
+ commentId: comment.id,
1445
+ source: "issue.comment.reopen",
1446
+ wakeReason: "issue_reopened_via_comment",
1447
+ reopenedFrom: reopenFromStatus,
1448
+ ...(interruptedRunId ? { interruptedRunId } : {}),
1449
+ },
1450
+ });
1451
+ } else {
1452
+ wakeups.set(assigneeId, {
1453
+ source: "automation",
1454
+ triggerDetail: "system",
1455
+ reason: "issue_commented",
1456
+ payload: {
1457
+ issueId: currentIssue.id,
1458
+ commentId: comment.id,
1459
+ mutation: "comment",
1460
+ ...(interruptedRunId ? { interruptedRunId } : {}),
1461
+ },
1462
+ requestedByActorType: actor.actorType,
1463
+ requestedByActorId: actor.actorId,
1464
+ contextSnapshot: {
1465
+ issueId: currentIssue.id,
1466
+ taskId: currentIssue.id,
1467
+ commentId: comment.id,
1468
+ source: "issue.comment",
1469
+ wakeReason: "issue_commented",
1470
+ ...(interruptedRunId ? { interruptedRunId } : {}),
1471
+ },
1472
+ });
1473
+ }
1474
+ }
1475
+
1476
+ let mentionedIds: string[] = [];
1477
+ try {
1478
+ mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body);
1479
+ } catch (err) {
1480
+ logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
1481
+ }
1482
+
1483
+ for (const mentionedId of mentionedIds) {
1484
+ if (wakeups.has(mentionedId)) continue;
1485
+ if (actorIsAgent && actor.actorId === mentionedId) continue;
1486
+ wakeups.set(mentionedId, {
1487
+ source: "automation",
1488
+ triggerDetail: "system",
1489
+ reason: "issue_comment_mentioned",
1490
+ payload: { issueId: id, commentId: comment.id },
1491
+ requestedByActorType: actor.actorType,
1492
+ requestedByActorId: actor.actorId,
1493
+ contextSnapshot: {
1494
+ issueId: id,
1495
+ taskId: id,
1496
+ commentId: comment.id,
1497
+ wakeCommentId: comment.id,
1498
+ wakeReason: "issue_comment_mentioned",
1499
+ source: "comment.mention",
1500
+ },
1501
+ });
1502
+ }
1503
+
1504
+ for (const [agentId, wakeup] of wakeups.entries()) {
1505
+ heartbeat
1506
+ .wakeup(agentId, wakeup)
1507
+ .catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
1508
+ }
1509
+ })();
1510
+
1511
+ res.status(201).json(comment);
1512
+ });
1513
+
1514
+ router.get("/issues/:id/attachments", async (req, res) => {
1515
+ const issueId = req.params.id as string;
1516
+ const issue = await svc.getById(issueId);
1517
+ if (!issue) {
1518
+ res.status(404).json({ error: "Issue not found" });
1519
+ return;
1520
+ }
1521
+ assertCompanyAccess(req, issue.companyId);
1522
+ const attachments = await svc.listAttachments(issueId);
1523
+ res.json(attachments.map(withContentPath));
1524
+ });
1525
+
1526
+ router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
1527
+ const companyId = req.params.companyId as string;
1528
+ const issueId = req.params.issueId as string;
1529
+ assertCompanyAccess(req, companyId);
1530
+ const issue = await svc.getById(issueId);
1531
+ if (!issue) {
1532
+ res.status(404).json({ error: "Issue not found" });
1533
+ return;
1534
+ }
1535
+ if (issue.companyId !== companyId) {
1536
+ res.status(422).json({ error: "Issue does not belong to company" });
1537
+ return;
1538
+ }
1539
+
1540
+ try {
1541
+ await runSingleFileUpload(req, res);
1542
+ } catch (err) {
1543
+ if (err instanceof multer.MulterError) {
1544
+ if (err.code === "LIMIT_FILE_SIZE") {
1545
+ res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
1546
+ return;
1547
+ }
1548
+ res.status(400).json({ error: err.message });
1549
+ return;
1550
+ }
1551
+ throw err;
1552
+ }
1553
+
1554
+ const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
1555
+ if (!file) {
1556
+ res.status(400).json({ error: "Missing file field 'file'" });
1557
+ return;
1558
+ }
1559
+ const contentType = (file.mimetype || "").toLowerCase();
1560
+ if (!isAllowedContentType(contentType)) {
1561
+ res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
1562
+ return;
1563
+ }
1564
+ if (file.buffer.length <= 0) {
1565
+ res.status(422).json({ error: "Attachment is empty" });
1566
+ return;
1567
+ }
1568
+
1569
+ const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
1570
+ if (!parsedMeta.success) {
1571
+ res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
1572
+ return;
1573
+ }
1574
+
1575
+ const actor = getActorInfo(req);
1576
+ const stored = await storage.putFile({
1577
+ companyId,
1578
+ namespace: `issues/${issueId}`,
1579
+ originalFilename: file.originalname || null,
1580
+ contentType,
1581
+ body: file.buffer,
1582
+ });
1583
+
1584
+ const attachment = await svc.createAttachment({
1585
+ issueId,
1586
+ issueCommentId: parsedMeta.data.issueCommentId ?? null,
1587
+ provider: stored.provider,
1588
+ objectKey: stored.objectKey,
1589
+ contentType: stored.contentType,
1590
+ byteSize: stored.byteSize,
1591
+ sha256: stored.sha256,
1592
+ originalFilename: stored.originalFilename,
1593
+ createdByAgentId: actor.agentId,
1594
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
1595
+ });
1596
+
1597
+ await logActivity(db, {
1598
+ companyId,
1599
+ actorType: actor.actorType,
1600
+ actorId: actor.actorId,
1601
+ agentId: actor.agentId,
1602
+ runId: actor.runId,
1603
+ action: "issue.attachment_added",
1604
+ entityType: "issue",
1605
+ entityId: issueId,
1606
+ details: {
1607
+ attachmentId: attachment.id,
1608
+ originalFilename: attachment.originalFilename,
1609
+ contentType: attachment.contentType,
1610
+ byteSize: attachment.byteSize,
1611
+ },
1612
+ });
1613
+
1614
+ res.status(201).json(withContentPath(attachment));
1615
+ });
1616
+
1617
+ router.get("/attachments/:attachmentId/content", async (req, res, next) => {
1618
+ const attachmentId = req.params.attachmentId as string;
1619
+ const attachment = await svc.getAttachmentById(attachmentId);
1620
+ if (!attachment) {
1621
+ res.status(404).json({ error: "Attachment not found" });
1622
+ return;
1623
+ }
1624
+ assertCompanyAccess(req, attachment.companyId);
1625
+
1626
+ const object = await storage.getObject(attachment.companyId, attachment.objectKey);
1627
+ res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
1628
+ res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
1629
+ res.setHeader("Cache-Control", "private, max-age=60");
1630
+ const filename = attachment.originalFilename ?? "attachment";
1631
+ res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
1632
+
1633
+ object.stream.on("error", (err) => {
1634
+ next(err);
1635
+ });
1636
+ object.stream.pipe(res);
1637
+ });
1638
+
1639
+ router.delete("/attachments/:attachmentId", async (req, res) => {
1640
+ const attachmentId = req.params.attachmentId as string;
1641
+ const attachment = await svc.getAttachmentById(attachmentId);
1642
+ if (!attachment) {
1643
+ res.status(404).json({ error: "Attachment not found" });
1644
+ return;
1645
+ }
1646
+ assertCompanyAccess(req, attachment.companyId);
1647
+
1648
+ try {
1649
+ await storage.deleteObject(attachment.companyId, attachment.objectKey);
1650
+ } catch (err) {
1651
+ logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
1652
+ }
1653
+
1654
+ const removed = await svc.removeAttachment(attachmentId);
1655
+ if (!removed) {
1656
+ res.status(404).json({ error: "Attachment not found" });
1657
+ return;
1658
+ }
1659
+
1660
+ const actor = getActorInfo(req);
1661
+ await logActivity(db, {
1662
+ companyId: removed.companyId,
1663
+ actorType: actor.actorType,
1664
+ actorId: actor.actorId,
1665
+ agentId: actor.agentId,
1666
+ runId: actor.runId,
1667
+ action: "issue.attachment_removed",
1668
+ entityType: "issue",
1669
+ entityId: removed.issueId,
1670
+ details: {
1671
+ attachmentId: removed.id,
1672
+ },
1673
+ });
1674
+
1675
+ res.json({ ok: true });
1676
+ });
1677
+
1678
+ return router;
1679
+ }
1680
+