@app-vitals/agent-admin 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,323 @@
1
+ /**
2
+ * agent/src/agent-envs.integration.test.ts
3
+ * Integration tests for AgentEnvService 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 { AgentEnvService } from "./agent-envs.ts";
11
+ import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
12
+ import { UnprocessableEntityError } from "./errors.ts";
13
+ import { createAdminPrismaClient } from "./prisma-client.ts";
14
+ import { identityCrypto } from "./token-crypto.ts";
15
+
16
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
17
+
18
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
19
+
20
+ function makePrisma(): PrismaClient {
21
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
22
+ return createAdminPrismaClient(TEST_DB as string);
23
+ }
24
+
25
+ async function createAgent(
26
+ prisma: PrismaClient,
27
+ name = "Test Agent",
28
+ ): Promise<string> {
29
+ const agent = await prisma.agent.create({ data: { name } });
30
+ return agent.id;
31
+ }
32
+
33
+ describeOrSkip("AgentEnvService (integration)", () => {
34
+ let prisma: PrismaClient;
35
+ let service: AgentEnvService;
36
+
37
+ beforeEach(async () => {
38
+ prisma = makePrisma();
39
+ // Clean all tables in FK dependency order
40
+ await prisma.agentToken.deleteMany();
41
+ await prisma.agentCronJob.deleteMany();
42
+ await prisma.agentTool.deleteMany();
43
+ await prisma.agentEnv.deleteMany();
44
+ await prisma.agent.deleteMany();
45
+ service = new AgentEnvService(prisma, identityCrypto);
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await prisma.$disconnect();
50
+ });
51
+
52
+ it("upsert() writes values and getByAgentId() returns them", async () => {
53
+ const agentId = await createAgent(prisma);
54
+ await service.upsert(agentId, { FOO: "bar", BAZ: "qux" });
55
+ const result = await service.getByAgentId(agentId);
56
+ expect(result).not.toBeNull();
57
+ expect(result?.env).toEqual({ FOO: "bar", BAZ: "qux" });
58
+ expect(result?.secretKeys).toEqual([]);
59
+ });
60
+
61
+ it("upsert() replaces all existing vars (delete + insert)", async () => {
62
+ const agentId = await createAgent(prisma);
63
+ await service.upsert(agentId, { FOO: "bar", OLD: "value" });
64
+ await service.upsert(agentId, { FOO: "new", FRESH: "yes" });
65
+ const result = await service.getByAgentId(agentId);
66
+ expect(result).not.toBeNull();
67
+ expect(result?.env).toEqual({ FOO: "new", FRESH: "yes" });
68
+ expect(result?.env).not.toHaveProperty("OLD");
69
+ });
70
+
71
+ it("upsert() encrypts values (round-trip with real crypto)", async () => {
72
+ // Use a real crypto instead of identity to verify encrypt→decrypt round-trip
73
+ const { makeTokenCrypto } = await import("./token-crypto.ts");
74
+ const realKey =
75
+ "0000000000000000000000000000000000000000000000000000000000000001";
76
+ const origKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
77
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = realKey;
78
+ const realCrypto = makeTokenCrypto();
79
+
80
+ try {
81
+ const encService = new AgentEnvService(prisma, realCrypto);
82
+ const agentId = await createAgent(prisma);
83
+ await encService.upsert(agentId, { SECRET: "my-api-key" });
84
+
85
+ // Raw DB value should be encrypted (not plain text)
86
+ const raw = await prisma.agentEnv.findFirst({
87
+ where: { agentId, key: "SECRET" },
88
+ });
89
+ expect(raw?.value).not.toBe("my-api-key");
90
+ expect(raw?.value).toContain(":"); // iv:ciphertext:authTag format
91
+
92
+ // But getByAgentId should return decrypted (not secret-flagged, so not masked)
93
+ const result = await encService.getByAgentId(agentId);
94
+ expect(result?.env.SECRET).toBe("my-api-key");
95
+ } finally {
96
+ if (origKey === undefined) {
97
+ delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
98
+ } else {
99
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = origKey;
100
+ }
101
+ }
102
+ });
103
+
104
+ it("upsert() throws UnprocessableEntityError for unknown agent", async () => {
105
+ await expect(
106
+ service.upsert("nonexistent-id", { FOO: "bar" }),
107
+ ).rejects.toBeInstanceOf(UnprocessableEntityError);
108
+ });
109
+
110
+ it("patch() upserts specific keys without touching others", async () => {
111
+ const agentId = await createAgent(prisma);
112
+ await service.upsert(agentId, { A: "1", B: "2" });
113
+ await service.patch(agentId, { B: "updated", C: "new" });
114
+ const result = await service.getByAgentId(agentId);
115
+ expect(result).not.toBeNull();
116
+ expect(result?.env).toEqual({ A: "1", B: "updated", C: "new" });
117
+ });
118
+
119
+ it("patch() throws UnprocessableEntityError for unknown agent", async () => {
120
+ await expect(
121
+ service.patch("nonexistent-id", { FOO: "bar" }),
122
+ ).rejects.toBeInstanceOf(UnprocessableEntityError);
123
+ });
124
+
125
+ it("getByAgentId() returns null when no env vars set", async () => {
126
+ const agentId = await createAgent(prisma);
127
+ const result = await service.getByAgentId(agentId);
128
+ expect(result).toBeNull();
129
+ });
130
+
131
+ it("getConfigBundle() returns env and agentId and allowedTools", async () => {
132
+ const agentId = await createAgent(prisma);
133
+ await service.upsert(agentId, { SLACK_BOT_TOKEN: "xoxb-test" });
134
+
135
+ const bundle = await service.getConfigBundle(agentId);
136
+ expect(bundle).not.toBeNull();
137
+ expect(bundle?.env.SLACK_BOT_TOKEN).toBe("xoxb-test");
138
+ expect(bundle?.agentId).toBe(agentId);
139
+ expect(Array.isArray(bundle?.allowedTools)).toBe(true);
140
+ });
141
+
142
+ it("getConfigBundle() returns null when no env vars", async () => {
143
+ const agentId = await createAgent(prisma);
144
+ const bundle = await service.getConfigBundle(agentId);
145
+ expect(bundle).toBeNull();
146
+ });
147
+
148
+ it("getConfigBundle() includes enabled tools in allowedTools", async () => {
149
+ const agentId = await createAgent(prisma);
150
+ await service.upsert(agentId, { FOO: "bar" });
151
+ await prisma.agentTool.create({
152
+ data: { agentId, pattern: "Read", enabled: true },
153
+ });
154
+ await prisma.agentTool.create({
155
+ data: { agentId, pattern: "Bash", enabled: false },
156
+ });
157
+
158
+ const bundle = await service.getConfigBundle(agentId);
159
+ expect(bundle?.allowedTools).toContain("Read");
160
+ expect(bundle?.allowedTools).not.toContain("Bash");
161
+ });
162
+
163
+ it("deleteKey() removes a specific env var", async () => {
164
+ const agentId = await createAgent(prisma);
165
+ await service.upsert(agentId, { A: "1", B: "2" });
166
+ await service.deleteKey(agentId, "A");
167
+ const result = await service.getByAgentId(agentId);
168
+ expect(result).not.toBeNull();
169
+ expect(result?.env).toEqual({ B: "2" });
170
+ });
171
+
172
+ it("deleteKey() no-ops for a key that doesn't exist", async () => {
173
+ const agentId = await createAgent(prisma);
174
+ await service.upsert(agentId, { B: "2" });
175
+ await expect(
176
+ service.deleteKey(agentId, "MISSING"),
177
+ ).resolves.toBeUndefined();
178
+ });
179
+
180
+ it("listAll() returns entries for all agents", async () => {
181
+ const id1 = await createAgent(prisma, "Agent 1");
182
+ const id2 = await createAgent(prisma, "Agent 2");
183
+ await service.upsert(id1, { KEY: "val1" });
184
+ await service.upsert(id2, { KEY: "val2" });
185
+
186
+ const all = await service.listAll();
187
+ expect(all).toHaveLength(2);
188
+ const entry1 = all.find((e) => e.agentId === id1);
189
+ const entry2 = all.find((e) => e.agentId === id2);
190
+ expect(entry1?.env.KEY).toBe("val1");
191
+ expect(entry2?.env.KEY).toBe("val2");
192
+ });
193
+
194
+ // ─── Secret flag tests ────────────────────────────────────────────────────
195
+
196
+ it("upsert() with secret=true masks value in getByAgentId() response", async () => {
197
+ const agentId = await createAgent(prisma);
198
+ await service.upsert(
199
+ agentId,
200
+ { MY_SECRET: "supersecret", PLAIN: "visible" },
201
+ new Set(["MY_SECRET"]),
202
+ );
203
+ const result = await service.getByAgentId(agentId);
204
+ expect(result).not.toBeNull();
205
+ expect(result?.env.MY_SECRET).toBe("***");
206
+ expect(result?.env.PLAIN).toBe("visible");
207
+ expect(result?.secretKeys).toContain("MY_SECRET");
208
+ expect(result?.secretKeys).not.toContain("PLAIN");
209
+ });
210
+
211
+ it("upsert() with secret=false returns real value in getByAgentId() response", async () => {
212
+ const agentId = await createAgent(prisma);
213
+ await service.upsert(agentId, { MY_KEY: "realvalue" });
214
+ const result = await service.getByAgentId(agentId);
215
+ expect(result).not.toBeNull();
216
+ expect(result?.env.MY_KEY).toBe("realvalue");
217
+ expect(result?.secretKeys).toEqual([]);
218
+ });
219
+
220
+ it("getConfigBundle() always decrypts regardless of secret flag", async () => {
221
+ const agentId = await createAgent(prisma);
222
+ await service.upsert(
223
+ agentId,
224
+ { MY_SECRET: "supersecret", PLAIN: "visible" },
225
+ new Set(["MY_SECRET"]),
226
+ );
227
+ const bundle = await service.getConfigBundle(agentId);
228
+ expect(bundle).not.toBeNull();
229
+ // Config bundle always returns real values — no masking
230
+ expect(bundle?.env.MY_SECRET).toBe("supersecret");
231
+ expect(bundle?.env.PLAIN).toBe("visible");
232
+ });
233
+
234
+ it("patch() with secretKeys updates secret flag", async () => {
235
+ const agentId = await createAgent(prisma);
236
+ await service.upsert(agentId, { A: "1", B: "2" });
237
+ // Patch B as secret
238
+ await service.patch(agentId, { B: "newsecret" }, new Set(["B"]));
239
+ const result = await service.getByAgentId(agentId);
240
+ expect(result).not.toBeNull();
241
+ expect(result?.env.A).toBe("1");
242
+ expect(result?.env.B).toBe("***");
243
+ expect(result?.secretKeys).toContain("B");
244
+ expect(result?.secretKeys).not.toContain("A");
245
+ });
246
+ });
247
+
248
+ describeOrSkip(
249
+ "AgentEnvService Anthropic federation guard (integration, WIF-2.1)",
250
+ () => {
251
+ let prisma: PrismaClient;
252
+ let federated: AgentEnvService;
253
+
254
+ const BLOCKED = [
255
+ "CLAUDE_CODE_OAUTH_TOKEN",
256
+ "ANTHROPIC_API_KEY",
257
+ "ANTHROPIC_AUTH_TOKEN",
258
+ ];
259
+
260
+ async function createAgentWith(selfHosted: boolean): Promise<string> {
261
+ const agent = await prisma.agent.create({
262
+ data: { name: "Guard Agent", selfHosted },
263
+ });
264
+ return agent.id;
265
+ }
266
+
267
+ beforeEach(async () => {
268
+ prisma = makePrisma();
269
+ await prisma.agentToken.deleteMany();
270
+ await prisma.agentCronJob.deleteMany();
271
+ await prisma.agentTool.deleteMany();
272
+ await prisma.agentEnv.deleteMany();
273
+ await prisma.agent.deleteMany();
274
+ federated = new AgentEnvService(prisma, identityCrypto, undefined, true);
275
+ });
276
+
277
+ afterEach(async () => {
278
+ await prisma.$disconnect();
279
+ });
280
+
281
+ for (const key of BLOCKED) {
282
+ it(`upsert() rejects ${key} (even empty) for a non-self-hosted agent and writes nothing`, async () => {
283
+ const agentId = await createAgentWith(false);
284
+ await federated.upsert(agentId, { KEEP: "me" });
285
+ await expect(
286
+ federated.upsert(agentId, { FOO: "bar", [key]: "" }),
287
+ ).rejects.toBeInstanceOf(AnthropicCredentialsBlockedError);
288
+ // Rejected before the delete+insert transaction: old bundle intact.
289
+ expect((await federated.getByAgentId(agentId))?.env).toEqual({
290
+ KEEP: "me",
291
+ });
292
+ });
293
+
294
+ it(`patch() rejects ${key} for a non-self-hosted agent`, async () => {
295
+ const agentId = await createAgentWith(false);
296
+ await expect(
297
+ federated.patch(agentId, { [key]: "secret" }),
298
+ ).rejects.toMatchObject({
299
+ code: "anthropic_credentials_blocked_federation",
300
+ statusCode: 400,
301
+ });
302
+ expect(await federated.getByAgentId(agentId)).toBeNull();
303
+ });
304
+ }
305
+
306
+ it("allows the keys for a self-hosted agent under federation", async () => {
307
+ const agentId = await createAgentWith(true);
308
+ await federated.patch(agentId, { ANTHROPIC_API_KEY: "sk-ant" });
309
+ expect((await federated.getByAgentId(agentId))?.env).toEqual({
310
+ ANTHROPIC_API_KEY: "sk-ant",
311
+ });
312
+ });
313
+
314
+ it("allows the keys when federation is disabled (default)", async () => {
315
+ const plain = new AgentEnvService(prisma, identityCrypto);
316
+ const agentId = await createAgentWith(false);
317
+ await plain.upsert(agentId, { CLAUDE_CODE_OAUTH_TOKEN: "tok" });
318
+ expect((await plain.getByAgentId(agentId))?.env).toEqual({
319
+ CLAUDE_CODE_OAUTH_TOKEN: "tok",
320
+ });
321
+ });
322
+ },
323
+ );
@@ -0,0 +1,244 @@
1
+ /**
2
+ * agent/src/agent-envs.ts
3
+ * AgentEnvService — generic env var store for deployed agents.
4
+ *
5
+ * Keys are env var names (e.g. SLACK_BOT_TOKEN). Values are encrypted at rest
6
+ * using SHIPWRIGHT_ENCRYPTION_KEY (AES-256-GCM).
7
+ */
8
+
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import {
11
+ AnthropicCredentialsBlockedError,
12
+ anthropicCredentialKeysIn,
13
+ } from "./anthropic-credential-guard.ts";
14
+ import { type Clock, SystemClock } from "./clock.ts";
15
+ import { ApiError, UnprocessableEntityError } from "./errors.ts";
16
+ import type { TokenCrypto } from "./token-crypto.ts";
17
+
18
+ // ─── Types ────────────────────────────────────────────────────────────────────
19
+
20
+ export interface AgentEnvBundle {
21
+ /** All stored env vars (decrypted). */
22
+ env: Record<string, string>;
23
+ agentId: string;
24
+ /** Enabled tool patterns for this agent. */
25
+ allowedTools: string[];
26
+ }
27
+
28
+ export interface AgentEnvEntry {
29
+ agentId: string;
30
+ env: Record<string, string>;
31
+ }
32
+
33
+ /**
34
+ * Return type for getByAgentId().
35
+ * Secret values are masked as "***" in env; their keys are listed in secretKeys.
36
+ */
37
+ interface AgentEnvResult {
38
+ env: Record<string, string>;
39
+ secretKeys: string[];
40
+ }
41
+
42
+ // ─── Service ──────────────────────────────────────────────────────────────────
43
+
44
+ export class AgentEnvService {
45
+ constructor(
46
+ private prisma: PrismaClient,
47
+ private crypto: TokenCrypto,
48
+ private clock: Clock = SystemClock(),
49
+ /** WIF-2.1: block Anthropic credential keys for non-self-hosted agents. */
50
+ private anthropicFederationEnabled = false,
51
+ ) {}
52
+
53
+ /**
54
+ * Reject Anthropic credential keys for a federated (non-self-hosted) agent.
55
+ * Every write path (API, admin UI, createAgent, Slack/GitHub provisioning)
56
+ * goes through upsert/patch, so this is the single enforcement point.
57
+ */
58
+ private assertNoBlockedCredentials(
59
+ agent: { selfHosted: boolean },
60
+ env: Record<string, string>,
61
+ ): void {
62
+ if (!this.anthropicFederationEnabled || agent.selfHosted) return;
63
+ const blocked = anthropicCredentialKeysIn(Object.keys(env));
64
+ if (blocked.length > 0) throw new AnthropicCredentialsBlockedError(blocked);
65
+ }
66
+
67
+ /**
68
+ * Replace all env vars for the given agent (delete existing + insert new).
69
+ * Validates that agentId references an existing Agent.
70
+ *
71
+ * @param secretKeys - optional set of key names to mark as secret
72
+ */
73
+ async upsert(
74
+ agentId: string,
75
+ env: Record<string, string>,
76
+ secretKeys?: Set<string>,
77
+ ): Promise<void> {
78
+ const agent = await this.prisma.agent.findUnique({
79
+ where: { id: agentId },
80
+ });
81
+ if (!agent) {
82
+ throw new UnprocessableEntityError("agent not found");
83
+ }
84
+ this.assertNoBlockedCredentials(agent, env);
85
+
86
+ const now = this.clock.now();
87
+ const rows = Object.entries(env).map(([key, value]) => ({
88
+ agentId,
89
+ key,
90
+ value: this.crypto.encrypt(value),
91
+ secret: secretKeys?.has(key) ?? false,
92
+ updatedAt: now,
93
+ }));
94
+
95
+ await this.prisma.$transaction([
96
+ this.prisma.agentEnv.deleteMany({ where: { agentId } }),
97
+ ...rows.map((row) =>
98
+ this.prisma.agentEnv.create({
99
+ data: {
100
+ agentId: row.agentId,
101
+ key: row.key,
102
+ value: row.value,
103
+ secret: row.secret,
104
+ updatedAt: row.updatedAt,
105
+ },
106
+ }),
107
+ ),
108
+ ]);
109
+ }
110
+
111
+ /**
112
+ * Upsert specific keys for the given agent without touching other keys.
113
+ * Validates that agentId references an existing Agent.
114
+ *
115
+ * @param secretKeys - optional set of key names to mark as secret
116
+ */
117
+ async patch(
118
+ agentId: string,
119
+ env: Record<string, string>,
120
+ secretKeys?: Set<string>,
121
+ ): Promise<void> {
122
+ const agent = await this.prisma.agent.findUnique({
123
+ where: { id: agentId },
124
+ });
125
+ if (!agent) {
126
+ throw new UnprocessableEntityError("agent not found");
127
+ }
128
+ this.assertNoBlockedCredentials(agent, env);
129
+
130
+ const now = this.clock.now();
131
+ await this.prisma.$transaction(
132
+ Object.entries(env).map(([key, value]) =>
133
+ this.prisma.agentEnv.upsert({
134
+ where: { agentId_key: { agentId, key } },
135
+ create: {
136
+ agentId,
137
+ key,
138
+ value: this.crypto.encrypt(value),
139
+ secret: secretKeys?.has(key) ?? false,
140
+ updatedAt: now,
141
+ },
142
+ update: {
143
+ value: this.crypto.encrypt(value),
144
+ secret: secretKeys?.has(key) ?? false,
145
+ updatedAt: now,
146
+ },
147
+ }),
148
+ ),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Get all env vars for an agent.
154
+ * Secret values are masked as "***"; their keys are listed in secretKeys.
155
+ * Returns null if the agent has no env vars set.
156
+ */
157
+ async getByAgentId(agentId: string): Promise<AgentEnvResult | null> {
158
+ const rows = await this.prisma.agentEnv.findMany({ where: { agentId } });
159
+ if (rows.length === 0) return null;
160
+
161
+ const env: Record<string, string> = {};
162
+ const secretKeys: string[] = [];
163
+
164
+ for (const row of rows) {
165
+ if (row.secret) {
166
+ env[row.key] = "***";
167
+ secretKeys.push(row.key);
168
+ } else {
169
+ env[row.key] = this.crypto.decrypt(row.value);
170
+ }
171
+ }
172
+
173
+ return { env, secretKeys };
174
+ }
175
+
176
+ /**
177
+ * Returns the full env bundle for an agent — stored env vars (decrypted) plus
178
+ * allowed tools. Always decrypts regardless of the secret flag.
179
+ *
180
+ * Returns null if the agent has no env vars set.
181
+ * Throws 500 if decryption fails.
182
+ */
183
+ async getConfigBundle(agentId: string): Promise<AgentEnvBundle | null> {
184
+ const rows = await this.prisma.agentEnv.findMany({ where: { agentId } });
185
+ if (rows.length === 0) return null;
186
+
187
+ let env: Record<string, string>;
188
+ try {
189
+ // Always decrypt — secret flag only affects the admin API response
190
+ env = this.decryptRows(rows);
191
+ } catch (err) {
192
+ console.error("[shipwright agent] failed to decrypt agent env vars", err);
193
+ throw new ApiError(500, "failed to decrypt agent env vars");
194
+ }
195
+
196
+ const tools = await this.prisma.agentTool.findMany({
197
+ where: { agentId, enabled: true },
198
+ });
199
+
200
+ return {
201
+ env,
202
+ agentId,
203
+ allowedTools: tools.map((t: { pattern: string }) => t.pattern),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Delete a single env var key for the given agent.
209
+ * No-ops if the key does not exist.
210
+ */
211
+ async deleteKey(agentId: string, key: string): Promise<void> {
212
+ await this.prisma.agentEnv.deleteMany({ where: { agentId, key } });
213
+ }
214
+
215
+ /**
216
+ * List all agents with their decrypted env vars.
217
+ */
218
+ async listAll(): Promise<AgentEnvEntry[]> {
219
+ const rows = await this.prisma.agentEnv.findMany();
220
+
221
+ // Group by agentId
222
+ const byAgent = new Map<string, typeof rows>();
223
+ for (const row of rows) {
224
+ const existing = byAgent.get(row.agentId) ?? [];
225
+ existing.push(row);
226
+ byAgent.set(row.agentId, existing);
227
+ }
228
+
229
+ return Array.from(byAgent.entries()).map(([agentId, agentRows]) => ({
230
+ agentId,
231
+ env: this.decryptRows(agentRows),
232
+ }));
233
+ }
234
+
235
+ private decryptRows(
236
+ rows: Array<{ key: string; value: string }>,
237
+ ): Record<string, string> {
238
+ const env: Record<string, string> = {};
239
+ for (const row of rows) {
240
+ env[row.key] = this.crypto.decrypt(row.value);
241
+ }
242
+ return env;
243
+ }
244
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * admin/src/agent-github-installations.integration.test.ts
3
+ * Integration tests for AgentGitHubInstallationsService — push/get.
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 { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
11
+ import { createAdminPrismaClient } from "./prisma-client.ts";
12
+
13
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
14
+
15
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
16
+
17
+ function makePrisma(): PrismaClient {
18
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
19
+ return createAdminPrismaClient(TEST_DB as string);
20
+ }
21
+
22
+ async function createAgent(
23
+ prisma: PrismaClient,
24
+ name = "Test Agent",
25
+ ): Promise<string> {
26
+ const agent = await prisma.agent.create({ data: { name } });
27
+ return agent.id;
28
+ }
29
+
30
+ describeOrSkip("AgentGitHubInstallationsService (integration)", () => {
31
+ let prisma: PrismaClient;
32
+ let service: AgentGitHubInstallationsService;
33
+
34
+ beforeEach(async () => {
35
+ prisma = makePrisma();
36
+ await prisma.agentGitHubInstallationsSnapshot.deleteMany();
37
+ await prisma.agentWorkQueueSnapshot.deleteMany();
38
+ await prisma.agentChatTokenUsageDailyByModel.deleteMany();
39
+ await prisma.agentCronRun.deleteMany();
40
+ await prisma.agentToken.deleteMany();
41
+ await prisma.agentCronJob.deleteMany();
42
+ await prisma.agentTool.deleteMany();
43
+ await prisma.agentEnv.deleteMany();
44
+ await prisma.agentMember.deleteMany();
45
+ await prisma.agent.deleteMany();
46
+ service = new AgentGitHubInstallationsService(prisma);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await prisma.$disconnect();
51
+ });
52
+
53
+ it("push() creates a snapshot and get() reads it back", async () => {
54
+ const agentId = await createAgent(prisma);
55
+ const reportedAt = new Date("2026-01-15T12:00:00.000Z");
56
+ const installations = [
57
+ { owner: "app-vitals", installationId: 1, state: "active" },
58
+ ];
59
+
60
+ const written = await service.push(agentId, { reportedAt, installations });
61
+ expect(written.agentId).toBe(agentId);
62
+ expect(written.installations).toEqual(installations);
63
+
64
+ const read = await service.get(agentId);
65
+ expect(read?.id).toBe(written.id);
66
+ expect(read?.reportedAt).toEqual(reportedAt);
67
+ });
68
+
69
+ it("get() returns null when nothing has been pushed", async () => {
70
+ const agentId = await createAgent(prisma);
71
+ expect(await service.get(agentId)).toBeNull();
72
+ });
73
+
74
+ it("a second push replaces the first, removing a dropped installation", async () => {
75
+ const agentId = await createAgent(prisma);
76
+ const first = await service.push(agentId, {
77
+ reportedAt: new Date("2026-01-15T12:00:00.000Z"),
78
+ installations: [
79
+ { owner: "a", installationId: 1, state: "active" },
80
+ { owner: "b", installationId: 2, state: "active" },
81
+ ],
82
+ });
83
+ const second = await service.push(agentId, {
84
+ reportedAt: new Date("2026-01-16T12:00:00.000Z"),
85
+ installations: [{ owner: "a", installationId: 1, state: "active" }],
86
+ });
87
+
88
+ expect(second.id).toBe(first.id);
89
+ const read = await service.get(agentId);
90
+ expect(read?.installations).toEqual([
91
+ { owner: "a", installationId: 1, state: "active" },
92
+ ]);
93
+ expect(
94
+ await prisma.agentGitHubInstallationsSnapshot.count({
95
+ where: { agentId },
96
+ }),
97
+ ).toBe(1);
98
+ });
99
+
100
+ it("the snapshot row is deleted when the agent is deleted", async () => {
101
+ const agentId = await createAgent(prisma);
102
+ await service.push(agentId, {
103
+ reportedAt: new Date(),
104
+ installations: [],
105
+ });
106
+
107
+ await prisma.agent.delete({ where: { id: agentId } });
108
+
109
+ expect(
110
+ await prisma.agentGitHubInstallationsSnapshot.count({
111
+ where: { agentId },
112
+ }),
113
+ ).toBe(0);
114
+ });
115
+ });