@app-vitals/agent-admin 0.0.1 → 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 +62 -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
@@ -0,0 +1,1736 @@
1
+ /**
2
+ * agent/src/agents.unit.test.ts
3
+ * Unit tests for AgentService (admin/src/agents.ts) — pure logic against an
4
+ * injected prisma test double. No real DB — see docs/testing.md for the
5
+ * unit-layer contract.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { DEFAULT_AGENT_ENV } from "@app-vitals/agent-lib/default-agent-env";
10
+ import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
11
+ import type { SpecialtyManifest } from "./specialty-registry.ts";
12
+ import type {
13
+ AgentDetail,
14
+ CreateAgentDeps,
15
+ CreateAgentFormInput,
16
+ UpdateAgentFieldsInput,
17
+ } from "./agents.ts";
18
+ import { AgentService, createAgent, resolveOwnerEmail } from "./agents.ts";
19
+ import type { PrismaTransactionClient } from "./prisma-tx.ts";
20
+
21
+ // ─── In-memory prisma.agent test double ────────────────────────────────────
22
+
23
+ interface FakeAgentRow {
24
+ id: string;
25
+ name: string;
26
+ slackId: string | null;
27
+ selfHosted: boolean;
28
+ repos: string[];
29
+ reviewAuthorAllowlist: string[];
30
+ patchAuthorAllowlist: string[];
31
+ restrictSlackToMembers: boolean;
32
+ typeName: string;
33
+ createdAt: Date;
34
+ updatedAt: Date;
35
+ // APM-1.3: the six real agent-policy fields (APM-1.1 DB columns).
36
+ autoPostReviews: boolean;
37
+ allowSelfReview: boolean;
38
+ minConfidence: number;
39
+ maxFindings: number;
40
+ cleanupMergedWorktrees: boolean;
41
+ cleanupAfterDays: number;
42
+ }
43
+
44
+ /** Projects a row down to just the keys named in a Prisma-style `select` object. */
45
+ function applySelect<T extends object>(
46
+ row: T,
47
+ select?: Partial<Record<keyof T, boolean>>,
48
+ ): T {
49
+ if (!select) return row;
50
+ const projected = {} as T;
51
+ for (const key of Object.keys(select) as (keyof T)[]) {
52
+ if (select[key]) projected[key] = row[key];
53
+ }
54
+ return projected;
55
+ }
56
+
57
+ /**
58
+ * In-memory prisma.agentEnv.findMany double, keyed by agentId — only the
59
+ * `key` column is ever selected by AgentService.getDetail(), mirroring the
60
+ * production query that never touches AgentEnv.value (secrets_in_logs).
61
+ */
62
+ function makeFakePrisma(
63
+ seed: FakeAgentRow[] = [],
64
+ envSeed: Record<string, string[]> = {},
65
+ memberSeed: Record<
66
+ string,
67
+ { id: string; agentId: string; email: string; createdAt: Date }[]
68
+ > = {},
69
+ ) {
70
+ const rows = new Map<string, FakeAgentRow>(seed.map((r) => [r.id, r]));
71
+ let nextId = 1;
72
+
73
+ const agent = {
74
+ async create({
75
+ data,
76
+ }: {
77
+ data: {
78
+ name: string;
79
+ slackId: string | null;
80
+ selfHosted: boolean;
81
+ repos?: string[];
82
+ reviewAuthorAllowlist?: string[];
83
+ patchAuthorAllowlist?: string[];
84
+ restrictSlackToMembers?: boolean;
85
+ };
86
+ }): Promise<FakeAgentRow> {
87
+ const row: FakeAgentRow = {
88
+ id: `agent-${nextId++}`,
89
+ name: data.name,
90
+ slackId: data.slackId,
91
+ selfHosted: data.selfHosted,
92
+ repos: data.repos ?? [],
93
+ reviewAuthorAllowlist: data.reviewAuthorAllowlist ?? [],
94
+ patchAuthorAllowlist: data.patchAuthorAllowlist ?? [],
95
+ restrictSlackToMembers: data.restrictSlackToMembers ?? false,
96
+ typeName: "coding",
97
+ createdAt: new Date("2024-01-01"),
98
+ updatedAt: new Date("2024-01-01"),
99
+ autoPostReviews: true,
100
+ allowSelfReview: false,
101
+ minConfidence: 75,
102
+ maxFindings: 5,
103
+ cleanupMergedWorktrees: true,
104
+ cleanupAfterDays: 14,
105
+ };
106
+ rows.set(row.id, row);
107
+ return row;
108
+ },
109
+ async delete({ where }: { where: { id: string } }): Promise<FakeAgentRow> {
110
+ const row = rows.get(where.id);
111
+ if (!row) throw new Error("record not found");
112
+ rows.delete(where.id);
113
+ return row;
114
+ },
115
+ async findMany({
116
+ select,
117
+ orderBy,
118
+ where,
119
+ }: {
120
+ select?: Partial<Record<keyof FakeAgentRow, boolean>>;
121
+ orderBy?: { name?: "asc" | "desc" };
122
+ where?: {
123
+ id?: { in: string[] };
124
+ name?: { contains: string; mode?: "insensitive" };
125
+ };
126
+ } = {}): Promise<FakeAgentRow[]> {
127
+ let all = Array.from(rows.values());
128
+ if (where?.id) {
129
+ const ids = new Set(where.id.in);
130
+ all = all.filter((r) => ids.has(r.id));
131
+ }
132
+ if (where?.name) {
133
+ const { contains, mode } = where.name;
134
+ all = all.filter((r) =>
135
+ mode === "insensitive"
136
+ ? r.name.toLowerCase().includes(contains.toLowerCase())
137
+ : r.name.includes(contains),
138
+ );
139
+ }
140
+ if (orderBy?.name) {
141
+ all = [...all].sort((a, b) =>
142
+ orderBy.name === "desc"
143
+ ? b.name.localeCompare(a.name)
144
+ : a.name.localeCompare(b.name),
145
+ );
146
+ }
147
+ return all.map((r) => applySelect(r, select));
148
+ },
149
+ async findUnique({
150
+ where,
151
+ select,
152
+ }: {
153
+ where: { id: string };
154
+ select?: Partial<Record<keyof FakeAgentRow, boolean>>;
155
+ }): Promise<FakeAgentRow | null> {
156
+ const row = rows.get(where.id);
157
+ if (!row) return null;
158
+ return applySelect(row, select);
159
+ },
160
+ async update({
161
+ where,
162
+ data,
163
+ select,
164
+ }: {
165
+ where: { id: string };
166
+ data: {
167
+ name?: string;
168
+ slackId?: string | null;
169
+ selfHosted?: boolean;
170
+ repos?: string[];
171
+ reviewAuthorAllowlist?: string[];
172
+ patchAuthorAllowlist?: string[];
173
+ restrictSlackToMembers?: boolean;
174
+ autoPostReviews?: boolean;
175
+ allowSelfReview?: boolean;
176
+ minConfidence?: number;
177
+ maxFindings?: number;
178
+ cleanupMergedWorktrees?: boolean;
179
+ cleanupAfterDays?: number;
180
+ };
181
+ select?: Partial<Record<keyof FakeAgentRow, boolean>>;
182
+ }): Promise<FakeAgentRow> {
183
+ const row = rows.get(where.id);
184
+ if (!row) throw new Error("record not found");
185
+ const updated: FakeAgentRow = {
186
+ ...row,
187
+ ...(data.name !== undefined && { name: data.name }),
188
+ ...(data.slackId !== undefined && { slackId: data.slackId }),
189
+ ...(data.selfHosted !== undefined && { selfHosted: data.selfHosted }),
190
+ ...(data.repos !== undefined && { repos: data.repos }),
191
+ ...(data.reviewAuthorAllowlist !== undefined && {
192
+ reviewAuthorAllowlist: data.reviewAuthorAllowlist,
193
+ }),
194
+ ...(data.patchAuthorAllowlist !== undefined && {
195
+ patchAuthorAllowlist: data.patchAuthorAllowlist,
196
+ }),
197
+ ...(data.restrictSlackToMembers !== undefined && {
198
+ restrictSlackToMembers: data.restrictSlackToMembers,
199
+ }),
200
+ ...(data.autoPostReviews !== undefined && {
201
+ autoPostReviews: data.autoPostReviews,
202
+ }),
203
+ ...(data.allowSelfReview !== undefined && {
204
+ allowSelfReview: data.allowSelfReview,
205
+ }),
206
+ ...(data.minConfidence !== undefined && {
207
+ minConfidence: data.minConfidence,
208
+ }),
209
+ ...(data.maxFindings !== undefined && {
210
+ maxFindings: data.maxFindings,
211
+ }),
212
+ ...(data.cleanupMergedWorktrees !== undefined && {
213
+ cleanupMergedWorktrees: data.cleanupMergedWorktrees,
214
+ }),
215
+ ...(data.cleanupAfterDays !== undefined && {
216
+ cleanupAfterDays: data.cleanupAfterDays,
217
+ }),
218
+ updatedAt: new Date("2024-01-02"),
219
+ };
220
+ rows.set(where.id, updated);
221
+ return applySelect(updated, select);
222
+ },
223
+ };
224
+
225
+ const agentEnv = {
226
+ async findMany({
227
+ where,
228
+ }: {
229
+ where: { agentId: string };
230
+ select?: { key: true };
231
+ }): Promise<{ key: string }[]> {
232
+ const keys = envSeed[where.agentId] ?? [];
233
+ return keys.map((key) => ({ key }));
234
+ },
235
+ };
236
+
237
+ const agentMember = {
238
+ async findMany({
239
+ where,
240
+ }: {
241
+ where: { agentId: string };
242
+ }): Promise<
243
+ { id: string; agentId: string; email: string; createdAt: Date }[]
244
+ > {
245
+ return memberSeed[where.agentId] ?? [];
246
+ },
247
+ };
248
+
249
+ return { agent, agentEnv, agentMember, __rows: rows };
250
+ }
251
+
252
+ type FakePrisma = ReturnType<typeof makeFakePrisma>;
253
+
254
+ /** A registry double that always returns a fixed manifest — mirrors the
255
+ * fakeRegistry() pattern in agent-cron-jobs.unit.test.ts. */
256
+ function fakeRegistry(
257
+ requiredEnvByType: Record<string, string[]>,
258
+ ): SpecialtyManifestResolver {
259
+ return {
260
+ getManifest(typeName: string): SpecialtyManifest {
261
+ const required = requiredEnvByType[typeName] ?? [];
262
+ return {
263
+ env: {
264
+ required: required.map((key) => ({
265
+ key,
266
+ description: `${key} description`,
267
+ secret: true,
268
+ })),
269
+ optional: [],
270
+ },
271
+ } as unknown as SpecialtyManifest;
272
+ },
273
+ tryGetManifest(typeName: string): SpecialtyManifest | undefined {
274
+ if (!(typeName in requiredEnvByType)) return undefined;
275
+ return this.getManifest(typeName);
276
+ },
277
+ listTypes() {
278
+ return Object.keys(requiredEnvByType).map((name) => ({
279
+ name,
280
+ displayName: name,
281
+ }));
282
+ },
283
+ };
284
+ }
285
+
286
+ function seedRow(overrides: Partial<FakeAgentRow> = {}): FakeAgentRow {
287
+ return {
288
+ id: "agent-existing",
289
+ name: "Existing Agent",
290
+ slackId: null,
291
+ selfHosted: false,
292
+ repos: [],
293
+ reviewAuthorAllowlist: [],
294
+ patchAuthorAllowlist: [],
295
+ restrictSlackToMembers: false,
296
+ typeName: "coding",
297
+ createdAt: new Date("2024-01-01"),
298
+ updatedAt: new Date("2024-01-01"),
299
+ autoPostReviews: true,
300
+ allowSelfReview: false,
301
+ minConfidence: 75,
302
+ maxFindings: 5,
303
+ cleanupMergedWorktrees: true,
304
+ cleanupAfterDays: 14,
305
+ ...overrides,
306
+ };
307
+ }
308
+
309
+ // ─── create ─────────────────────────────────────────────────────────────────
310
+
311
+ describe("AgentService.create", () => {
312
+ it("creates an agent with the given name, slackId, and selfHosted flag", async () => {
313
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
314
+ const service = new AgentService(prisma as never);
315
+
316
+ const agent = await service.create({
317
+ name: "New Agent",
318
+ slackId: "U123",
319
+ selfHosted: true,
320
+ });
321
+
322
+ expect(agent.name).toBe("New Agent");
323
+ expect(agent.slackId).toBe("U123");
324
+ expect(agent.selfHosted).toBe(true);
325
+ expect(agent.id).toBeDefined();
326
+ });
327
+
328
+ it("defaults slackId to null and selfHosted to false when omitted", async () => {
329
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
330
+ const service = new AgentService(prisma as never);
331
+
332
+ const agent = await service.create({ name: "Plain Agent" });
333
+
334
+ expect(agent.slackId).toBeNull();
335
+ expect(agent.selfHosted).toBe(false);
336
+ });
337
+
338
+ it("creates an agent with the given reviewAuthorAllowlist", async () => {
339
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
340
+ const service = new AgentService(prisma as never);
341
+
342
+ const agent = await service.create({
343
+ name: "New Agent",
344
+ reviewAuthorAllowlist: ["octocat", "hubot"],
345
+ });
346
+
347
+ expect(agent.reviewAuthorAllowlist).toEqual(["octocat", "hubot"]);
348
+ });
349
+
350
+ it("defaults reviewAuthorAllowlist to an empty array when omitted", async () => {
351
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
352
+ const service = new AgentService(prisma as never);
353
+
354
+ const agent = await service.create({ name: "Plain Agent" });
355
+
356
+ expect(agent.reviewAuthorAllowlist).toEqual([]);
357
+ });
358
+
359
+ it("lists a required env key in missingRequiredEnv for a freshly created agent (no AgentEnv rows yet)", async () => {
360
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
361
+ const service = new AgentService(
362
+ prisma as never,
363
+ fakeRegistry({ coding: ["CLAUDE_CODE_OAUTH_TOKEN"] }),
364
+ );
365
+
366
+ const agent = await service.create({ name: "New Agent" });
367
+
368
+ expect(agent.missingRequiredEnv).toEqual(["CLAUDE_CODE_OAUTH_TOKEN"]);
369
+ });
370
+
371
+ it("returns an empty missingRequiredEnv array (not undefined/null) when the type's required contract is empty", async () => {
372
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
373
+ const service = new AgentService(
374
+ prisma as never,
375
+ fakeRegistry({ coding: [] }),
376
+ );
377
+
378
+ const agent = await service.create({ name: "New Agent" });
379
+
380
+ expect(agent.missingRequiredEnv).toEqual([]);
381
+ expect(agent.missingRequiredEnv).not.toBeUndefined();
382
+ });
383
+ });
384
+
385
+ // ─── delete ─────────────────────────────────────────────────────────────────
386
+
387
+ describe("AgentService.delete", () => {
388
+ it("deletes the agent row by id", async () => {
389
+ const row = seedRow();
390
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
391
+ const service = new AgentService(prisma as never);
392
+
393
+ await service.delete(row.id);
394
+
395
+ expect(await prisma.agent.findUnique({ where: { id: row.id } })).toBeNull();
396
+ });
397
+ });
398
+
399
+ // ─── list ───────────────────────────────────────────────────────────────────
400
+
401
+ describe("AgentService.list", () => {
402
+ it("returns id/name/selfHosted/typeName for all agents, ordered by name asc", async () => {
403
+ const prisma = makeFakePrisma([
404
+ seedRow({ id: "a1", name: "Zeta", selfHosted: false }),
405
+ seedRow({ id: "a2", name: "Alpha", selfHosted: true }),
406
+ ]) as unknown as FakePrisma;
407
+ const service = new AgentService(prisma as never);
408
+
409
+ const result = await service.list();
410
+
411
+ expect(result).toEqual([
412
+ { id: "a2", name: "Alpha", selfHosted: true, typeName: "coding" },
413
+ { id: "a1", name: "Zeta", selfHosted: false, typeName: "coding" },
414
+ ]);
415
+ });
416
+
417
+ it("returns an empty array when there are no agents", async () => {
418
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
419
+ const service = new AgentService(prisma as never);
420
+
421
+ expect(await service.list()).toEqual([]);
422
+ });
423
+ });
424
+
425
+ // ─── getSummary ─────────────────────────────────────────────────────────────
426
+
427
+ describe("AgentService.getSummary", () => {
428
+ it("returns {id, name, selfHosted, typeName} for an existing agent", async () => {
429
+ const row = seedRow({ id: "a1", name: "Existing", selfHosted: true });
430
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
431
+ const service = new AgentService(prisma as never);
432
+
433
+ expect(await service.getSummary("a1")).toEqual({
434
+ id: "a1",
435
+ name: "Existing",
436
+ selfHosted: true,
437
+ typeName: "coding",
438
+ });
439
+ });
440
+
441
+ it("returns null when the agent does not exist", async () => {
442
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
443
+ const service = new AgentService(prisma as never);
444
+
445
+ expect(await service.getSummary("missing")).toBeNull();
446
+ });
447
+ });
448
+
449
+ // ─── getDetail ──────────────────────────────────────────────────────────────
450
+
451
+ describe("AgentService.getDetail", () => {
452
+ it("returns the full record including repos, typeName, timestamps, and an empty missingRequiredEnv when the type has no required contract", async () => {
453
+ const row = seedRow({
454
+ id: "a1",
455
+ name: "Existing",
456
+ slackId: "U999",
457
+ repos: ["org/repo"],
458
+ reviewAuthorAllowlist: ["octocat"],
459
+ });
460
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
461
+ const service = new AgentService(
462
+ prisma as never,
463
+ fakeRegistry({ coding: [] }),
464
+ );
465
+
466
+ const detail = await service.getDetail("a1");
467
+
468
+ expect(detail).toEqual({
469
+ id: "a1",
470
+ name: "Existing",
471
+ slackId: "U999",
472
+ selfHosted: false,
473
+ repos: ["org/repo"],
474
+ reviewAuthorAllowlist: ["octocat"],
475
+ patchAuthorAllowlist: [],
476
+ restrictSlackToMembers: false,
477
+ typeName: "coding",
478
+ createdAt: row.createdAt,
479
+ updatedAt: row.updatedAt,
480
+ missingRequiredEnv: [],
481
+ autoPostReviews: true,
482
+ allowSelfReview: false,
483
+ minConfidence: 75,
484
+ maxFindings: 5,
485
+ cleanupMergedWorktrees: true,
486
+ cleanupAfterDays: 14,
487
+ });
488
+ });
489
+
490
+ it("returns the six agent-policy fields (APM-1.3) reflecting the seeded row's values", async () => {
491
+ const row = seedRow({
492
+ id: "a1",
493
+ autoPostReviews: false,
494
+ allowSelfReview: true,
495
+ minConfidence: 90,
496
+ maxFindings: 3,
497
+ cleanupMergedWorktrees: false,
498
+ cleanupAfterDays: 30,
499
+ });
500
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
501
+ const service = new AgentService(
502
+ prisma as never,
503
+ fakeRegistry({ coding: [] }),
504
+ );
505
+
506
+ const detail = await service.getDetail("a1");
507
+
508
+ expect(detail?.autoPostReviews).toBe(false);
509
+ expect(detail?.allowSelfReview).toBe(true);
510
+ expect(detail?.minConfidence).toBe(90);
511
+ expect(detail?.maxFindings).toBe(3);
512
+ expect(detail?.cleanupMergedWorktrees).toBe(false);
513
+ expect(detail?.cleanupAfterDays).toBe(30);
514
+ });
515
+
516
+ it("defaults typeName to 'coding' when the row was seeded without an explicit value", async () => {
517
+ const row = seedRow({ id: "a1" });
518
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
519
+ const service = new AgentService(
520
+ prisma as never,
521
+ fakeRegistry({ coding: [] }),
522
+ );
523
+
524
+ const detail = await service.getDetail("a1");
525
+
526
+ expect(detail?.typeName).toBe("coding");
527
+ });
528
+
529
+ it("returns null when the agent does not exist", async () => {
530
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
531
+ const service = new AgentService(prisma as never, fakeRegistry({}));
532
+
533
+ expect(await service.getDetail("missing")).toBeNull();
534
+ });
535
+
536
+ it("lists a required env key in missingRequiredEnv when no AgentEnv row exists for it", async () => {
537
+ const row = seedRow({ id: "a1", typeName: "coding" });
538
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
539
+ const service = new AgentService(
540
+ prisma as never,
541
+ fakeRegistry({ coding: ["CLAUDE_CODE_OAUTH_TOKEN"] }),
542
+ );
543
+
544
+ const detail = await service.getDetail("a1");
545
+
546
+ expect(detail?.missingRequiredEnv).toEqual(["CLAUDE_CODE_OAUTH_TOKEN"]);
547
+ });
548
+
549
+ it("clears a required key from missingRequiredEnv once its AgentEnv row is seeded", async () => {
550
+ const row = seedRow({ id: "a1", typeName: "coding" });
551
+ const prisma = makeFakePrisma([row], {
552
+ a1: ["CLAUDE_CODE_OAUTH_TOKEN"],
553
+ }) as unknown as FakePrisma;
554
+ const service = new AgentService(
555
+ prisma as never,
556
+ fakeRegistry({ coding: ["CLAUDE_CODE_OAUTH_TOKEN"] }),
557
+ );
558
+
559
+ const detail = await service.getDetail("a1");
560
+
561
+ expect(detail?.missingRequiredEnv).toEqual([]);
562
+ });
563
+
564
+ it("returns an empty missingRequiredEnv array (not undefined/null) when the manifest's required contract is empty, without querying AgentEnv", async () => {
565
+ const row = seedRow({ id: "a1", typeName: "coding" });
566
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
567
+ let findManyCalled = false;
568
+ const originalFindMany = prisma.agentEnv.findMany.bind(prisma.agentEnv);
569
+ prisma.agentEnv.findMany = (async (args: never) => {
570
+ findManyCalled = true;
571
+ return originalFindMany(args);
572
+ }) as typeof prisma.agentEnv.findMany;
573
+ const service = new AgentService(
574
+ prisma as never,
575
+ fakeRegistry({ coding: [] }),
576
+ );
577
+
578
+ const detail = await service.getDetail("a1");
579
+
580
+ expect(detail?.missingRequiredEnv).toEqual([]);
581
+ expect(detail?.missingRequiredEnv).not.toBeUndefined();
582
+ expect(findManyCalled).toBe(false);
583
+ });
584
+
585
+ it("never includes AgentEnv values in the computed result — only key names are read", async () => {
586
+ const row = seedRow({ id: "a1", typeName: "coding" });
587
+ const prisma = makeFakePrisma([row], {
588
+ a1: ["CLAUDE_CODE_OAUTH_TOKEN"],
589
+ }) as unknown as FakePrisma;
590
+ const service = new AgentService(
591
+ prisma as never,
592
+ fakeRegistry({ coding: ["CLAUDE_CODE_OAUTH_TOKEN"] }),
593
+ );
594
+
595
+ const detail = await service.getDetail("a1");
596
+
597
+ expect(JSON.stringify(detail)).not.toContain("value");
598
+ expect(detail?.missingRequiredEnv).toEqual([]);
599
+ });
600
+ });
601
+
602
+ // ─── exists ─────────────────────────────────────────────────────────────────
603
+
604
+ describe("AgentService.exists", () => {
605
+ it("returns true when the agent exists", async () => {
606
+ const row = seedRow({ id: "a1" });
607
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
608
+ const service = new AgentService(prisma as never);
609
+
610
+ expect(await service.exists("a1")).toBe(true);
611
+ });
612
+
613
+ it("returns false when the agent does not exist", async () => {
614
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
615
+ const service = new AgentService(prisma as never);
616
+
617
+ expect(await service.exists("missing")).toBe(false);
618
+ });
619
+ });
620
+
621
+ // ─── updateSelfHosted ───────────────────────────────────────────────────────
622
+
623
+ describe("AgentService.updateSelfHosted", () => {
624
+ it("updates the selfHosted flag and returns the full record", async () => {
625
+ const row = seedRow({ id: "a1", selfHosted: false });
626
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
627
+ const service = new AgentService(prisma as never, fakeRegistry({}));
628
+
629
+ const updated = await service.updateSelfHosted("a1", { selfHosted: true });
630
+
631
+ expect(updated.selfHosted).toBe(true);
632
+ expect(updated.id).toBe("a1");
633
+ });
634
+
635
+ it("updates repos when provided", async () => {
636
+ const row = seedRow({ id: "a1", repos: [] });
637
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
638
+ const service = new AgentService(prisma as never, fakeRegistry({}));
639
+
640
+ const updated = await service.updateSelfHosted("a1", {
641
+ selfHosted: false,
642
+ repos: ["org/repo"],
643
+ });
644
+
645
+ expect(updated.repos).toEqual(["org/repo"]);
646
+ });
647
+
648
+ it("leaves repos untouched when not provided", async () => {
649
+ const row = seedRow({ id: "a1", repos: ["org/existing"] });
650
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
651
+ const service = new AgentService(prisma as never, fakeRegistry({}));
652
+
653
+ const updated = await service.updateSelfHosted("a1", { selfHosted: true });
654
+
655
+ expect(updated.repos).toEqual(["org/existing"]);
656
+ });
657
+
658
+ it("updates the six agent-policy fields (APM-1.3) when provided", async () => {
659
+ const row = seedRow({ id: "a1" });
660
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
661
+ const service = new AgentService(prisma as never, fakeRegistry({}));
662
+
663
+ const updated = await service.updateSelfHosted("a1", {
664
+ selfHosted: false,
665
+ autoPostReviews: false,
666
+ allowSelfReview: true,
667
+ minConfidence: 90,
668
+ maxFindings: 3,
669
+ cleanupMergedWorktrees: false,
670
+ cleanupAfterDays: 30,
671
+ });
672
+
673
+ expect(updated.autoPostReviews).toBe(false);
674
+ expect(updated.allowSelfReview).toBe(true);
675
+ expect(updated.minConfidence).toBe(90);
676
+ expect(updated.maxFindings).toBe(3);
677
+ expect(updated.cleanupMergedWorktrees).toBe(false);
678
+ expect(updated.cleanupAfterDays).toBe(30);
679
+ });
680
+
681
+ it("leaves the six agent-policy fields untouched when not provided", async () => {
682
+ const row = seedRow({
683
+ id: "a1",
684
+ autoPostReviews: false,
685
+ cleanupAfterDays: 30,
686
+ });
687
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
688
+ const service = new AgentService(prisma as never, fakeRegistry({}));
689
+
690
+ const updated = await service.updateSelfHosted("a1", { selfHosted: true });
691
+
692
+ expect(updated.autoPostReviews).toBe(false);
693
+ expect(updated.cleanupAfterDays).toBe(30);
694
+ });
695
+
696
+ it("updates reviewAuthorAllowlist when provided", async () => {
697
+ const row = seedRow({ id: "a1", reviewAuthorAllowlist: [] });
698
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
699
+ const service = new AgentService(prisma as never, fakeRegistry({}));
700
+
701
+ const updated = await service.updateSelfHosted("a1", {
702
+ selfHosted: false,
703
+ reviewAuthorAllowlist: ["octocat"],
704
+ });
705
+
706
+ expect(updated.reviewAuthorAllowlist).toEqual(["octocat"]);
707
+ });
708
+
709
+ it("leaves reviewAuthorAllowlist untouched when not provided", async () => {
710
+ const row = seedRow({ id: "a1", reviewAuthorAllowlist: ["octocat"] });
711
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
712
+ const service = new AgentService(prisma as never, fakeRegistry({}));
713
+
714
+ const updated = await service.updateSelfHosted("a1", { selfHosted: true });
715
+
716
+ expect(updated.reviewAuthorAllowlist).toEqual(["octocat"]);
717
+ });
718
+ });
719
+
720
+ // ─── getById ────────────────────────────────────────────────────────────────
721
+
722
+ describe("AgentService.getById", () => {
723
+ it("returns {id, repos} for an existing agent", async () => {
724
+ const row = seedRow({ id: "a1", repos: ["org/repo1", "org/repo2"] });
725
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
726
+ const service = new AgentService(prisma as never);
727
+
728
+ expect(await service.getById("a1")).toEqual({
729
+ id: "a1",
730
+ repos: ["org/repo1", "org/repo2"],
731
+ reviewAuthorAllowlist: [],
732
+ patchAuthorAllowlist: [],
733
+ restrictSlackToMembers: false,
734
+ memberEmails: [],
735
+ accountStatus: null,
736
+ // ARD-6.1: getById() now selects typeName for the runtime config response.
737
+ typeName: "coding",
738
+ selfHosted: false,
739
+ // APM-1.5: getById() now selects these 6 policy fields too.
740
+ autoPostReviews: true,
741
+ allowSelfReview: false,
742
+ minConfidence: 75,
743
+ maxFindings: 5,
744
+ cleanupMergedWorktrees: true,
745
+ cleanupAfterDays: 14,
746
+ });
747
+ });
748
+
749
+ it("returns null when the agent does not exist", async () => {
750
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
751
+ const service = new AgentService(prisma as never);
752
+
753
+ expect(await service.getById("missing")).toBeNull();
754
+ });
755
+
756
+ it("includes reviewAuthorAllowlist for the runtime config lookup", async () => {
757
+ const row = seedRow({
758
+ id: "a1",
759
+ repos: ["org/repo1"],
760
+ reviewAuthorAllowlist: ["octocat", "hubot"],
761
+ });
762
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
763
+ const service = new AgentService(prisma as never);
764
+
765
+ expect(await service.getById("a1")).toEqual({
766
+ id: "a1",
767
+ repos: ["org/repo1"],
768
+ reviewAuthorAllowlist: ["octocat", "hubot"],
769
+ patchAuthorAllowlist: [],
770
+ restrictSlackToMembers: false,
771
+ memberEmails: [],
772
+ accountStatus: null,
773
+ // ARD-6.1: getById() now selects typeName for the runtime config response.
774
+ typeName: "coding",
775
+ selfHosted: false,
776
+ // APM-1.5: getById() now selects these 6 policy fields too.
777
+ autoPostReviews: true,
778
+ allowSelfReview: false,
779
+ minConfidence: 75,
780
+ maxFindings: 5,
781
+ cleanupMergedWorktrees: true,
782
+ cleanupAfterDays: 14,
783
+ });
784
+ });
785
+
786
+ it("includes memberEmails scoped to the agent, excluding other agents' members", async () => {
787
+ const row = seedRow({ id: "a1", repos: ["org/repo1"] });
788
+ const otherRow = seedRow({ id: "a2", repos: [] });
789
+ const prisma = makeFakePrisma([row, otherRow], undefined, {
790
+ a1: [
791
+ {
792
+ id: "member-1",
793
+ agentId: "a1",
794
+ email: "dev@example.com",
795
+ createdAt: new Date("2024-01-01"),
796
+ },
797
+ {
798
+ id: "member-2",
799
+ agentId: "a1",
800
+ email: "ops@example.com",
801
+ createdAt: new Date("2024-01-01"),
802
+ },
803
+ ],
804
+ a2: [
805
+ {
806
+ id: "member-3",
807
+ agentId: "a2",
808
+ email: "other@example.com",
809
+ createdAt: new Date("2024-01-01"),
810
+ },
811
+ ],
812
+ }) as unknown as FakePrisma;
813
+ const service = new AgentService(prisma as never);
814
+
815
+ const result = await service.getById("a1");
816
+
817
+ expect(result?.memberEmails).toEqual([
818
+ "dev@example.com",
819
+ "ops@example.com",
820
+ ]);
821
+ });
822
+
823
+ it("returns restrictSlackToMembers: true when set on the seeded row", async () => {
824
+ const row = seedRow({
825
+ id: "a1",
826
+ repos: ["org/repo1"],
827
+ restrictSlackToMembers: true,
828
+ });
829
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
830
+ const service = new AgentService(prisma as never);
831
+
832
+ const result = await service.getById("a1");
833
+
834
+ expect(result?.restrictSlackToMembers).toBe(true);
835
+ });
836
+ });
837
+
838
+ // ─── listAll ────────────────────────────────────────────────────────────────
839
+
840
+ describe("AgentService.listAll", () => {
841
+ it("returns every agent with all fields, no filtering", async () => {
842
+ const prisma = makeFakePrisma([
843
+ seedRow({ id: "a1", name: "Zeta" }),
844
+ seedRow({ id: "a2", name: "Alpha" }),
845
+ ]) as unknown as FakePrisma;
846
+ const service = new AgentService(prisma as never);
847
+
848
+ const result = await service.listAll();
849
+
850
+ expect(result.map((r) => r.id).sort()).toEqual(["a1", "a2"]);
851
+ expect(result[0]).toHaveProperty("createdAt");
852
+ expect(result[0]).toHaveProperty("slackId");
853
+ });
854
+
855
+ it("returns an empty array when there are no agents", async () => {
856
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
857
+ const service = new AgentService(prisma as never);
858
+
859
+ expect(await service.listAll()).toEqual([]);
860
+ });
861
+ });
862
+
863
+ // ─── listByIds ──────────────────────────────────────────────────────────────
864
+
865
+ describe("AgentService.listByIds", () => {
866
+ it("returns only the agents matching the given ids", async () => {
867
+ const prisma = makeFakePrisma([
868
+ seedRow({ id: "a1", name: "One" }),
869
+ seedRow({ id: "a2", name: "Two" }),
870
+ seedRow({ id: "a3", name: "Three" }),
871
+ ]) as unknown as FakePrisma;
872
+ const service = new AgentService(prisma as never);
873
+
874
+ const result = await service.listByIds(["a1", "a3"]);
875
+
876
+ expect(result.map((r) => r.id).sort()).toEqual(["a1", "a3"]);
877
+ });
878
+
879
+ it("returns an empty array when no ids match", async () => {
880
+ const prisma = makeFakePrisma([
881
+ seedRow({ id: "a1", name: "One" }),
882
+ ]) as unknown as FakePrisma;
883
+ const service = new AgentService(prisma as never);
884
+
885
+ expect(await service.listByIds(["missing"])).toEqual([]);
886
+ });
887
+
888
+ it("returns an empty array when given an empty id list", async () => {
889
+ const prisma = makeFakePrisma([
890
+ seedRow({ id: "a1", name: "One" }),
891
+ ]) as unknown as FakePrisma;
892
+ const service = new AgentService(prisma as never);
893
+
894
+ expect(await service.listByIds([])).toEqual([]);
895
+ });
896
+ });
897
+
898
+ // ─── searchByName ───────────────────────────────────────────────────────────
899
+
900
+ describe("AgentService.searchByName", () => {
901
+ it("returns agents whose name contains the query, case-insensitive", async () => {
902
+ const prisma = makeFakePrisma([
903
+ seedRow({ id: "a1", name: "Shipwright Bot" }),
904
+ seedRow({ id: "a2", name: "Other Product Agent" }),
905
+ seedRow({ id: "a3", name: "Other" }),
906
+ ]) as unknown as FakePrisma;
907
+ const service = new AgentService(prisma as never);
908
+
909
+ const result = await service.searchByName("ship");
910
+
911
+ expect(result.map((r) => r.id)).toEqual(["a1"]);
912
+ });
913
+
914
+ it("returns an empty array when nothing matches", async () => {
915
+ const prisma = makeFakePrisma([
916
+ seedRow({ id: "a1", name: "Shipwright Bot" }),
917
+ ]) as unknown as FakePrisma;
918
+ const service = new AgentService(prisma as never);
919
+
920
+ expect(await service.searchByName("nomatch")).toEqual([]);
921
+ });
922
+ });
923
+
924
+ // ─── listOptions ────────────────────────────────────────────────────────────
925
+
926
+ describe("AgentService.listOptions", () => {
927
+ it("returns {id, name} for all agents, ordered by name asc", async () => {
928
+ const prisma = makeFakePrisma([
929
+ seedRow({ id: "a1", name: "Zeta" }),
930
+ seedRow({ id: "a2", name: "Alpha" }),
931
+ ]) as unknown as FakePrisma;
932
+ const service = new AgentService(prisma as never);
933
+
934
+ const result = await service.listOptions();
935
+
936
+ expect(result).toEqual([
937
+ { id: "a2", name: "Alpha" },
938
+ { id: "a1", name: "Zeta" },
939
+ ]);
940
+ });
941
+
942
+ it("returns an empty array when there are no agents", async () => {
943
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
944
+ const service = new AgentService(prisma as never);
945
+
946
+ expect(await service.listOptions()).toEqual([]);
947
+ });
948
+ });
949
+
950
+ // ─── updateFields ───────────────────────────────────────────────────────────
951
+
952
+ describe("AgentService.updateFields", () => {
953
+ it("updates only the provided fields and returns the full detail record", async () => {
954
+ const row = seedRow({
955
+ id: "a1",
956
+ name: "Old Name",
957
+ slackId: "U000",
958
+ selfHosted: false,
959
+ repos: ["org/old"],
960
+ });
961
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
962
+ const service = new AgentService(prisma as never, fakeRegistry({}));
963
+
964
+ const updated = await service.updateFields("a1", { name: "New Name" });
965
+
966
+ expect(updated.name).toBe("New Name");
967
+ expect(updated.slackId).toBe("U000");
968
+ expect(updated.selfHosted).toBe(false);
969
+ expect(updated.repos).toEqual(["org/old"]);
970
+ });
971
+
972
+ it("updates repos when provided", async () => {
973
+ const row = seedRow({ id: "a1", repos: [] });
974
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
975
+ const service = new AgentService(prisma as never, fakeRegistry({}));
976
+
977
+ const updated = await service.updateFields("a1", {
978
+ repos: ["org/new-repo"],
979
+ });
980
+
981
+ expect(updated.repos).toEqual(["org/new-repo"]);
982
+ });
983
+
984
+ it("updates reviewAuthorAllowlist when provided", async () => {
985
+ const row = seedRow({ id: "a1", reviewAuthorAllowlist: [] });
986
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
987
+ const service = new AgentService(prisma as never, fakeRegistry({}));
988
+
989
+ const updated = await service.updateFields("a1", {
990
+ reviewAuthorAllowlist: ["octocat"],
991
+ });
992
+
993
+ expect(updated.reviewAuthorAllowlist).toEqual(["octocat"]);
994
+ });
995
+
996
+ it("updates selfHosted when provided", async () => {
997
+ const row = seedRow({ id: "a1", selfHosted: false });
998
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
999
+ const service = new AgentService(prisma as never, fakeRegistry({}));
1000
+
1001
+ const updated = await service.updateFields("a1", { selfHosted: true });
1002
+
1003
+ expect(updated.selfHosted).toBe(true);
1004
+ });
1005
+
1006
+ it("updates slackId to null when explicitly passed null", async () => {
1007
+ const row = seedRow({ id: "a1", slackId: "U123" });
1008
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
1009
+ const service = new AgentService(prisma as never, fakeRegistry({}));
1010
+
1011
+ const updated = await service.updateFields("a1", { slackId: null });
1012
+
1013
+ expect(updated.slackId).toBeNull();
1014
+ });
1015
+
1016
+ it("leaves fields untouched when not provided", async () => {
1017
+ const row = seedRow({
1018
+ id: "a1",
1019
+ name: "Untouched",
1020
+ slackId: "U999",
1021
+ selfHosted: true,
1022
+ repos: ["org/keep"],
1023
+ reviewAuthorAllowlist: ["octocat"],
1024
+ });
1025
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
1026
+ const service = new AgentService(
1027
+ prisma as never,
1028
+ fakeRegistry({ coding: [] }),
1029
+ );
1030
+
1031
+ const updated = await service.updateFields("a1", {});
1032
+
1033
+ expect(updated).toEqual({
1034
+ id: "a1",
1035
+ name: "Untouched",
1036
+ slackId: "U999",
1037
+ selfHosted: true,
1038
+ repos: ["org/keep"],
1039
+ reviewAuthorAllowlist: ["octocat"],
1040
+ patchAuthorAllowlist: [],
1041
+ restrictSlackToMembers: false,
1042
+ typeName: "coding",
1043
+ createdAt: row.createdAt,
1044
+ updatedAt: new Date("2024-01-02"),
1045
+ missingRequiredEnv: [],
1046
+ autoPostReviews: true,
1047
+ allowSelfReview: false,
1048
+ minConfidence: 75,
1049
+ maxFindings: 5,
1050
+ cleanupMergedWorktrees: true,
1051
+ cleanupAfterDays: 14,
1052
+ });
1053
+ });
1054
+
1055
+ it("updates the six agent-policy fields (APM-1.3) when provided", async () => {
1056
+ const row = seedRow({ id: "a1" });
1057
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
1058
+ const service = new AgentService(
1059
+ prisma as never,
1060
+ fakeRegistry({ coding: [] }),
1061
+ );
1062
+
1063
+ const updated = await service.updateFields("a1", {
1064
+ autoPostReviews: false,
1065
+ allowSelfReview: true,
1066
+ minConfidence: 90,
1067
+ maxFindings: 3,
1068
+ cleanupMergedWorktrees: false,
1069
+ cleanupAfterDays: 30,
1070
+ });
1071
+
1072
+ expect(updated.autoPostReviews).toBe(false);
1073
+ expect(updated.allowSelfReview).toBe(true);
1074
+ expect(updated.minConfidence).toBe(90);
1075
+ expect(updated.maxFindings).toBe(3);
1076
+ expect(updated.cleanupMergedWorktrees).toBe(false);
1077
+ expect(updated.cleanupAfterDays).toBe(30);
1078
+ });
1079
+
1080
+ it("leaves the six agent-policy fields untouched when not provided", async () => {
1081
+ const row = seedRow({
1082
+ id: "a1",
1083
+ autoPostReviews: false,
1084
+ minConfidence: 90,
1085
+ });
1086
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
1087
+ const service = new AgentService(
1088
+ prisma as never,
1089
+ fakeRegistry({ coding: [] }),
1090
+ );
1091
+
1092
+ const updated = await service.updateFields("a1", { name: "Renamed" });
1093
+
1094
+ expect(updated.autoPostReviews).toBe(false);
1095
+ expect(updated.minConfidence).toBe(90);
1096
+ });
1097
+ });
1098
+
1099
+ // ─── createAgent() ──────────────────────────────────────────────────────────
1100
+ //
1101
+ // Pure-logic coverage of createAgent()'s branching/orchestration against
1102
+ // fully injected fakes — no real Prisma, no real transaction. The `agents`
1103
+ // map below stands in for "what a real DB would contain": a roll-back path
1104
+ // is only correct if it both (a) explicitly calls agentService.delete() —
1105
+ // verified by admin-ui.smoke.test.ts's mocked-call assertions, which this
1106
+ // suite mirrors — and (b) actually removes the row from `agents`, proving
1107
+ // createAgent() doesn't leave a dangling entry behind even at this layer.
1108
+ // True atomicity under a REAL Postgres transaction (the acceptance
1109
+ // criterion this whole task is about) is covered separately by
1110
+ // agents.integration.test.ts against a real DB.
1111
+
1112
+ const CODING_MANIFEST: SpecialtyManifest = {
1113
+ apiVersion: "shipwright.dev/v1alpha1",
1114
+ kind: "Specialty",
1115
+ metadata: {
1116
+ name: "coding",
1117
+ displayName: "Coding Agent",
1118
+ description: "test manifest",
1119
+ version: "1.0.0",
1120
+ skills: [],
1121
+ },
1122
+ identity: { templatesDir: "workspace/" },
1123
+ crons: [],
1124
+ plugins: ["shipwright"],
1125
+ tools: ["Read", "Write", "Bash"],
1126
+ env: { required: [], optional: [] },
1127
+ members: [],
1128
+ repos: [],
1129
+ chat: true,
1130
+ voice: true,
1131
+ } as unknown as SpecialtyManifest;
1132
+
1133
+ function fakeCreateSpecialtyRegistry(
1134
+ byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
1135
+ ): Pick<SpecialtyManifestResolver, "tryGetManifest"> {
1136
+ return {
1137
+ tryGetManifest: (typeName: string) => byType[typeName],
1138
+ };
1139
+ }
1140
+
1141
+ interface CreateAgentHarnessCalls {
1142
+ created: Array<{
1143
+ name: string;
1144
+ typeName?: string;
1145
+ selfHosted?: boolean;
1146
+ }>;
1147
+ deleted: string[];
1148
+ toolsAdded: Array<{ agentId: string; pattern: string }>;
1149
+ pluginsAdded: Array<{ agentId: string; name: string }>;
1150
+ membersAdded: Array<{ agentId: string; email: string }>;
1151
+ updateFieldsCalls: Array<{ id: string; fields: UpdateAgentFieldsInput }>;
1152
+ envPatched: Array<{ agentId: string; env: Record<string, string> }>;
1153
+ provisioned: string[];
1154
+ reconciled: string[];
1155
+ }
1156
+
1157
+ function makeCreateAgentHarness(
1158
+ opts: {
1159
+ manifests?: Record<string, SpecialtyManifest>;
1160
+ canProvision?: boolean;
1161
+ provisionImpl?: (agentId: string) => Promise<unknown>;
1162
+ toolAddImpl?: (agentId: string, pattern: string) => Promise<unknown>;
1163
+ pluginAddImpl?: (agentId: string, name: string) => Promise<unknown>;
1164
+ reconcileImpl?: (agentId: string) => Promise<unknown>;
1165
+ } = {},
1166
+ ): {
1167
+ deps: CreateAgentDeps;
1168
+ calls: CreateAgentHarnessCalls;
1169
+ agents: Map<string, AgentDetail>;
1170
+ } {
1171
+ const calls: CreateAgentHarnessCalls = {
1172
+ created: [],
1173
+ deleted: [],
1174
+ toolsAdded: [],
1175
+ pluginsAdded: [],
1176
+ membersAdded: [],
1177
+ updateFieldsCalls: [],
1178
+ envPatched: [],
1179
+ provisioned: [],
1180
+ reconciled: [],
1181
+ };
1182
+ const agents = new Map<string, AgentDetail>();
1183
+ let nextId = 1;
1184
+
1185
+ const agentService: CreateAgentDeps["agentService"] = {
1186
+ create: async (input) => {
1187
+ const id = `agent-${nextId++}`;
1188
+ const detail: AgentDetail = {
1189
+ id,
1190
+ name: input.name,
1191
+ slackId: input.slackId ?? null,
1192
+ selfHosted: input.selfHosted ?? false,
1193
+ repos: input.repos ?? [],
1194
+ reviewAuthorAllowlist: input.reviewAuthorAllowlist ?? [],
1195
+ patchAuthorAllowlist: input.patchAuthorAllowlist ?? [],
1196
+ restrictSlackToMembers: input.restrictSlackToMembers ?? false,
1197
+ typeName: input.typeName ?? "coding",
1198
+ createdAt: new Date("2024-01-01"),
1199
+ updatedAt: new Date("2024-01-01"),
1200
+ missingRequiredEnv: [],
1201
+ };
1202
+ agents.set(id, detail);
1203
+ calls.created.push({
1204
+ name: input.name,
1205
+ typeName: input.typeName,
1206
+ selfHosted: input.selfHosted,
1207
+ });
1208
+ return detail;
1209
+ },
1210
+ delete: async (id) => {
1211
+ agents.delete(id);
1212
+ calls.deleted.push(id);
1213
+ },
1214
+ updateFields: async (id, fields) => {
1215
+ const existing = agents.get(id);
1216
+ if (!existing) throw new Error(`agent ${id} not found`);
1217
+ const updated = { ...existing, ...fields };
1218
+ agents.set(id, updated);
1219
+ calls.updateFieldsCalls.push({ id, fields });
1220
+ return updated;
1221
+ },
1222
+ // No real transaction here — see the file-level comment above. tx is
1223
+ // simply threaded through as `undefined`, mirroring exactly what
1224
+ // admin-ui.ts's own non-transactional fallback does for test doubles
1225
+ // that don't implement runTransaction (see AdminUIDeps.agentService).
1226
+ runTransaction: async (fn) =>
1227
+ fn(undefined as unknown as PrismaTransactionClient),
1228
+ };
1229
+
1230
+ const agentToolService: CreateAgentDeps["agentToolService"] = {
1231
+ add: async (agentId, pattern) => {
1232
+ if (opts.toolAddImpl) await opts.toolAddImpl(agentId, pattern);
1233
+ calls.toolsAdded.push({ agentId, pattern });
1234
+ return {} as never;
1235
+ },
1236
+ };
1237
+
1238
+ const agentPluginService: CreateAgentDeps["agentPluginService"] = {
1239
+ add: async (agentId, name) => {
1240
+ if (opts.pluginAddImpl) await opts.pluginAddImpl(agentId, name);
1241
+ calls.pluginsAdded.push({ agentId, name });
1242
+ return {} as never;
1243
+ },
1244
+ };
1245
+
1246
+ const agentMemberService: CreateAgentDeps["agentMemberService"] = {
1247
+ add: async (agentId, email) => {
1248
+ calls.membersAdded.push({ agentId, email });
1249
+ return {} as never;
1250
+ },
1251
+ };
1252
+
1253
+ const agentEnvService: CreateAgentDeps["agentEnvService"] = {
1254
+ patch: async (agentId, env) => {
1255
+ calls.envPatched.push({ agentId, env });
1256
+ },
1257
+ };
1258
+
1259
+ const agentCronJobService: CreateAgentDeps["agentCronJobService"] = {
1260
+ reconcileSystemCrons: async (agentId) => {
1261
+ if (opts.reconcileImpl) await opts.reconcileImpl(agentId);
1262
+ calls.reconciled.push(agentId);
1263
+ return { created: 0, updated: 0, deleted: 0 };
1264
+ },
1265
+ };
1266
+
1267
+ const provisioner: CreateAgentDeps["provisioner"] = {
1268
+ canProvision: opts.canProvision ?? false,
1269
+ provision: async (agentId) => {
1270
+ if (opts.provisionImpl) await opts.provisionImpl(agentId);
1271
+ calls.provisioned.push(agentId);
1272
+ return { resourceName: "r", secretName: "s", deploymentName: "d" };
1273
+ },
1274
+ };
1275
+
1276
+ const specialtyRegistry = fakeCreateSpecialtyRegistry(opts.manifests);
1277
+
1278
+ return {
1279
+ deps: {
1280
+ agentService,
1281
+ agentToolService,
1282
+ agentPluginService,
1283
+ agentMemberService,
1284
+ agentEnvService,
1285
+ agentCronJobService,
1286
+ provisioner,
1287
+ specialtyRegistry,
1288
+ },
1289
+ calls,
1290
+ agents,
1291
+ };
1292
+ }
1293
+
1294
+ function baseCreateAgentInput(
1295
+ overrides: Partial<CreateAgentFormInput> = {},
1296
+ ): CreateAgentFormInput {
1297
+ return {
1298
+ name: "Test Agent",
1299
+ typeName: "coding",
1300
+ runtime: undefined,
1301
+ reposRaw: undefined,
1302
+ authorAllowlistRaw: undefined,
1303
+ patchAuthorAllowlistRaw: undefined,
1304
+ memberEmailsRaw: undefined,
1305
+ restrictSlackToMembersRaw: undefined,
1306
+ claudeCodeOauthToken: undefined,
1307
+ anthropicApiKey: undefined,
1308
+ ...overrides,
1309
+ };
1310
+ }
1311
+
1312
+ describe("createAgent()", () => {
1313
+ it("passes accountId through to agentService.create and tolerates an undefined tx (SSP-4.1)", async () => {
1314
+ const { deps, calls } = makeCreateAgentHarness();
1315
+ let seen: string | undefined;
1316
+ const origCreate = deps.agentService.create;
1317
+ deps.agentService.create = async (input, tx) => {
1318
+ seen = input.accountId;
1319
+ return origCreate(input, tx);
1320
+ };
1321
+
1322
+ const result = await createAgent(
1323
+ deps,
1324
+ baseCreateAgentInput({ accountId: "acct-1" }),
1325
+ );
1326
+
1327
+ expect(result.ok).toBe(true);
1328
+ expect(seen).toBe("acct-1");
1329
+ expect(calls.created).toHaveLength(1);
1330
+ });
1331
+
1332
+ it("does not set accountId when none is supplied (regression)", async () => {
1333
+ const { deps } = makeCreateAgentHarness();
1334
+ let seen: Record<string, unknown> | undefined;
1335
+ const origCreate = deps.agentService.create;
1336
+ deps.agentService.create = async (input, tx) => {
1337
+ seen = input as unknown as Record<string, unknown>;
1338
+ return origCreate(input, tx);
1339
+ };
1340
+
1341
+ await createAgent(deps, baseCreateAgentInput());
1342
+
1343
+ expect(seen && "accountId" in seen).toBe(false);
1344
+ });
1345
+
1346
+ it("passes a trimmed, lower-cased ownerEmail to agentService.create (PAI-1.2)", async () => {
1347
+ const { deps } = makeCreateAgentHarness();
1348
+ let seen: Record<string, unknown> | undefined;
1349
+ const origCreate = deps.agentService.create;
1350
+ deps.agentService.create = async (input, tx) => {
1351
+ seen = input as unknown as Record<string, unknown>;
1352
+ return origCreate(input, tx);
1353
+ };
1354
+
1355
+ const result = await createAgent(
1356
+ deps,
1357
+ baseCreateAgentInput({ ownerEmail: " Alice@Example.COM " }),
1358
+ );
1359
+
1360
+ expect(result.ok).toBe(true);
1361
+ expect(seen?.ownerEmail).toBe("alice@example.com");
1362
+ });
1363
+
1364
+ it.each([undefined, "", " "])(
1365
+ "omits ownerEmail when the input is %p (PAI-1.2)",
1366
+ async (ownerEmail) => {
1367
+ const { deps } = makeCreateAgentHarness();
1368
+ let seen: Record<string, unknown> | undefined;
1369
+ const origCreate = deps.agentService.create;
1370
+ deps.agentService.create = async (input, tx) => {
1371
+ seen = input as unknown as Record<string, unknown>;
1372
+ return origCreate(input, tx);
1373
+ };
1374
+
1375
+ await createAgent(deps, baseCreateAgentInput({ ownerEmail }));
1376
+
1377
+ expect(seen && "ownerEmail" in seen).toBe(false);
1378
+ },
1379
+ );
1380
+
1381
+ it.each(["quota_exceeded", "account_inactive"] as const)(
1382
+ "surfaces %s from the quota check without creating anything",
1383
+ async (code) => {
1384
+ const { deps, calls } = makeCreateAgentHarness();
1385
+ const fakeTx = {
1386
+ $queryRaw: async () =>
1387
+ code === "account_inactive"
1388
+ ? [{ status: "suspended", maxAgents: 5 }]
1389
+ : [{ status: "active", maxAgents: 1 }],
1390
+ agent: { count: async () => 1 },
1391
+ };
1392
+ deps.agentService.runTransaction = (async (
1393
+ fn: (tx: unknown) => unknown,
1394
+ ) => fn(fakeTx)) as typeof deps.agentService.runTransaction;
1395
+
1396
+ const result = await createAgent(
1397
+ deps,
1398
+ baseCreateAgentInput({ accountId: "acct-1" }),
1399
+ );
1400
+
1401
+ expect(result).toEqual({ ok: false, errorCode: code });
1402
+ expect(calls.created).toEqual([]);
1403
+ expect(calls.envPatched).toEqual([]);
1404
+ expect(calls.provisioned).toEqual([]);
1405
+ },
1406
+ );
1407
+
1408
+ it("returns missing_fields and creates nothing when name is empty", async () => {
1409
+ const { deps, calls } = makeCreateAgentHarness();
1410
+
1411
+ const result = await createAgent(
1412
+ deps,
1413
+ baseCreateAgentInput({ name: undefined }),
1414
+ );
1415
+
1416
+ expect(result).toEqual({ ok: false, errorCode: "missing_fields" });
1417
+ expect(calls.created).toEqual([]);
1418
+ });
1419
+
1420
+ it("returns invalid_type and creates nothing when typeName is unknown", async () => {
1421
+ const { deps, calls } = makeCreateAgentHarness();
1422
+
1423
+ const result = await createAgent(
1424
+ deps,
1425
+ baseCreateAgentInput({ typeName: "nonexistent" }),
1426
+ );
1427
+
1428
+ expect(result).toEqual({ ok: false, errorCode: "invalid_type" });
1429
+ expect(calls.created).toEqual([]);
1430
+ });
1431
+
1432
+ it("returns invalid_type and creates nothing when typeName is missing", async () => {
1433
+ const { deps, calls } = makeCreateAgentHarness();
1434
+
1435
+ const result = await createAgent(
1436
+ deps,
1437
+ baseCreateAgentInput({ typeName: undefined }),
1438
+ );
1439
+
1440
+ expect(result).toEqual({ ok: false, errorCode: "invalid_type" });
1441
+ expect(calls.created).toEqual([]);
1442
+ });
1443
+
1444
+ it("returns provisioning_disabled and creates nothing when runtime=in-cluster but the provisioner can't provision", async () => {
1445
+ const { deps, calls } = makeCreateAgentHarness({ canProvision: false });
1446
+
1447
+ const result = await createAgent(
1448
+ deps,
1449
+ baseCreateAgentInput({ runtime: "in-cluster" }),
1450
+ );
1451
+
1452
+ expect(result).toEqual({ ok: false, errorCode: "provisioning_disabled" });
1453
+ expect(calls.created).toEqual([]);
1454
+ });
1455
+
1456
+ it("happy path: creates the agent, seeds manifest tools/plugins, attaches repos/allowlists/members, and reconciles crons", async () => {
1457
+ const { deps, calls } = makeCreateAgentHarness({ canProvision: false });
1458
+
1459
+ const result = await createAgent(
1460
+ deps,
1461
+ baseCreateAgentInput({
1462
+ reposRaw: "org/repo1\norg/repo2",
1463
+ authorAllowlistRaw: "octocat\nhubot\noctocat",
1464
+ patchAuthorAllowlistRaw: "patcher1",
1465
+ memberEmailsRaw: "Dev@Example.com\ndev@example.com",
1466
+ restrictSlackToMembersRaw: "true",
1467
+ }),
1468
+ );
1469
+
1470
+ expect(result.ok).toBe(true);
1471
+ if (!result.ok) throw new Error("expected success");
1472
+ expect(result.agent.name).toBe("Test Agent");
1473
+ expect(result.restrictSlackToMembers).toBe(true);
1474
+
1475
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual([
1476
+ "Read",
1477
+ "Write",
1478
+ "Bash",
1479
+ ]);
1480
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["shipwright"]);
1481
+ expect(calls.updateFieldsCalls).toEqual([
1482
+ { id: result.agent.id, fields: { repos: ["org/repo1", "org/repo2"] } },
1483
+ {
1484
+ id: result.agent.id,
1485
+ fields: { reviewAuthorAllowlist: ["octocat", "hubot"] },
1486
+ },
1487
+ {
1488
+ id: result.agent.id,
1489
+ fields: { patchAuthorAllowlist: ["patcher1"] },
1490
+ },
1491
+ ]);
1492
+ // memberEmails is lower-cased then deduped, so the two variants of the
1493
+ // same address collapse into a single add() call.
1494
+ expect(calls.membersAdded).toEqual([
1495
+ { agentId: result.agent.id, email: "dev@example.com" },
1496
+ ]);
1497
+ expect(calls.deleted).toEqual([]);
1498
+ expect(calls.reconciled).toEqual([result.agent.id]);
1499
+ expect(calls.provisioned).toEqual([]);
1500
+ });
1501
+
1502
+ it("repos are trimmed/filtered but NOT deduped, matching the pre-APA-1.1 inline handler", async () => {
1503
+ const { deps, calls } = makeCreateAgentHarness();
1504
+
1505
+ const result = await createAgent(
1506
+ deps,
1507
+ baseCreateAgentInput({ reposRaw: "org/repo1\norg/repo1" }),
1508
+ );
1509
+
1510
+ expect(result.ok).toBe(true);
1511
+ if (!result.ok) throw new Error("expected success");
1512
+ expect(calls.updateFieldsCalls).toEqual([
1513
+ { id: result.agent.id, fields: { repos: ["org/repo1", "org/repo1"] } },
1514
+ ]);
1515
+ });
1516
+
1517
+ it("seed_failed: a tool-seeding failure rolls back the agent (delete called) and leaves zero surviving rows", async () => {
1518
+ const { deps, calls, agents } = makeCreateAgentHarness({
1519
+ toolAddImpl: async () => {
1520
+ throw new Error("boom: tool seeding failed");
1521
+ },
1522
+ });
1523
+
1524
+ const result = await createAgent(deps, baseCreateAgentInput());
1525
+
1526
+ expect(result).toEqual({ ok: false, errorCode: "seed_failed" });
1527
+ expect(calls.created.length).toBe(1);
1528
+ expect(calls.deleted.length).toBe(1);
1529
+ expect(agents.size).toBe(0);
1530
+ });
1531
+
1532
+ it("seed_failed: a plugin-seeding failure rolls back the agent and leaves zero surviving rows", async () => {
1533
+ const { deps, calls, agents } = makeCreateAgentHarness({
1534
+ pluginAddImpl: async () => {
1535
+ throw new Error("boom: plugin seeding failed");
1536
+ },
1537
+ });
1538
+
1539
+ const result = await createAgent(deps, baseCreateAgentInput());
1540
+
1541
+ expect(result).toEqual({ ok: false, errorCode: "seed_failed" });
1542
+ expect(calls.deleted.length).toBe(1);
1543
+ expect(agents.size).toBe(0);
1544
+ });
1545
+
1546
+ it("invalid_repo_format: rolls back the agent, leaves zero surviving rows, and never reaches the allowlist steps", async () => {
1547
+ const { deps, calls, agents } = makeCreateAgentHarness();
1548
+
1549
+ const result = await createAgent(
1550
+ deps,
1551
+ baseCreateAgentInput({
1552
+ reposRaw: "not-a-valid-repo!!",
1553
+ authorAllowlistRaw: "octocat",
1554
+ }),
1555
+ );
1556
+
1557
+ expect(result).toEqual({ ok: false, errorCode: "invalid_repo_format" });
1558
+ expect(calls.deleted.length).toBe(1);
1559
+ expect(agents.size).toBe(0);
1560
+ expect(calls.updateFieldsCalls).toEqual([]);
1561
+ });
1562
+
1563
+ it("invalid_author_allowlist_format: an invalid reviewAuthorAllowlist entry rolls back the agent", async () => {
1564
+ const { deps, calls, agents } = makeCreateAgentHarness();
1565
+
1566
+ const result = await createAgent(
1567
+ deps,
1568
+ baseCreateAgentInput({
1569
+ authorAllowlistRaw: "octocat\nnot a valid login!",
1570
+ }),
1571
+ );
1572
+
1573
+ expect(result).toEqual({
1574
+ ok: false,
1575
+ errorCode: "invalid_author_allowlist_format",
1576
+ });
1577
+ expect(calls.deleted.length).toBe(1);
1578
+ expect(agents.size).toBe(0);
1579
+ });
1580
+
1581
+ it("invalid_author_allowlist_format: an invalid patchAuthorAllowlist entry rolls back the agent", async () => {
1582
+ const { deps, calls, agents } = makeCreateAgentHarness();
1583
+
1584
+ const result = await createAgent(
1585
+ deps,
1586
+ baseCreateAgentInput({
1587
+ patchAuthorAllowlistRaw: "octocat\nnot a valid login!",
1588
+ }),
1589
+ );
1590
+
1591
+ expect(result).toEqual({
1592
+ ok: false,
1593
+ errorCode: "invalid_author_allowlist_format",
1594
+ });
1595
+ expect(calls.deleted.length).toBe(1);
1596
+ expect(agents.size).toBe(0);
1597
+ });
1598
+
1599
+ it("member add failures (e.g. duplicate) are swallowed and do not roll back the agent", async () => {
1600
+ const { deps, calls, agents } = makeCreateAgentHarness();
1601
+ const failingMemberDeps: CreateAgentDeps = {
1602
+ ...deps,
1603
+ agentMemberService: {
1604
+ add: async () => {
1605
+ throw new Error("unique constraint violation");
1606
+ },
1607
+ },
1608
+ };
1609
+
1610
+ const result = await createAgent(
1611
+ failingMemberDeps,
1612
+ baseCreateAgentInput({ memberEmailsRaw: "dev@example.com" }),
1613
+ );
1614
+
1615
+ expect(result.ok).toBe(true);
1616
+ expect(calls.deleted).toEqual([]);
1617
+ expect(agents.size).toBe(1);
1618
+ });
1619
+
1620
+ it("patches Claude env credentials when provided, keyed correctly", async () => {
1621
+ const { deps, calls } = makeCreateAgentHarness();
1622
+
1623
+ const result = await createAgent(
1624
+ deps,
1625
+ baseCreateAgentInput({
1626
+ claudeCodeOauthToken: "tok-123",
1627
+ anthropicApiKey: "key-456",
1628
+ }),
1629
+ );
1630
+
1631
+ expect(result.ok).toBe(true);
1632
+ if (!result.ok) throw new Error("expected success");
1633
+ expect(calls.envPatched).toEqual([
1634
+ {
1635
+ agentId: result.agent.id,
1636
+ env: {
1637
+ ...DEFAULT_AGENT_ENV,
1638
+ CLAUDE_CODE_OAUTH_TOKEN: "tok-123",
1639
+ ANTHROPIC_API_KEY: "key-456",
1640
+ },
1641
+ },
1642
+ ]);
1643
+ });
1644
+
1645
+ it("seeds only the non-secret default env when no credentials are provided", async () => {
1646
+ const { deps, calls } = makeCreateAgentHarness();
1647
+
1648
+ const result = await createAgent(deps, baseCreateAgentInput());
1649
+
1650
+ expect(result.ok).toBe(true);
1651
+ if (!result.ok) throw new Error("expected success");
1652
+ expect(calls.envPatched).toEqual([
1653
+ { agentId: result.agent.id, env: { ...DEFAULT_AGENT_ENV } },
1654
+ ]);
1655
+ expect(DEFAULT_AGENT_ENV.ANTHROPIC_MODEL).toBeTruthy();
1656
+ expect(Object.keys(DEFAULT_AGENT_ENV)).not.toContain(
1657
+ "CLAUDE_CODE_OAUTH_TOKEN",
1658
+ );
1659
+ });
1660
+
1661
+ it("provisions Kubernetes resources when runtime=in-cluster and the provisioner allows it", async () => {
1662
+ const { deps, calls } = makeCreateAgentHarness({ canProvision: true });
1663
+
1664
+ const result = await createAgent(
1665
+ deps,
1666
+ baseCreateAgentInput({ runtime: "in-cluster" }),
1667
+ );
1668
+
1669
+ expect(result.ok).toBe(true);
1670
+ if (!result.ok) throw new Error("expected success");
1671
+ expect(calls.provisioned).toEqual([result.agent.id]);
1672
+ expect(calls.deleted).toEqual([]);
1673
+ });
1674
+
1675
+ it("provision_failed: a provisioning failure deletes the already-created agent row (compensating delete, not a DB rollback) and skips cron reconcile", async () => {
1676
+ const { deps, calls, agents } = makeCreateAgentHarness({
1677
+ canProvision: true,
1678
+ provisionImpl: async () => {
1679
+ throw new Error("boom: provisioning failed");
1680
+ },
1681
+ });
1682
+
1683
+ const result = await createAgent(
1684
+ deps,
1685
+ baseCreateAgentInput({ runtime: "in-cluster" }),
1686
+ );
1687
+
1688
+ expect(result).toEqual({ ok: false, errorCode: "provision_failed" });
1689
+ expect(calls.created.length).toBe(1);
1690
+ expect(calls.deleted.length).toBe(1);
1691
+ expect(agents.size).toBe(0);
1692
+ expect(calls.reconciled).toEqual([]);
1693
+ });
1694
+
1695
+ it("reconcileSystemCrons failures are non-fatal — the agent survives and creation still succeeds", async () => {
1696
+ const { deps, agents } = makeCreateAgentHarness({
1697
+ reconcileImpl: async () => {
1698
+ throw new Error("boom: cron reconcile failed");
1699
+ },
1700
+ });
1701
+
1702
+ const result = await createAgent(deps, baseCreateAgentInput());
1703
+
1704
+ expect(result.ok).toBe(true);
1705
+ if (!result.ok) throw new Error("expected success");
1706
+ expect(agents.size).toBe(1);
1707
+ expect(agents.has(result.agent.id)).toBe(true);
1708
+ });
1709
+ });
1710
+
1711
+ describe("resolveOwnerEmail() (PAI-1.2)", () => {
1712
+ it("prefers the session callerEmail", () => {
1713
+ expect(
1714
+ resolveOwnerEmail("Alice@Example.com", {
1715
+ name: "ops@example.com",
1716
+ }),
1717
+ ).toBe("alice@example.com");
1718
+ });
1719
+
1720
+ it("uses a bearer admin key name that is an email address, lower-cased", () => {
1721
+ expect(resolveOwnerEmail(undefined, { name: "Ops@Example.com" })).toBe(
1722
+ "ops@example.com",
1723
+ );
1724
+ });
1725
+
1726
+ it.each(["admin", "ci-bot", "not an@email", "a@b", "agent-123"])(
1727
+ "returns undefined for a non-email bearer name %p",
1728
+ (name) => {
1729
+ expect(resolveOwnerEmail(undefined, { name })).toBeUndefined();
1730
+ },
1731
+ );
1732
+
1733
+ it("returns undefined when there is no caller at all", () => {
1734
+ expect(resolveOwnerEmail(undefined, undefined)).toBeUndefined();
1735
+ });
1736
+ });