@app-vitals/agent-admin 0.0.0-stage → 0.1.0

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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +64 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
package/src/agents.ts ADDED
@@ -0,0 +1,1097 @@
1
+ /**
2
+ * agent/src/agents.ts
3
+ * AgentService — CRUD/read access to the Agent model.
4
+ *
5
+ * Mirrors the sibling *Service modules (AgentEnvService, AgentTokenService,
6
+ * etc.) so route handlers never call prisma.agent.* directly.
7
+ */
8
+
9
+ import { findRepoNameCollisions } from "@app-vitals/agent-lib/clone-plan";
10
+ import { DEFAULT_AGENT_ENV } from "@app-vitals/agent-lib/default-agent-env";
11
+ import { isGithubLogin } from "@app-vitals/agent-lib/github-login";
12
+ import { isOrgRepo } from "@app-vitals/agent-lib/org-repo";
13
+ import { SECRET_ENV_VARS } from "@app-vitals/agent-lib/secret-env-vars";
14
+ import type { PrismaClient } from "../prisma/client/client.ts";
15
+ import type { AgentAnthropicIdentity } from "./agent-anthropic-identity.ts";
16
+ import type { AgentCronJobService } from "./agent-cron-jobs.ts";
17
+ import type { AgentEnvService } from "./agent-envs.ts";
18
+ import { AgentMemberService } from "./agent-members.ts";
19
+ import type { AgentPluginService } from "./agent-plugins.ts";
20
+ import type { AgentProvisioner } from "./agent-provisioner.ts";
21
+ import type { AgentToolService } from "./agent-tools.ts";
22
+ import {
23
+ type SpecialtyManifestResolver,
24
+ SpecialtyRegistry,
25
+ } from "./specialty-manifest-loader.ts";
26
+ import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
27
+ import type { PrismaTransactionClient } from "./prisma-tx.ts";
28
+
29
+ // ─── Types ────────────────────────────────────────────────────────────────────
30
+
31
+ interface CreateAgentInput {
32
+ name: string;
33
+ slackId?: string | null;
34
+ selfHosted?: boolean;
35
+ /** Specialty name. Omitted means Prisma's column default ("coding") applies. */
36
+ typeName?: string;
37
+ /** Initial repos[] — the manifest repos merged with any request-supplied repos. */
38
+ repos?: string[];
39
+ /** Initial reviewAuthorAllowlist[]. */
40
+ reviewAuthorAllowlist?: string[];
41
+ /** Initial patchAuthorAllowlist[]. Independent of reviewAuthorAllowlist. */
42
+ patchAuthorAllowlist?: string[];
43
+ /** Initial restrictSlackToMembers flag. Omitted means the column default (false) applies. */
44
+ restrictSlackToMembers?: boolean;
45
+ /** Owning Account (SSP-4.1). Omitted means the agent has no account (legacy behavior). */
46
+ accountId?: string;
47
+ /** Owner's email (PAI-1.2). Already normalized by createAgent(); omitted means no owner. */
48
+ ownerEmail?: string;
49
+ }
50
+
51
+ export interface AgentRecord {
52
+ id: string;
53
+ name: string;
54
+ slackId: string | null;
55
+ selfHosted: boolean;
56
+ typeName: string;
57
+ createdAt: Date;
58
+ updatedAt: Date;
59
+ /** Owning account (SSP-5.2 admin Account column); null for legacy agents. */
60
+ accountId?: string | null;
61
+ /**
62
+ * Optional here even though listAll()/listByIds()/searchByName() below
63
+ * always return the full Prisma row (repos included, since none of them
64
+ * pass a `select` clause) — kept optional so existing call sites/test
65
+ * doubles constructing a narrower AgentRecord literal don't need updating.
66
+ * Consumed by the merged fleet-wide queue-activity view (AAV-2.1) to build
67
+ * the repo -> agentId[] eligibility index (agent-work-queue-merge.ts's
68
+ * buildEligibilityIndex()).
69
+ */
70
+ repos?: string[];
71
+ }
72
+
73
+ /**
74
+ * The owner and per-agent Anthropic identity IDs (inherited, all optional) are
75
+ * selected by list()/getSummary() so reconcile targets carry them; GET /agents
76
+ * strips them from its response.
77
+ */
78
+ export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
79
+ id: string;
80
+ name: string;
81
+ selfHosted: boolean;
82
+ typeName: string;
83
+ /** Owning account; null/absent for platform agents. */
84
+ accountId?: string | null;
85
+ }
86
+
87
+ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
88
+ id: string;
89
+ name: string;
90
+ slackId: string | null;
91
+ selfHosted: boolean;
92
+ repos: string[];
93
+ reviewAuthorAllowlist: string[];
94
+ patchAuthorAllowlist: string[];
95
+ restrictSlackToMembers: boolean;
96
+ typeName: string;
97
+ /**
98
+ * Owning account, null for unassigned (platform) agents. Optional here so
99
+ * existing test doubles constructing a narrower AgentDetail literal don't
100
+ * need updating.
101
+ */
102
+ accountId?: string | null;
103
+ createdAt: Date;
104
+ updatedAt: Date;
105
+ /**
106
+ * Required env keys declared by the agent's type manifest that have no
107
+ * corresponding AgentEnv row yet — key names only, never values
108
+ * (secrets_in_logs). Always present (empty array when the type's required
109
+ * contract is empty or every required key is set). Informational only —
110
+ * not a create blocker or provisioning gate (ATS-4.2).
111
+ */
112
+ missingRequiredEnv: string[];
113
+ /**
114
+ * ATE-1.1: trial expiry tracking, both additive/nullable. trialExpiresAt is
115
+ * settable via PATCH /agents/:id (see UpdateSelfHostedInput below).
116
+ * trialExpiryWarnedAt is written internally by ATE-2.1's warning check —
117
+ * read-only via this route, GET/PATCH responses only. Optional here (like
118
+ * `repos` above) so existing test doubles constructing a narrower
119
+ * AgentDetail literal don't need updating.
120
+ */
121
+ trialExpiresAt?: Date | null;
122
+ trialExpiryWarnedAt?: Date | null;
123
+ /**
124
+ * APM-1.3: the six real agent-policy fields (APM-1.1 DB columns,
125
+ * Prisma-column defaults noted per field below). Optional here (like
126
+ * `repos`/`trialExpiresAt` above) so existing test doubles constructing a
127
+ * narrower AgentDetail literal don't need updating.
128
+ */
129
+ autoPostReviews?: boolean;
130
+ allowSelfReview?: boolean;
131
+ minConfidence?: number;
132
+ maxFindings?: number;
133
+ cleanupMergedWorktrees?: boolean;
134
+ cleanupAfterDays?: number;
135
+ }
136
+
137
+ interface UpdateSelfHostedInput {
138
+ /** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
139
+ accountId?: string | null;
140
+ /**
141
+ * Optional to mirror Prisma's generated AgentUpdateInput shape (undefined
142
+ * means "leave unchanged"); callers such as PATCH /agents/:id currently
143
+ * always pass a value since selfHosted is treated as required at the route
144
+ * level, but the type stays permissive to match the underlying data layer.
145
+ */
146
+ selfHosted?: boolean;
147
+ repos?: string[];
148
+ reviewAuthorAllowlist?: string[];
149
+ patchAuthorAllowlist?: string[];
150
+ restrictSlackToMembers?: boolean;
151
+ /**
152
+ * Backfills agent.slackId (UAP-1.3) — nullable so it can also be
153
+ * explicitly cleared via PATCH /agents/:id.
154
+ */
155
+ slackId?: string | null;
156
+ /**
157
+ * ATE-1.1: nullable so it can also be explicitly cleared via
158
+ * PATCH /agents/:id (omitted entirely means "leave unchanged").
159
+ */
160
+ trialExpiresAt?: Date | null;
161
+ /** APM-1.3: see AgentDetail's doc comment for these six fields. */
162
+ autoPostReviews?: boolean;
163
+ allowSelfReview?: boolean;
164
+ minConfidence?: number;
165
+ maxFindings?: number;
166
+ cleanupMergedWorktrees?: boolean;
167
+ cleanupAfterDays?: number;
168
+ }
169
+
170
+ interface AgentIdAndRepos {
171
+ id: string;
172
+ repos: string[];
173
+ reviewAuthorAllowlist: string[];
174
+ patchAuthorAllowlist: string[];
175
+ restrictSlackToMembers: boolean;
176
+ memberEmails: string[];
177
+ /**
178
+ * ATE-3.1: read by the runtime config route so the agent process can gate
179
+ * Slack access once its trial has passed. Optional so pre-existing
180
+ * fixtures/doubles built against this interface before ATE-3.1 (e.g.
181
+ * agents.unit.test.ts's getById() `toEqual` fixtures) keep compiling
182
+ * unchanged.
183
+ */
184
+ trialExpiresAt?: Date | null;
185
+ /**
186
+ * WIF-2.1: read by the runtime config route to decide whether Anthropic
187
+ * credentials are stripped under federation. Optional for fixture compat.
188
+ */
189
+ selfHosted?: boolean;
190
+ /**
191
+ * SSP-8.3: status of the owning Account, or null when the agent has none.
192
+ * Read by the runtime config route so the agent process can pause Slack.
193
+ * Optional for the same fixture-compat reason as trialExpiresAt.
194
+ */
195
+ accountStatus?: string | null;
196
+ /**
197
+ * ARD-6.1: the agent's Specialty name, surfaced on the runtime config
198
+ * response so the agent process can pick its workspace template directory.
199
+ * Optional for the same fixture-compat reason as trialExpiresAt.
200
+ */
201
+ typeName?: string;
202
+ /**
203
+ * APM-1.5: the 6 agent-policy fields, read by the runtime GET /:id/config
204
+ * route for direct passthrough. Required (not optional) since the Prisma
205
+ * select below always returns them (each column has a schema default).
206
+ */
207
+ autoPostReviews: boolean;
208
+ allowSelfReview: boolean;
209
+ minConfidence: number;
210
+ maxFindings: number;
211
+ cleanupMergedWorktrees: boolean;
212
+ cleanupAfterDays: number;
213
+ }
214
+
215
+ export interface AgentOption {
216
+ id: string;
217
+ name: string;
218
+ }
219
+
220
+ export interface UpdateAgentFieldsInput {
221
+ name?: string;
222
+ /** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
223
+ accountId?: string | null;
224
+ repos?: string[];
225
+ reviewAuthorAllowlist?: string[];
226
+ patchAuthorAllowlist?: string[];
227
+ restrictSlackToMembers?: boolean;
228
+ selfHosted?: boolean;
229
+ slackId?: string | null;
230
+ /**
231
+ * ATE-2.1: stamped internally by TrialExpiryWarningSweeper once a warning
232
+ * Slack alert has been sent — never accepted from PATCH /agents/:id (see
233
+ * agents-api.ts's PatchAgentBodySchema, which omits this field entirely).
234
+ */
235
+ trialExpiryWarnedAt?: Date | null;
236
+ /** APM-1.3: see AgentDetail's doc comment for these six fields. */
237
+ autoPostReviews?: boolean;
238
+ allowSelfReview?: boolean;
239
+ minConfidence?: number;
240
+ maxFindings?: number;
241
+ cleanupMergedWorktrees?: boolean;
242
+ cleanupAfterDays?: number;
243
+ }
244
+
245
+ // ─── Select shapes ────────────────────────────────────────────────────────────
246
+
247
+ const SUMMARY_SELECT = {
248
+ id: true,
249
+ name: true,
250
+ selfHosted: true,
251
+ typeName: true,
252
+ accountId: true,
253
+ ownerEmail: true,
254
+ anthropicIdentityName: true,
255
+ anthropicServiceAccountId: true,
256
+ anthropicFederationRuleId: true,
257
+ k8sServiceAccountName: true,
258
+ } as const;
259
+
260
+ const DETAIL_SELECT = {
261
+ id: true,
262
+ name: true,
263
+ slackId: true,
264
+ selfHosted: true,
265
+ repos: true,
266
+ reviewAuthorAllowlist: true,
267
+ patchAuthorAllowlist: true,
268
+ restrictSlackToMembers: true,
269
+ typeName: true,
270
+ accountId: true,
271
+ createdAt: true,
272
+ updatedAt: true,
273
+ trialExpiresAt: true,
274
+ trialExpiryWarnedAt: true,
275
+ autoPostReviews: true,
276
+ allowSelfReview: true,
277
+ minConfidence: true,
278
+ maxFindings: true,
279
+ cleanupMergedWorktrees: true,
280
+ cleanupAfterDays: true,
281
+ // ownerEmail and the three Anthropic identity fields are returned by GET
282
+ // /agents/:id (serializeAgent picks them explicitly). k8sServiceAccountName
283
+ // stays out of the API response; the post-PATCH reconcile of the agent's
284
+ // workload needs it.
285
+ ownerEmail: true,
286
+ anthropicIdentityName: true,
287
+ anthropicServiceAccountId: true,
288
+ anthropicFederationRuleId: true,
289
+ k8sServiceAccountName: true,
290
+ } as const;
291
+
292
+ // ─── Service ──────────────────────────────────────────────────────────────────
293
+
294
+ export class AgentService {
295
+ constructor(
296
+ private prisma: PrismaClient,
297
+ private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
298
+ private agentMemberService: Pick<
299
+ AgentMemberService,
300
+ "listByAgentId"
301
+ > = new AgentMemberService(prisma),
302
+ ) {}
303
+
304
+ /**
305
+ * Create a new agent row, plus `missingRequiredEnv` (see
306
+ * computeMissingRequiredEnv) — a freshly created agent has no AgentEnv rows
307
+ * yet, so this always reflects the full set of the type's required keys.
308
+ *
309
+ * @param client - defaults to `this.prisma`; pass the `tx` argument from a
310
+ * caller's `prisma.$transaction(async (tx) => ...)` to make this write
311
+ * participate in that transaction (see createAgent() below).
312
+ */
313
+ async create(
314
+ input: CreateAgentInput,
315
+ client: PrismaTransactionClient = this.prisma,
316
+ ): Promise<AgentDetail> {
317
+ const row = await client.agent.create({
318
+ data: {
319
+ name: input.name,
320
+ slackId: input.slackId ?? null,
321
+ selfHosted: input.selfHosted ?? false,
322
+ ...(input.typeName !== undefined ? { typeName: input.typeName } : {}),
323
+ ...(input.repos !== undefined ? { repos: input.repos } : {}),
324
+ ...(input.reviewAuthorAllowlist !== undefined
325
+ ? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
326
+ : {}),
327
+ ...(input.patchAuthorAllowlist !== undefined
328
+ ? { patchAuthorAllowlist: input.patchAuthorAllowlist }
329
+ : {}),
330
+ ...(input.restrictSlackToMembers !== undefined
331
+ ? { restrictSlackToMembers: input.restrictSlackToMembers }
332
+ : {}),
333
+ ...(input.accountId !== undefined
334
+ ? { accountId: input.accountId }
335
+ : {}),
336
+ ...(input.ownerEmail !== undefined
337
+ ? { ownerEmail: input.ownerEmail }
338
+ : {}),
339
+ },
340
+ });
341
+ const missingRequiredEnv = await this.computeMissingRequiredEnv(
342
+ row.id,
343
+ row.typeName,
344
+ client,
345
+ );
346
+ return { ...row, missingRequiredEnv };
347
+ }
348
+
349
+ /**
350
+ * Delete an agent row by id. Used as the provisioning-failure rollback path
351
+ * on create(), and (out of scope here) internally by deleteAgentFully().
352
+ *
353
+ * @param client - defaults to `this.prisma`; pass the `tx` argument from a
354
+ * caller's `prisma.$transaction(async (tx) => ...)` to make this write
355
+ * participate in that transaction (see createAgent() below).
356
+ */
357
+ async delete(
358
+ id: string,
359
+ client: PrismaTransactionClient = this.prisma,
360
+ ): Promise<void> {
361
+ await client.agent.delete({ where: { id } });
362
+ }
363
+
364
+ /**
365
+ * Run a function inside a single interactive Prisma transaction — a thin
366
+ * wrapper over `this.prisma.$transaction()` so callers outside this class
367
+ * (e.g. createAgent() below, driven from admin-ui.ts with injected service
368
+ * doubles in tests) never need direct access to the raw PrismaClient.
369
+ */
370
+ async runTransaction<T>(
371
+ fn: (tx: PrismaTransactionClient) => Promise<T>,
372
+ ): Promise<T> {
373
+ return this.prisma.$transaction(fn);
374
+ }
375
+
376
+ /**
377
+ * List all agents (id + name + selfHosted + typeName), ordered by name asc.
378
+ */
379
+ async list(): Promise<AgentSummary[]> {
380
+ return this.prisma.agent.findMany({
381
+ select: SUMMARY_SELECT,
382
+ orderBy: { name: "asc" },
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Get {id, name, selfHosted, typeName} for a single agent. Returns null if
388
+ * not found.
389
+ */
390
+ async getSummary(id: string): Promise<AgentSummary | null> {
391
+ return this.prisma.agent.findUnique({
392
+ where: { id },
393
+ select: SUMMARY_SELECT,
394
+ });
395
+ }
396
+
397
+ /**
398
+ * The env-contract gap: the type manifest's required env keys minus the
399
+ * keys already present in this agent's AgentEnv rows (key names only,
400
+ * never values — the AgentEnv query below selects only `key`, never
401
+ * `value`, so decrypted secrets can never reach this computation).
402
+ * Skips the AgentEnv query entirely when the manifest declares no required
403
+ * keys. Purely informational: never blocks create or gates provisioning
404
+ * (ATS-4.2).
405
+ */
406
+ private async computeMissingRequiredEnv(
407
+ agentId: string,
408
+ typeName: string,
409
+ client: PrismaTransactionClient = this.prisma,
410
+ ): Promise<string[]> {
411
+ const requiredKeys = this.specialtyRegistry
412
+ .getManifest(typeName)
413
+ .env.required.map((entry) => entry.key);
414
+
415
+ if (requiredKeys.length === 0) return [];
416
+
417
+ // Key names only — never select/decrypt AgentEnv.value here.
418
+ const presentEnvRows = await client.agentEnv.findMany({
419
+ where: { agentId },
420
+ select: { key: true },
421
+ });
422
+ const presentKeys = new Set(presentEnvRows.map((r) => r.key));
423
+ return requiredKeys.filter((key) => !presentKeys.has(key));
424
+ }
425
+
426
+ /**
427
+ * Get the full agent record (incl. repos/typeName/timestamps), plus
428
+ * `missingRequiredEnv` (see computeMissingRequiredEnv). Returns null if not
429
+ * found.
430
+ */
431
+ async getDetail(id: string): Promise<AgentDetail | null> {
432
+ const row = await this.prisma.agent.findUnique({
433
+ where: { id },
434
+ select: DETAIL_SELECT,
435
+ });
436
+ if (!row) return null;
437
+
438
+ const missingRequiredEnv = await this.computeMissingRequiredEnv(
439
+ id,
440
+ row.typeName,
441
+ );
442
+ return { ...row, missingRequiredEnv };
443
+ }
444
+
445
+ /**
446
+ * Returns whether an agent with the given id exists.
447
+ */
448
+ async exists(id: string): Promise<boolean> {
449
+ const agent = await this.prisma.agent.findUnique({
450
+ where: { id },
451
+ select: { id: true },
452
+ });
453
+ return agent !== null;
454
+ }
455
+
456
+ /**
457
+ * Update selfHosted (and optionally repos) for an agent. Returns the full
458
+ * updated record.
459
+ */
460
+ async updateSelfHosted(
461
+ id: string,
462
+ input: UpdateSelfHostedInput,
463
+ ): Promise<AgentDetail> {
464
+ const row = await this.prisma.agent.update({
465
+ where: { id },
466
+ data: {
467
+ selfHosted: input.selfHosted,
468
+ ...(input.accountId !== undefined
469
+ ? { accountId: input.accountId }
470
+ : {}),
471
+ ...(input.repos !== undefined ? { repos: input.repos } : {}),
472
+ ...(input.reviewAuthorAllowlist !== undefined
473
+ ? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
474
+ : {}),
475
+ ...(input.patchAuthorAllowlist !== undefined
476
+ ? { patchAuthorAllowlist: input.patchAuthorAllowlist }
477
+ : {}),
478
+ ...(input.restrictSlackToMembers !== undefined
479
+ ? { restrictSlackToMembers: input.restrictSlackToMembers }
480
+ : {}),
481
+ ...(input.slackId !== undefined ? { slackId: input.slackId } : {}),
482
+ ...(input.trialExpiresAt !== undefined
483
+ ? { trialExpiresAt: input.trialExpiresAt }
484
+ : {}),
485
+ ...(input.autoPostReviews !== undefined
486
+ ? { autoPostReviews: input.autoPostReviews }
487
+ : {}),
488
+ ...(input.allowSelfReview !== undefined
489
+ ? { allowSelfReview: input.allowSelfReview }
490
+ : {}),
491
+ ...(input.minConfidence !== undefined
492
+ ? { minConfidence: input.minConfidence }
493
+ : {}),
494
+ ...(input.maxFindings !== undefined
495
+ ? { maxFindings: input.maxFindings }
496
+ : {}),
497
+ ...(input.cleanupMergedWorktrees !== undefined
498
+ ? { cleanupMergedWorktrees: input.cleanupMergedWorktrees }
499
+ : {}),
500
+ ...(input.cleanupAfterDays !== undefined
501
+ ? { cleanupAfterDays: input.cleanupAfterDays }
502
+ : {}),
503
+ },
504
+ select: DETAIL_SELECT,
505
+ });
506
+ const missingRequiredEnv = await this.computeMissingRequiredEnv(
507
+ id,
508
+ row.typeName,
509
+ );
510
+ return { ...row, missingRequiredEnv };
511
+ }
512
+
513
+ /**
514
+ * Get {id, repos, reviewAuthorAllowlist, patchAuthorAllowlist,
515
+ * restrictSlackToMembers, memberEmails, trialExpiresAt, accountStatus, and the 6 APM-1.5
516
+ * agent-policy fields} for a single agent — used by the runtime
517
+ * config/crons routes. Returns null if not found.
518
+ */
519
+ async getById(id: string): Promise<AgentIdAndRepos | null> {
520
+ const row = await this.prisma.agent.findUnique({
521
+ where: { id },
522
+ select: {
523
+ id: true,
524
+ repos: true,
525
+ reviewAuthorAllowlist: true,
526
+ patchAuthorAllowlist: true,
527
+ restrictSlackToMembers: true,
528
+ trialExpiresAt: true,
529
+ selfHosted: true,
530
+ typeName: true,
531
+ account: { select: { status: true } },
532
+ autoPostReviews: true,
533
+ allowSelfReview: true,
534
+ minConfidence: true,
535
+ maxFindings: true,
536
+ cleanupMergedWorktrees: true,
537
+ cleanupAfterDays: true,
538
+ },
539
+ });
540
+ if (!row) return null;
541
+
542
+ const members = await this.agentMemberService.listByAgentId(id);
543
+ const memberEmails = members.map((m) => m.email);
544
+ const { account, ...rest } = row;
545
+ return { ...rest, memberEmails, accountStatus: account?.status ?? null };
546
+ }
547
+
548
+ /**
549
+ * List every agent, full record, no filtering — used by dashboard-style
550
+ * pages (e.g. /admin/agents isAdmin branch, /admin/provision, /admin/tasks,
551
+ * /admin/prs, /admin/chat) that want every field back in whatever default
552
+ * order Prisma returns.
553
+ */
554
+ async listAll(): Promise<AgentRecord[]> {
555
+ return this.prisma.agent.findMany();
556
+ }
557
+
558
+ /**
559
+ * List agents matching a given set of ids — used for batch-resolving
560
+ * agent ids to full records (e.g. the non-admin /admin/agents filter path
561
+ * and agentNames-resolution on the tasks/PRs pages).
562
+ */
563
+ async listByIds(ids: string[]): Promise<AgentRecord[]> {
564
+ return this.prisma.agent.findMany({ where: { id: { in: ids } } });
565
+ }
566
+
567
+ /**
568
+ * Search agents by name, case-insensitive substring match — used by the
569
+ * /admin/tasks agent-name filter.
570
+ */
571
+ async searchByName(query: string): Promise<AgentRecord[]> {
572
+ return this.prisma.agent.findMany({
573
+ where: { name: { contains: query, mode: "insensitive" } },
574
+ });
575
+ }
576
+
577
+ /**
578
+ * List {id, name} for all agents, ordered by name asc. Backs both the
579
+ * full-record-mapped-to-{id,name} call sites (chat page, provision pages)
580
+ * and the name-only autocomplete call site (tasks page).
581
+ */
582
+ async listOptions(): Promise<AgentOption[]> {
583
+ return this.prisma.agent.findMany({
584
+ select: { id: true, name: true },
585
+ orderBy: { name: "asc" },
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Generic partial-field update for an agent's
591
+ * name/repos/reviewAuthorAllowlist/patchAuthorAllowlist/
592
+ * restrictSlackToMembers/selfHosted/slackId. Only fields present in the
593
+ * input are touched. Returns the full updated detail record.
594
+ */
595
+ async updateFields(
596
+ id: string,
597
+ input: UpdateAgentFieldsInput,
598
+ client: PrismaTransactionClient = this.prisma,
599
+ ): Promise<AgentDetail> {
600
+ const row = await client.agent.update({
601
+ where: { id },
602
+ data: {
603
+ ...(input.name !== undefined && { name: input.name }),
604
+ ...(input.accountId !== undefined && { accountId: input.accountId }),
605
+ ...(input.repos !== undefined && { repos: input.repos }),
606
+ ...(input.reviewAuthorAllowlist !== undefined && {
607
+ reviewAuthorAllowlist: input.reviewAuthorAllowlist,
608
+ }),
609
+ ...(input.patchAuthorAllowlist !== undefined && {
610
+ patchAuthorAllowlist: input.patchAuthorAllowlist,
611
+ }),
612
+ ...(input.restrictSlackToMembers !== undefined && {
613
+ restrictSlackToMembers: input.restrictSlackToMembers,
614
+ }),
615
+ ...(input.selfHosted !== undefined && {
616
+ selfHosted: input.selfHosted,
617
+ }),
618
+ ...(input.slackId !== undefined && { slackId: input.slackId }),
619
+ ...(input.trialExpiryWarnedAt !== undefined && {
620
+ trialExpiryWarnedAt: input.trialExpiryWarnedAt,
621
+ }),
622
+ ...(input.autoPostReviews !== undefined && {
623
+ autoPostReviews: input.autoPostReviews,
624
+ }),
625
+ ...(input.allowSelfReview !== undefined && {
626
+ allowSelfReview: input.allowSelfReview,
627
+ }),
628
+ ...(input.minConfidence !== undefined && {
629
+ minConfidence: input.minConfidence,
630
+ }),
631
+ ...(input.maxFindings !== undefined && {
632
+ maxFindings: input.maxFindings,
633
+ }),
634
+ ...(input.cleanupMergedWorktrees !== undefined && {
635
+ cleanupMergedWorktrees: input.cleanupMergedWorktrees,
636
+ }),
637
+ ...(input.cleanupAfterDays !== undefined && {
638
+ cleanupAfterDays: input.cleanupAfterDays,
639
+ }),
640
+ },
641
+ select: DETAIL_SELECT,
642
+ });
643
+ const missingRequiredEnv = await this.computeMissingRequiredEnv(
644
+ id,
645
+ row.typeName,
646
+ client,
647
+ );
648
+ return { ...row, missingRequiredEnv };
649
+ }
650
+ }
651
+
652
+ // ─── createAgent() ────────────────────────────────────────────────────────────
653
+
654
+ /**
655
+ * Raw form-shaped input for createAgent() — mirrors exactly what
656
+ * admin-ui.ts's POST /admin/agents handler extracts from the submitted
657
+ * FormData, before any parsing/validation. createAgent() owns all of the
658
+ * parsing (multi-line repo/allowlist/member lists, dedup, format validation)
659
+ * that the old inline handler used to do itself, so there is exactly one
660
+ * implementation of agent creation (APA-1.1).
661
+ */
662
+ export interface CreateAgentFormInput {
663
+ name: string | undefined;
664
+ typeName: string | undefined;
665
+ /**
666
+ * "in-cluster" means K8s-provisioned; anything else (including absent)
667
+ * means self-hosted — matches the pre-APA-1.1 handler's fallback rule.
668
+ */
669
+ runtime: string | undefined;
670
+ reposRaw: string | undefined;
671
+ authorAllowlistRaw: string | undefined;
672
+ patchAuthorAllowlistRaw: string | undefined;
673
+ memberEmailsRaw: string | undefined;
674
+ /** Raw "true"/"false" form value — only the literal string "true" enables it. */
675
+ restrictSlackToMembersRaw: string | undefined;
676
+ claudeCodeOauthToken: string | undefined;
677
+ anthropicApiKey: string | undefined;
678
+ /**
679
+ * Owning Account (SSP-4.1). Service-level input only — callers must
680
+ * resolve it from the authenticated principal, never from a request body.
681
+ * When set, the account's status and maxAgents quota are enforced
682
+ * race-safely inside the creation transaction.
683
+ */
684
+ accountId?: string;
685
+ /**
686
+ * Agent owner (PAI-1.2). Service-level input only — callers must resolve it
687
+ * from the authenticated principal, never from a request body. createAgent()
688
+ * trims and lower-cases it; blank is treated as absent.
689
+ */
690
+ ownerEmail?: string;
691
+ }
692
+
693
+ // Deliberately simple: one @, no whitespace, a dotted domain. Enough to keep
694
+ // a non-email admin key name out of the ownerEmail column.
695
+ const OWNER_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
696
+
697
+ /**
698
+ * Resolve the agent owner for the JSON API (PAI-1.2): the session user's
699
+ * email when present; otherwise the bearer caller's name, but only if it is
700
+ * itself a valid email address (an admin API key named like one). Anything
701
+ * else — a plain key name or an agent id — yields undefined so a non-email
702
+ * never lands in the email column. Always lower-cased.
703
+ */
704
+ export function resolveOwnerEmail(
705
+ callerEmail: string | undefined,
706
+ caller: { name: string } | undefined,
707
+ ): string | undefined {
708
+ const fromSession = callerEmail?.trim().toLowerCase();
709
+ if (fromSession) return fromSession;
710
+ const name = caller?.name.trim().toLowerCase();
711
+ return name && OWNER_EMAIL_RE.test(name) ? name : undefined;
712
+ }
713
+
714
+ /**
715
+ * Error codes matching the `?error=` query-param values the pre-APA-1.1
716
+ * inline handler redirected with — preserved 1:1 so createAgent() is a
717
+ * drop-in replacement: admin-ui.ts maps `errorCode` straight into the
718
+ * redirect URL (`/admin/agents/new?error=${errorCode}`).
719
+ */
720
+ type CreateAgentErrorCode =
721
+ | "missing_fields"
722
+ | "invalid_type"
723
+ | "provisioning_disabled"
724
+ | "seed_failed"
725
+ | "invalid_repo_format"
726
+ | "repo_name_collision"
727
+ | "invalid_author_allowlist_format"
728
+ | "provision_failed"
729
+ | "quota_exceeded"
730
+ | "account_inactive"
731
+ | typeof ANTHROPIC_CREDENTIALS_BLOCKED_CODE;
732
+
733
+ type CreateAgentResult =
734
+ | { ok: true; agent: AgentDetail; restrictSlackToMembers: boolean }
735
+ | { ok: false; errorCode: CreateAgentErrorCode; errorMessage?: string };
736
+
737
+ /**
738
+ * Returns a message naming both repos of the first same-basename collision
739
+ * (different owners) in `repos`, or null when there is none. `repos/<name>`
740
+ * is keyed by basename alone, so such repos would share one clone folder.
741
+ */
742
+ export function describeRepoNameCollision(repos: string[]): string | null {
743
+ const [collision] = findRepoNameCollisions(repos);
744
+ if (!collision) return null;
745
+ return `repos ${collision.collidesWith} and ${collision.repo} share the same name — repos/<name> clones would collide`;
746
+ }
747
+
748
+ /**
749
+ * Injected service dependencies for createAgent() — the same instances
750
+ * admin-ui.ts already constructs/receives via AdminUIDeps, narrowed to just
751
+ * the methods this function calls.
752
+ */
753
+ export interface CreateAgentDeps {
754
+ agentService: Pick<
755
+ AgentService,
756
+ "create" | "delete" | "updateFields" | "runTransaction"
757
+ >;
758
+ agentToolService: Pick<AgentToolService, "add">;
759
+ agentPluginService: Pick<AgentPluginService, "add">;
760
+ agentMemberService: Pick<AgentMemberService, "add">;
761
+ agentEnvService: Pick<AgentEnvService, "patch">;
762
+ agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
763
+ provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
764
+ specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
765
+ /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
766
+ anthropicFederationEnabled?: boolean;
767
+ }
768
+
769
+ /**
770
+ * Parses a textarea's newline-separated lines into a trimmed, non-empty
771
+ * list — optionally lower-cased and/or deduped. Mirrors the ad-hoc parsing
772
+ * the pre-APA-1.1 inline handler did separately for each of
773
+ * repos/authorAllowlist/patchAuthorAllowlist/memberEmails (repos was never
774
+ * deduped; the other three were).
775
+ */
776
+ function parseLines(
777
+ raw: string,
778
+ opts: { lower?: boolean; dedup?: boolean } = {},
779
+ ): string[] {
780
+ const lines = raw
781
+ .split(/\r?\n/)
782
+ .map((l) => (opts.lower ? l.trim().toLowerCase() : l.trim()))
783
+ .filter((l) => l.length > 0);
784
+ return opts.dedup ? [...new Set(lines)] : lines;
785
+ }
786
+
787
+ /**
788
+ * Locks the account row (SELECT ... FOR UPDATE) so concurrent creates for the
789
+ * same account serialize, then checks it is active and under its maxAgents
790
+ * quota. Returns the failing error code, or null when a slot is available.
791
+ * The lock is held until the surrounding transaction commits, so the count
792
+ * below cannot be invalidated by a concurrent create.
793
+ */
794
+ async function checkAccountQuota(
795
+ tx: PrismaTransactionClient,
796
+ accountId: string,
797
+ ): Promise<"account_inactive" | "quota_exceeded" | null> {
798
+ const rows = await tx.$queryRaw<{ status: string; maxAgents: number }[]>`
799
+ SELECT "status", "maxAgents" FROM "Account" WHERE "id" = ${accountId} FOR UPDATE`;
800
+ const account = rows[0];
801
+ if (!account) return "account_inactive";
802
+ if (account.status !== "active") return "account_inactive";
803
+ const count = await tx.agent.count({ where: { accountId } });
804
+ return count >= account.maxAgents ? "quota_exceeded" : null;
805
+ }
806
+
807
+ /** Discriminated outcome of the transactional portion of createAgent(). */
808
+ type CreateAgentTxOutcome =
809
+ | { ok: true; agent: AgentDetail }
810
+ | { ok: false; errorCode: CreateAgentErrorCode; errorMessage?: string };
811
+
812
+ /**
813
+ * Atomically create an agent: validate name/typeName, create the Agent row,
814
+ * seed AgentTool/AgentPlugin from the resolved type manifest, attach
815
+ * repos/allowlists/members, patch Claude credentials, provision Kubernetes
816
+ * resources when requested, and best-effort reconcile system crons.
817
+ *
818
+ * Extracted from admin-ui.ts's POST /admin/agents form handler (APA-1.1) so
819
+ * there is exactly one implementation of agent creation.
820
+ *
821
+ * All DB-writing steps — create, tool/plugin seeding, and attaching
822
+ * repos/allowlists/members — run inside a single interactive Prisma
823
+ * transaction via `agentService.runTransaction()`. A mid-sequence failure
824
+ * (seeding throws, or a repo/allowlist fails format validation) is handled
825
+ * by an explicit compensating `agentService.delete()` call *inside that same
826
+ * transaction*, followed by a normal (non-throwing) return — the delete
827
+ * commits atomically alongside everything that preceded it, so external
828
+ * readers never observe the agent in a half-seeded state and zero rows
829
+ * persist once the transaction commits. This preserves the pre-APA-1.1
830
+ * behavior of explicitly calling delete() on failure (observable by
831
+ * callers/tests that inject their own AgentService) while still giving the
832
+ * whole sequence true single-transaction atomicity.
833
+ *
834
+ * Kubernetes provisioning is an external side effect that cannot join that
835
+ * transaction: it runs after the transaction has already committed, and on
836
+ * failure the just-created Agent row is explicitly deleted as a separate
837
+ * statement — matching the pre-APA-1.1 delete-on-failure behavior exactly.
838
+ *
839
+ * reconcileSystemCrons() runs last and is best-effort/non-blocking —
840
+ * failures are logged, never surfaced as an error (mirrors the same call at
841
+ * agent boot, agent/src/index.ts).
842
+ */
843
+ export async function createAgent(
844
+ deps: CreateAgentDeps,
845
+ input: CreateAgentFormInput,
846
+ ): Promise<CreateAgentResult> {
847
+ const name = input.name;
848
+ if (!name) {
849
+ return { ok: false, errorCode: "missing_fields" };
850
+ }
851
+
852
+ // Resolve the requested type BEFORE creating any row — an unknown/missing
853
+ // type must fail with zero rows created. The resolved manifest is
854
+ // captured (not discarded) so its tools/plugins can be seeded below.
855
+ const typeName = input.typeName;
856
+ const manifest = typeName
857
+ ? deps.specialtyRegistry.tryGetManifest(typeName)
858
+ : undefined;
859
+ if (!typeName || !manifest) {
860
+ return { ok: false, errorCode: "invalid_type" };
861
+ }
862
+
863
+ // Absent/unrecognized runtime means self-hosted — the historical behavior
864
+ // of this form.
865
+ const inCluster = input.runtime === "in-cluster";
866
+ // Same "validate before creating any row" rule as the type check above: a
867
+ // no-op provisioner would let provision() succeed while creating nothing,
868
+ // leaving an agent row with no workload behind it.
869
+ if (inCluster && !deps.provisioner.canProvision) {
870
+ return { ok: false, errorCode: "provisioning_disabled" };
871
+ }
872
+
873
+ // WIF-2.1: federated (in-cluster) agents must never carry Anthropic
874
+ // credentials — reject before any row is created.
875
+ if (
876
+ deps.anthropicFederationEnabled &&
877
+ inCluster &&
878
+ (input.claudeCodeOauthToken || input.anthropicApiKey)
879
+ ) {
880
+ return {
881
+ ok: false,
882
+ errorCode: ANTHROPIC_CREDENTIALS_BLOCKED_CODE,
883
+ errorMessage: `${ANTHROPIC_CREDENTIALS_BLOCKED_CODE}: Claude credentials cannot be set on an agent that uses Anthropic workload identity federation`,
884
+ };
885
+ }
886
+
887
+ const restrictSlackToMembers = input.restrictSlackToMembersRaw === "true";
888
+ const ownerEmail = input.ownerEmail?.trim().toLowerCase() || undefined;
889
+
890
+ const txResult = await deps.agentService.runTransaction<CreateAgentTxOutcome>(
891
+ async (tx) => {
892
+ // Quota enforcement must precede the create so a rejected request
893
+ // writes nothing. Skipped when tx is undefined (admin-ui.ts test
894
+ // doubles without runTransaction pass no real transaction).
895
+ if (input.accountId !== undefined && tx) {
896
+ const denied = await checkAccountQuota(tx, input.accountId);
897
+ if (denied) return { ok: false, errorCode: denied };
898
+ }
899
+
900
+ const agent = await deps.agentService.create(
901
+ {
902
+ name,
903
+ selfHosted: !inCluster,
904
+ typeName,
905
+ restrictSlackToMembers,
906
+ ...(input.accountId !== undefined
907
+ ? { accountId: input.accountId }
908
+ : {}),
909
+ ...(ownerEmail !== undefined ? { ownerEmail } : {}),
910
+ },
911
+ tx,
912
+ );
913
+
914
+ // Seed AgentTool/AgentPlugin rows from the resolved manifest. Roll the
915
+ // agent row back on any seeding failure so a retry with the same name
916
+ // doesn't collide with a half-seeded agent. Members/repos are NOT
917
+ // seeded from the manifest here — they have their own dedicated
918
+ // handling below, and the manifest's members/repos arrays are empty
919
+ // for every specialty that exists today.
920
+ try {
921
+ for (const pattern of manifest.tools) {
922
+ await deps.agentToolService.add(agent.id, pattern, tx);
923
+ }
924
+ for (const pluginName of manifest.plugins) {
925
+ await deps.agentPluginService.add(
926
+ agent.id,
927
+ pluginName,
928
+ undefined,
929
+ tx,
930
+ );
931
+ }
932
+ } catch (err) {
933
+ console.error(
934
+ "[agents] createAgent: tool/plugin seeding failed, rolling back:",
935
+ err,
936
+ );
937
+ await deps.agentService.delete(agent.id, tx).catch((cleanupErr) => {
938
+ console.error(
939
+ "[agents] createAgent: failed to roll back agent after seeding error:",
940
+ cleanupErr,
941
+ );
942
+ });
943
+ return { ok: false, errorCode: "seed_failed" };
944
+ }
945
+
946
+ // Attach repos if provided.
947
+ if (input.reposRaw) {
948
+ const repos = parseLines(input.reposRaw);
949
+ const invalid = repos.filter((r) => !isOrgRepo(r));
950
+ if (invalid.length > 0) {
951
+ await deps.agentService.delete(agent.id, tx);
952
+ return { ok: false, errorCode: "invalid_repo_format" };
953
+ }
954
+ const collision = describeRepoNameCollision(repos);
955
+ if (collision) {
956
+ await deps.agentService.delete(agent.id, tx);
957
+ return {
958
+ ok: false,
959
+ errorCode: "repo_name_collision",
960
+ errorMessage: collision,
961
+ };
962
+ }
963
+ if (repos.length > 0) {
964
+ await deps.agentService.updateFields(agent.id, { repos }, tx);
965
+ }
966
+ }
967
+
968
+ // Attach authorAllowlist if provided.
969
+ if (input.authorAllowlistRaw) {
970
+ const authorAllowlist = parseLines(input.authorAllowlistRaw, {
971
+ dedup: true,
972
+ });
973
+ const invalid = authorAllowlist.filter((l) => !isGithubLogin(l));
974
+ if (invalid.length > 0) {
975
+ await deps.agentService.delete(agent.id, tx);
976
+ return { ok: false, errorCode: "invalid_author_allowlist_format" };
977
+ }
978
+ if (authorAllowlist.length > 0) {
979
+ await deps.agentService.updateFields(
980
+ agent.id,
981
+ { reviewAuthorAllowlist: authorAllowlist },
982
+ tx,
983
+ );
984
+ }
985
+ }
986
+
987
+ // Attach patchAuthorAllowlist if provided.
988
+ if (input.patchAuthorAllowlistRaw) {
989
+ const patchAuthorAllowlist = parseLines(input.patchAuthorAllowlistRaw, {
990
+ dedup: true,
991
+ });
992
+ const invalid = patchAuthorAllowlist.filter((l) => !isGithubLogin(l));
993
+ if (invalid.length > 0) {
994
+ await deps.agentService.delete(agent.id, tx);
995
+ return { ok: false, errorCode: "invalid_author_allowlist_format" };
996
+ }
997
+ if (patchAuthorAllowlist.length > 0) {
998
+ await deps.agentService.updateFields(
999
+ agent.id,
1000
+ { patchAuthorAllowlist },
1001
+ tx,
1002
+ );
1003
+ }
1004
+ }
1005
+
1006
+ // Attach member emails if provided — best-effort, mirrors the
1007
+ // single-add POST /admin/agents/:id/members route: no format
1008
+ // validation, and a failed/duplicate add is silently ignored rather
1009
+ // than rolling back the agent.
1010
+ if (input.memberEmailsRaw) {
1011
+ const memberEmails = parseLines(input.memberEmailsRaw, {
1012
+ lower: true,
1013
+ dedup: true,
1014
+ });
1015
+ for (const email of memberEmails) {
1016
+ try {
1017
+ await deps.agentMemberService.add(agent.id, email, tx);
1018
+ } catch {
1019
+ // unique constraint violation — already a member, ignore
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ return { ok: true, agent };
1025
+ },
1026
+ );
1027
+
1028
+ if (!txResult.ok) {
1029
+ return {
1030
+ ok: false,
1031
+ errorCode: txResult.errorCode,
1032
+ errorMessage: txResult.errorMessage,
1033
+ };
1034
+ }
1035
+ const agent = txResult.agent;
1036
+
1037
+ // Store the Claude credentials before provisioning so the pod comes up
1038
+ // with them already in its env bundle rather than failing its first turn.
1039
+ // Deliberately outside the transaction above (AgentEnvService.patch runs
1040
+ // its own internal transaction) and not rolled back on failure, matching
1041
+ // pre-APA-1.1 behavior: an uncaught error here propagates to the caller,
1042
+ // leaving the already-committed agent row in place.
1043
+ // Non-secret defaults (e.g. ANTHROPIC_MODEL) are seeded alongside so they
1044
+ // show up in the agent's env view; the secret credentials below are
1045
+ // operator-supplied only and never defaulted.
1046
+ const initialEnv: Record<string, string> = { ...DEFAULT_AGENT_ENV };
1047
+ if (input.claudeCodeOauthToken) {
1048
+ initialEnv.CLAUDE_CODE_OAUTH_TOKEN = input.claudeCodeOauthToken;
1049
+ }
1050
+ if (input.anthropicApiKey) {
1051
+ initialEnv.ANTHROPIC_API_KEY = input.anthropicApiKey;
1052
+ }
1053
+ await deps.agentEnvService.patch(
1054
+ agent.id,
1055
+ initialEnv,
1056
+ new Set(SECRET_ENV_VARS),
1057
+ );
1058
+
1059
+ if (inCluster) {
1060
+ // Kubernetes provisioning is an external side effect that cannot join
1061
+ // the DB transaction above — it runs after that transaction has already
1062
+ // committed. Roll the row back explicitly on failure so a retry with the
1063
+ // same name doesn't collide with a half-created agent.
1064
+ try {
1065
+ await deps.provisioner.provision(agent.id, {
1066
+ slug: agent.name,
1067
+ accountId: agent.accountId,
1068
+ });
1069
+ } catch (err) {
1070
+ console.error(
1071
+ "[agents] createAgent: provisioning failed, rolling back:",
1072
+ err,
1073
+ );
1074
+ await deps.agentService.delete(agent.id).catch((cleanupErr) => {
1075
+ console.error(
1076
+ "[agents] createAgent: failed to roll back agent after provision error:",
1077
+ cleanupErr,
1078
+ );
1079
+ });
1080
+ return { ok: false, errorCode: "provision_failed" };
1081
+ }
1082
+ }
1083
+
1084
+ // Best-effort, mirroring the same call at agent boot (agent/src/index.ts).
1085
+ // reconcileSystemCrons is a full three-pass reconcile, so a second run is a
1086
+ // no-op — and failing here would strand the operator next to a live agent.
1087
+ try {
1088
+ await deps.agentCronJobService.reconcileSystemCrons(agent.id);
1089
+ } catch (err) {
1090
+ console.error(
1091
+ "[agents] createAgent: failed to seed system crons (non-fatal):",
1092
+ err,
1093
+ );
1094
+ }
1095
+
1096
+ return { ok: true, agent, restrictSlackToMembers };
1097
+ }