@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.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- 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
|
+
});
|