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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +64 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * admin/src/trial-expiry-warning-sweeper.ts
3
+ *
4
+ * TrialExpiryWarningSweeper (ATE-2.1) — one of the admin service's background
5
+ * loops, alongside session-alert-sweeper.ts and trial-expiry-sweeper.ts.
6
+ *
7
+ * Deliberately a separate module from trial-expiry-sweeper.ts, which owns the
8
+ * *lockdown* half of trial expiry (ATE-3.1: disabling an expired agent's crons
9
+ * once `trialExpiresAt` has passed). The two are sequential halves of the same
10
+ * lifecycle but share no state and run on different cadences — this one fires
11
+ * once per agent *before* expiry (hourly ticks, dedup'd via
12
+ * `trialExpiryWarnedAt`), the lockdown sweeper fires continuously *after* it
13
+ * (minutely ticks, idempotent by construction). Keeping them apart keeps each
14
+ * one's failure modes, counters, and log prefix
15
+ * (`[trial-expiry-warning-sweeper]` vs `[trial-expiry-sweeper]`) distinct.
16
+ *
17
+ * Every tick it:
18
+ *
19
+ * 1. Finds Agent rows with `trialExpiresAt` set and `trialExpiryWarnedAt`
20
+ * still null (an unset `trialExpiresAt` — the ATE-1.1 default — is never
21
+ * considered; see isDueForWarning below).
22
+ * 2. For each candidate whose `trialExpiresAt` falls within the configured
23
+ * warning band — up to `warningDays` ahead (default 3) and no more than
24
+ * `graceDays` behind (defaults to the same 3, so a trial that lapsed
25
+ * long ago is never retroactively alerted) — looks up that agent's
26
+ * own `SLACK_BOT_TOKEN` / `SLACK_ALERT_CHANNEL` (AgentEnv rows, decrypted
27
+ * via AgentEnvService.getConfigBundle) and posts one Slack warning
28
+ * naming the expiry date.
29
+ * 3. On a successful send, stamps `trialExpiryWarnedAt = now` via
30
+ * AgentService.updateFields so the same agent is never warned twice.
31
+ *
32
+ * Structure mirrors session-alert-sweeper.ts: an injected Clock, a narrow
33
+ * *PrismaLike interface for the one read this sweeper does directly, a
34
+ * per-agent try/catch so one bad row can never abort the sweep, an in-flight
35
+ * `sweeping` guard on tick(), and registration via `setInterval` in
36
+ * admin/src/main.ts (NEVER inside an app factory, which must stay
37
+ * side-effect-free).
38
+ *
39
+ * Unlike session-alert-sweeper.ts, the write path reuses AgentService's
40
+ * existing updateFields() rather than a raw Prisma call — admin/src/agents.ts
41
+ * already owns "generic partial-field update for an agent" and ATE-2.1 is one
42
+ * more field on that same whitelist (see UpdateAgentFieldsInput).
43
+ *
44
+ * Admin has no bot-token WebClient of its own: each tenant agent's Slack
45
+ * credentials are its own AgentEnv rows, not a shared admin-side secret. So
46
+ * `sendSlackMessage` is injected (defaulting to sendTrialExpiryWarning, which
47
+ * constructs a fresh @slack/web-api WebClient per call using *that agent's*
48
+ * token) rather than a single shared client living on the sweeper.
49
+ *
50
+ * Failed-send / missing-config policy (see sweepAgent below): neither case
51
+ * stamps `trialExpiryWarnedAt`. A missing SLACK_BOT_TOKEN/SLACK_ALERT_CHANNEL
52
+ * or a failed postMessage both leave the dedup gate open so the next tick
53
+ * retries — silently stamping over an alert nobody received would satisfy
54
+ * "never warn twice" at the cost of "never warn at all" for a
55
+ * misconfigured/flaky tenant, which is worse. Both cases are logged (not
56
+ * swallowed) each tick so a persistently failing agent stays visible in the
57
+ * admin service's logs rather than silently going dark forever.
58
+ */
59
+
60
+ import { WebClient } from "@slack/web-api";
61
+ import type { AgentEnvBundle, AgentEnvService } from "./agent-envs.ts";
62
+ import type { AgentService } from "./agents.ts";
63
+ import { type Clock, SystemClock } from "./clock.ts";
64
+
65
+ // ─── Types ──────────────────────────────────────────────────────────────────
66
+
67
+ /** The slice of an Agent row this sweeper's candidate query needs. */
68
+ export interface TrialExpiryAgentRow {
69
+ id: string;
70
+ name: string;
71
+ trialExpiresAt: Date | null;
72
+ trialExpiryWarnedAt: Date | null;
73
+ }
74
+
75
+ /**
76
+ * The narrow slice of PrismaClient this sweeper touches directly — same
77
+ * *PrismaLike pattern as session-alert-sweeper.ts's SessionAlertPrismaLike,
78
+ * so tests inject a plain object double instead of a real client. The write
79
+ * path (stamping trialExpiryWarnedAt) goes through AgentService.updateFields
80
+ * instead, so this interface only needs the one read.
81
+ */
82
+ export interface TrialExpiryPrismaLike {
83
+ agent: {
84
+ findMany(args: {
85
+ where: { trialExpiresAt: { not: null }; trialExpiryWarnedAt: null };
86
+ }): Promise<TrialExpiryAgentRow[]>;
87
+ };
88
+ }
89
+
90
+ /** Params passed to the injected Slack-sending function. */
91
+ export interface SendSlackWarningParams {
92
+ botToken: string;
93
+ channel: string;
94
+ text: string;
95
+ }
96
+
97
+ /**
98
+ * Sends one Slack message and reports whether it actually went out. Returns
99
+ * `false` (never throws) on any failure so a Slack outage can't abort the
100
+ * sweep — see the header comment for why a failed send does not stamp
101
+ * `trialExpiryWarnedAt`.
102
+ */
103
+ export type SendSlackWarning = (
104
+ params: SendSlackWarningParams,
105
+ ) => Promise<boolean>;
106
+
107
+ export interface TrialExpirySweeperDeps {
108
+ prisma: TrialExpiryPrismaLike;
109
+ agentService: Pick<AgentService, "updateFields">;
110
+ agentEnvService: Pick<AgentEnvService, "getConfigBundle">;
111
+ /**
112
+ * Injected so tests substitute a double instead of constructing a real
113
+ * @slack/web-api WebClient / hitting a real Slack API. Defaults to
114
+ * sendTrialExpiryWarning (the production implementation) when omitted.
115
+ */
116
+ sendSlackMessage?: SendSlackWarning;
117
+ clock?: Clock;
118
+ /** How many days out from `trialExpiresAt` the warning should fire. */
119
+ warningDays?: number;
120
+ /**
121
+ * How many days *past* `trialExpiresAt` the warning still fires. Defaults
122
+ * to `warningDays` (a symmetric band around expiry) — see isDueForWarning.
123
+ */
124
+ graceDays?: number;
125
+ /** Line logger for per-agent outcomes; defaults to `console.log`. */
126
+ log?: (line: string) => void;
127
+ }
128
+
129
+ /** Per-tick counters, also the value `tick()` resolves to. */
130
+ export interface TrialExpirySweepResult {
131
+ /** Warning sent and trialExpiryWarnedAt stamped. */
132
+ warned: number;
133
+ /** Due for a warning, but the agent has no usable Slack config. */
134
+ skipped: number;
135
+ /** Due for a warning, Slack config present, but the send itself failed. */
136
+ failed: number;
137
+ /**
138
+ * Never warned, and `trialExpiresAt` is now more than `graceDays` in the
139
+ * past — deliberately not alerted (see isPastWarningGrace), counted so the
140
+ * decision stays visible in the per-tick summary instead of being silent.
141
+ */
142
+ stale: number;
143
+ }
144
+
145
+ /** Matches the task description's default warning window. */
146
+ export const DEFAULT_TRIAL_EXPIRY_WARNING_DAYS = 3;
147
+
148
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
149
+
150
+ // ─── Pure helpers ───────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Whether an agent's `trialExpiresAt` is so far in the past that a warning is
154
+ * no longer worth sending. `trialExpiresAt` is freely admin-settable to an
155
+ * arbitrary past date (PATCH /agents/:id applies no future-only validation),
156
+ * and has been since ATE-1.1 — without this bound the first tick after this
157
+ * sweeper deploys would Slack-warn *every* long-lapsed trial agent at once,
158
+ * with wording about an expiry that came and went months ago.
159
+ *
160
+ * Exported so the sweeper can count (and surface) these rather than dropping
161
+ * them silently.
162
+ */
163
+ export function isPastWarningGrace(
164
+ trialExpiresAt: Date,
165
+ now: Date,
166
+ graceDays: number,
167
+ ): boolean {
168
+ return trialExpiresAt.getTime() - now.getTime() < -graceDays * MS_PER_DAY;
169
+ }
170
+
171
+ /**
172
+ * Whether an agent is due for a trial-expiry warning right now, given only
173
+ * the two schema fields (trialExpiresAt, trialExpiryWarnedAt) plus the
174
+ * current time and the configured window:
175
+ *
176
+ * - `trialExpiresAt` unset (ATE-1.1 default) → never due
177
+ * - `trialExpiryWarnedAt` already set → never due (dedup —
178
+ * acceptance criterion 2: a warned agent is never warned twice)
179
+ * - `trialExpiresAt` more than `graceDays` in the past → never due (see
180
+ * isPastWarningGrace: no retroactive alert burst for trials that lapsed
181
+ * long before this sweeper existed)
182
+ * - otherwise due once `trialExpiresAt` is at most `windowDays` away —
183
+ * still including a *recently*-past `trialExpiresAt` (a trial that
184
+ * expired days ago before anyone noticed deserves exactly one warning,
185
+ * not silence — the message reads "expired on", see
186
+ * buildTrialExpiryWarningMessage)
187
+ *
188
+ * `graceDays` defaults to `windowDays`, making the firing band symmetric
189
+ * around expiry: the same number of days of lateness the sweeper is willing
190
+ * to warn early about, it is willing to warn late about.
191
+ */
192
+ export function isDueForWarning(
193
+ trialExpiresAt: Date | null,
194
+ trialExpiryWarnedAt: Date | null,
195
+ now: Date,
196
+ windowDays: number = DEFAULT_TRIAL_EXPIRY_WARNING_DAYS,
197
+ graceDays: number = windowDays,
198
+ ): boolean {
199
+ if (!trialExpiresAt) return false;
200
+ if (trialExpiryWarnedAt) return false;
201
+ if (trialExpiresAt.getTime() - now.getTime() > windowDays * MS_PER_DAY) {
202
+ return false;
203
+ }
204
+ return !isPastWarningGrace(trialExpiresAt, now, graceDays);
205
+ }
206
+
207
+ /**
208
+ * The Slack warning text for a given agent + expiry date. Explicitly does
209
+ * NOT say "deprovisioned" — ATE-3.1 was corrected (same planning session,
210
+ * same day) from auto-deprovisioning to lockdown-only: crons disabled and
211
+ * Slack access blocked, agent never deleted. Naming the actual date (rather
212
+ * than "in N days") matters because the sweeper's own dedup means this is
213
+ * often the *only* warning an operator gets before lockdown.
214
+ *
215
+ * Two tenses, because the sweeper deliberately still fires for a trial that
216
+ * lapsed within the grace window (see isDueForWarning): "expires on ..." for
217
+ * a future date, "expired on ..." for a past one. Same lockdown consequence
218
+ * either way — only the tense changes, so a late alert doesn't read as if
219
+ * the deadline were still ahead.
220
+ */
221
+ export function buildTrialExpiryWarningMessage(
222
+ agentName: string,
223
+ trialExpiresAt: Date,
224
+ now: Date,
225
+ ): string {
226
+ const dateStr = trialExpiresAt.toISOString().slice(0, 10);
227
+ const alreadyExpired = trialExpiresAt.getTime() <= now.getTime();
228
+ if (alreadyExpired) {
229
+ return (
230
+ `:warning: Trial for agent \`${agentName}\` expired on ${dateStr}. ` +
231
+ `This agent's crons will be disabled and its Slack access will be ` +
232
+ `blocked until the trial is renewed.`
233
+ );
234
+ }
235
+ return (
236
+ `:warning: Trial for agent \`${agentName}\` expires on ${dateStr}. ` +
237
+ `After that date this agent's crons will be disabled and its Slack ` +
238
+ `access will be blocked until the trial is renewed.`
239
+ );
240
+ }
241
+
242
+ // ─── Production Slack sender ────────────────────────────────────────────────
243
+
244
+ /**
245
+ * The slice of @slack/web-api's WebClient this module actually calls. Narrow
246
+ * on purpose so tests can inject a typed double covering the real send path
247
+ * (same approach as agent/src/runtime/startup-dm.ts's injected WebClient).
248
+ */
249
+ export type SlackPostMessageClient = Pick<WebClient, "chat">;
250
+
251
+ /** Builds a Slack client for one agent's bot token. */
252
+ type SlackClientFactory = (botToken: string) => SlackPostMessageClient;
253
+
254
+ /**
255
+ * The production factory: a fresh @slack/web-api WebClient per call, built
256
+ * from *the target agent's own* bot token — mirrors (not reuses, since it's
257
+ * cross-service) the alert-posting pattern in agent/src/runtime/cron-handler.ts.
258
+ * There is no long-lived client to share/cache: every tenant agent has a
259
+ * distinct token, and posting is infrequent (at most once per agent, ever,
260
+ * thanks to the dedup gate).
261
+ */
262
+ export const defaultSlackClientFactory: SlackClientFactory = (botToken) =>
263
+ new WebClient(botToken);
264
+
265
+ /**
266
+ * Production implementation of SendSlackWarning: posts one message through a
267
+ * real @slack/web-api client and converts any throw into `false` so a Slack
268
+ * outage can never abort the sweep.
269
+ *
270
+ * `createClient` is injected (defaulting to defaultSlackClientFactory) purely
271
+ * so tests can exercise this function's own send + try/catch path against a
272
+ * typed WebClient double instead of a live Slack workspace.
273
+ */
274
+ export async function sendTrialExpiryWarning(
275
+ params: SendSlackWarningParams,
276
+ createClient: SlackClientFactory = defaultSlackClientFactory,
277
+ ): Promise<boolean> {
278
+ try {
279
+ const client = createClient(params.botToken);
280
+ await client.chat.postMessage({
281
+ channel: params.channel,
282
+ text: params.text,
283
+ });
284
+ return true;
285
+ } catch (err) {
286
+ console.error(
287
+ "[trial-expiry-warning-sweeper] Slack chat.postMessage failed:",
288
+ err,
289
+ );
290
+ return false;
291
+ }
292
+ }
293
+
294
+ // ─── Sweeper ────────────────────────────────────────────────────────────────
295
+
296
+ export class TrialExpiryWarningSweeper {
297
+ private readonly clock: Clock;
298
+ private readonly warningDays: number;
299
+ private readonly graceDays: number;
300
+ private readonly sendSlackMessage: SendSlackWarning;
301
+ private readonly log: (line: string) => void;
302
+ /** True while a sweep is running — mirrors SessionAlertSweeper's guard. */
303
+ private sweeping = false;
304
+
305
+ constructor(private readonly deps: TrialExpirySweeperDeps) {
306
+ this.clock = deps.clock ?? SystemClock();
307
+ this.warningDays = deps.warningDays ?? DEFAULT_TRIAL_EXPIRY_WARNING_DAYS;
308
+ this.graceDays = deps.graceDays ?? this.warningDays;
309
+ this.sendSlackMessage = deps.sendSlackMessage ?? sendTrialExpiryWarning;
310
+ this.log = deps.log ?? ((line) => console.log(line));
311
+ }
312
+
313
+ /**
314
+ * One sweep. Never throws: a candidate-fetch failure short-circuits to an
315
+ * all-zero result, and every per-agent step is individually try/caught so
316
+ * one bad row can't abort the rest.
317
+ *
318
+ * Not re-entrant by design, for the same reason as SessionAlertSweeper: the
319
+ * dedup check-then-write straddles network I/O (Slack + Prisma), so two
320
+ * overlapping ticks could both observe `trialExpiryWarnedAt = null` and
321
+ * both send. If a previous tick is still in flight this returns an
322
+ * all-zero result immediately rather than racing it.
323
+ */
324
+ async tick(): Promise<TrialExpirySweepResult> {
325
+ if (this.sweeping) {
326
+ console.warn(
327
+ "[trial-expiry-warning-sweeper] previous tick still in flight — skipping",
328
+ );
329
+ return { warned: 0, skipped: 0, failed: 0, stale: 0 };
330
+ }
331
+ this.sweeping = true;
332
+ try {
333
+ return await this.sweep();
334
+ } finally {
335
+ this.sweeping = false;
336
+ }
337
+ }
338
+
339
+ private async sweep(): Promise<TrialExpirySweepResult> {
340
+ const result: TrialExpirySweepResult = {
341
+ warned: 0,
342
+ skipped: 0,
343
+ failed: 0,
344
+ stale: 0,
345
+ };
346
+
347
+ let candidates: TrialExpiryAgentRow[];
348
+ try {
349
+ candidates = await this.deps.prisma.agent.findMany({
350
+ where: { trialExpiresAt: { not: null }, trialExpiryWarnedAt: null },
351
+ });
352
+ } catch (err) {
353
+ console.error(
354
+ "[trial-expiry-warning-sweeper] candidate fetch failed:",
355
+ err,
356
+ );
357
+ return result;
358
+ }
359
+
360
+ const now = this.clock.now();
361
+ for (const agent of candidates) {
362
+ try {
363
+ await this.sweepAgent(agent, now, result);
364
+ } catch (err) {
365
+ console.error(
366
+ `[trial-expiry-warning-sweeper] agent ${agent.id} failed:`,
367
+ err,
368
+ );
369
+ }
370
+ }
371
+
372
+ const acted = result.warned + result.skipped + result.failed + result.stale;
373
+ if (acted > 0) {
374
+ console.log(
375
+ `[trial-expiry-warning-sweeper] warned=${result.warned} skipped=${result.skipped} failed=${result.failed} stale=${result.stale}`,
376
+ );
377
+ }
378
+
379
+ return result;
380
+ }
381
+
382
+ private async sweepAgent(
383
+ agent: TrialExpiryAgentRow,
384
+ now: Date,
385
+ result: TrialExpirySweepResult,
386
+ ): Promise<void> {
387
+ if (
388
+ !isDueForWarning(
389
+ agent.trialExpiresAt,
390
+ agent.trialExpiryWarnedAt,
391
+ now,
392
+ this.warningDays,
393
+ this.graceDays,
394
+ )
395
+ ) {
396
+ // Distinguish "not due yet / already warned" (nothing to say) from
397
+ // "expired too long ago to be worth warning about" — the latter is a
398
+ // deliberate non-alert, so count it rather than letting a long-lapsed
399
+ // trial disappear from the sweep with no trace.
400
+ if (
401
+ agent.trialExpiresAt &&
402
+ !agent.trialExpiryWarnedAt &&
403
+ isPastWarningGrace(agent.trialExpiresAt, now, this.graceDays)
404
+ ) {
405
+ result.stale++;
406
+ }
407
+ return;
408
+ }
409
+
410
+ const bundle: AgentEnvBundle | null =
411
+ await this.deps.agentEnvService.getConfigBundle(agent.id);
412
+ const botToken = bundle?.env.SLACK_BOT_TOKEN;
413
+ const channel = bundle?.env.SLACK_ALERT_CHANNEL;
414
+ if (!botToken || !channel) {
415
+ this.log(
416
+ `[trial-expiry-warning-sweeper] skip ${agent.id} (${agent.name}): missing SLACK_BOT_TOKEN or SLACK_ALERT_CHANNEL — will retry next tick`,
417
+ );
418
+ result.skipped++;
419
+ return;
420
+ }
421
+
422
+ // trialExpiresAt is guaranteed non-null here (isDueForWarning returned
423
+ // true), but TypeScript can't see through that without a cast.
424
+ const trialExpiresAt = agent.trialExpiresAt as Date;
425
+ const text = buildTrialExpiryWarningMessage(
426
+ agent.name,
427
+ trialExpiresAt,
428
+ now,
429
+ );
430
+ const sent = await this.sendSlackMessage({ botToken, channel, text });
431
+ if (!sent) {
432
+ console.error(
433
+ `[trial-expiry-warning-sweeper] failed to post trial-expiry warning for agent ${agent.id} (${agent.name}) — will retry next tick`,
434
+ );
435
+ result.failed++;
436
+ return;
437
+ }
438
+
439
+ await this.deps.agentService.updateFields(agent.id, {
440
+ trialExpiryWarnedAt: now,
441
+ });
442
+ result.warned++;
443
+ this.log(
444
+ `[trial-expiry-warning-sweeper] warned ${agent.id} (${agent.name}): trial expires ${trialExpiresAt.toISOString()}`,
445
+ );
446
+ }
447
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * admin/src/trial-expiry-warning-sweeper.unit.test.ts
3
+ * Unit tests for the pure warning-window / dedup logic behind
4
+ * TrialExpiryWarningSweeper (ATE-2.1). No I/O, no doubles — just the exported
5
+ * pure functions.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ buildTrialExpiryWarningMessage,
11
+ DEFAULT_TRIAL_EXPIRY_WARNING_DAYS,
12
+ isDueForWarning,
13
+ isPastWarningGrace,
14
+ } from "./trial-expiry-warning-sweeper.ts";
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const NOW = new Date("2026-09-25T12:00:00.000Z");
18
+
19
+ function daysFromNow(days: number): Date {
20
+ return new Date(NOW.getTime() + days * DAY_MS);
21
+ }
22
+
23
+ describe("isDueForWarning", () => {
24
+ it("is never due when trialExpiresAt is unset (ATE-1.1 agents)", () => {
25
+ expect(isDueForWarning(null, null, NOW, 3)).toBe(false);
26
+ });
27
+
28
+ it("is due when trialExpiresAt falls inside the warning window and never warned", () => {
29
+ expect(isDueForWarning(daysFromNow(2), null, NOW, 3)).toBe(true);
30
+ });
31
+
32
+ it("is not due when trialExpiresAt is further away than the warning window", () => {
33
+ expect(isDueForWarning(daysFromNow(10), null, NOW, 3)).toBe(false);
34
+ });
35
+
36
+ it("is due exactly at the window boundary (<=)", () => {
37
+ expect(isDueForWarning(daysFromNow(3), null, NOW, 3)).toBe(true);
38
+ });
39
+
40
+ it("is not due just past the window boundary", () => {
41
+ expect(isDueForWarning(daysFromNow(3.01), null, NOW, 3)).toBe(false);
42
+ });
43
+
44
+ it("is due for a recently-expired trial that was never warned", () => {
45
+ expect(isDueForWarning(daysFromNow(-1), null, NOW, 3)).toBe(true);
46
+ });
47
+
48
+ it("is still due exactly at the grace boundary behind expiry", () => {
49
+ expect(isDueForWarning(daysFromNow(-3), null, NOW, 3)).toBe(true);
50
+ });
51
+
52
+ it("is not due for a trial that lapsed further back than the grace window", () => {
53
+ // Without this bound the first tick after deploy would Slack-warn every
54
+ // long-lapsed trial agent at once — trialExpiresAt is freely settable to
55
+ // any past date via PATCH /agents/:id.
56
+ expect(isDueForWarning(daysFromNow(-3.01), null, NOW, 3)).toBe(false);
57
+ expect(isDueForWarning(daysFromNow(-180), null, NOW, 3)).toBe(false);
58
+ });
59
+
60
+ it("honours an explicit graceDays wider than the warning window", () => {
61
+ expect(isDueForWarning(daysFromNow(-10), null, NOW, 3, 14)).toBe(true);
62
+ expect(isDueForWarning(daysFromNow(-20), null, NOW, 3, 14)).toBe(false);
63
+ });
64
+
65
+ it("is never due once trialExpiryWarnedAt is set, no matter how close expiry is (dedup)", () => {
66
+ expect(isDueForWarning(daysFromNow(1), NOW, NOW, 3)).toBe(false);
67
+ });
68
+
69
+ it("is never due once warned even if the trial later gets pushed back out and back in the window", () => {
70
+ // Re-running the check after the alert has fired must never re-send for the
71
+ // same agent — trialExpiryWarnedAt gates it regardless of the current gap
72
+ // to trialExpiresAt (acceptance criterion 2).
73
+ const warnedAt = daysFromNow(-1);
74
+ expect(isDueForWarning(daysFromNow(2), warnedAt, NOW, 3)).toBe(false);
75
+ });
76
+
77
+ it("defaults the window to 3 days when not passed explicitly", () => {
78
+ expect(isDueForWarning(daysFromNow(2), null, NOW)).toBe(true);
79
+ expect(DEFAULT_TRIAL_EXPIRY_WARNING_DAYS).toBe(3);
80
+ });
81
+
82
+ it("defaults graceDays to the warning window (symmetric band)", () => {
83
+ expect(isDueForWarning(daysFromNow(-2), null, NOW)).toBe(true);
84
+ expect(isDueForWarning(daysFromNow(-4), null, NOW)).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("isPastWarningGrace", () => {
89
+ it("is false for a future expiry", () => {
90
+ expect(isPastWarningGrace(daysFromNow(1), NOW, 3)).toBe(false);
91
+ });
92
+
93
+ it("is false inside the grace window and true beyond it", () => {
94
+ expect(isPastWarningGrace(daysFromNow(-3), NOW, 3)).toBe(false);
95
+ expect(isPastWarningGrace(daysFromNow(-3.01), NOW, 3)).toBe(true);
96
+ });
97
+ });
98
+
99
+ describe("buildTrialExpiryWarningMessage", () => {
100
+ it("names the agent and the actual expiry date", () => {
101
+ const text = buildTrialExpiryWarningMessage(
102
+ "acme-agent",
103
+ new Date("2026-09-28T00:00:00.000Z"),
104
+ NOW,
105
+ );
106
+ expect(text).toContain("acme-agent");
107
+ expect(text).toContain("2026-09-28");
108
+ });
109
+
110
+ it("uses future tense for an expiry that has not happened yet", () => {
111
+ const text = buildTrialExpiryWarningMessage(
112
+ "acme-agent",
113
+ daysFromNow(2),
114
+ NOW,
115
+ );
116
+ expect(text).toContain("expires on");
117
+ expect(text).not.toContain("expired on");
118
+ expect(text).toContain("After that date");
119
+ });
120
+
121
+ it("uses past tense for a trial that already lapsed (grace-window alert)", () => {
122
+ // The sweeper still fires for a trial that lapsed within the grace
123
+ // window; "will be locked down after <date in the past>" would read as if
124
+ // the deadline were still ahead.
125
+ const text = buildTrialExpiryWarningMessage(
126
+ "acme-agent",
127
+ daysFromNow(-2),
128
+ NOW,
129
+ );
130
+ expect(text).toContain("expired on");
131
+ expect(text).not.toContain("expires on");
132
+ expect(text).not.toContain("After that date");
133
+ // Same lockdown consequence either way.
134
+ expect(text).toContain("crons will be disabled");
135
+ });
136
+
137
+ it("describes lockdown (crons disabled + Slack blocked), never deprovisioning", () => {
138
+ const text = buildTrialExpiryWarningMessage(
139
+ "acme-agent",
140
+ new Date("2026-09-28T00:00:00.000Z"),
141
+ NOW,
142
+ );
143
+ // ATE-3.1 was corrected to lockdown-only (crons disabled, Slack blocked) —
144
+ // it never deletes the agent. The warning text must not claim otherwise.
145
+ expect(text.toLowerCase()).not.toContain("deprovision");
146
+ expect(text.toLowerCase()).not.toContain("deleted");
147
+ });
148
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * admin/src/voice-contract.unit.test.ts
3
+ *
4
+ * Locks the voice WIRE CONTRACT at the admin layer — the env var NAMES the admin
5
+ * provisioner stamps onto provisioned agent pods — and documents the Whisper
6
+ * image ⇄ agent client contract so the two halves never drift.
7
+ *
8
+ * ── Whisper image vs agent client contract (CV-1.1) ─────────────────────────
9
+ * The chart's `provider=whisper` pod runs `onerahmet/openai-whisper-asr-webservice`.
10
+ * That image exposes a SINGLE ASR endpoint:
11
+ *
12
+ * POST /asr?encode=true&task=transcribe&output=txt
13
+ *
14
+ * with the audio attached as the multipart field `audio_file`, returning the
15
+ * transcription as a PLAIN-TEXT body (with output=txt) — NOT the OpenAI
16
+ * `POST /v1/audio/transcriptions` JSON contract, and NOT a custom `/transcribe`
17
+ * JSON endpoint. The agent's `makeWhisperSvcClient` (agent/src/runtime/voice.ts) targets
18
+ * exactly that endpoint; the contract is exercised end-to-end in
19
+ * agent/src/runtime/voice.unit.test.ts and agent/src/runtime/voice.integration.test.ts.
20
+ *
21
+ * The admin's only responsibility is to hand the agent the Whisper Service URL
22
+ * under the agreed env var name `WHISPER_SERVICE_URL` (read in agent/src/runtime/config.ts).
23
+ * This file pins that name — and the ElevenLabs/Groq names — against the manifest
24
+ * builder so a rename on either side fails loudly here. No agent code is imported
25
+ * (admin must not couple to the agent package); the assertion is against the wire
26
+ * shape the admin emits.
27
+ */
28
+
29
+ import { describe, expect, it } from "bun:test";
30
+ import {
31
+ type AgentDeploymentOpts,
32
+ buildAgentDeploymentManifest,
33
+ } from "./agent-manifest.ts";
34
+
35
+ const baseOpts: AgentDeploymentOpts = {
36
+ agentId: "agent_42",
37
+ namespace: "shipwright",
38
+ image: "ghcr.io/app-vitals/shipwright-agent",
39
+ imageTag: "v1.2.3",
40
+ apiUrl: "http://shipwright-admin.shipwright.svc:3001",
41
+ pvcName: "agent-42-home",
42
+ secretName: "agent-42-token",
43
+ };
44
+
45
+ /** The exact env var the agent reads for the self-hosted Whisper Service URL. */
46
+ const WHISPER_SERVICE_URL_ENV = "WHISPER_SERVICE_URL";
47
+
48
+ describe("voice wire contract — env var names the admin stamps onto agent pods", () => {
49
+ it("uses the env var name WHISPER_SERVICE_URL (onerahmet /asr service URL)", () => {
50
+ const d = buildAgentDeploymentManifest({
51
+ ...baseOpts,
52
+ voice: { whisperServiceUrl: "http://r-shipwright-whisper:9000" },
53
+ });
54
+ const env = d.spec.template.spec.containers[0].env ?? [];
55
+ const names = env.map((e) => e.name);
56
+
57
+ // The agent's config.ts reads exactly this key; the onerahmet image is
58
+ // reached at `${WHISPER_SERVICE_URL}/asr?...` by makeWhisperSvcClient.
59
+ expect(names).toContain(WHISPER_SERVICE_URL_ENV);
60
+ const whisper = env.find((e) => e.name === WHISPER_SERVICE_URL_ENV);
61
+ expect(whisper?.value).toBe("http://r-shipwright-whisper:9000");
62
+ });
63
+
64
+ it("passes the Whisper URL as a plain value, never a secretKeyRef (the URL is not a secret)", () => {
65
+ const d = buildAgentDeploymentManifest({
66
+ ...baseOpts,
67
+ voice: { whisperServiceUrl: "http://r-shipwright-whisper:9000" },
68
+ });
69
+ const whisper = (d.spec.template.spec.containers[0].env ?? []).find(
70
+ (e) => e.name === WHISPER_SERVICE_URL_ENV,
71
+ );
72
+ expect(whisper?.value).toBeDefined();
73
+ expect(whisper?.valueFrom).toBeUndefined();
74
+ });
75
+
76
+ it("uses the conventional third-party env var names for keys (ELEVENLABS_API_KEY, GROQ_API_KEY)", () => {
77
+ const whisperMode = buildAgentDeploymentManifest({
78
+ ...baseOpts,
79
+ voice: {
80
+ whisperServiceUrl: "http://w:9000",
81
+ elevenLabsApiKey: "el",
82
+ },
83
+ });
84
+ const whisperNames = (
85
+ whisperMode.spec.template.spec.containers[0].env ?? []
86
+ ).map((e) => e.name);
87
+ expect(whisperNames).toContain("ELEVENLABS_API_KEY");
88
+ // whisper STT never flows a Groq key
89
+ expect(whisperNames).not.toContain("GROQ_API_KEY");
90
+
91
+ const groqMode = buildAgentDeploymentManifest({
92
+ ...baseOpts,
93
+ voice: { groqApiKey: "g", elevenLabsApiKey: "el" },
94
+ });
95
+ const groqNames = (groqMode.spec.template.spec.containers[0].env ?? []).map(
96
+ (e) => e.name,
97
+ );
98
+ expect(groqNames).toContain("GROQ_API_KEY");
99
+ // groq STT never points at a self-hosted Whisper pod
100
+ expect(groqNames).not.toContain(WHISPER_SERVICE_URL_ENV);
101
+ });
102
+ });