@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
+ * admin/src/agent-deletion.integration.test.ts
3
+ * Integration tests for deleteAgentFully() against a real PostgreSQL DB.
4
+ *
5
+ * Unlike agent-deletion.unit.test.ts (fully-mocked Prisma double), this suite
6
+ * passes a REAL PrismaClient for the DB-touching parts of the orchestration —
7
+ * proving the Agent row and every child row actually cascade-delete together
8
+ * (or don't, on failure) against the real schema's `onDelete: Cascade` FKs.
9
+ * The non-DB dependencies (provisioner/taskStore/chatService/slack/decrypt)
10
+ * are hand-written no-op stubs — they are not the thing under test here.
11
+ *
12
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
16
+ import type { PrismaClient } from "../prisma/client/client.ts";
17
+ import {
18
+ type DeleteAgentFullyDeps,
19
+ deleteAgentFully,
20
+ } from "./agent-deletion.ts";
21
+ import { createAdminPrismaClient } from "./prisma-client.ts";
22
+
23
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
24
+
25
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
26
+
27
+ function makePrisma(): PrismaClient {
28
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
29
+ return createAdminPrismaClient(TEST_DB as string);
30
+ }
31
+
32
+ async function createAgent(
33
+ prisma: PrismaClient,
34
+ name = "Test Agent",
35
+ ): Promise<string> {
36
+ const agent = await prisma.agent.create({ data: { name } });
37
+ return agent.id;
38
+ }
39
+
40
+ /**
41
+ * Populate one row in every child model that carries (directly or
42
+ * transitively) an agentId FK, so a single "full-fixture" agent exercises the
43
+ * entire cascade — including the two-hop Agent → AgentCronJob → AgentCronRun
44
+ * path.
45
+ */
46
+ async function seedFullFixture(
47
+ prisma: PrismaClient,
48
+ agentId: string,
49
+ ): Promise<void> {
50
+ await prisma.agentEnv.create({
51
+ data: { agentId, key: "GH_TOKEN", value: "encrypted-value", secret: true },
52
+ });
53
+ await prisma.agentToken.create({
54
+ data: { agentId, token: "a".repeat(64), label: "CI Token" },
55
+ });
56
+ const cronJob = await prisma.agentCronJob.create({
57
+ data: {
58
+ agentId,
59
+ schedule: "0 9 * * *",
60
+ prompt: "Good morning",
61
+ channel: "C123456",
62
+ },
63
+ });
64
+ await prisma.agentCronRun.create({
65
+ data: {
66
+ cronId: cronJob.id,
67
+ agentId,
68
+ startedAt: new Date(),
69
+ completedAt: new Date(),
70
+ skipped: false,
71
+ outcome: "success",
72
+ },
73
+ });
74
+ await prisma.agentTool.create({
75
+ data: { agentId, pattern: "Read" },
76
+ });
77
+ await prisma.agentPlugin.create({
78
+ data: { agentId, name: "@shipwright/plugin" },
79
+ });
80
+ await prisma.agentMember.create({
81
+ data: { agentId, email: "member@example.com" },
82
+ });
83
+ await prisma.agentChatTokenUsageDailyByModel.create({
84
+ data: {
85
+ agentId,
86
+ date: "2026-07-14",
87
+ model: "claude-sonnet-4-5",
88
+ inputTokens: 100,
89
+ outputTokens: 50,
90
+ },
91
+ });
92
+ }
93
+
94
+ interface FixtureCounts {
95
+ agent: number;
96
+ agentEnv: number;
97
+ agentToken: number;
98
+ agentCronJob: number;
99
+ agentCronRun: number;
100
+ agentTool: number;
101
+ agentPlugin: number;
102
+ agentMember: number;
103
+ agentChatTokenUsageDailyByModel: number;
104
+ }
105
+
106
+ async function countFixtureRows(
107
+ prisma: PrismaClient,
108
+ agentId: string,
109
+ ): Promise<FixtureCounts> {
110
+ const where = { where: { agentId } };
111
+ return {
112
+ agent: (await prisma.agent.findUnique({ where: { id: agentId } })) ? 1 : 0,
113
+ agentEnv: await prisma.agentEnv.count(where),
114
+ agentToken: await prisma.agentToken.count(where),
115
+ agentCronJob: await prisma.agentCronJob.count(where),
116
+ agentCronRun: await prisma.agentCronRun.count(where),
117
+ agentTool: await prisma.agentTool.count(where),
118
+ agentPlugin: await prisma.agentPlugin.count(where),
119
+ agentMember: await prisma.agentMember.count(where),
120
+ agentChatTokenUsageDailyByModel:
121
+ await prisma.agentChatTokenUsageDailyByModel.count(where),
122
+ };
123
+ }
124
+
125
+ const ALL_ZERO: FixtureCounts = {
126
+ agent: 0,
127
+ agentEnv: 0,
128
+ agentToken: 0,
129
+ agentCronJob: 0,
130
+ agentCronRun: 0,
131
+ agentTool: 0,
132
+ agentPlugin: 0,
133
+ agentMember: 0,
134
+ agentChatTokenUsageDailyByModel: 0,
135
+ };
136
+
137
+ const ALL_ONE: FixtureCounts = {
138
+ agent: 1,
139
+ agentEnv: 1,
140
+ agentToken: 1,
141
+ agentCronJob: 1,
142
+ agentCronRun: 1,
143
+ agentTool: 1,
144
+ agentPlugin: 1,
145
+ agentMember: 1,
146
+ agentChatTokenUsageDailyByModel: 1,
147
+ };
148
+
149
+ /** Hand-written no-op stub deps — everything succeeds. Not the thing under test. */
150
+ function makeSucceedingDeps(prisma: PrismaClient): DeleteAgentFullyDeps {
151
+ return {
152
+ prisma,
153
+ provisioner: {
154
+ deprovision: async () => {
155
+ // no-op: real k8s deprovision is out of scope for this DB-cascade test
156
+ },
157
+ },
158
+ serviceIntegrations: [
159
+ {
160
+ name: "task-store",
161
+ listTokensForAgent: async () => [],
162
+ revokeToken: async () => {
163
+ // no-op
164
+ },
165
+ },
166
+ ],
167
+ chatService: {
168
+ listTokensForAgent: async () => [],
169
+ revokeToken: async () => {
170
+ // no-op
171
+ },
172
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
173
+ },
174
+ slack: {
175
+ deleteApp: async () => {
176
+ // no-op
177
+ },
178
+ },
179
+ decrypt: (value) => value,
180
+ };
181
+ }
182
+
183
+ describeOrSkip("deleteAgentFully (integration)", () => {
184
+ let prisma: PrismaClient;
185
+
186
+ beforeEach(async () => {
187
+ prisma = makePrisma();
188
+ // FK-safe order: children before parents.
189
+ await prisma.agentCronRun.deleteMany();
190
+ await prisma.agentCronJob.deleteMany();
191
+ await prisma.agentToken.deleteMany();
192
+ await prisma.agentTool.deleteMany();
193
+ await prisma.agentPlugin.deleteMany();
194
+ await prisma.agentMember.deleteMany();
195
+ await prisma.agentChatTokenUsageDailyByModel.deleteMany();
196
+ await prisma.agentEnv.deleteMany();
197
+ await prisma.agent.deleteMany();
198
+ });
199
+
200
+ afterEach(async () => {
201
+ await prisma.$disconnect();
202
+ });
203
+
204
+ it("deletes the Agent row and cascades every child row on full success", async () => {
205
+ const agentId = await createAgent(prisma);
206
+ await seedFullFixture(prisma, agentId);
207
+
208
+ // Sanity: the fixture is fully seeded before we call the function under test.
209
+ expect(await countFixtureRows(prisma, agentId)).toEqual(ALL_ONE);
210
+
211
+ const deps = makeSucceedingDeps(prisma);
212
+ const result = await deleteAgentFully(agentId, deps, {
213
+ xoxpToken: "xoxp-user",
214
+ });
215
+
216
+ expect(result.agentDeleted).toBe(true);
217
+ expect(result.failed).toEqual([]);
218
+
219
+ // Every row for this agentId — including the transitively-cascaded
220
+ // AgentCronRun via AgentCronJob — must now be gone.
221
+ expect(await countFixtureRows(prisma, agentId)).toEqual(ALL_ZERO);
222
+ });
223
+
224
+ it("does not delete the Agent row (or cascade anything) if an automatable step fails", async () => {
225
+ const agentId = await createAgent(prisma);
226
+ await seedFullFixture(prisma, agentId);
227
+
228
+ expect(await countFixtureRows(prisma, agentId)).toEqual(ALL_ONE);
229
+
230
+ const deps = makeSucceedingDeps(prisma);
231
+ deps.provisioner = {
232
+ deprovision: async () => {
233
+ throw new Error("k8s deprovision failed");
234
+ },
235
+ };
236
+
237
+ const result = await deleteAgentFully(agentId, deps, {
238
+ xoxpToken: "xoxp-user",
239
+ });
240
+
241
+ expect(result.agentDeleted).toBe(false);
242
+ expect(result.failed).toEqual([
243
+ { step: "k8s", error: "k8s deprovision failed" },
244
+ ]);
245
+
246
+ // Nothing was cascaded: the Agent row is deliberately left in place, and
247
+ // so is every child row — proving the row-deleted-last rollback-safety
248
+ // guarantee holds against a real DB, not just the in-memory unit double.
249
+ expect(await countFixtureRows(prisma, agentId)).toEqual(ALL_ONE);
250
+ });
251
+
252
+ it("cascades AgentCronRun via its parent AgentCronJob on success, but leaves it intact on failure", async () => {
253
+ // Targeted check on the trickiest FK path — the two-hop
254
+ // Agent -> AgentCronJob -> AgentCronRun cascade — in isolation from the
255
+ // rest of the fixture.
256
+ const successAgentId = await createAgent(prisma, "Cascade Success Agent");
257
+ const successCron = await prisma.agentCronJob.create({
258
+ data: {
259
+ agentId: successAgentId,
260
+ schedule: "0 9 * * *",
261
+ prompt: "Good morning",
262
+ channel: "C1",
263
+ },
264
+ });
265
+ const successRun = await prisma.agentCronRun.create({
266
+ data: {
267
+ cronId: successCron.id,
268
+ agentId: successAgentId,
269
+ startedAt: new Date(),
270
+ completedAt: new Date(),
271
+ skipped: false,
272
+ outcome: "success",
273
+ },
274
+ });
275
+
276
+ const successResult = await deleteAgentFully(
277
+ successAgentId,
278
+ makeSucceedingDeps(prisma),
279
+ );
280
+ expect(successResult.agentDeleted).toBe(true);
281
+ expect(
282
+ await prisma.agentCronRun.findUnique({ where: { id: successRun.id } }),
283
+ ).toBeNull();
284
+ expect(
285
+ await prisma.agentCronJob.findUnique({ where: { id: successCron.id } }),
286
+ ).toBeNull();
287
+
288
+ const failAgentId = await createAgent(prisma, "Cascade Failure Agent");
289
+ const failCron = await prisma.agentCronJob.create({
290
+ data: {
291
+ agentId: failAgentId,
292
+ schedule: "0 9 * * *",
293
+ prompt: "Good morning",
294
+ channel: "C1",
295
+ },
296
+ });
297
+ const failRun = await prisma.agentCronRun.create({
298
+ data: {
299
+ cronId: failCron.id,
300
+ agentId: failAgentId,
301
+ startedAt: new Date(),
302
+ completedAt: new Date(),
303
+ skipped: false,
304
+ outcome: "success",
305
+ },
306
+ });
307
+
308
+ const deps = makeSucceedingDeps(prisma);
309
+ deps.provisioner = {
310
+ deprovision: async () => {
311
+ throw new Error("k8s deprovision failed");
312
+ },
313
+ };
314
+ const failResult = await deleteAgentFully(failAgentId, deps);
315
+ expect(failResult.agentDeleted).toBe(false);
316
+ expect(
317
+ await prisma.agentCronRun.findUnique({ where: { id: failRun.id } }),
318
+ ).not.toBeNull();
319
+ expect(
320
+ await prisma.agentCronJob.findUnique({ where: { id: failCron.id } }),
321
+ ).not.toBeNull();
322
+ });
323
+ });
@@ -0,0 +1,329 @@
1
+ /**
2
+ * admin/src/agent-deletion.ts
3
+ *
4
+ * `deleteAgentFully()` — the single shared orchestration both agent-delete entry
5
+ * points (DELETE /agents/:id and the admin-ui "danger zone") are wired into.
6
+ * Previously each caller independently ran
7
+ * `provisioner.deprovision()` + `prisma.agent.delete()` with no shared cleanup,
8
+ * so any fix to one drifted from the other. This module centralizes the full
9
+ * teardown so there is one place to fix.
10
+ *
11
+ * ── Critical ordering ──────────────────────────────────────────────────────
12
+ * The Agent DB row is deleted LAST, and only once every automatable cleanup
13
+ * step has succeeded. This makes the whole operation IDEMPOTENT and RETRYABLE:
14
+ * a caller can re-invoke `deleteAgentFully` after a partial failure and it only
15
+ * re-attempts what didn't finish, because every underlying step is itself
16
+ * individually idempotent:
17
+ * - K8s deprovision swallows 404 (agent-provisioner.ts deprovision()).
18
+ * - Token revoke on an already-revoked token is a no-op (token-service).
19
+ * - Thread delete tolerates 404 (chat-service deleteThreadsForAgent()).
20
+ *
21
+ * The Agent row is the retry anchor: while it still exists, a retry re-reads it
22
+ * and re-runs the remaining cleanup; once it's deleted, the operation is done.
23
+ *
24
+ * Steps, in order:
25
+ * 1. Read the Agent row + its AgentEnv rows.
26
+ * 2. K8s deprovision (Deployment + Secret + PVC).
27
+ * Then, only for an agent with a per-agent Anthropic identity, remove it:
28
+ * archive the federation rule, then the Anthropic service account, then
29
+ * delete the K8s ServiceAccount (Anthropic refuses to archive a service
30
+ * account while a live rule references it). A failure keeps the row and
31
+ * adds the leftover pieces to the manual-steps checklist.
32
+ * 3. Revoke task-store token(s).
33
+ * 4. Revoke chat-service token(s) + delete chat threads.
34
+ * 5. (Optional) Delete the Slack app, gated on a supplied user token.
35
+ * 6. Build the secret checklist (always included, never blocks).
36
+ * 7. If every automatable step succeeded → delete the Agent row.
37
+ * 8. Otherwise → leave the Agent row in place so the caller can retry.
38
+ *
39
+ * Out of scope (decided during planning): no mutation of task-store Task rows
40
+ * for the deleted agent — tasks keep their historical assignment.
41
+ */
42
+
43
+ import {
44
+ buildManualStepsChecklist,
45
+ type LeftoverIdentity,
46
+ type ManualStep,
47
+ } from "./agent-deletion-checklist.ts";
48
+ import type { AgentProvisioner } from "./agent-provisioner.ts";
49
+ import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
50
+ import { NotFoundError } from "./errors.ts";
51
+ import type { DeletionServiceIntegration } from "./service-integration.ts";
52
+ import type { SlackProvisioningClient } from "./slack-provisioning-client.ts";
53
+
54
+ // ─── Step identifiers ─────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Stable identifiers for each automatable cleanup step. Used both in the
58
+ * `completed` list and to tag entries in the `failed` array so a caller (or a
59
+ * test) can tell exactly which dependency broke.
60
+ */
61
+ type DeleteAgentStep =
62
+ | "k8s"
63
+ | "anthropic-identity"
64
+ | `${string}-tokens`
65
+ | "chat-service-tokens-and-threads"
66
+ | "slack-app";
67
+
68
+ /** A step that threw, with its error message for the caller to log/surface. */
69
+ interface FailedStep {
70
+ step: DeleteAgentStep;
71
+ error: string;
72
+ }
73
+
74
+ // ─── Result ────────────────────────────────────────────────────────────────────
75
+
76
+ interface DeleteAgentResult {
77
+ /**
78
+ * True only when every automatable step succeeded AND the Agent row was
79
+ * deleted. False when any step failed (the row is left in place for retry).
80
+ */
81
+ agentDeleted: boolean;
82
+ /** Steps that completed successfully, in execution order. */
83
+ completed: DeleteAgentStep[];
84
+ /**
85
+ * Steps that failed. Empty when `agentDeleted` is true. A failed step does
86
+ * NOT abort the remaining steps — they are still attempted so a single retry
87
+ * makes maximum forward progress.
88
+ */
89
+ failed: FailedStep[];
90
+ /**
91
+ * Operator-facing manual reminders (hand-pasted secrets with no automated
92
+ * revocation, plus an optional Slack-app entry when a token wasn't supplied).
93
+ * Always populated; never blocks the delete.
94
+ */
95
+ manualStepsRequired: ManualStep[];
96
+ }
97
+
98
+ // ─── Dependencies (DI boundary) ─────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Minimal Prisma surface this orchestration needs: read the Agent row, read its
102
+ * AgentEnv rows, and delete the Agent row (cascades child rows). Typed as a
103
+ * structural shape rather than `Pick<PrismaClient, ...>` so tests can inject a
104
+ * tiny in-memory double without pulling in the generated client.
105
+ */
106
+ interface DeleteAgentPrisma {
107
+ agent: {
108
+ findUnique(args: {
109
+ where: { id: string };
110
+ select: {
111
+ id: true;
112
+ name: true;
113
+ anthropicFederationRuleId: true;
114
+ anthropicServiceAccountId: true;
115
+ k8sServiceAccountName: true;
116
+ };
117
+ }): Promise<({ id: string; name: string } & LeftoverIdentity) | null>;
118
+ delete(args: { where: { id: string } }): Promise<unknown>;
119
+ };
120
+ agentEnv: {
121
+ findMany(args: {
122
+ where: { agentId: string };
123
+ select: { key: true; value: true; secret: true };
124
+ }): Promise<{ key: string; value: string; secret: boolean }[]>;
125
+ };
126
+ }
127
+
128
+ export interface DeleteAgentFullyDeps {
129
+ prisma: DeleteAgentPrisma;
130
+ provisioner: Pick<
131
+ AgentProvisioner,
132
+ "deprovision" | "removeAnthropicIdentity"
133
+ >;
134
+ /**
135
+ * Optional service integrations (e.g. task-store, registered by the coding
136
+ * extension), each cleaned up as its own `${name}-tokens` step, in list
137
+ * order, before the built-in chat-service step.
138
+ */
139
+ serviceIntegrations: DeletionServiceIntegration[];
140
+ chatService: Pick<
141
+ ChatServiceProvisioningClient,
142
+ "listTokensForAgent" | "revokeToken" | "deleteThreadsForAgent"
143
+ >;
144
+ slack: Pick<SlackProvisioningClient, "deleteApp">;
145
+ /**
146
+ * Decrypt a stored AgentEnv value. AgentEnv values are AES-256-GCM encrypted
147
+ * at rest; only the SLACK_APP_ID value is decrypted here (to pass to the
148
+ * Slack delete call). Injected so the orchestration stays free of concrete
149
+ * crypto and is trivially unit-testable.
150
+ */
151
+ decrypt(value: string): string;
152
+ }
153
+
154
+ interface DeleteAgentFullyOpts {
155
+ /**
156
+ * Slack user token (xoxp-) authorizing the Slack app deletion. Supplied
157
+ * per-request (same UX as the sync-manifest flow) — when omitted, a Slack app
158
+ * (if present) is NOT auto-deleted and instead becomes a manual checklist
159
+ * entry. Its absence is never a failure.
160
+ */
161
+ xoxpToken?: string;
162
+ }
163
+
164
+ // ─── Orchestration ──────────────────────────────────────────────────────────────
165
+
166
+ const errMessage = (err: unknown): string =>
167
+ err instanceof Error ? err.message : String(err);
168
+
169
+ /**
170
+ * Fully delete an agent: tear down its workload and external state, then delete
171
+ * the Agent DB row LAST (only if every automatable step succeeded).
172
+ *
173
+ * Idempotent and retryable — see the module header. Throws `NotFoundError` only
174
+ * when the Agent row is genuinely absent at the start of the call: that means
175
+ * either the id was never valid, or a prior call already completed the delete.
176
+ * Treating a missing row as an error (rather than a silent success) keeps retry
177
+ * semantics honest — the caller asked to delete something that isn't there. A
178
+ * caller that races two deletes can catch it; the common retry-after-partial
179
+ * path always still sees the row (it's deleted last) and proceeds normally.
180
+ */
181
+ export async function deleteAgentFully(
182
+ agentId: string,
183
+ deps: DeleteAgentFullyDeps,
184
+ opts: DeleteAgentFullyOpts = {},
185
+ ): Promise<DeleteAgentResult> {
186
+ // ── Step 1: read the Agent row + its AgentEnv rows ──────────────────────────
187
+ const agentSelect = {
188
+ id: true,
189
+ name: true,
190
+ anthropicFederationRuleId: true,
191
+ anthropicServiceAccountId: true,
192
+ k8sServiceAccountName: true,
193
+ } as const;
194
+ const agent = await deps.prisma.agent.findUnique({
195
+ where: { id: agentId },
196
+ select: agentSelect,
197
+ });
198
+ if (!agent) {
199
+ throw new NotFoundError(`agent ${agentId} not found`);
200
+ }
201
+ const envRows = await deps.prisma.agentEnv.findMany({
202
+ where: { agentId },
203
+ select: { key: true, value: true, secret: true },
204
+ });
205
+
206
+ const completed: DeleteAgentStep[] = [];
207
+ const failed: FailedStep[] = [];
208
+
209
+ const runStep = async (
210
+ step: DeleteAgentStep,
211
+ fn: () => Promise<void>,
212
+ ): Promise<void> => {
213
+ try {
214
+ await fn();
215
+ completed.push(step);
216
+ } catch (err) {
217
+ failed.push({ step, error: errMessage(err) });
218
+ }
219
+ };
220
+
221
+ // ── Step 2: K8s deprovision (Deployment + Secret + PVC) ─────────────────────
222
+ await runStep("k8s", async () => {
223
+ await deps.provisioner.deprovision(agentId, { slug: agent.name });
224
+ });
225
+
226
+ // ── Step 2b: remove the per-agent Anthropic identity ────────────────────────
227
+ // Runs after k8s so the pods are gone before their identity is pulled. Only
228
+ // present when the agent has a saved identity piece: agents without one (and
229
+ // shared/no-federation mode) get no step and behave exactly as before. The
230
+ // provisioner removes rule → Anthropic service account → K8s ServiceAccount
231
+ // and throws at the first failure.
232
+ const hasIdentity = Boolean(
233
+ agent.anthropicFederationRuleId ||
234
+ agent.anthropicServiceAccountId ||
235
+ agent.k8sServiceAccountName,
236
+ );
237
+ if (hasIdentity) {
238
+ await runStep("anthropic-identity", async () => {
239
+ if (!deps.provisioner.removeAnthropicIdentity) {
240
+ throw new Error(
241
+ "the provisioner cannot remove a per-agent Anthropic identity",
242
+ );
243
+ }
244
+ await deps.provisioner.removeAnthropicIdentity(agentId);
245
+ });
246
+ }
247
+
248
+ // ── Step 3: revoke each service integration's token(s) (e.g. task-store) ───
249
+ for (const integration of deps.serviceIntegrations) {
250
+ await runStep(`${integration.name}-tokens`, async () => {
251
+ await revokeAllTokens(integration, agentId);
252
+ });
253
+ }
254
+
255
+ // ── Step 4: revoke chat-service token(s) + delete chat threads ──────────────
256
+ await runStep("chat-service-tokens-and-threads", async () => {
257
+ await revokeAllTokens(deps.chatService, agentId);
258
+ await deps.chatService.deleteThreadsForAgent(agentId);
259
+ });
260
+
261
+ // ── Step 5: (optional) delete the Slack app ─────────────────────────────────
262
+ // Only present when the agent stores a SLACK_APP_ID. Gated on a supplied user
263
+ // token: with one we auto-delete (a real cleanup step that can fail); without
264
+ // one it's an expected manual gap that adds a checklist entry, not a failure.
265
+ const slackAppIdRow = envRows.find((r) => r.key === "SLACK_APP_ID");
266
+ const manualSlackSteps: ManualStep[] = [];
267
+ if (slackAppIdRow) {
268
+ const slackAppId = deps.decrypt(slackAppIdRow.value);
269
+ if (opts.xoxpToken) {
270
+ await runStep("slack-app", async () => {
271
+ await deps.slack.deleteApp(opts.xoxpToken as string, slackAppId);
272
+ });
273
+ } else {
274
+ manualSlackSteps.push({
275
+ key: "SLACK_APP_ID",
276
+ message: `Slack app ${slackAppId} was not deleted automatically — no Slack user token was supplied. Delete it manually at api.slack.com/apps or retry with a token.`,
277
+ });
278
+ }
279
+ }
280
+
281
+ // ── Step 6: build the secret checklist (always included) ────────────────────
282
+ // When the identity step failed, re-read the IDs so the checklist lists only
283
+ // what is actually still live (the provisioner clears each ID as it goes).
284
+ let leftoverIdentity: LeftoverIdentity | undefined;
285
+ if (failed.some((f) => f.step === "anthropic-identity")) {
286
+ leftoverIdentity =
287
+ (await deps.prisma.agent.findUnique({
288
+ where: { id: agentId },
289
+ select: agentSelect,
290
+ })) ?? agent;
291
+ }
292
+ const manualStepsRequired = [
293
+ ...buildManualStepsChecklist(envRows, leftoverIdentity),
294
+ ...manualSlackSteps,
295
+ ];
296
+
297
+ // ── Steps 7/8: delete the Agent row LAST, only if nothing failed ────────────
298
+ if (failed.length === 0) {
299
+ await deps.prisma.agent.delete({ where: { id: agentId } });
300
+ return { agentDeleted: true, completed, failed, manualStepsRequired };
301
+ }
302
+
303
+ return { agentDeleted: false, completed, failed, manualStepsRequired };
304
+ }
305
+
306
+ /**
307
+ * List every token scoped to the agent and revoke each. Revocation is
308
+ * individually idempotent (already-revoked / 404 is a no-op), so one failure
309
+ * mid-loop doesn't abort the rest — we attempt every token, then re-throw the
310
+ * first error so the enclosing step is recorded as failed for retry.
311
+ */
312
+ async function revokeAllTokens(
313
+ client: Pick<
314
+ DeletionServiceIntegration,
315
+ "listTokensForAgent" | "revokeToken"
316
+ >,
317
+ agentId: string,
318
+ ): Promise<void> {
319
+ const tokens = await client.listTokensForAgent(agentId);
320
+ let firstError: unknown;
321
+ for (const token of tokens) {
322
+ try {
323
+ await client.revokeToken(token.id);
324
+ } catch (err) {
325
+ if (firstError === undefined) firstError = err;
326
+ }
327
+ }
328
+ if (firstError !== undefined) throw firstError;
329
+ }