@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,391 @@
1
+ /**
2
+ * admin/src/trial-expiry-sweeper.unit.test.ts
3
+ * Unit tests for TrialExpirySweeper orchestration and its interval resolver
4
+ * (ATE-3.1).
5
+ *
6
+ * Sibling of session-alert-sweeper.unit.test.ts: everything the sweeper
7
+ * touches is injected — an in-memory AgentCronJobService double, a recording
8
+ * line logger, and a FixedClock. No mock.module(), no global overrides, no
9
+ * real Prisma, no Postgres — per the repo's hard test-isolation rule
10
+ * (CLAUDE.md). The real join query and write path are covered separately in
11
+ * trial-expiry-sweeper.integration.test.ts, which skips entirely unless
12
+ * DATABASE_URL_ADMIN_TEST is set; this file is what runs on every `task test`.
13
+ */
14
+
15
+ import { describe, expect, it } from "bun:test";
16
+ import { FixedClock } from "./clock.ts";
17
+ import {
18
+ DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS,
19
+ type TrialExpiryCronJobServiceLike,
20
+ TrialExpirySweeper,
21
+ resolveTrialExpirySweepIntervalMs,
22
+ } from "./trial-expiry-sweeper.ts";
23
+
24
+ const NOW = new Date("2026-06-15T00:00:00.000Z");
25
+
26
+ interface FakeRow {
27
+ id: string;
28
+ agentId: string;
29
+ enabled: boolean;
30
+ }
31
+
32
+ interface SetEnabledCall {
33
+ agentId: string;
34
+ cronId: string;
35
+ enabled: boolean;
36
+ lockdown?: boolean;
37
+ }
38
+
39
+ /**
40
+ * In-memory stand-in for AgentCronJobService. `listEnabledWithExpiredTrial`
41
+ * returns only rows still flagged enabled, so a second tick naturally sees
42
+ * less work — the same shape the real join query has, which is what makes the
43
+ * sweeper idempotent without any dedup state.
44
+ */
45
+ function fakeCronJobService(
46
+ seed: FakeRow[] = [],
47
+ opts: {
48
+ failListing?: boolean;
49
+ failSetEnabledFor?: (cronId: string) => boolean;
50
+ } = {},
51
+ ): {
52
+ service: TrialExpiryCronJobServiceLike;
53
+ rows: FakeRow[];
54
+ listedNow: Date[];
55
+ calls: SetEnabledCall[];
56
+ } {
57
+ const rows = seed.map((r) => ({ ...r }));
58
+ const listedNow: Date[] = [];
59
+ const calls: SetEnabledCall[] = [];
60
+
61
+ const service: TrialExpiryCronJobServiceLike = {
62
+ listEnabledWithExpiredTrial: async (now: Date) => {
63
+ listedNow.push(now);
64
+ if (opts.failListing) throw new Error("db down");
65
+ return rows
66
+ .filter((r) => r.enabled)
67
+ .map((r) => ({ id: r.id, agentId: r.agentId }));
68
+ },
69
+ setEnabled: async (
70
+ agentId: string,
71
+ cronId: string,
72
+ enabled: boolean,
73
+ setOpts?: { lockdown?: boolean },
74
+ ) => {
75
+ calls.push({ agentId, cronId, enabled, lockdown: setOpts?.lockdown });
76
+ if (opts.failSetEnabledFor?.(cronId)) throw new Error("boom");
77
+ const row = rows.find((r) => r.id === cronId);
78
+ if (!row) throw new Error(`no such cron ${cronId}`);
79
+ row.enabled = enabled;
80
+ return row;
81
+ },
82
+ };
83
+
84
+ return { service, rows, listedNow, calls };
85
+ }
86
+
87
+ function sweeperFor(
88
+ service: TrialExpiryCronJobServiceLike,
89
+ lines: string[] = [],
90
+ ): TrialExpirySweeper {
91
+ return new TrialExpirySweeper({
92
+ agentCronJobService: service,
93
+ clock: FixedClock(NOW),
94
+ log: (line) => lines.push(line),
95
+ });
96
+ }
97
+
98
+ // ─── resolveTrialExpirySweepIntervalMs ──────────────────────────────────────
99
+
100
+ // The pure env rule behind the trial-expiry sweeper's setInterval cadence in
101
+ // startServer(). Tested without touching process.env.
102
+ describe("resolveTrialExpirySweepIntervalMs", () => {
103
+ it("defaults to 60s when unset", () => {
104
+ expect(resolveTrialExpirySweepIntervalMs({})).toBe(
105
+ DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS,
106
+ );
107
+ expect(DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS).toBe(60_000);
108
+ });
109
+
110
+ it("uses an explicit positive override", () => {
111
+ expect(
112
+ resolveTrialExpirySweepIntervalMs({
113
+ SHIPWRIGHT_ADMIN_TRIAL_EXPIRY_SWEEP_INTERVAL_MS: "5000",
114
+ }),
115
+ ).toBe(5000);
116
+ });
117
+
118
+ it("trims surrounding whitespace around a valid value", () => {
119
+ expect(
120
+ resolveTrialExpirySweepIntervalMs({
121
+ SHIPWRIGHT_ADMIN_TRIAL_EXPIRY_SWEEP_INTERVAL_MS: " 1500 ",
122
+ }),
123
+ ).toBe(1500);
124
+ });
125
+
126
+ it("falls back to the default for blank, non-numeric, zero, or negative values", () => {
127
+ // A 0/NaN interval would make setInterval spin; a negative one is
128
+ // nonsense. Both must resolve to the default rather than reach setInterval.
129
+ for (const raw of ["", " ", "soon", "NaN", "0", "-1", "-5000"]) {
130
+ expect(
131
+ resolveTrialExpirySweepIntervalMs({
132
+ SHIPWRIGHT_ADMIN_TRIAL_EXPIRY_SWEEP_INTERVAL_MS: raw,
133
+ }),
134
+ ).toBe(DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS);
135
+ }
136
+ });
137
+
138
+ it("falls back to the default for Infinity", () => {
139
+ expect(
140
+ resolveTrialExpirySweepIntervalMs({
141
+ SHIPWRIGHT_ADMIN_TRIAL_EXPIRY_SWEEP_INTERVAL_MS: "Infinity",
142
+ }),
143
+ ).toBe(DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS);
144
+ });
145
+
146
+ it("ignores unrelated env vars", () => {
147
+ expect(
148
+ resolveTrialExpirySweepIntervalMs({
149
+ SHIPWRIGHT_ADMIN_SESSION_ALERT_INTERVAL_MS: "1234",
150
+ }),
151
+ ).toBe(DEFAULT_TRIAL_EXPIRY_SWEEP_INTERVAL_MS);
152
+ });
153
+ });
154
+
155
+ // ─── tick(): the happy path ─────────────────────────────────────────────────
156
+
157
+ describe("TrialExpirySweeper.tick — disabling expired-trial crons", () => {
158
+ it("disables every listed row and counts them", async () => {
159
+ const { service, rows, calls } = fakeCronJobService([
160
+ { id: "cron_a", agentId: "agt_1", enabled: true },
161
+ { id: "cron_b", agentId: "agt_1", enabled: true },
162
+ { id: "cron_c", agentId: "agt_2", enabled: true },
163
+ ]);
164
+
165
+ const result = await sweeperFor(service).tick();
166
+
167
+ expect(result).toEqual({ disabled: 3 });
168
+ expect(rows.every((r) => r.enabled === false)).toBe(true);
169
+ expect(calls).toEqual([
170
+ { agentId: "agt_1", cronId: "cron_a", enabled: false, lockdown: true },
171
+ { agentId: "agt_1", cronId: "cron_b", enabled: false, lockdown: true },
172
+ { agentId: "agt_2", cronId: "cron_c", enabled: false, lockdown: true },
173
+ ]);
174
+ });
175
+
176
+ it("passes the injected clock's time through to the listing query", async () => {
177
+ const { service, listedNow } = fakeCronJobService([]);
178
+
179
+ await sweeperFor(service).tick();
180
+
181
+ expect(listedNow).toHaveLength(1);
182
+ expect(listedNow[0]?.toISOString()).toBe(NOW.toISOString());
183
+ });
184
+
185
+ it("no-ops cleanly when nothing is listed", async () => {
186
+ const lines: string[] = [];
187
+ const { service, calls } = fakeCronJobService([]);
188
+
189
+ const result = await sweeperFor(service, lines).tick();
190
+
191
+ expect(result).toEqual({ disabled: 0 });
192
+ expect(calls).toHaveLength(0);
193
+ // Nothing disabled → no log line (an idle sweeper must stay quiet; it
194
+ // ticks once a minute forever).
195
+ expect(lines).toHaveLength(0);
196
+ });
197
+
198
+ it("logs a single count line only when it disabled something", async () => {
199
+ const lines: string[] = [];
200
+ const { service } = fakeCronJobService([
201
+ { id: "cron_a", agentId: "agt_1", enabled: true },
202
+ { id: "cron_b", agentId: "agt_1", enabled: true },
203
+ ]);
204
+
205
+ const sweeper = sweeperFor(service, lines);
206
+ await sweeper.tick();
207
+ expect(lines).toEqual(["[trial-expiry-sweeper] disabled=2"]);
208
+
209
+ // Second tick has nothing left → no further lines.
210
+ await sweeper.tick();
211
+ expect(lines).toHaveLength(1);
212
+ });
213
+ });
214
+
215
+ // ─── Idempotency ────────────────────────────────────────────────────────────
216
+
217
+ // The sweeper carries no "have I already swept this agent" state (unlike
218
+ // session-alert-sweeper's SessionAlertState dedup): once its crons are
219
+ // disabled the listing simply stops returning them.
220
+ describe("TrialExpirySweeper.tick — idempotency", () => {
221
+ it("finds nothing left to do on a second tick", async () => {
222
+ const { service, calls } = fakeCronJobService([
223
+ { id: "cron_a", agentId: "agt_1", enabled: true },
224
+ ]);
225
+ const sweeper = sweeperFor(service);
226
+
227
+ expect(await sweeper.tick()).toEqual({ disabled: 1 });
228
+ expect(await sweeper.tick()).toEqual({ disabled: 0 });
229
+ expect(await sweeper.tick()).toEqual({ disabled: 0 });
230
+ expect(calls).toHaveLength(1);
231
+ });
232
+
233
+ it("never re-counts an already-disabled row the listing excludes", async () => {
234
+ const { service, calls } = fakeCronJobService([
235
+ { id: "cron_on", agentId: "agt_1", enabled: true },
236
+ { id: "cron_off", agentId: "agt_1", enabled: false },
237
+ ]);
238
+
239
+ const result = await sweeperFor(service).tick();
240
+
241
+ expect(result).toEqual({ disabled: 1 });
242
+ expect(calls.map((c) => c.cronId)).toEqual(["cron_on"]);
243
+ });
244
+
245
+ it("is safe under overlapping ticks — no re-entrancy guard needed", async () => {
246
+ // Two concurrent sweeps both disabling the same row is harmless because
247
+ // setEnabled is idempotent; the sweeper deliberately has no in-flight
248
+ // guard (unlike the session sweeper, whose pushes have side effects).
249
+ const { service, rows } = fakeCronJobService([
250
+ { id: "cron_a", agentId: "agt_1", enabled: true },
251
+ ]);
252
+ const sweeper = sweeperFor(service);
253
+
254
+ const [first, second] = await Promise.all([sweeper.tick(), sweeper.tick()]);
255
+
256
+ expect(first.disabled + second.disabled).toBeGreaterThanOrEqual(1);
257
+ expect(rows[0]?.enabled).toBe(false);
258
+ });
259
+ });
260
+
261
+ // ─── Resilience: one bad row never aborts the sweep ─────────────────────────
262
+
263
+ describe("TrialExpirySweeper.tick — per-row resilience", () => {
264
+ it("keeps sweeping after one row's setEnabled throws", async () => {
265
+ const { service, rows, calls } = fakeCronJobService(
266
+ [
267
+ { id: "cron_bad", agentId: "agt_1", enabled: true },
268
+ { id: "cron_good", agentId: "agt_1", enabled: true },
269
+ { id: "cron_also_good", agentId: "agt_2", enabled: true },
270
+ ],
271
+ { failSetEnabledFor: (cronId) => cronId === "cron_bad" },
272
+ );
273
+
274
+ const result = await sweeperFor(service).tick();
275
+
276
+ // The failure is counted out, not thrown out: the two healthy rows are
277
+ // still disabled and every row was attempted.
278
+ expect(result).toEqual({ disabled: 2 });
279
+ expect(calls).toHaveLength(3);
280
+ expect(rows.find((r) => r.id === "cron_bad")?.enabled).toBe(true);
281
+ expect(rows.find((r) => r.id === "cron_good")?.enabled).toBe(false);
282
+ expect(rows.find((r) => r.id === "cron_also_good")?.enabled).toBe(false);
283
+ });
284
+
285
+ it("retries the failed row on the next tick", async () => {
286
+ // Because the failed row stays enabled, the next listing returns it again
287
+ // — a transient write failure self-heals rather than leaking a cron that
288
+ // outlives the trial.
289
+ let fail = true;
290
+ const { service, rows } = fakeCronJobService(
291
+ [
292
+ { id: "cron_flaky", agentId: "agt_1", enabled: true },
293
+ { id: "cron_good", agentId: "agt_1", enabled: true },
294
+ ],
295
+ { failSetEnabledFor: (cronId) => fail && cronId === "cron_flaky" },
296
+ );
297
+ const sweeper = sweeperFor(service);
298
+
299
+ expect(await sweeper.tick()).toEqual({ disabled: 1 });
300
+
301
+ fail = false;
302
+ expect(await sweeper.tick()).toEqual({ disabled: 1 });
303
+ expect(rows.every((r) => r.enabled === false)).toBe(true);
304
+ });
305
+
306
+ it("returns disabled=0 (and never throws) when the listing fails", async () => {
307
+ const lines: string[] = [];
308
+ const { service, calls } = fakeCronJobService(
309
+ [{ id: "cron_a", agentId: "agt_1", enabled: true }],
310
+ { failListing: true },
311
+ );
312
+
313
+ // Fail-open: a DB blip must not crash the setInterval callback in main.ts.
314
+ const result = await sweeperFor(service, lines).tick();
315
+
316
+ expect(result).toEqual({ disabled: 0 });
317
+ expect(calls).toHaveLength(0);
318
+ expect(lines).toHaveLength(0);
319
+ });
320
+
321
+ it("recovers on the next tick after a listing failure", async () => {
322
+ const rows: FakeRow[] = [{ id: "cron_a", agentId: "agt_1", enabled: true }];
323
+ let broken = true;
324
+ const service: TrialExpiryCronJobServiceLike = {
325
+ listEnabledWithExpiredTrial: async () => {
326
+ if (broken) throw new Error("db down");
327
+ return rows
328
+ .filter((r) => r.enabled)
329
+ .map((r) => ({ id: r.id, agentId: r.agentId }));
330
+ },
331
+ setEnabled: async (_agentId, cronId, enabled) => {
332
+ const row = rows.find((r) => r.id === cronId);
333
+ if (row) row.enabled = enabled;
334
+ return row;
335
+ },
336
+ };
337
+ const sweeper = sweeperFor(service);
338
+
339
+ expect(await sweeper.tick()).toEqual({ disabled: 0 });
340
+
341
+ broken = false;
342
+ expect(await sweeper.tick()).toEqual({ disabled: 1 });
343
+ expect(rows[0]?.enabled).toBe(false);
344
+ });
345
+ });
346
+
347
+ // ─── Deliberate non-behavior ────────────────────────────────────────────────
348
+
349
+ // A trial ending must never destroy the workspace — the sweeper only flips
350
+ // AgentCronJob.enabled. The narrow injected interface is the guard rail: if
351
+ // deprovisioning were ever added, this test's double would have to grow a
352
+ // method to satisfy it.
353
+ describe("TrialExpirySweeper.tick — scope", () => {
354
+ it("only ever calls setEnabled(..., false) — no other service method", async () => {
355
+ const seen: string[] = [];
356
+ const base = fakeCronJobService([
357
+ { id: "cron_a", agentId: "agt_1", enabled: true },
358
+ { id: "cron_b", agentId: "agt_2", enabled: true },
359
+ ]);
360
+ // A double that also fails loudly on anything deprovision-shaped: if the
361
+ // sweeper ever grew a deleteAgentFully()-style call, it would land here.
362
+ const recording: TrialExpiryCronJobServiceLike & Record<string, unknown> = {
363
+ listEnabledWithExpiredTrial: (now: Date) => {
364
+ seen.push("listEnabledWithExpiredTrial");
365
+ return base.service.listEnabledWithExpiredTrial(now);
366
+ },
367
+ setEnabled: (
368
+ agentId: string,
369
+ cronId: string,
370
+ enabled: boolean,
371
+ opts?: { lockdown?: boolean },
372
+ ) => {
373
+ seen.push("setEnabled");
374
+ return base.service.setEnabled(agentId, cronId, enabled, opts);
375
+ },
376
+ deleteAgentFully: () => {
377
+ throw new Error("sweeper must never deprovision an agent");
378
+ },
379
+ };
380
+
381
+ await sweeperFor(recording).tick();
382
+
383
+ expect([...new Set(seen)].sort()).toEqual([
384
+ "listEnabledWithExpiredTrial",
385
+ "setEnabled",
386
+ ]);
387
+ expect(base.calls.every((c) => c.enabled === false)).toBe(true);
388
+ expect(base.calls.every((c) => c.lockdown === true)).toBe(true);
389
+ expect(base.rows.every((r) => r.enabled === false)).toBe(true);
390
+ });
391
+ });