@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,69 @@
1
+ /**
2
+ * agent/src/agent-members.ts
3
+ * AgentMemberService — authorized human members per agent (access control).
4
+ *
5
+ * Membership rows are keyed by [agentId, email] (unique constraint) — an
6
+ * email can be a member of multiple agents, and an agent can have multiple
7
+ * member emails.
8
+ */
9
+
10
+ import type { AgentMember, PrismaClient } from "../prisma/client/client.ts";
11
+ import type { PrismaTransactionClient } from "./prisma-tx.ts";
12
+
13
+ export type { AgentMember };
14
+
15
+ export class AgentMemberService {
16
+ constructor(private prisma: PrismaClient) {}
17
+
18
+ /**
19
+ * List all memberships for a given agent.
20
+ */
21
+ async listByAgentId(agentId: string): Promise<AgentMember[]> {
22
+ return this.prisma.agentMember.findMany({ where: { agentId } });
23
+ }
24
+
25
+ /**
26
+ * List all memberships for a given email, case-sensitive as stored.
27
+ * Callers are expected to lowercase the email before calling.
28
+ */
29
+ async listByEmail(email: string): Promise<AgentMember[]> {
30
+ return this.prisma.agentMember.findMany({ where: { email } });
31
+ }
32
+
33
+ /**
34
+ * Returns whether a membership row exists for the given agentId+email pair.
35
+ */
36
+ async exists(agentId: string, email: string): Promise<boolean> {
37
+ const membership = await this.prisma.agentMember.findUnique({
38
+ where: { agentId_email: { agentId, email } },
39
+ });
40
+ return membership !== null;
41
+ }
42
+
43
+ /**
44
+ * Create a membership row for the given agentId and email.
45
+ * Throws (unique-constraint violation) if the membership already exists —
46
+ * callers are expected to catch and handle "already a member" as a no-op.
47
+ *
48
+ * @param client - defaults to `this.prisma`; pass the `tx` argument from a
49
+ * caller's `prisma.$transaction(async (tx) => ...)` to make this write
50
+ * participate in that transaction (see createAgent() in agents.ts).
51
+ */
52
+ async add(
53
+ agentId: string,
54
+ email: string,
55
+ client: PrismaTransactionClient = this.prisma,
56
+ ): Promise<AgentMember> {
57
+ return client.agentMember.create({ data: { agentId, email } });
58
+ }
59
+
60
+ /**
61
+ * Delete a membership by id, scoped to agentId. No-ops if the id doesn't
62
+ * exist or belongs to a different agent.
63
+ */
64
+ async remove(agentId: string, memberId: string): Promise<void> {
65
+ await this.prisma.agentMember.deleteMany({
66
+ where: { id: memberId, agentId },
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * agent/src/agent-members.unit.test.ts
3
+ * Unit tests for AgentMemberService (admin/src/agent-members.ts) — pure logic
4
+ * against an injected prisma test double. No real DB — see docs/testing.md
5
+ * for the unit-layer contract.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { AgentMemberService } from "./agent-members.ts";
10
+
11
+ // ─── In-memory prisma.agentMember test double ──────────────────────────────
12
+
13
+ interface FakeAgentMemberRow {
14
+ id: string;
15
+ agentId: string;
16
+ email: string;
17
+ createdAt: Date;
18
+ }
19
+
20
+ function makeFakePrisma(seed: FakeAgentMemberRow[] = []) {
21
+ const rows = new Map<string, FakeAgentMemberRow>(seed.map((r) => [r.id, r]));
22
+ let nextId = 1;
23
+
24
+ const agentMember = {
25
+ async findMany({
26
+ where,
27
+ }: {
28
+ where?: { agentId?: string; email?: string };
29
+ } = {}): Promise<FakeAgentMemberRow[]> {
30
+ let all = Array.from(rows.values());
31
+ if (where?.agentId !== undefined) {
32
+ all = all.filter((r) => r.agentId === where.agentId);
33
+ }
34
+ if (where?.email !== undefined) {
35
+ all = all.filter((r) => r.email === where.email);
36
+ }
37
+ return all;
38
+ },
39
+ async findUnique({
40
+ where,
41
+ }: {
42
+ where: { agentId_email: { agentId: string; email: string } };
43
+ }): Promise<FakeAgentMemberRow | null> {
44
+ const { agentId, email } = where.agentId_email;
45
+ const row = Array.from(rows.values()).find(
46
+ (r) => r.agentId === agentId && r.email === email,
47
+ );
48
+ return row ?? null;
49
+ },
50
+ async create({
51
+ data,
52
+ }: {
53
+ data: { agentId: string; email: string };
54
+ }): Promise<FakeAgentMemberRow> {
55
+ const existing = Array.from(rows.values()).find(
56
+ (r) => r.agentId === data.agentId && r.email === data.email,
57
+ );
58
+ if (existing) {
59
+ throw new Error(
60
+ "Unique constraint failed on the fields: (`agentId`,`email`)",
61
+ );
62
+ }
63
+ const row: FakeAgentMemberRow = {
64
+ id: `member-${nextId++}`,
65
+ agentId: data.agentId,
66
+ email: data.email,
67
+ createdAt: new Date("2024-01-01"),
68
+ };
69
+ rows.set(row.id, row);
70
+ return row;
71
+ },
72
+ async deleteMany({
73
+ where,
74
+ }: {
75
+ where: { id: string; agentId: string };
76
+ }): Promise<{ count: number }> {
77
+ const row = rows.get(where.id);
78
+ if (row && row.agentId === where.agentId) {
79
+ rows.delete(where.id);
80
+ return { count: 1 };
81
+ }
82
+ return { count: 0 };
83
+ },
84
+ };
85
+
86
+ return { agentMember, __rows: rows };
87
+ }
88
+
89
+ type FakePrisma = ReturnType<typeof makeFakePrisma>;
90
+
91
+ function seedRow(overrides: Partial<FakeAgentMemberRow> = {}): FakeAgentMemberRow {
92
+ return {
93
+ id: "member-existing",
94
+ agentId: "agent-1",
95
+ email: "person@example.com",
96
+ createdAt: new Date("2024-01-01"),
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ // ─── listByAgentId ──────────────────────────────────────────────────────────
102
+
103
+ describe("AgentMemberService.listByAgentId", () => {
104
+ it("returns all memberships for the given agentId", async () => {
105
+ const prisma = makeFakePrisma([
106
+ seedRow({ id: "m1", agentId: "agent-1", email: "a@example.com" }),
107
+ seedRow({ id: "m2", agentId: "agent-1", email: "b@example.com" }),
108
+ seedRow({ id: "m3", agentId: "agent-2", email: "c@example.com" }),
109
+ ]) as unknown as FakePrisma;
110
+ const service = new AgentMemberService(prisma as never);
111
+
112
+ const result = await service.listByAgentId("agent-1");
113
+
114
+ expect(result.map((r) => r.email).sort()).toEqual([
115
+ "a@example.com",
116
+ "b@example.com",
117
+ ]);
118
+ });
119
+
120
+ it("returns an empty array when the agent has no memberships", async () => {
121
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
122
+ const service = new AgentMemberService(prisma as never);
123
+
124
+ expect(await service.listByAgentId("agent-1")).toEqual([]);
125
+ });
126
+ });
127
+
128
+ // ─── listByEmail ────────────────────────────────────────────────────────────
129
+
130
+ describe("AgentMemberService.listByEmail", () => {
131
+ it("returns all memberships for the given email, case-sensitive as stored", async () => {
132
+ const prisma = makeFakePrisma([
133
+ seedRow({ id: "m1", agentId: "agent-1", email: "person@example.com" }),
134
+ seedRow({ id: "m2", agentId: "agent-2", email: "person@example.com" }),
135
+ seedRow({ id: "m3", agentId: "agent-3", email: "other@example.com" }),
136
+ ]) as unknown as FakePrisma;
137
+ const service = new AgentMemberService(prisma as never);
138
+
139
+ const result = await service.listByEmail("person@example.com");
140
+
141
+ expect(result.map((r) => r.agentId).sort()).toEqual(["agent-1", "agent-2"]);
142
+ });
143
+
144
+ it("does not match on a differently-cased email", async () => {
145
+ const prisma = makeFakePrisma([
146
+ seedRow({ id: "m1", agentId: "agent-1", email: "person@example.com" }),
147
+ ]) as unknown as FakePrisma;
148
+ const service = new AgentMemberService(prisma as never);
149
+
150
+ expect(await service.listByEmail("Person@example.com")).toEqual([]);
151
+ });
152
+
153
+ it("returns an empty array when no memberships match", async () => {
154
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
155
+ const service = new AgentMemberService(prisma as never);
156
+
157
+ expect(await service.listByEmail("nobody@example.com")).toEqual([]);
158
+ });
159
+ });
160
+
161
+ // ─── exists ─────────────────────────────────────────────────────────────────
162
+
163
+ describe("AgentMemberService.exists", () => {
164
+ it("returns true when a membership row exists for the agentId+email pair", async () => {
165
+ const prisma = makeFakePrisma([
166
+ seedRow({ agentId: "agent-1", email: "person@example.com" }),
167
+ ]) as unknown as FakePrisma;
168
+ const service = new AgentMemberService(prisma as never);
169
+
170
+ expect(await service.exists("agent-1", "person@example.com")).toBe(true);
171
+ });
172
+
173
+ it("returns false when no membership row exists for the pair", async () => {
174
+ const prisma = makeFakePrisma([
175
+ seedRow({ agentId: "agent-1", email: "person@example.com" }),
176
+ ]) as unknown as FakePrisma;
177
+ const service = new AgentMemberService(prisma as never);
178
+
179
+ expect(await service.exists("agent-1", "someone-else@example.com")).toBe(
180
+ false,
181
+ );
182
+ expect(await service.exists("agent-2", "person@example.com")).toBe(false);
183
+ });
184
+
185
+ it("returns false when there are no memberships at all", async () => {
186
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
187
+ const service = new AgentMemberService(prisma as never);
188
+
189
+ expect(await service.exists("agent-1", "person@example.com")).toBe(false);
190
+ });
191
+ });
192
+
193
+ // ─── add ────────────────────────────────────────────────────────────────────
194
+
195
+ describe("AgentMemberService.add", () => {
196
+ it("creates a membership row for the given agentId and email", async () => {
197
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
198
+ const service = new AgentMemberService(prisma as never);
199
+
200
+ const row = await service.add("agent-1", "person@example.com");
201
+
202
+ expect(row.agentId).toBe("agent-1");
203
+ expect(row.email).toBe("person@example.com");
204
+ expect(row.id).toBeDefined();
205
+ });
206
+
207
+ it("propagates a unique-constraint error when the membership already exists", async () => {
208
+ const prisma = makeFakePrisma([
209
+ seedRow({ agentId: "agent-1", email: "person@example.com" }),
210
+ ]) as unknown as FakePrisma;
211
+ const service = new AgentMemberService(prisma as never);
212
+
213
+ await expect(
214
+ (async () => service.add("agent-1", "person@example.com"))(),
215
+ ).rejects.toThrow();
216
+ });
217
+ });
218
+
219
+ // ─── remove ─────────────────────────────────────────────────────────────────
220
+
221
+ describe("AgentMemberService.remove", () => {
222
+ it("deletes a membership by id when scoped to the correct agentId", async () => {
223
+ const row = seedRow({ id: "m1", agentId: "agent-1" });
224
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
225
+ const service = new AgentMemberService(prisma as never);
226
+
227
+ await service.remove("agent-1", "m1");
228
+
229
+ expect(await service.listByAgentId("agent-1")).toEqual([]);
230
+ });
231
+
232
+ it("no-ops when the memberId does not exist", async () => {
233
+ const prisma = makeFakePrisma() as unknown as FakePrisma;
234
+ const service = new AgentMemberService(prisma as never);
235
+
236
+ await service.remove("agent-1", "missing");
237
+
238
+ expect(await service.listByAgentId("agent-1")).toEqual([]);
239
+ });
240
+
241
+ it("no-ops when the memberId exists but belongs to a different agentId", async () => {
242
+ const row = seedRow({ id: "m1", agentId: "agent-1" });
243
+ const prisma = makeFakePrisma([row]) as unknown as FakePrisma;
244
+ const service = new AgentMemberService(prisma as never);
245
+
246
+ await service.remove("agent-2", "m1");
247
+
248
+ expect(await service.listByAgentId("agent-1")).toEqual([row]);
249
+ });
250
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * admin/src/agent-patch-author-allowlist.integration.test.ts
3
+ * Integration tests for the Agent.patchAuthorAllowlist column, against a
4
+ * real PostgreSQL DB.
5
+ *
6
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
7
+ *
8
+ * Mirrors agents-api.integration.test.ts's API round-trip pattern, but for
9
+ * the patchAuthorAllowlist column (DBR-1.1). Unlike the legacy
10
+ * authorAllowlist column (removed in DBR-2.4), this one was added fresh with
11
+ * NOT NULL DEFAULT ARRAY[]::TEXT[] from the start (one migration, no backfill
12
+ * needed) — so there is no NULL-row / NOT NULL-constraint scenario to replay
13
+ * here.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
17
+ import { sign } from "hono/jwt";
18
+ import type { PrismaClient } from "../prisma/client/client.ts";
19
+ import { AgentChatTokenService } from "./agent-chat-tokens.ts";
20
+ import { AgentCronJobService } from "./agent-cron-jobs.ts";
21
+ import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
22
+ import { AgentCronRunService } from "./agent-cron-runs.ts";
23
+ import { AgentEnvService } from "./agent-envs.ts";
24
+ import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
25
+ import { AgentMemberService } from "./agent-members.ts";
26
+ import { AgentPluginService } from "./agent-plugins.ts";
27
+ import { NoopAgentProvisioner } from "./agent-provisioner.ts";
28
+ import { AgentTokenService } from "./agent-tokens.ts";
29
+ import { AgentToolService } from "./agent-tools.ts";
30
+ import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
31
+ import type { SpecialtyManifest } from "./specialty-registry.ts";
32
+ import { AgentService } from "./agents.ts";
33
+ import type { AdminDeps } from "./agents-api.ts";
34
+ import { createAdminApp } from "./agents-api.ts";
35
+ import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
36
+ import { createAdminPrismaClient } from "./prisma-client.ts";
37
+ import { makeTokenCrypto } from "./token-crypto.ts";
38
+
39
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
40
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
41
+
42
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
43
+ const REAL_KEY =
44
+ "0000000000000000000000000000000000000000000000000000000000000001";
45
+
46
+ async function makeSessionCookie(): Promise<string> {
47
+ return sign(
48
+ {
49
+ isAdmin: true,
50
+ userId: "user-123",
51
+ email: "admin@example.com",
52
+ name: "Admin User",
53
+ iat: Math.floor(Date.now() / 1000),
54
+ exp: Math.floor(Date.now() / 1000) + 3600,
55
+ },
56
+ SESSION_SECRET,
57
+ "HS256",
58
+ );
59
+ }
60
+
61
+ function makePrisma(): PrismaClient {
62
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
63
+ return createAdminPrismaClient(TEST_DB as string);
64
+ }
65
+
66
+ const CODING_MANIFEST: SpecialtyManifest = {
67
+ apiVersion: "shipwright.dev/v1alpha1",
68
+ kind: "Specialty",
69
+ metadata: {
70
+ name: "coding",
71
+ displayName: "Coding Agent",
72
+ description: "test manifest",
73
+ version: "1.0.0",
74
+ skills: [],
75
+ },
76
+ identity: { templatesDir: "workspace/" },
77
+ crons: [],
78
+ plugins: ["shipwright"],
79
+ tools: ["Read", "Write", "Edit"],
80
+ env: { required: [], optional: [] },
81
+ members: [],
82
+ repos: [],
83
+ chat: true,
84
+ voice: true,
85
+ };
86
+
87
+ function fakeSpecialtyRegistry(): SpecialtyManifestResolver {
88
+ const byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST };
89
+ return {
90
+ getManifest(typeName: string): SpecialtyManifest {
91
+ return byType[typeName] ?? (byType.coding as SpecialtyManifest);
92
+ },
93
+ tryGetManifest(typeName: string): SpecialtyManifest | undefined {
94
+ return byType[typeName];
95
+ },
96
+ listTypes() {
97
+ return Object.entries(byType).map(([name, manifest]) => ({
98
+ name,
99
+ displayName: manifest.metadata.displayName,
100
+ }));
101
+ },
102
+ };
103
+ }
104
+
105
+ function makeDeps(prisma: PrismaClient): AdminDeps {
106
+ const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
107
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
108
+ const crypto = makeTokenCrypto();
109
+ if (savedKey === undefined) {
110
+ delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
111
+ } else {
112
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
113
+ }
114
+
115
+ return {
116
+ agentService: new AgentService(prisma),
117
+ agentEnvService: new AgentEnvService(prisma, crypto),
118
+ agentCronJobService: new AgentCronJobService(prisma),
119
+ agentCronRunService: new AgentCronRunService(prisma),
120
+ agentCronRunStatsService: new AgentCronRunStatsService(prisma),
121
+ agentToolService: new AgentToolService(prisma),
122
+ agentTokenService: new AgentTokenService(prisma),
123
+ agentPluginService: new AgentPluginService(prisma),
124
+ agentMemberService: new AgentMemberService(prisma),
125
+ specialtyRegistry: fakeSpecialtyRegistry(),
126
+ agentChatTokenService: new AgentChatTokenService(prisma),
127
+ agentGitHubInstallationsService: new AgentGitHubInstallationsService(
128
+ prisma,
129
+ ),
130
+ prisma,
131
+ provisioner: new NoopAgentProvisioner(),
132
+ serviceIntegrations: [
133
+ {
134
+ name: "task-store",
135
+ listTokensForAgent: async () => [],
136
+ revokeToken: async () => {},
137
+ },
138
+ ],
139
+ chatService: new NoopChatServiceProvisioningClient(),
140
+ slack: {
141
+ deleteApp: async () => {},
142
+ },
143
+ decrypt: (value: string) => crypto.decrypt(value),
144
+ sessionSecret: SESSION_SECRET,
145
+ };
146
+ }
147
+
148
+ describeOrSkip("Agent.patchAuthorAllowlist (integration)", () => {
149
+ let prisma: PrismaClient;
150
+
151
+ beforeEach(async () => {
152
+ prisma = makePrisma();
153
+ await prisma.agentWorkQueueSnapshot.deleteMany();
154
+ await prisma.agentPlugin.deleteMany();
155
+ await prisma.agentToken.deleteMany();
156
+ await prisma.agentCronJob.deleteMany();
157
+ await prisma.agentTool.deleteMany();
158
+ await prisma.agentEnv.deleteMany();
159
+ await prisma.agentMember.deleteMany();
160
+ await prisma.agent.deleteMany();
161
+ });
162
+
163
+ afterEach(async () => {
164
+ await prisma.$disconnect();
165
+ });
166
+
167
+ it("defaults to [] for a newly created row", async () => {
168
+ const agent = await prisma.agent.create({
169
+ data: { name: "Default Agent" },
170
+ });
171
+ expect(agent.patchAuthorAllowlist).toEqual([]);
172
+ });
173
+
174
+ it("round-trips via PATCH /agents/:id and GET /agents/:id, independent of reviewAuthorAllowlist", async () => {
175
+ const agentId = await prisma.agent
176
+ .create({ data: { name: "Round Trip Agent" } })
177
+ .then((a) => a.id);
178
+ const cookie = await makeSessionCookie();
179
+ const app = createAdminApp(makeDeps(prisma));
180
+
181
+ // PATCH sets patchAuthorAllowlist, leaving reviewAuthorAllowlist untouched
182
+ const patchRes = await app.request(`/agents/${agentId}`, {
183
+ method: "PATCH",
184
+ body: JSON.stringify({ patchAuthorAllowlist: ["octocat"] }),
185
+ headers: {
186
+ "Content-Type": "application/json",
187
+ Cookie: `admin_session=${cookie}`,
188
+ },
189
+ });
190
+ expect(patchRes.status).toBe(200);
191
+ const patchBody = await patchRes.json();
192
+ expect(patchBody.patchAuthorAllowlist).toEqual(["octocat"]);
193
+ expect(patchBody.reviewAuthorAllowlist).toEqual([]);
194
+
195
+ // GET confirms persisted value
196
+ const getRes = await app.request(`/agents/${agentId}`, {
197
+ headers: { Cookie: `admin_session=${cookie}` },
198
+ });
199
+ expect(getRes.status).toBe(200);
200
+ const getBody = await getRes.json();
201
+ expect(getBody.patchAuthorAllowlist).toEqual(["octocat"]);
202
+ expect(getBody.reviewAuthorAllowlist).toEqual([]);
203
+
204
+ // Setting reviewAuthorAllowlist afterwards must not disturb patchAuthorAllowlist
205
+ const secondPatchRes = await app.request(`/agents/${agentId}`, {
206
+ method: "PATCH",
207
+ body: JSON.stringify({ reviewAuthorAllowlist: ["hubot"] }),
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ Cookie: `admin_session=${cookie}`,
211
+ },
212
+ });
213
+ expect(secondPatchRes.status).toBe(200);
214
+ const secondPatchBody = await secondPatchRes.json();
215
+ expect(secondPatchBody.reviewAuthorAllowlist).toEqual(["hubot"]);
216
+ expect(secondPatchBody.patchAuthorAllowlist).toEqual(["octocat"]);
217
+ });
218
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * agent/src/agent-plugins.integration.test.ts
3
+ * Integration tests for AgentPluginService against a real PostgreSQL DB.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { AgentPluginService } from "./agent-plugins.ts";
11
+ import { NotFoundError } from "./errors.ts";
12
+ import { createAdminPrismaClient } from "./prisma-client.ts";
13
+
14
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
15
+
16
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
17
+
18
+ function makePrisma(): PrismaClient {
19
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
20
+ return createAdminPrismaClient(TEST_DB as string);
21
+ }
22
+
23
+ async function createAgent(
24
+ prisma: PrismaClient,
25
+ name = "Test Agent",
26
+ ): Promise<string> {
27
+ const agent = await prisma.agent.create({ data: { name } });
28
+ return agent.id;
29
+ }
30
+
31
+ describeOrSkip("AgentPluginService (integration)", () => {
32
+ let prisma: PrismaClient;
33
+ let service: AgentPluginService;
34
+
35
+ beforeEach(async () => {
36
+ prisma = makePrisma();
37
+ await prisma.agentPlugin.deleteMany();
38
+ await prisma.agentToken.deleteMany();
39
+ await prisma.agentCronJob.deleteMany();
40
+ await prisma.agentTool.deleteMany();
41
+ await prisma.agentEnv.deleteMany();
42
+ await prisma.agent.deleteMany();
43
+ service = new AgentPluginService(prisma);
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await prisma.$disconnect();
48
+ });
49
+
50
+ it("add() creates a new plugin", async () => {
51
+ const agentId = await createAgent(prisma);
52
+ const plugin = await service.add(agentId, "shipwright@shipwright");
53
+ expect(plugin.agentId).toBe(agentId);
54
+ expect(plugin.name).toBe("shipwright@shipwright");
55
+ expect(plugin.version).toBeNull();
56
+ expect(plugin.enabled).toBe(true);
57
+ });
58
+
59
+ it("add() accepts an optional version", async () => {
60
+ const agentId = await createAgent(prisma);
61
+ const plugin = await service.add(agentId, "shipwright@shipwright", "1.2.3");
62
+ expect(plugin.version).toBe("1.2.3");
63
+ });
64
+
65
+ it("add() creates a fresh row after the original was removed (unique constraint freed)", async () => {
66
+ const agentId = await createAgent(prisma);
67
+ const plugin = await service.add(agentId, "shipwright@shipwright", "1.0.0");
68
+ await service.remove(agentId, plugin.id);
69
+ // After remove, re-adding creates a fresh row (unique constraint no longer occupied)
70
+ const recreated = await service.add(
71
+ agentId,
72
+ "shipwright@shipwright",
73
+ "2.0.0",
74
+ );
75
+ expect(recreated.version).toBe("2.0.0");
76
+ expect(recreated.enabled).toBe(true);
77
+ });
78
+
79
+ it("add() upserts in place when called twice without removal", async () => {
80
+ const agentId = await createAgent(prisma);
81
+ const first = await service.add(agentId, "shipwright@shipwright", "1.0.0");
82
+ const second = await service.add(agentId, "shipwright@shipwright", "2.0.0");
83
+ expect(second.id).toBe(first.id);
84
+ expect(second.version).toBe("2.0.0");
85
+ expect(second.enabled).toBe(true);
86
+ });
87
+
88
+ it("list() returns all plugins for an agent ordered by createdAt", async () => {
89
+ const agentId = await createAgent(prisma);
90
+ await service.add(agentId, "plugin-a@shipwright");
91
+ await service.add(agentId, "plugin-b@shipwright");
92
+ await service.add(agentId, "plugin-c@shipwright");
93
+ const plugins = await service.list(agentId);
94
+ expect(plugins).toHaveLength(3);
95
+ expect(plugins.map((p) => p.name)).toEqual([
96
+ "plugin-a@shipwright",
97
+ "plugin-b@shipwright",
98
+ "plugin-c@shipwright",
99
+ ]);
100
+ });
101
+
102
+ it("list() does not return plugins from other agents", async () => {
103
+ const agentId1 = await createAgent(prisma, "Agent 1");
104
+ const agentId2 = await createAgent(prisma, "Agent 2");
105
+ await service.add(agentId1, "plugin-a@shipwright");
106
+ const plugins = await service.list(agentId2);
107
+ expect(plugins).toHaveLength(0);
108
+ });
109
+
110
+ it("remove() deletes a plugin by ID", async () => {
111
+ const agentId = await createAgent(prisma);
112
+ const plugin = await service.add(agentId, "shipwright@shipwright");
113
+ await service.remove(agentId, plugin.id);
114
+ const plugins = await service.list(agentId);
115
+ expect(plugins).toHaveLength(0);
116
+ });
117
+
118
+ it("remove() throws NotFoundError for unknown pluginId", async () => {
119
+ const agentId = await createAgent(prisma);
120
+ await expect(service.remove(agentId, "nonexistent")).rejects.toBeInstanceOf(
121
+ NotFoundError,
122
+ );
123
+ });
124
+
125
+ it("remove() throws NotFoundError when pluginId belongs to a different agent", async () => {
126
+ const agentId1 = await createAgent(prisma, "Agent 1");
127
+ const agentId2 = await createAgent(prisma, "Agent 2");
128
+ const plugin = await service.add(agentId1, "shipwright@shipwright");
129
+ await expect(service.remove(agentId2, plugin.id)).rejects.toBeInstanceOf(
130
+ NotFoundError,
131
+ );
132
+ });
133
+
134
+ it("removeByName() deletes a plugin by name", async () => {
135
+ const agentId = await createAgent(prisma);
136
+ await service.add(agentId, "shipwright@shipwright");
137
+ await service.removeByName(agentId, "shipwright@shipwright");
138
+ const plugins = await service.list(agentId);
139
+ expect(plugins).toHaveLength(0);
140
+ });
141
+
142
+ it("removeByName() throws NotFoundError for unknown name", async () => {
143
+ const agentId = await createAgent(prisma);
144
+ await expect(
145
+ service.removeByName(agentId, "nonexistent@shipwright"),
146
+ ).rejects.toBeInstanceOf(NotFoundError);
147
+ });
148
+
149
+ it("removeByName() throws NotFoundError when the name belongs to a different agent", async () => {
150
+ const agentId1 = await createAgent(prisma, "Agent 1");
151
+ const agentId2 = await createAgent(prisma, "Agent 2");
152
+ await service.add(agentId1, "shipwright@shipwright");
153
+ await expect(
154
+ service.removeByName(agentId2, "shipwright@shipwright"),
155
+ ).rejects.toBeInstanceOf(NotFoundError);
156
+ });
157
+ });