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,1828 @@
1
+ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
2
+ import type { Db } from "@corporateai/db";
3
+ import {
4
+ activityLog,
5
+ agents,
6
+ assets,
7
+ companies,
8
+ companyMemberships,
9
+ documents,
10
+ goals,
11
+ heartbeatRuns,
12
+ executionWorkspaces,
13
+ issueAttachments,
14
+ issueLabels,
15
+ issueComments,
16
+ issueDocuments,
17
+ issueReadStates,
18
+ issues,
19
+ labels,
20
+ projectWorkspaces,
21
+ projects,
22
+ } from "@corporateai/db";
23
+ import { extractAgentMentionIds, extractProjectMentionIds } from "@corporateai/shared";
24
+ import { conflict, notFound, unprocessable } from "../errors.js";
25
+ import {
26
+ defaultIssueExecutionWorkspaceSettingsForProject,
27
+ gateProjectExecutionWorkspacePolicy,
28
+ parseProjectExecutionWorkspacePolicy,
29
+ } from "./execution-workspace-policy.js";
30
+ import { instanceSettingsService } from "./instance-settings.js";
31
+ import { redactCurrentUserText } from "../log-redaction.js";
32
+ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
33
+ import { getDefaultCompanyGoal } from "./goals.js";
34
+
35
+ const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
36
+ const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
37
+
38
+ function assertTransition(from: string, to: string) {
39
+ if (from === to) return;
40
+ if (!ALL_ISSUE_STATUSES.includes(to)) {
41
+ throw conflict(`Unknown issue status: ${to}`);
42
+ }
43
+ }
44
+
45
+ function applyStatusSideEffects(
46
+ status: string | undefined,
47
+ patch: Partial<typeof issues.$inferInsert>,
48
+ ): Partial<typeof issues.$inferInsert> {
49
+ if (!status) return patch;
50
+
51
+ if (status === "in_progress" && !patch.startedAt) {
52
+ patch.startedAt = new Date();
53
+ }
54
+ if (status === "done") {
55
+ patch.completedAt = new Date();
56
+ }
57
+ if (status === "cancelled") {
58
+ patch.cancelledAt = new Date();
59
+ }
60
+ return patch;
61
+ }
62
+
63
+ export interface IssueFilters {
64
+ status?: string;
65
+ assigneeAgentId?: string;
66
+ participantAgentId?: string;
67
+ assigneeUserId?: string;
68
+ touchedByUserId?: string;
69
+ inboxArchivedByUserId?: string;
70
+ unreadForUserId?: string;
71
+ projectId?: string;
72
+ parentId?: string;
73
+ labelId?: string;
74
+ originKind?: string;
75
+ originId?: string;
76
+ includeRoutineExecutions?: boolean;
77
+ q?: string;
78
+ }
79
+
80
+ type IssueRow = typeof issues.$inferSelect;
81
+ type IssueLabelRow = typeof labels.$inferSelect;
82
+ type IssueActiveRunRow = {
83
+ id: string;
84
+ status: string;
85
+ agentId: string;
86
+ invocationSource: string;
87
+ triggerDetail: string | null;
88
+ startedAt: Date | null;
89
+ finishedAt: Date | null;
90
+ createdAt: Date;
91
+ };
92
+ type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
93
+ type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
94
+ type IssueUserCommentStats = {
95
+ issueId: string;
96
+ myLastCommentAt: Date | null;
97
+ lastExternalCommentAt: Date | null;
98
+ };
99
+ type IssueUserContextInput = {
100
+ createdByUserId: string | null;
101
+ assigneeUserId: string | null;
102
+ createdAt: Date | string;
103
+ updatedAt: Date | string;
104
+ };
105
+ type ProjectGoalReader = Pick<Db, "select">;
106
+
107
+ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
108
+ if (actorRunId) return checkoutRunId === actorRunId;
109
+ return checkoutRunId == null;
110
+ }
111
+
112
+ const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
113
+
114
+ function escapeLikePattern(value: string): string {
115
+ return value.replace(/[\\%_]/g, "\\$&");
116
+ }
117
+
118
+ async function getProjectDefaultGoalId(
119
+ db: ProjectGoalReader,
120
+ companyId: string,
121
+ projectId: string | null | undefined,
122
+ ) {
123
+ if (!projectId) return null;
124
+ const row = await db
125
+ .select({ goalId: projects.goalId })
126
+ .from(projects)
127
+ .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
128
+ .then((rows) => rows[0] ?? null);
129
+ return row?.goalId ?? null;
130
+ }
131
+
132
+ function touchedByUserCondition(companyId: string, userId: string) {
133
+ return sql<boolean>`
134
+ (
135
+ ${issues.createdByUserId} = ${userId}
136
+ OR ${issues.assigneeUserId} = ${userId}
137
+ OR EXISTS (
138
+ SELECT 1
139
+ FROM ${issueReadStates}
140
+ WHERE ${issueReadStates.issueId} = ${issues.id}
141
+ AND ${issueReadStates.companyId} = ${companyId}
142
+ AND ${issueReadStates.userId} = ${userId}
143
+ )
144
+ OR EXISTS (
145
+ SELECT 1
146
+ FROM ${issueComments}
147
+ WHERE ${issueComments.issueId} = ${issues.id}
148
+ AND ${issueComments.companyId} = ${companyId}
149
+ AND ${issueComments.authorUserId} = ${userId}
150
+ )
151
+ )
152
+ `;
153
+ }
154
+
155
+ function participatedByAgentCondition(companyId: string, agentId: string) {
156
+ return sql<boolean>`
157
+ (
158
+ ${issues.createdByAgentId} = ${agentId}
159
+ OR ${issues.assigneeAgentId} = ${agentId}
160
+ OR EXISTS (
161
+ SELECT 1
162
+ FROM ${issueComments}
163
+ WHERE ${issueComments.issueId} = ${issues.id}
164
+ AND ${issueComments.companyId} = ${companyId}
165
+ AND ${issueComments.authorAgentId} = ${agentId}
166
+ )
167
+ OR EXISTS (
168
+ SELECT 1
169
+ FROM ${activityLog}
170
+ WHERE ${activityLog.companyId} = ${companyId}
171
+ AND ${activityLog.entityType} = 'issue'
172
+ AND ${activityLog.entityId} = ${issues.id}::text
173
+ AND ${activityLog.agentId} = ${agentId}
174
+ )
175
+ )
176
+ `;
177
+ }
178
+
179
+ function myLastCommentAtExpr(companyId: string, userId: string) {
180
+ return sql<Date | null>`
181
+ (
182
+ SELECT MAX(${issueComments.createdAt})
183
+ FROM ${issueComments}
184
+ WHERE ${issueComments.issueId} = ${issues.id}
185
+ AND ${issueComments.companyId} = ${companyId}
186
+ AND ${issueComments.authorUserId} = ${userId}
187
+ )
188
+ `;
189
+ }
190
+
191
+ function myLastReadAtExpr(companyId: string, userId: string) {
192
+ return sql<Date | null>`
193
+ (
194
+ SELECT MAX(${issueReadStates.lastReadAt})
195
+ FROM ${issueReadStates}
196
+ WHERE ${issueReadStates.issueId} = ${issues.id}
197
+ AND ${issueReadStates.companyId} = ${companyId}
198
+ AND ${issueReadStates.userId} = ${userId}
199
+ )
200
+ `;
201
+ }
202
+
203
+ function myLastTouchAtExpr(companyId: string, userId: string) {
204
+ const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
205
+ const myLastReadAt = myLastReadAtExpr(companyId, userId);
206
+ return sql<Date | null>`
207
+ GREATEST(
208
+ COALESCE(${myLastCommentAt}, to_timestamp(0)),
209
+ COALESCE(${myLastReadAt}, to_timestamp(0)),
210
+ COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
211
+ COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
212
+ )
213
+ `;
214
+ }
215
+
216
+ function lastExternalCommentAtExpr(companyId: string, userId: string) {
217
+ return sql<Date | null>`
218
+ (
219
+ SELECT MAX(${issueComments.createdAt})
220
+ FROM ${issueComments}
221
+ WHERE ${issueComments.issueId} = ${issues.id}
222
+ AND ${issueComments.companyId} = ${companyId}
223
+ AND (
224
+ ${issueComments.authorUserId} IS NULL
225
+ OR ${issueComments.authorUserId} <> ${userId}
226
+ )
227
+ )
228
+ `;
229
+ }
230
+
231
+ function issueLastActivityAtExpr(companyId: string, userId: string) {
232
+ const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
233
+ const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
234
+ return sql<Date>`
235
+ COALESCE(
236
+ ${lastExternalCommentAt},
237
+ CASE
238
+ WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
239
+ THEN ${issues.updatedAt}
240
+ ELSE to_timestamp(0)
241
+ END
242
+ )
243
+ `;
244
+ }
245
+
246
+ function unreadForUserCondition(companyId: string, userId: string) {
247
+ const touchedCondition = touchedByUserCondition(companyId, userId);
248
+ const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
249
+ return sql<boolean>`
250
+ (
251
+ ${touchedCondition}
252
+ AND EXISTS (
253
+ SELECT 1
254
+ FROM ${issueComments}
255
+ WHERE ${issueComments.issueId} = ${issues.id}
256
+ AND ${issueComments.companyId} = ${companyId}
257
+ AND (
258
+ ${issueComments.authorUserId} IS NULL
259
+ OR ${issueComments.authorUserId} <> ${userId}
260
+ )
261
+ AND ${issueComments.createdAt} > ${myLastTouchAt}
262
+ )
263
+ )
264
+ `;
265
+ }
266
+
267
+ function inboxVisibleForUserCondition(companyId: string, userId: string) {
268
+ const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId);
269
+ return sql<boolean>`
270
+ NOT EXISTS (
271
+ SELECT 1
272
+ FROM ${issueReadStates}
273
+ WHERE ${issueReadStates.issueId} = ${issues.id}
274
+ AND ${issueReadStates.companyId} = ${companyId}
275
+ AND ${issueReadStates.userId} = ${userId}
276
+ AND ${issueReadStates.updatedAt} >= ${issueLastActivityAt}
277
+ )
278
+ `;
279
+ }
280
+
281
+ /** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
282
+ const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
283
+ amp: "&",
284
+ apos: "'",
285
+ copy: "\u00A9",
286
+ gt: ">",
287
+ lt: "<",
288
+ nbsp: "\u00A0",
289
+ quot: '"',
290
+ ensp: "\u2002",
291
+ emsp: "\u2003",
292
+ thinsp: "\u2009",
293
+ };
294
+
295
+ function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null {
296
+ const n = Number.parseInt(digits, radix);
297
+ if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null;
298
+ try {
299
+ return String.fromCodePoint(n);
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */
306
+ export function normalizeAgentMentionToken(raw: string): string {
307
+ let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full);
308
+ s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full);
309
+ s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => {
310
+ const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
311
+ return decoded !== undefined ? decoded : full;
312
+ });
313
+ return s.trim();
314
+ }
315
+
316
+ export function deriveIssueUserContext(
317
+ issue: IssueUserContextInput,
318
+ userId: string,
319
+ stats:
320
+ | {
321
+ myLastCommentAt: Date | string | null;
322
+ myLastReadAt: Date | string | null;
323
+ lastExternalCommentAt: Date | string | null;
324
+ }
325
+ | null
326
+ | undefined,
327
+ ) {
328
+ const normalizeDate = (value: Date | string | null | undefined) => {
329
+ if (!value) return null;
330
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
331
+ const parsed = new Date(value);
332
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
333
+ };
334
+
335
+ const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
336
+ const myLastReadAt = normalizeDate(stats?.myLastReadAt);
337
+ const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
338
+ const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
339
+ const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
340
+ .filter((value): value is Date => value instanceof Date)
341
+ .sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
342
+ const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
343
+ const isUnreadForMe = Boolean(
344
+ myLastTouchAt &&
345
+ lastExternalCommentAt &&
346
+ lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
347
+ );
348
+
349
+ return {
350
+ myLastTouchAt,
351
+ lastExternalCommentAt,
352
+ isUnreadForMe,
353
+ };
354
+ }
355
+
356
+ async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
357
+ const map = new Map<string, IssueLabelRow[]>();
358
+ if (issueIds.length === 0) return map;
359
+ const rows = await dbOrTx
360
+ .select({
361
+ issueId: issueLabels.issueId,
362
+ label: labels,
363
+ })
364
+ .from(issueLabels)
365
+ .innerJoin(labels, eq(issueLabels.labelId, labels.id))
366
+ .where(inArray(issueLabels.issueId, issueIds))
367
+ .orderBy(asc(labels.name), asc(labels.id));
368
+
369
+ for (const row of rows) {
370
+ const existing = map.get(row.issueId);
371
+ if (existing) existing.push(row.label);
372
+ else map.set(row.issueId, [row.label]);
373
+ }
374
+ return map;
375
+ }
376
+
377
+ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWithLabels[]> {
378
+ if (rows.length === 0) return [];
379
+ const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id));
380
+ return rows.map((row) => {
381
+ const issueLabels = labelsByIssueId.get(row.id) ?? [];
382
+ return {
383
+ ...row,
384
+ labels: issueLabels,
385
+ labelIds: issueLabels.map((label) => label.id),
386
+ };
387
+ });
388
+ }
389
+
390
+ const ACTIVE_RUN_STATUSES = ["queued", "running"];
391
+
392
+ async function activeRunMapForIssues(
393
+ dbOrTx: any,
394
+ issueRows: IssueWithLabels[],
395
+ ): Promise<Map<string, IssueActiveRunRow>> {
396
+ const map = new Map<string, IssueActiveRunRow>();
397
+ const runIds = issueRows
398
+ .map((row) => row.executionRunId)
399
+ .filter((id): id is string => id != null);
400
+ if (runIds.length === 0) return map;
401
+
402
+ const rows = await dbOrTx
403
+ .select({
404
+ id: heartbeatRuns.id,
405
+ status: heartbeatRuns.status,
406
+ agentId: heartbeatRuns.agentId,
407
+ invocationSource: heartbeatRuns.invocationSource,
408
+ triggerDetail: heartbeatRuns.triggerDetail,
409
+ startedAt: heartbeatRuns.startedAt,
410
+ finishedAt: heartbeatRuns.finishedAt,
411
+ createdAt: heartbeatRuns.createdAt,
412
+ })
413
+ .from(heartbeatRuns)
414
+ .where(
415
+ and(
416
+ inArray(heartbeatRuns.id, runIds),
417
+ inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES),
418
+ ),
419
+ );
420
+
421
+ for (const row of rows) {
422
+ map.set(row.id, row);
423
+ }
424
+ return map;
425
+ }
426
+
427
+ function withActiveRuns(
428
+ issueRows: IssueWithLabels[],
429
+ runMap: Map<string, IssueActiveRunRow>,
430
+ ): IssueWithLabelsAndRun[] {
431
+ return issueRows.map((row) => ({
432
+ ...row,
433
+ activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null,
434
+ }));
435
+ }
436
+
437
+ export function issueService(db: Db) {
438
+ const instanceSettings = instanceSettingsService(db);
439
+
440
+ function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
441
+ return {
442
+ ...comment,
443
+ body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
444
+ };
445
+ }
446
+
447
+ async function assertAssignableAgent(companyId: string, agentId: string) {
448
+ const assignee = await db
449
+ .select({
450
+ id: agents.id,
451
+ companyId: agents.companyId,
452
+ status: agents.status,
453
+ })
454
+ .from(agents)
455
+ .where(eq(agents.id, agentId))
456
+ .then((rows) => rows[0] ?? null);
457
+
458
+ if (!assignee) throw notFound("Assignee agent not found");
459
+ if (assignee.companyId !== companyId) {
460
+ throw unprocessable("Assignee must belong to same company");
461
+ }
462
+ if (assignee.status === "pending_approval") {
463
+ throw conflict("Cannot assign work to pending approval agents");
464
+ }
465
+ if (assignee.status === "terminated") {
466
+ throw conflict("Cannot assign work to terminated agents");
467
+ }
468
+ }
469
+
470
+ async function assertAssignableUser(companyId: string, userId: string) {
471
+ const membership = await db
472
+ .select({ id: companyMemberships.id })
473
+ .from(companyMemberships)
474
+ .where(
475
+ and(
476
+ eq(companyMemberships.companyId, companyId),
477
+ eq(companyMemberships.principalType, "user"),
478
+ eq(companyMemberships.principalId, userId),
479
+ eq(companyMemberships.status, "active"),
480
+ ),
481
+ )
482
+ .then((rows) => rows[0] ?? null);
483
+ if (!membership) {
484
+ throw notFound("Assignee user not found");
485
+ }
486
+ }
487
+
488
+ async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) {
489
+ const workspace = await db
490
+ .select({
491
+ id: projectWorkspaces.id,
492
+ companyId: projectWorkspaces.companyId,
493
+ projectId: projectWorkspaces.projectId,
494
+ })
495
+ .from(projectWorkspaces)
496
+ .where(eq(projectWorkspaces.id, projectWorkspaceId))
497
+ .then((rows) => rows[0] ?? null);
498
+ if (!workspace) throw notFound("Project workspace not found");
499
+ if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company");
500
+ if (projectId && workspace.projectId !== projectId) {
501
+ throw unprocessable("Project workspace must belong to the selected project");
502
+ }
503
+ }
504
+
505
+ async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) {
506
+ const workspace = await db
507
+ .select({
508
+ id: executionWorkspaces.id,
509
+ companyId: executionWorkspaces.companyId,
510
+ projectId: executionWorkspaces.projectId,
511
+ })
512
+ .from(executionWorkspaces)
513
+ .where(eq(executionWorkspaces.id, executionWorkspaceId))
514
+ .then((rows) => rows[0] ?? null);
515
+ if (!workspace) throw notFound("Execution workspace not found");
516
+ if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company");
517
+ if (projectId && workspace.projectId !== projectId) {
518
+ throw unprocessable("Execution workspace must belong to the selected project");
519
+ }
520
+ }
521
+
522
+ async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
523
+ if (labelIds.length === 0) return;
524
+ const existing = await dbOrTx
525
+ .select({ id: labels.id })
526
+ .from(labels)
527
+ .where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)));
528
+ if (existing.length !== new Set(labelIds).size) {
529
+ throw unprocessable("One or more labels are invalid for this company");
530
+ }
531
+ }
532
+
533
+ async function syncIssueLabels(
534
+ issueId: string,
535
+ companyId: string,
536
+ labelIds: string[],
537
+ dbOrTx: any = db,
538
+ ) {
539
+ const deduped = [...new Set(labelIds)];
540
+ await assertValidLabelIds(companyId, deduped, dbOrTx);
541
+ await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
542
+ if (deduped.length === 0) return;
543
+ await dbOrTx.insert(issueLabels).values(
544
+ deduped.map((labelId) => ({
545
+ issueId,
546
+ labelId,
547
+ companyId,
548
+ })),
549
+ );
550
+ }
551
+
552
+ async function isTerminalOrMissingHeartbeatRun(runId: string) {
553
+ const run = await db
554
+ .select({ status: heartbeatRuns.status })
555
+ .from(heartbeatRuns)
556
+ .where(eq(heartbeatRuns.id, runId))
557
+ .then((rows) => rows[0] ?? null);
558
+ if (!run) return true;
559
+ return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status);
560
+ }
561
+
562
+ async function adoptStaleCheckoutRun(input: {
563
+ issueId: string;
564
+ actorAgentId: string;
565
+ actorRunId: string;
566
+ expectedCheckoutRunId: string;
567
+ }) {
568
+ const stale = await isTerminalOrMissingHeartbeatRun(input.expectedCheckoutRunId);
569
+ if (!stale) return null;
570
+
571
+ const now = new Date();
572
+ const adopted = await db
573
+ .update(issues)
574
+ .set({
575
+ checkoutRunId: input.actorRunId,
576
+ executionRunId: input.actorRunId,
577
+ executionLockedAt: now,
578
+ updatedAt: now,
579
+ })
580
+ .where(
581
+ and(
582
+ eq(issues.id, input.issueId),
583
+ eq(issues.status, "in_progress"),
584
+ eq(issues.assigneeAgentId, input.actorAgentId),
585
+ eq(issues.checkoutRunId, input.expectedCheckoutRunId),
586
+ ),
587
+ )
588
+ .returning({
589
+ id: issues.id,
590
+ status: issues.status,
591
+ assigneeAgentId: issues.assigneeAgentId,
592
+ checkoutRunId: issues.checkoutRunId,
593
+ executionRunId: issues.executionRunId,
594
+ })
595
+ .then((rows) => rows[0] ?? null);
596
+
597
+ return adopted;
598
+ }
599
+
600
+ return {
601
+ list: async (companyId: string, filters?: IssueFilters) => {
602
+ const conditions = [eq(issues.companyId, companyId)];
603
+ const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
604
+ const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
605
+ const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
606
+ const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
607
+ const rawSearch = filters?.q?.trim() ?? "";
608
+ const hasSearch = rawSearch.length > 0;
609
+ const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
610
+ const startsWithPattern = `${escapedSearch}%`;
611
+ const containsPattern = `%${escapedSearch}%`;
612
+ const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
613
+ const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
614
+ const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
615
+ const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
616
+ const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
617
+ const commentContainsMatch = sql<boolean>`
618
+ EXISTS (
619
+ SELECT 1
620
+ FROM ${issueComments}
621
+ WHERE ${issueComments.issueId} = ${issues.id}
622
+ AND ${issueComments.companyId} = ${companyId}
623
+ AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
624
+ )
625
+ `;
626
+ if (filters?.status) {
627
+ const statuses = filters.status.split(",").map((s) => s.trim());
628
+ conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
629
+ }
630
+ if (filters?.assigneeAgentId) {
631
+ conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
632
+ }
633
+ if (filters?.participantAgentId) {
634
+ conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
635
+ }
636
+ if (filters?.assigneeUserId) {
637
+ conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
638
+ }
639
+ if (touchedByUserId) {
640
+ conditions.push(touchedByUserCondition(companyId, touchedByUserId));
641
+ }
642
+ if (inboxArchivedByUserId) {
643
+ conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId));
644
+ }
645
+ if (unreadForUserId) {
646
+ conditions.push(unreadForUserCondition(companyId, unreadForUserId));
647
+ }
648
+ if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
649
+ if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
650
+ if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
651
+ if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
652
+ if (filters?.labelId) {
653
+ const labeledIssueIds = await db
654
+ .select({ issueId: issueLabels.issueId })
655
+ .from(issueLabels)
656
+ .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId)));
657
+ if (labeledIssueIds.length === 0) return [];
658
+ conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
659
+ }
660
+ if (hasSearch) {
661
+ conditions.push(
662
+ or(
663
+ titleContainsMatch,
664
+ identifierContainsMatch,
665
+ descriptionContainsMatch,
666
+ commentContainsMatch,
667
+ )!,
668
+ );
669
+ }
670
+ if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
671
+ conditions.push(ne(issues.originKind, "routine_execution"));
672
+ }
673
+ conditions.push(isNull(issues.hiddenAt));
674
+
675
+ const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
676
+ const searchOrder = sql<number>`
677
+ CASE
678
+ WHEN ${titleStartsWithMatch} THEN 0
679
+ WHEN ${titleContainsMatch} THEN 1
680
+ WHEN ${identifierStartsWithMatch} THEN 2
681
+ WHEN ${identifierContainsMatch} THEN 3
682
+ WHEN ${descriptionContainsMatch} THEN 4
683
+ WHEN ${commentContainsMatch} THEN 5
684
+ ELSE 6
685
+ END
686
+ `;
687
+ const rows = await db
688
+ .select()
689
+ .from(issues)
690
+ .where(and(...conditions))
691
+ .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
692
+ const withLabels = await withIssueLabels(db, rows);
693
+ const runMap = await activeRunMapForIssues(db, withLabels);
694
+ const withRuns = withActiveRuns(withLabels, runMap);
695
+ if (!contextUserId || withRuns.length === 0) {
696
+ return withRuns;
697
+ }
698
+
699
+ const issueIds = withRuns.map((row) => row.id);
700
+ const statsRows = await db
701
+ .select({
702
+ issueId: issueComments.issueId,
703
+ myLastCommentAt: sql<Date | null>`
704
+ MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
705
+ `,
706
+ lastExternalCommentAt: sql<Date | null>`
707
+ MAX(
708
+ CASE
709
+ WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
710
+ THEN ${issueComments.createdAt}
711
+ END
712
+ )
713
+ `,
714
+ })
715
+ .from(issueComments)
716
+ .where(
717
+ and(
718
+ eq(issueComments.companyId, companyId),
719
+ inArray(issueComments.issueId, issueIds),
720
+ ),
721
+ )
722
+ .groupBy(issueComments.issueId);
723
+ const readRows = await db
724
+ .select({
725
+ issueId: issueReadStates.issueId,
726
+ myLastReadAt: issueReadStates.lastReadAt,
727
+ })
728
+ .from(issueReadStates)
729
+ .where(
730
+ and(
731
+ eq(issueReadStates.companyId, companyId),
732
+ eq(issueReadStates.userId, contextUserId),
733
+ inArray(issueReadStates.issueId, issueIds),
734
+ ),
735
+ );
736
+ const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
737
+ const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
738
+
739
+ return withRuns.map((row) => ({
740
+ ...row,
741
+ ...deriveIssueUserContext(row, contextUserId, {
742
+ myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
743
+ myLastReadAt: readByIssueId.get(row.id) ?? null,
744
+ lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
745
+ }),
746
+ }));
747
+ },
748
+
749
+ countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
750
+ const conditions = [
751
+ eq(issues.companyId, companyId),
752
+ isNull(issues.hiddenAt),
753
+ unreadForUserCondition(companyId, userId),
754
+ ne(issues.originKind, "routine_execution"),
755
+ ];
756
+ if (status) {
757
+ const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
758
+ if (statuses.length === 1) {
759
+ conditions.push(eq(issues.status, statuses[0]));
760
+ } else if (statuses.length > 1) {
761
+ conditions.push(inArray(issues.status, statuses));
762
+ }
763
+ }
764
+ const [row] = await db
765
+ .select({ count: sql<number>`count(*)` })
766
+ .from(issues)
767
+ .where(and(...conditions));
768
+ return Number(row?.count ?? 0);
769
+ },
770
+
771
+ markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => {
772
+ const now = new Date();
773
+ const [row] = await db
774
+ .insert(issueReadStates)
775
+ .values({
776
+ companyId,
777
+ issueId,
778
+ userId,
779
+ lastReadAt: readAt,
780
+ updatedAt: now,
781
+ })
782
+ .onConflictDoUpdate({
783
+ target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
784
+ set: {
785
+ lastReadAt: readAt,
786
+ updatedAt: now,
787
+ },
788
+ })
789
+ .returning();
790
+ return row;
791
+ },
792
+
793
+ archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
794
+ const now = new Date();
795
+ const [row] = await db
796
+ .insert(issueReadStates)
797
+ .values({
798
+ companyId,
799
+ issueId,
800
+ userId,
801
+ lastReadAt: archivedAt,
802
+ updatedAt: now,
803
+ })
804
+ .onConflictDoUpdate({
805
+ target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
806
+ set: {
807
+ lastReadAt: archivedAt,
808
+ updatedAt: now,
809
+ },
810
+ })
811
+ .returning();
812
+ return {
813
+ ...row,
814
+ archivedAt,
815
+ };
816
+ },
817
+
818
+ unarchiveInbox: async (companyId: string, issueId: string, userId: string) => {
819
+ const [row] = await db
820
+ .delete(issueReadStates)
821
+ .where(
822
+ and(
823
+ eq(issueReadStates.companyId, companyId),
824
+ eq(issueReadStates.issueId, issueId),
825
+ eq(issueReadStates.userId, userId),
826
+ ),
827
+ )
828
+ .returning();
829
+ return row ?? null;
830
+ },
831
+
832
+ getById: async (id: string) => {
833
+ const row = await db
834
+ .select()
835
+ .from(issues)
836
+ .where(eq(issues.id, id))
837
+ .then((rows) => rows[0] ?? null);
838
+ if (!row) return null;
839
+ const [enriched] = await withIssueLabels(db, [row]);
840
+ return enriched;
841
+ },
842
+
843
+ getByIdentifier: async (identifier: string) => {
844
+ const row = await db
845
+ .select()
846
+ .from(issues)
847
+ .where(eq(issues.identifier, identifier.toUpperCase()))
848
+ .then((rows) => rows[0] ?? null);
849
+ if (!row) return null;
850
+ const [enriched] = await withIssueLabels(db, [row]);
851
+ return enriched;
852
+ },
853
+
854
+ create: async (
855
+ companyId: string,
856
+ data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
857
+ ) => {
858
+ const { labelIds: inputLabelIds, ...issueData } = data;
859
+ const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
860
+ if (!isolatedWorkspacesEnabled) {
861
+ delete issueData.executionWorkspaceId;
862
+ delete issueData.executionWorkspacePreference;
863
+ delete issueData.executionWorkspaceSettings;
864
+ }
865
+ if (data.assigneeAgentId && data.assigneeUserId) {
866
+ throw unprocessable("Issue can only have one assignee");
867
+ }
868
+ if (data.assigneeAgentId) {
869
+ await assertAssignableAgent(companyId, data.assigneeAgentId);
870
+ }
871
+ if (data.assigneeUserId) {
872
+ await assertAssignableUser(companyId, data.assigneeUserId);
873
+ }
874
+ if (data.projectWorkspaceId) {
875
+ await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
876
+ }
877
+ if (data.executionWorkspaceId) {
878
+ await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
879
+ }
880
+ if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
881
+ throw unprocessable("in_progress issues require an assignee");
882
+ }
883
+ return db.transaction(async (tx) => {
884
+ const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
885
+ const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
886
+ let executionWorkspaceSettings =
887
+ (issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
888
+ if (executionWorkspaceSettings == null && issueData.projectId) {
889
+ const project = await tx
890
+ .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
891
+ .from(projects)
892
+ .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
893
+ .then((rows) => rows[0] ?? null);
894
+ executionWorkspaceSettings =
895
+ defaultIssueExecutionWorkspaceSettingsForProject(
896
+ gateProjectExecutionWorkspacePolicy(
897
+ parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
898
+ isolatedWorkspacesEnabled,
899
+ ),
900
+ ) as Record<string, unknown> | null;
901
+ }
902
+ let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
903
+ if (!projectWorkspaceId && issueData.projectId) {
904
+ const project = await tx
905
+ .select({
906
+ executionWorkspacePolicy: projects.executionWorkspacePolicy,
907
+ })
908
+ .from(projects)
909
+ .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
910
+ .then((rows) => rows[0] ?? null);
911
+ const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy);
912
+ projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
913
+ if (!projectWorkspaceId) {
914
+ projectWorkspaceId = await tx
915
+ .select({ id: projectWorkspaces.id })
916
+ .from(projectWorkspaces)
917
+ .where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId)))
918
+ .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
919
+ .then((rows) => rows[0]?.id ?? null);
920
+ }
921
+ }
922
+ const [company] = await tx
923
+ .update(companies)
924
+ .set({ issueCounter: sql`${companies.issueCounter} + 1` })
925
+ .where(eq(companies.id, companyId))
926
+ .returning({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix });
927
+
928
+ const issueNumber = company.issueCounter;
929
+ const identifier = `${company.issuePrefix}-${issueNumber}`;
930
+
931
+ const values = {
932
+ ...issueData,
933
+ originKind: issueData.originKind ?? "manual",
934
+ goalId: resolveIssueGoalId({
935
+ projectId: issueData.projectId,
936
+ goalId: issueData.goalId,
937
+ projectGoalId,
938
+ defaultGoalId: defaultCompanyGoal?.id ?? null,
939
+ }),
940
+ ...(projectWorkspaceId ? { projectWorkspaceId } : {}),
941
+ ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
942
+ companyId,
943
+ issueNumber,
944
+ identifier,
945
+ } as typeof issues.$inferInsert;
946
+ if (values.status === "in_progress" && !values.startedAt) {
947
+ values.startedAt = new Date();
948
+ }
949
+ if (values.status === "done") {
950
+ values.completedAt = new Date();
951
+ }
952
+ if (values.status === "cancelled") {
953
+ values.cancelledAt = new Date();
954
+ }
955
+
956
+ const [issue] = await tx.insert(issues).values(values).returning();
957
+ if (inputLabelIds) {
958
+ await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
959
+ }
960
+ const [enriched] = await withIssueLabels(tx, [issue]);
961
+ return enriched;
962
+ });
963
+ },
964
+
965
+ update: async (id: string, data: Partial<typeof issues.$inferInsert> & { labelIds?: string[] }) => {
966
+ const existing = await db
967
+ .select()
968
+ .from(issues)
969
+ .where(eq(issues.id, id))
970
+ .then((rows) => rows[0] ?? null);
971
+ if (!existing) return null;
972
+
973
+ const { labelIds: nextLabelIds, ...issueData } = data;
974
+ const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
975
+ if (!isolatedWorkspacesEnabled) {
976
+ delete issueData.executionWorkspaceId;
977
+ delete issueData.executionWorkspacePreference;
978
+ delete issueData.executionWorkspaceSettings;
979
+ }
980
+
981
+ if (issueData.status) {
982
+ assertTransition(existing.status, issueData.status);
983
+ }
984
+
985
+ const patch: Partial<typeof issues.$inferInsert> = {
986
+ ...issueData,
987
+ updatedAt: new Date(),
988
+ };
989
+
990
+ const nextAssigneeAgentId =
991
+ issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
992
+ const nextAssigneeUserId =
993
+ issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId;
994
+
995
+ if (nextAssigneeAgentId && nextAssigneeUserId) {
996
+ throw unprocessable("Issue can only have one assignee");
997
+ }
998
+ if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) {
999
+ throw unprocessable("in_progress issues require an assignee");
1000
+ }
1001
+ if (issueData.assigneeAgentId) {
1002
+ await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
1003
+ }
1004
+ if (issueData.assigneeUserId) {
1005
+ await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
1006
+ }
1007
+ const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
1008
+ const nextProjectWorkspaceId =
1009
+ issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
1010
+ const nextExecutionWorkspaceId =
1011
+ issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
1012
+ if (nextProjectWorkspaceId) {
1013
+ await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
1014
+ }
1015
+ if (nextExecutionWorkspaceId) {
1016
+ await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
1017
+ }
1018
+
1019
+ applyStatusSideEffects(issueData.status, patch);
1020
+ if (issueData.status && issueData.status !== "done") {
1021
+ patch.completedAt = null;
1022
+ }
1023
+ if (issueData.status && issueData.status !== "cancelled") {
1024
+ patch.cancelledAt = null;
1025
+ }
1026
+ if (issueData.status && issueData.status !== "in_progress") {
1027
+ patch.checkoutRunId = null;
1028
+ }
1029
+ if (
1030
+ (issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) ||
1031
+ (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId)
1032
+ ) {
1033
+ patch.checkoutRunId = null;
1034
+ }
1035
+
1036
+ return db.transaction(async (tx) => {
1037
+ const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
1038
+ const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
1039
+ getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
1040
+ getProjectDefaultGoalId(
1041
+ tx,
1042
+ existing.companyId,
1043
+ issueData.projectId !== undefined ? issueData.projectId : existing.projectId,
1044
+ ),
1045
+ ]);
1046
+ patch.goalId = resolveNextIssueGoalId({
1047
+ currentProjectId: existing.projectId,
1048
+ currentGoalId: existing.goalId,
1049
+ currentProjectGoalId,
1050
+ projectId: issueData.projectId,
1051
+ goalId: issueData.goalId,
1052
+ projectGoalId: nextProjectGoalId,
1053
+ defaultGoalId: defaultCompanyGoal?.id ?? null,
1054
+ });
1055
+ const updated = await tx
1056
+ .update(issues)
1057
+ .set(patch)
1058
+ .where(eq(issues.id, id))
1059
+ .returning()
1060
+ .then((rows) => rows[0] ?? null);
1061
+ if (!updated) return null;
1062
+ if (nextLabelIds !== undefined) {
1063
+ await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
1064
+ }
1065
+ const [enriched] = await withIssueLabels(tx, [updated]);
1066
+ return enriched;
1067
+ });
1068
+ },
1069
+
1070
+ remove: (id: string) =>
1071
+ db.transaction(async (tx) => {
1072
+ const attachmentAssetIds = await tx
1073
+ .select({ assetId: issueAttachments.assetId })
1074
+ .from(issueAttachments)
1075
+ .where(eq(issueAttachments.issueId, id));
1076
+ const issueDocumentIds = await tx
1077
+ .select({ documentId: issueDocuments.documentId })
1078
+ .from(issueDocuments)
1079
+ .where(eq(issueDocuments.issueId, id));
1080
+
1081
+ const removedIssue = await tx
1082
+ .delete(issues)
1083
+ .where(eq(issues.id, id))
1084
+ .returning()
1085
+ .then((rows) => rows[0] ?? null);
1086
+
1087
+ if (removedIssue && attachmentAssetIds.length > 0) {
1088
+ await tx
1089
+ .delete(assets)
1090
+ .where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
1091
+ }
1092
+
1093
+ if (removedIssue && issueDocumentIds.length > 0) {
1094
+ await tx
1095
+ .delete(documents)
1096
+ .where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId)));
1097
+ }
1098
+
1099
+ if (!removedIssue) return null;
1100
+ const [enriched] = await withIssueLabels(tx, [removedIssue]);
1101
+ return enriched;
1102
+ }),
1103
+
1104
+ checkout: async (id: string, agentId: string, expectedStatuses: string[], checkoutRunId: string | null) => {
1105
+ const issueCompany = await db
1106
+ .select({ companyId: issues.companyId })
1107
+ .from(issues)
1108
+ .where(eq(issues.id, id))
1109
+ .then((rows) => rows[0] ?? null);
1110
+ if (!issueCompany) throw notFound("Issue not found");
1111
+ await assertAssignableAgent(issueCompany.companyId, agentId);
1112
+
1113
+ const now = new Date();
1114
+ const sameRunAssigneeCondition = checkoutRunId
1115
+ ? and(
1116
+ eq(issues.assigneeAgentId, agentId),
1117
+ or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, checkoutRunId)),
1118
+ )
1119
+ : and(eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId));
1120
+ const executionLockCondition = checkoutRunId
1121
+ ? or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId))
1122
+ : isNull(issues.executionRunId);
1123
+ const updated = await db
1124
+ .update(issues)
1125
+ .set({
1126
+ assigneeAgentId: agentId,
1127
+ assigneeUserId: null,
1128
+ checkoutRunId,
1129
+ executionRunId: checkoutRunId,
1130
+ status: "in_progress",
1131
+ startedAt: now,
1132
+ updatedAt: now,
1133
+ })
1134
+ .where(
1135
+ and(
1136
+ eq(issues.id, id),
1137
+ inArray(issues.status, expectedStatuses),
1138
+ or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition),
1139
+ executionLockCondition,
1140
+ ),
1141
+ )
1142
+ .returning()
1143
+ .then((rows) => rows[0] ?? null);
1144
+
1145
+ if (updated) {
1146
+ const [enriched] = await withIssueLabels(db, [updated]);
1147
+ return enriched;
1148
+ }
1149
+
1150
+ const current = await db
1151
+ .select({
1152
+ id: issues.id,
1153
+ status: issues.status,
1154
+ assigneeAgentId: issues.assigneeAgentId,
1155
+ checkoutRunId: issues.checkoutRunId,
1156
+ executionRunId: issues.executionRunId,
1157
+ })
1158
+ .from(issues)
1159
+ .where(eq(issues.id, id))
1160
+ .then((rows) => rows[0] ?? null);
1161
+
1162
+ if (!current) throw notFound("Issue not found");
1163
+
1164
+ if (
1165
+ current.assigneeAgentId === agentId &&
1166
+ current.status === "in_progress" &&
1167
+ current.checkoutRunId == null &&
1168
+ (current.executionRunId == null || current.executionRunId === checkoutRunId) &&
1169
+ checkoutRunId
1170
+ ) {
1171
+ const adopted = await db
1172
+ .update(issues)
1173
+ .set({
1174
+ checkoutRunId,
1175
+ executionRunId: checkoutRunId,
1176
+ updatedAt: new Date(),
1177
+ })
1178
+ .where(
1179
+ and(
1180
+ eq(issues.id, id),
1181
+ eq(issues.status, "in_progress"),
1182
+ eq(issues.assigneeAgentId, agentId),
1183
+ isNull(issues.checkoutRunId),
1184
+ or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)),
1185
+ ),
1186
+ )
1187
+ .returning()
1188
+ .then((rows) => rows[0] ?? null);
1189
+ if (adopted) return adopted;
1190
+ }
1191
+
1192
+ if (
1193
+ checkoutRunId &&
1194
+ current.assigneeAgentId === agentId &&
1195
+ current.status === "in_progress" &&
1196
+ current.checkoutRunId &&
1197
+ current.checkoutRunId !== checkoutRunId
1198
+ ) {
1199
+ const adopted = await adoptStaleCheckoutRun({
1200
+ issueId: id,
1201
+ actorAgentId: agentId,
1202
+ actorRunId: checkoutRunId,
1203
+ expectedCheckoutRunId: current.checkoutRunId,
1204
+ });
1205
+ if (adopted) {
1206
+ const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
1207
+ const [enriched] = await withIssueLabels(db, [row]);
1208
+ return enriched;
1209
+ }
1210
+ }
1211
+
1212
+ // If this run already owns it and it's in_progress, return it (no self-409)
1213
+ if (
1214
+ current.assigneeAgentId === agentId &&
1215
+ current.status === "in_progress" &&
1216
+ sameRunLock(current.checkoutRunId, checkoutRunId)
1217
+ ) {
1218
+ const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
1219
+ const [enriched] = await withIssueLabels(db, [row]);
1220
+ return enriched;
1221
+ }
1222
+
1223
+ throw conflict("Issue checkout conflict", {
1224
+ issueId: current.id,
1225
+ status: current.status,
1226
+ assigneeAgentId: current.assigneeAgentId,
1227
+ checkoutRunId: current.checkoutRunId,
1228
+ executionRunId: current.executionRunId,
1229
+ });
1230
+ },
1231
+
1232
+ assertCheckoutOwner: async (id: string, actorAgentId: string, actorRunId: string | null) => {
1233
+ const current = await db
1234
+ .select({
1235
+ id: issues.id,
1236
+ status: issues.status,
1237
+ assigneeAgentId: issues.assigneeAgentId,
1238
+ checkoutRunId: issues.checkoutRunId,
1239
+ })
1240
+ .from(issues)
1241
+ .where(eq(issues.id, id))
1242
+ .then((rows) => rows[0] ?? null);
1243
+
1244
+ if (!current) throw notFound("Issue not found");
1245
+
1246
+ if (
1247
+ current.status === "in_progress" &&
1248
+ current.assigneeAgentId === actorAgentId &&
1249
+ sameRunLock(current.checkoutRunId, actorRunId)
1250
+ ) {
1251
+ return { ...current, adoptedFromRunId: null as string | null };
1252
+ }
1253
+
1254
+ if (
1255
+ actorRunId &&
1256
+ current.status === "in_progress" &&
1257
+ current.assigneeAgentId === actorAgentId &&
1258
+ current.checkoutRunId &&
1259
+ current.checkoutRunId !== actorRunId
1260
+ ) {
1261
+ const adopted = await adoptStaleCheckoutRun({
1262
+ issueId: id,
1263
+ actorAgentId,
1264
+ actorRunId,
1265
+ expectedCheckoutRunId: current.checkoutRunId,
1266
+ });
1267
+
1268
+ if (adopted) {
1269
+ return {
1270
+ ...adopted,
1271
+ adoptedFromRunId: current.checkoutRunId,
1272
+ };
1273
+ }
1274
+ }
1275
+
1276
+ throw conflict("Issue run ownership conflict", {
1277
+ issueId: current.id,
1278
+ status: current.status,
1279
+ assigneeAgentId: current.assigneeAgentId,
1280
+ checkoutRunId: current.checkoutRunId,
1281
+ actorAgentId,
1282
+ actorRunId,
1283
+ });
1284
+ },
1285
+
1286
+ release: async (id: string, actorAgentId?: string, actorRunId?: string | null) => {
1287
+ const existing = await db
1288
+ .select()
1289
+ .from(issues)
1290
+ .where(eq(issues.id, id))
1291
+ .then((rows) => rows[0] ?? null);
1292
+
1293
+ if (!existing) return null;
1294
+ if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
1295
+ throw conflict("Only assignee can release issue");
1296
+ }
1297
+ if (
1298
+ actorAgentId &&
1299
+ existing.status === "in_progress" &&
1300
+ existing.assigneeAgentId === actorAgentId &&
1301
+ existing.checkoutRunId &&
1302
+ !sameRunLock(existing.checkoutRunId, actorRunId ?? null)
1303
+ ) {
1304
+ throw conflict("Only checkout run can release issue", {
1305
+ issueId: existing.id,
1306
+ assigneeAgentId: existing.assigneeAgentId,
1307
+ checkoutRunId: existing.checkoutRunId,
1308
+ actorRunId: actorRunId ?? null,
1309
+ });
1310
+ }
1311
+
1312
+ const updated = await db
1313
+ .update(issues)
1314
+ .set({
1315
+ status: "todo",
1316
+ assigneeAgentId: null,
1317
+ checkoutRunId: null,
1318
+ updatedAt: new Date(),
1319
+ })
1320
+ .where(eq(issues.id, id))
1321
+ .returning()
1322
+ .then((rows) => rows[0] ?? null);
1323
+ if (!updated) return null;
1324
+ const [enriched] = await withIssueLabels(db, [updated]);
1325
+ return enriched;
1326
+ },
1327
+
1328
+ listLabels: (companyId: string) =>
1329
+ db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)),
1330
+
1331
+ getLabelById: (id: string) =>
1332
+ db
1333
+ .select()
1334
+ .from(labels)
1335
+ .where(eq(labels.id, id))
1336
+ .then((rows) => rows[0] ?? null),
1337
+
1338
+ createLabel: async (companyId: string, data: Pick<typeof labels.$inferInsert, "name" | "color">) => {
1339
+ const [created] = await db
1340
+ .insert(labels)
1341
+ .values({
1342
+ companyId,
1343
+ name: data.name.trim(),
1344
+ color: data.color,
1345
+ })
1346
+ .returning();
1347
+ return created;
1348
+ },
1349
+
1350
+ deleteLabel: async (id: string) =>
1351
+ db
1352
+ .delete(labels)
1353
+ .where(eq(labels.id, id))
1354
+ .returning()
1355
+ .then((rows) => rows[0] ?? null),
1356
+
1357
+ listComments: async (
1358
+ issueId: string,
1359
+ opts?: {
1360
+ afterCommentId?: string | null;
1361
+ order?: "asc" | "desc";
1362
+ limit?: number | null;
1363
+ },
1364
+ ) => {
1365
+ const order = opts?.order === "asc" ? "asc" : "desc";
1366
+ const afterCommentId = opts?.afterCommentId?.trim() || null;
1367
+ const limit =
1368
+ opts?.limit && opts.limit > 0
1369
+ ? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
1370
+ : null;
1371
+
1372
+ const conditions = [eq(issueComments.issueId, issueId)];
1373
+ if (afterCommentId) {
1374
+ const anchor = await db
1375
+ .select({
1376
+ id: issueComments.id,
1377
+ createdAt: issueComments.createdAt,
1378
+ })
1379
+ .from(issueComments)
1380
+ .where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId)))
1381
+ .then((rows) => rows[0] ?? null);
1382
+
1383
+ if (!anchor) return [];
1384
+ conditions.push(
1385
+ order === "asc"
1386
+ ? sql<boolean>`(
1387
+ ${issueComments.createdAt} > ${anchor.createdAt}
1388
+ OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
1389
+ )`
1390
+ : sql<boolean>`(
1391
+ ${issueComments.createdAt} < ${anchor.createdAt}
1392
+ OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id})
1393
+ )`,
1394
+ );
1395
+ }
1396
+
1397
+ const query = db
1398
+ .select()
1399
+ .from(issueComments)
1400
+ .where(and(...conditions))
1401
+ .orderBy(
1402
+ order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt),
1403
+ order === "asc" ? asc(issueComments.id) : desc(issueComments.id),
1404
+ );
1405
+
1406
+ const comments = limit ? await query.limit(limit) : await query;
1407
+ const { censorUsernameInLogs } = await instanceSettings.getGeneral();
1408
+ return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
1409
+ },
1410
+
1411
+ getCommentCursor: async (issueId: string) => {
1412
+ const [latest, countRow] = await Promise.all([
1413
+ db
1414
+ .select({
1415
+ latestCommentId: issueComments.id,
1416
+ latestCommentAt: issueComments.createdAt,
1417
+ })
1418
+ .from(issueComments)
1419
+ .where(eq(issueComments.issueId, issueId))
1420
+ .orderBy(desc(issueComments.createdAt), desc(issueComments.id))
1421
+ .limit(1)
1422
+ .then((rows) => rows[0] ?? null),
1423
+ db
1424
+ .select({
1425
+ totalComments: sql<number>`count(*)::int`,
1426
+ })
1427
+ .from(issueComments)
1428
+ .where(eq(issueComments.issueId, issueId))
1429
+ .then((rows) => rows[0] ?? null),
1430
+ ]);
1431
+
1432
+ return {
1433
+ totalComments: Number(countRow?.totalComments ?? 0),
1434
+ latestCommentId: latest?.latestCommentId ?? null,
1435
+ latestCommentAt: latest?.latestCommentAt ?? null,
1436
+ };
1437
+ },
1438
+
1439
+ getComment: (commentId: string) =>
1440
+ instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
1441
+ db
1442
+ .select()
1443
+ .from(issueComments)
1444
+ .where(eq(issueComments.id, commentId))
1445
+ .then((rows) => {
1446
+ const comment = rows[0] ?? null;
1447
+ return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
1448
+ })),
1449
+
1450
+ addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
1451
+ const issue = await db
1452
+ .select({ companyId: issues.companyId })
1453
+ .from(issues)
1454
+ .where(eq(issues.id, issueId))
1455
+ .then((rows) => rows[0] ?? null);
1456
+
1457
+ if (!issue) throw notFound("Issue not found");
1458
+
1459
+ const currentUserRedactionOptions = {
1460
+ enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
1461
+ };
1462
+ const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
1463
+ const [comment] = await db
1464
+ .insert(issueComments)
1465
+ .values({
1466
+ companyId: issue.companyId,
1467
+ issueId,
1468
+ authorAgentId: actor.agentId ?? null,
1469
+ authorUserId: actor.userId ?? null,
1470
+ body: redactedBody,
1471
+ })
1472
+ .returning();
1473
+
1474
+ // Update issue's updatedAt so comment activity is reflected in recency sorting
1475
+ await db
1476
+ .update(issues)
1477
+ .set({ updatedAt: new Date() })
1478
+ .where(eq(issues.id, issueId));
1479
+
1480
+ return redactIssueComment(comment, currentUserRedactionOptions.enabled);
1481
+ },
1482
+
1483
+ createAttachment: async (input: {
1484
+ issueId: string;
1485
+ issueCommentId?: string | null;
1486
+ provider: string;
1487
+ objectKey: string;
1488
+ contentType: string;
1489
+ byteSize: number;
1490
+ sha256: string;
1491
+ originalFilename?: string | null;
1492
+ createdByAgentId?: string | null;
1493
+ createdByUserId?: string | null;
1494
+ }) => {
1495
+ const issue = await db
1496
+ .select({ id: issues.id, companyId: issues.companyId })
1497
+ .from(issues)
1498
+ .where(eq(issues.id, input.issueId))
1499
+ .then((rows) => rows[0] ?? null);
1500
+ if (!issue) throw notFound("Issue not found");
1501
+
1502
+ if (input.issueCommentId) {
1503
+ const comment = await db
1504
+ .select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId })
1505
+ .from(issueComments)
1506
+ .where(eq(issueComments.id, input.issueCommentId))
1507
+ .then((rows) => rows[0] ?? null);
1508
+ if (!comment) throw notFound("Issue comment not found");
1509
+ if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) {
1510
+ throw unprocessable("Attachment comment must belong to same issue and company");
1511
+ }
1512
+ }
1513
+
1514
+ return db.transaction(async (tx) => {
1515
+ const [asset] = await tx
1516
+ .insert(assets)
1517
+ .values({
1518
+ companyId: issue.companyId,
1519
+ provider: input.provider,
1520
+ objectKey: input.objectKey,
1521
+ contentType: input.contentType,
1522
+ byteSize: input.byteSize,
1523
+ sha256: input.sha256,
1524
+ originalFilename: input.originalFilename ?? null,
1525
+ createdByAgentId: input.createdByAgentId ?? null,
1526
+ createdByUserId: input.createdByUserId ?? null,
1527
+ })
1528
+ .returning();
1529
+
1530
+ const [attachment] = await tx
1531
+ .insert(issueAttachments)
1532
+ .values({
1533
+ companyId: issue.companyId,
1534
+ issueId: issue.id,
1535
+ assetId: asset.id,
1536
+ issueCommentId: input.issueCommentId ?? null,
1537
+ })
1538
+ .returning();
1539
+
1540
+ return {
1541
+ id: attachment.id,
1542
+ companyId: attachment.companyId,
1543
+ issueId: attachment.issueId,
1544
+ issueCommentId: attachment.issueCommentId,
1545
+ assetId: attachment.assetId,
1546
+ provider: asset.provider,
1547
+ objectKey: asset.objectKey,
1548
+ contentType: asset.contentType,
1549
+ byteSize: asset.byteSize,
1550
+ sha256: asset.sha256,
1551
+ originalFilename: asset.originalFilename,
1552
+ createdByAgentId: asset.createdByAgentId,
1553
+ createdByUserId: asset.createdByUserId,
1554
+ createdAt: attachment.createdAt,
1555
+ updatedAt: attachment.updatedAt,
1556
+ };
1557
+ });
1558
+ },
1559
+
1560
+ listAttachments: async (issueId: string) =>
1561
+ db
1562
+ .select({
1563
+ id: issueAttachments.id,
1564
+ companyId: issueAttachments.companyId,
1565
+ issueId: issueAttachments.issueId,
1566
+ issueCommentId: issueAttachments.issueCommentId,
1567
+ assetId: issueAttachments.assetId,
1568
+ provider: assets.provider,
1569
+ objectKey: assets.objectKey,
1570
+ contentType: assets.contentType,
1571
+ byteSize: assets.byteSize,
1572
+ sha256: assets.sha256,
1573
+ originalFilename: assets.originalFilename,
1574
+ createdByAgentId: assets.createdByAgentId,
1575
+ createdByUserId: assets.createdByUserId,
1576
+ createdAt: issueAttachments.createdAt,
1577
+ updatedAt: issueAttachments.updatedAt,
1578
+ })
1579
+ .from(issueAttachments)
1580
+ .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
1581
+ .where(eq(issueAttachments.issueId, issueId))
1582
+ .orderBy(desc(issueAttachments.createdAt)),
1583
+
1584
+ getAttachmentById: async (id: string) =>
1585
+ db
1586
+ .select({
1587
+ id: issueAttachments.id,
1588
+ companyId: issueAttachments.companyId,
1589
+ issueId: issueAttachments.issueId,
1590
+ issueCommentId: issueAttachments.issueCommentId,
1591
+ assetId: issueAttachments.assetId,
1592
+ provider: assets.provider,
1593
+ objectKey: assets.objectKey,
1594
+ contentType: assets.contentType,
1595
+ byteSize: assets.byteSize,
1596
+ sha256: assets.sha256,
1597
+ originalFilename: assets.originalFilename,
1598
+ createdByAgentId: assets.createdByAgentId,
1599
+ createdByUserId: assets.createdByUserId,
1600
+ createdAt: issueAttachments.createdAt,
1601
+ updatedAt: issueAttachments.updatedAt,
1602
+ })
1603
+ .from(issueAttachments)
1604
+ .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
1605
+ .where(eq(issueAttachments.id, id))
1606
+ .then((rows) => rows[0] ?? null),
1607
+
1608
+ removeAttachment: async (id: string) =>
1609
+ db.transaction(async (tx) => {
1610
+ const existing = await tx
1611
+ .select({
1612
+ id: issueAttachments.id,
1613
+ companyId: issueAttachments.companyId,
1614
+ issueId: issueAttachments.issueId,
1615
+ issueCommentId: issueAttachments.issueCommentId,
1616
+ assetId: issueAttachments.assetId,
1617
+ provider: assets.provider,
1618
+ objectKey: assets.objectKey,
1619
+ contentType: assets.contentType,
1620
+ byteSize: assets.byteSize,
1621
+ sha256: assets.sha256,
1622
+ originalFilename: assets.originalFilename,
1623
+ createdByAgentId: assets.createdByAgentId,
1624
+ createdByUserId: assets.createdByUserId,
1625
+ createdAt: issueAttachments.createdAt,
1626
+ updatedAt: issueAttachments.updatedAt,
1627
+ })
1628
+ .from(issueAttachments)
1629
+ .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
1630
+ .where(eq(issueAttachments.id, id))
1631
+ .then((rows) => rows[0] ?? null);
1632
+ if (!existing) return null;
1633
+
1634
+ await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
1635
+ await tx.delete(assets).where(eq(assets.id, existing.assetId));
1636
+ return existing;
1637
+ }),
1638
+
1639
+ findMentionedAgents: async (companyId: string, body: string) => {
1640
+ const re = /\B@([^\s@,!?.]+)/g;
1641
+ const tokens = new Set<string>();
1642
+ let m: RegExpExecArray | null;
1643
+ while ((m = re.exec(body)) !== null) {
1644
+ const normalized = normalizeAgentMentionToken(m[1]);
1645
+ if (normalized) tokens.add(normalized.toLowerCase());
1646
+ }
1647
+
1648
+ const explicitAgentMentionIds = extractAgentMentionIds(body);
1649
+ if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return [];
1650
+ const rows = await db.select({ id: agents.id, name: agents.name })
1651
+ .from(agents).where(eq(agents.companyId, companyId));
1652
+ const resolved = new Set<string>(explicitAgentMentionIds);
1653
+ for (const agent of rows) {
1654
+ if (tokens.has(agent.name.toLowerCase())) {
1655
+ resolved.add(agent.id);
1656
+ }
1657
+ }
1658
+ return [...resolved];
1659
+ },
1660
+
1661
+ findMentionedProjectIds: async (issueId: string) => {
1662
+ const issue = await db
1663
+ .select({
1664
+ companyId: issues.companyId,
1665
+ title: issues.title,
1666
+ description: issues.description,
1667
+ })
1668
+ .from(issues)
1669
+ .where(eq(issues.id, issueId))
1670
+ .then((rows) => rows[0] ?? null);
1671
+ if (!issue) return [];
1672
+
1673
+ const comments = await db
1674
+ .select({ body: issueComments.body })
1675
+ .from(issueComments)
1676
+ .where(eq(issueComments.issueId, issueId));
1677
+
1678
+ const mentionedIds = new Set<string>();
1679
+ for (const source of [
1680
+ issue.title,
1681
+ issue.description ?? "",
1682
+ ...comments.map((comment) => comment.body),
1683
+ ]) {
1684
+ for (const projectId of extractProjectMentionIds(source)) {
1685
+ mentionedIds.add(projectId);
1686
+ }
1687
+ }
1688
+ if (mentionedIds.size === 0) return [];
1689
+
1690
+ const rows = await db
1691
+ .select({ id: projects.id })
1692
+ .from(projects)
1693
+ .where(
1694
+ and(
1695
+ eq(projects.companyId, issue.companyId),
1696
+ inArray(projects.id, [...mentionedIds]),
1697
+ ),
1698
+ );
1699
+ const valid = new Set(rows.map((row) => row.id));
1700
+ return [...mentionedIds].filter((projectId) => valid.has(projectId));
1701
+ },
1702
+
1703
+ getAncestors: async (issueId: string) => {
1704
+ const raw: Array<{
1705
+ id: string; identifier: string | null; title: string; description: string | null;
1706
+ status: string; priority: string;
1707
+ assigneeAgentId: string | null; projectId: string | null; goalId: string | null;
1708
+ }> = [];
1709
+ const visited = new Set<string>([issueId]);
1710
+ const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null);
1711
+ let currentId = start?.parentId ?? null;
1712
+ while (currentId && !visited.has(currentId) && raw.length < 50) {
1713
+ visited.add(currentId);
1714
+ const parent = await db.select({
1715
+ id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description,
1716
+ status: issues.status, priority: issues.priority,
1717
+ assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId,
1718
+ goalId: issues.goalId, parentId: issues.parentId,
1719
+ }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null);
1720
+ if (!parent) break;
1721
+ raw.push({
1722
+ id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null,
1723
+ status: parent.status, priority: parent.priority,
1724
+ assigneeAgentId: parent.assigneeAgentId ?? null,
1725
+ projectId: parent.projectId ?? null, goalId: parent.goalId ?? null,
1726
+ });
1727
+ currentId = parent.parentId ?? null;
1728
+ }
1729
+
1730
+ // Batch-fetch referenced projects and goals
1731
+ const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))];
1732
+ const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))];
1733
+
1734
+ const projectMap = new Map<string, {
1735
+ id: string;
1736
+ name: string;
1737
+ description: string | null;
1738
+ status: string;
1739
+ goalId: string | null;
1740
+ workspaces: Array<{
1741
+ id: string;
1742
+ companyId: string;
1743
+ projectId: string;
1744
+ name: string;
1745
+ cwd: string | null;
1746
+ repoUrl: string | null;
1747
+ repoRef: string | null;
1748
+ metadata: Record<string, unknown> | null;
1749
+ isPrimary: boolean;
1750
+ createdAt: Date;
1751
+ updatedAt: Date;
1752
+ }>;
1753
+ primaryWorkspace: {
1754
+ id: string;
1755
+ companyId: string;
1756
+ projectId: string;
1757
+ name: string;
1758
+ cwd: string | null;
1759
+ repoUrl: string | null;
1760
+ repoRef: string | null;
1761
+ metadata: Record<string, unknown> | null;
1762
+ isPrimary: boolean;
1763
+ createdAt: Date;
1764
+ updatedAt: Date;
1765
+ } | null;
1766
+ }>();
1767
+ const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>();
1768
+
1769
+ if (projectIds.length > 0) {
1770
+ const workspaceRows = await db
1771
+ .select()
1772
+ .from(projectWorkspaces)
1773
+ .where(inArray(projectWorkspaces.projectId, projectIds))
1774
+ .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
1775
+ const workspaceMap = new Map<string, Array<(typeof workspaceRows)[number]>>();
1776
+ for (const workspace of workspaceRows) {
1777
+ const existing = workspaceMap.get(workspace.projectId);
1778
+ if (existing) existing.push(workspace);
1779
+ else workspaceMap.set(workspace.projectId, [workspace]);
1780
+ }
1781
+
1782
+ const rows = await db.select({
1783
+ id: projects.id, name: projects.name, description: projects.description,
1784
+ status: projects.status, goalId: projects.goalId,
1785
+ }).from(projects).where(inArray(projects.id, projectIds));
1786
+ for (const r of rows) {
1787
+ const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
1788
+ const workspaces = projectWorkspaceRows.map((workspace) => ({
1789
+ id: workspace.id,
1790
+ companyId: workspace.companyId,
1791
+ projectId: workspace.projectId,
1792
+ name: workspace.name,
1793
+ cwd: workspace.cwd,
1794
+ repoUrl: workspace.repoUrl ?? null,
1795
+ repoRef: workspace.repoRef ?? null,
1796
+ metadata: (workspace.metadata as Record<string, unknown> | null) ?? null,
1797
+ isPrimary: workspace.isPrimary,
1798
+ createdAt: workspace.createdAt,
1799
+ updatedAt: workspace.updatedAt,
1800
+ }));
1801
+ const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
1802
+ projectMap.set(r.id, {
1803
+ ...r,
1804
+ workspaces,
1805
+ primaryWorkspace,
1806
+ });
1807
+ // Also collect goalIds from projects
1808
+ if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
1809
+ }
1810
+ }
1811
+
1812
+ if (goalIds.length > 0) {
1813
+ const rows = await db.select({
1814
+ id: goals.id, title: goals.title, description: goals.description,
1815
+ level: goals.level, status: goals.status,
1816
+ }).from(goals).where(inArray(goals.id, goalIds));
1817
+ for (const r of rows) goalMap.set(r.id, r);
1818
+ }
1819
+
1820
+ return raw.map(a => ({
1821
+ ...a,
1822
+ project: a.projectId ? projectMap.get(a.projectId) ?? null : null,
1823
+ goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
1824
+ }));
1825
+ },
1826
+ };
1827
+ }
1828
+