@app-vitals/agent-admin 0.0.1 → 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.
- 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,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/trial-expiry-warning-sweeper.integration.test.ts
|
|
3
|
+
* Integration tests for TrialExpiryWarningSweeper (ATE-2.1) against fixture
|
|
4
|
+
* Agent rows via an injected *PrismaLike double, an injected AgentService
|
|
5
|
+
* double (for the trialExpiryWarnedAt write), an injected AgentEnvService
|
|
6
|
+
* double (for the per-agent SLACK_BOT_TOKEN/SLACK_ALERT_CHANNEL lookup), an
|
|
7
|
+
* injected Slack-sending double, and a FixedClock. No mock.module(), no
|
|
8
|
+
* global.fetch override, no real Slack call — per the repo's hard
|
|
9
|
+
* test-isolation rule (CLAUDE.md).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
13
|
+
import { WebClient } from "@slack/web-api";
|
|
14
|
+
import { FixedClock } from "./clock.ts";
|
|
15
|
+
import {
|
|
16
|
+
defaultSlackClientFactory,
|
|
17
|
+
type SlackPostMessageClient,
|
|
18
|
+
sendTrialExpiryWarning,
|
|
19
|
+
type TrialExpiryAgentRow,
|
|
20
|
+
type TrialExpiryPrismaLike,
|
|
21
|
+
type TrialExpirySweeperDeps,
|
|
22
|
+
TrialExpiryWarningSweeper,
|
|
23
|
+
} from "./trial-expiry-warning-sweeper.ts";
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const NOW = new Date("2026-09-25T12:00:00.000Z");
|
|
27
|
+
|
|
28
|
+
function daysFromNow(days: number): Date {
|
|
29
|
+
return new Date(NOW.getTime() + days * DAY_MS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Doubles ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface FakeAgentRow extends TrialExpiryAgentRow {
|
|
35
|
+
slackBotToken?: string;
|
|
36
|
+
slackAlertChannel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fakePrisma(agents: FakeAgentRow[]): TrialExpiryPrismaLike {
|
|
40
|
+
return {
|
|
41
|
+
agent: {
|
|
42
|
+
// Mirrors a real `WHERE "trialExpiresAt" IS NOT NULL AND
|
|
43
|
+
// "trialExpiryWarnedAt" IS NULL` — the two dedup/eligibility filters
|
|
44
|
+
// ATE-1.1 agents (unset trialExpiresAt) and already-warned agents rely
|
|
45
|
+
// on the sweeper never even seeing them.
|
|
46
|
+
findMany: async () =>
|
|
47
|
+
agents
|
|
48
|
+
.filter((a) => a.trialExpiresAt !== null)
|
|
49
|
+
.filter((a) => a.trialExpiryWarnedAt === null)
|
|
50
|
+
.map(({ id, name, trialExpiresAt, trialExpiryWarnedAt }) => ({
|
|
51
|
+
id,
|
|
52
|
+
name,
|
|
53
|
+
trialExpiresAt,
|
|
54
|
+
trialExpiryWarnedAt,
|
|
55
|
+
})),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RecordedSend {
|
|
61
|
+
agentId: string;
|
|
62
|
+
botToken: string;
|
|
63
|
+
channel: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildDeps(
|
|
68
|
+
agents: FakeAgentRow[],
|
|
69
|
+
overrides: {
|
|
70
|
+
sendResult?: (agentId: string) => boolean;
|
|
71
|
+
} = {},
|
|
72
|
+
): TrialExpirySweeperDeps & {
|
|
73
|
+
sends: RecordedSend[];
|
|
74
|
+
agents: FakeAgentRow[];
|
|
75
|
+
} {
|
|
76
|
+
const sends: RecordedSend[] = [];
|
|
77
|
+
const byId = new Map(agents.map((a) => [a.id, a]));
|
|
78
|
+
|
|
79
|
+
const agentService: TrialExpirySweeperDeps["agentService"] = {
|
|
80
|
+
updateFields: async (id, input) => {
|
|
81
|
+
const agent = byId.get(id);
|
|
82
|
+
if (!agent) throw new Error(`unknown agent ${id}`);
|
|
83
|
+
if (input.trialExpiryWarnedAt !== undefined) {
|
|
84
|
+
agent.trialExpiryWarnedAt = input.trialExpiryWarnedAt;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id: agent.id,
|
|
88
|
+
name: agent.name,
|
|
89
|
+
slackId: null,
|
|
90
|
+
selfHosted: false,
|
|
91
|
+
typeName: "coding",
|
|
92
|
+
createdAt: NOW,
|
|
93
|
+
updatedAt: NOW,
|
|
94
|
+
missingRequiredEnv: [],
|
|
95
|
+
} as never;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const agentEnvService: TrialExpirySweeperDeps["agentEnvService"] = {
|
|
100
|
+
getConfigBundle: async (agentId) => {
|
|
101
|
+
const agent = byId.get(agentId);
|
|
102
|
+
if (!agent || (!agent.slackBotToken && !agent.slackAlertChannel)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const env: Record<string, string> = {};
|
|
106
|
+
if (agent.slackBotToken) env.SLACK_BOT_TOKEN = agent.slackBotToken;
|
|
107
|
+
if (agent.slackAlertChannel)
|
|
108
|
+
env.SLACK_ALERT_CHANNEL = agent.slackAlertChannel;
|
|
109
|
+
return { env, agentId, allowedTools: [] };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const sendSlackMessage: TrialExpirySweeperDeps["sendSlackMessage"] = async (
|
|
114
|
+
params,
|
|
115
|
+
) => {
|
|
116
|
+
// agentId isn't part of the wire params (the real WebClient call doesn't
|
|
117
|
+
// need one), but tests need to correlate sends back to a fixture agent —
|
|
118
|
+
// recover it from the channel/token pairing set up per-fixture below.
|
|
119
|
+
const agent = agents.find(
|
|
120
|
+
(a) =>
|
|
121
|
+
a.slackBotToken === params.botToken &&
|
|
122
|
+
a.slackAlertChannel === params.channel,
|
|
123
|
+
);
|
|
124
|
+
sends.push({
|
|
125
|
+
agentId: agent?.id ?? "unknown",
|
|
126
|
+
botToken: params.botToken,
|
|
127
|
+
channel: params.channel,
|
|
128
|
+
text: params.text,
|
|
129
|
+
});
|
|
130
|
+
return overrides.sendResult ? overrides.sendResult(agent?.id ?? "") : true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
prisma: fakePrisma(agents),
|
|
135
|
+
agentService,
|
|
136
|
+
agentEnvService,
|
|
137
|
+
sendSlackMessage,
|
|
138
|
+
clock: FixedClock(NOW),
|
|
139
|
+
sends,
|
|
140
|
+
agents,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeAgent(
|
|
145
|
+
overrides: Partial<FakeAgentRow> & { id: string },
|
|
146
|
+
): FakeAgentRow {
|
|
147
|
+
return {
|
|
148
|
+
name: overrides.id,
|
|
149
|
+
trialExpiresAt: null,
|
|
150
|
+
trialExpiryWarnedAt: null,
|
|
151
|
+
// Unique per agent (not a shared constant) so the sendSlackMessage
|
|
152
|
+
// double below can correlate a recorded send back to the fixture agent
|
|
153
|
+
// that triggered it, purely a test-correlation concern.
|
|
154
|
+
slackBotToken: `xoxb-fake-token-${overrides.id}`,
|
|
155
|
+
slackAlertChannel: `#alerts-${overrides.id}`,
|
|
156
|
+
...overrides,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("TrialExpiryWarningSweeper", () => {
|
|
163
|
+
it("sends exactly one Slack alert for an agent 2 days from expiry with a 3-day window (AC1)", async () => {
|
|
164
|
+
const agentA = makeAgent({ id: "agent-a", trialExpiresAt: daysFromNow(2) });
|
|
165
|
+
const deps = buildDeps([agentA]);
|
|
166
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
167
|
+
|
|
168
|
+
const result = await sweeper.tick();
|
|
169
|
+
|
|
170
|
+
expect(deps.sends).toHaveLength(1);
|
|
171
|
+
expect(deps.sends[0].agentId).toBe("agent-a");
|
|
172
|
+
expect(deps.sends[0].text).toContain(
|
|
173
|
+
(agentA.trialExpiresAt as Date).toISOString().slice(0, 10),
|
|
174
|
+
);
|
|
175
|
+
expect(result.warned).toBe(1);
|
|
176
|
+
expect(agentA.trialExpiryWarnedAt).toEqual(NOW);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not send a second alert for the same agent on a re-run (AC2, dedup via trialExpiryWarnedAt)", async () => {
|
|
180
|
+
const agentA = makeAgent({ id: "agent-a", trialExpiresAt: daysFromNow(2) });
|
|
181
|
+
const deps = buildDeps([agentA]);
|
|
182
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
183
|
+
|
|
184
|
+
await sweeper.tick();
|
|
185
|
+
expect(deps.sends).toHaveLength(1);
|
|
186
|
+
|
|
187
|
+
// Re-run against the same fixture store (trialExpiryWarnedAt now set by
|
|
188
|
+
// the previous tick — the sweeper's own findMany-equivalent filter must
|
|
189
|
+
// exclude it, exactly like a real `WHERE trialExpiryWarnedAt IS NULL`).
|
|
190
|
+
const result2 = await sweeper.tick();
|
|
191
|
+
|
|
192
|
+
expect(deps.sends).toHaveLength(1);
|
|
193
|
+
expect(result2.warned).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never considers an agent with trialExpiresAt unset (AC3)", async () => {
|
|
197
|
+
const untouched = makeAgent({
|
|
198
|
+
id: "agent-untouched",
|
|
199
|
+
trialExpiresAt: null,
|
|
200
|
+
});
|
|
201
|
+
const dueSoon = makeAgent({
|
|
202
|
+
id: "agent-due",
|
|
203
|
+
trialExpiresAt: daysFromNow(1),
|
|
204
|
+
});
|
|
205
|
+
const deps = buildDeps([untouched, dueSoon]);
|
|
206
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
207
|
+
|
|
208
|
+
const result = await sweeper.tick();
|
|
209
|
+
|
|
210
|
+
expect(deps.sends).toHaveLength(1);
|
|
211
|
+
expect(deps.sends[0].agentId).toBe("agent-due");
|
|
212
|
+
expect(result.warned).toBe(1);
|
|
213
|
+
expect(untouched.trialExpiryWarnedAt).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("skips (and does not stamp) an agent missing SLACK_BOT_TOKEN or SLACK_ALERT_CHANNEL", async () => {
|
|
217
|
+
const noToken = makeAgent({
|
|
218
|
+
id: "agent-no-token",
|
|
219
|
+
trialExpiresAt: daysFromNow(1),
|
|
220
|
+
slackBotToken: undefined,
|
|
221
|
+
});
|
|
222
|
+
const noChannel = makeAgent({
|
|
223
|
+
id: "agent-no-channel",
|
|
224
|
+
trialExpiresAt: daysFromNow(1),
|
|
225
|
+
slackAlertChannel: undefined,
|
|
226
|
+
});
|
|
227
|
+
const deps = buildDeps([noToken, noChannel]);
|
|
228
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
229
|
+
|
|
230
|
+
const result = await sweeper.tick();
|
|
231
|
+
|
|
232
|
+
expect(deps.sends).toHaveLength(0);
|
|
233
|
+
expect(result.warned).toBe(0);
|
|
234
|
+
expect(result.skipped).toBe(2);
|
|
235
|
+
expect(noToken.trialExpiryWarnedAt).toBeNull();
|
|
236
|
+
expect(noChannel.trialExpiryWarnedAt).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("does not stamp trialExpiryWarnedAt when the Slack send fails, so the next tick retries", async () => {
|
|
240
|
+
const agentA = makeAgent({ id: "agent-a", trialExpiresAt: daysFromNow(1) });
|
|
241
|
+
const deps = buildDeps([agentA], { sendResult: () => false });
|
|
242
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
243
|
+
|
|
244
|
+
const result = await sweeper.tick();
|
|
245
|
+
|
|
246
|
+
expect(deps.sends).toHaveLength(1);
|
|
247
|
+
expect(result.warned).toBe(0);
|
|
248
|
+
expect(result.failed).toBe(1);
|
|
249
|
+
expect(agentA.trialExpiryWarnedAt).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("does not warn a trial that lapsed further back than the grace window", async () => {
|
|
253
|
+
// trialExpiresAt is admin-settable to any past date (PATCH /agents/:id
|
|
254
|
+
// applies no future-only validation), so without the grace bound the
|
|
255
|
+
// first tick after deploy would alert every long-lapsed trial at once.
|
|
256
|
+
const longLapsed = makeAgent({
|
|
257
|
+
id: "agent-long-lapsed",
|
|
258
|
+
trialExpiresAt: daysFromNow(-90),
|
|
259
|
+
});
|
|
260
|
+
const recentlyLapsed = makeAgent({
|
|
261
|
+
id: "agent-recently-lapsed",
|
|
262
|
+
trialExpiresAt: daysFromNow(-1),
|
|
263
|
+
});
|
|
264
|
+
const deps = buildDeps([longLapsed, recentlyLapsed]);
|
|
265
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
266
|
+
|
|
267
|
+
const result = await sweeper.tick();
|
|
268
|
+
|
|
269
|
+
expect(deps.sends.map((s) => s.agentId)).toEqual(["agent-recently-lapsed"]);
|
|
270
|
+
expect(result.warned).toBe(1);
|
|
271
|
+
expect(result.stale).toBe(1);
|
|
272
|
+
expect(longLapsed.trialExpiryWarnedAt).toBeNull();
|
|
273
|
+
// A grace-window alert reads in the past tense.
|
|
274
|
+
expect(deps.sends[0].text).toContain("expired on");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("honours a custom graceDays wider than the warning window", async () => {
|
|
278
|
+
const lapsed = makeAgent({
|
|
279
|
+
id: "agent-lapsed",
|
|
280
|
+
trialExpiresAt: daysFromNow(-10),
|
|
281
|
+
});
|
|
282
|
+
const deps = buildDeps([lapsed]);
|
|
283
|
+
const sweeper = new TrialExpiryWarningSweeper({ ...deps, graceDays: 14 });
|
|
284
|
+
|
|
285
|
+
const result = await sweeper.tick();
|
|
286
|
+
|
|
287
|
+
expect(result.warned).toBe(1);
|
|
288
|
+
expect(result.stale).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("ignores an agent whose expiry is outside the warning window", async () => {
|
|
292
|
+
const farOut = makeAgent({
|
|
293
|
+
id: "agent-far",
|
|
294
|
+
trialExpiresAt: daysFromNow(30),
|
|
295
|
+
});
|
|
296
|
+
const deps = buildDeps([farOut]);
|
|
297
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
298
|
+
|
|
299
|
+
const result = await sweeper.tick();
|
|
300
|
+
|
|
301
|
+
expect(deps.sends).toHaveLength(0);
|
|
302
|
+
expect(result.warned).toBe(0);
|
|
303
|
+
expect(farOut.trialExpiryWarnedAt).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("handles multiple fixture agents in one sweep independently", async () => {
|
|
307
|
+
const dueA = makeAgent({
|
|
308
|
+
id: "agent-due-a",
|
|
309
|
+
trialExpiresAt: daysFromNow(1),
|
|
310
|
+
});
|
|
311
|
+
const dueB = makeAgent({
|
|
312
|
+
id: "agent-due-b",
|
|
313
|
+
trialExpiresAt: daysFromNow(3),
|
|
314
|
+
});
|
|
315
|
+
const notDue = makeAgent({
|
|
316
|
+
id: "agent-not-due",
|
|
317
|
+
trialExpiresAt: daysFromNow(10),
|
|
318
|
+
});
|
|
319
|
+
const unset = makeAgent({ id: "agent-unset", trialExpiresAt: null });
|
|
320
|
+
const deps = buildDeps([dueA, dueB, notDue, unset]);
|
|
321
|
+
const sweeper = new TrialExpiryWarningSweeper(deps);
|
|
322
|
+
|
|
323
|
+
const result = await sweeper.tick();
|
|
324
|
+
|
|
325
|
+
expect(result.warned).toBe(2);
|
|
326
|
+
expect(deps.sends.map((s) => s.agentId).sort()).toEqual([
|
|
327
|
+
"agent-due-a",
|
|
328
|
+
"agent-due-b",
|
|
329
|
+
]);
|
|
330
|
+
expect(dueA.trialExpiryWarnedAt).toEqual(NOW);
|
|
331
|
+
expect(dueB.trialExpiryWarnedAt).toEqual(NOW);
|
|
332
|
+
expect(notDue.trialExpiryWarnedAt).toBeNull();
|
|
333
|
+
expect(unset.trialExpiryWarnedAt).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("respects a custom warningDays option", async () => {
|
|
337
|
+
const agentA = makeAgent({ id: "agent-a", trialExpiresAt: daysFromNow(6) });
|
|
338
|
+
const deps = buildDeps([agentA]);
|
|
339
|
+
const sweeper = new TrialExpiryWarningSweeper({ ...deps, warningDays: 7 });
|
|
340
|
+
|
|
341
|
+
const result = await sweeper.tick();
|
|
342
|
+
|
|
343
|
+
expect(result.warned).toBe(1);
|
|
344
|
+
expect(deps.sends).toHaveLength(1);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("does not overlap ticks — an in-flight tick returns an all-zero result for a concurrent call", async () => {
|
|
348
|
+
const agentA = makeAgent({ id: "agent-a", trialExpiresAt: daysFromNow(1) });
|
|
349
|
+
const deps = buildDeps([agentA]);
|
|
350
|
+
let resolveFindMany: (() => void) | undefined;
|
|
351
|
+
const gate = new Promise<void>((resolve) => {
|
|
352
|
+
resolveFindMany = resolve;
|
|
353
|
+
});
|
|
354
|
+
const slowPrisma: TrialExpiryPrismaLike = {
|
|
355
|
+
agent: {
|
|
356
|
+
findMany: async (args) => {
|
|
357
|
+
await gate;
|
|
358
|
+
return deps.prisma.agent.findMany(args);
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
const sweeper = new TrialExpiryWarningSweeper({
|
|
363
|
+
...deps,
|
|
364
|
+
prisma: slowPrisma,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const firstTick = sweeper.tick();
|
|
368
|
+
const secondTick = await sweeper.tick();
|
|
369
|
+
expect(secondTick).toEqual({
|
|
370
|
+
warned: 0,
|
|
371
|
+
skipped: 0,
|
|
372
|
+
failed: 0,
|
|
373
|
+
stale: 0,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
resolveFindMany?.();
|
|
377
|
+
const firstResult = await firstTick;
|
|
378
|
+
expect(firstResult.warned).toBe(1);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ─── The real @slack/web-api send path ──────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
describe("sendTrialExpiryWarning", () => {
|
|
385
|
+
const params = {
|
|
386
|
+
botToken: "xoxb-fake-token",
|
|
387
|
+
channel: "#alerts",
|
|
388
|
+
text: ":warning: Trial for agent `acme-agent` expires on 2026-09-28.",
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
function makeSlackClient(
|
|
392
|
+
behaviour: { error?: Error } = {},
|
|
393
|
+
): SlackPostMessageClient & {
|
|
394
|
+
chat: { postMessage: ReturnType<typeof mock> };
|
|
395
|
+
} {
|
|
396
|
+
const postMessage = mock(async (_args: unknown) => {
|
|
397
|
+
if (behaviour.error) throw behaviour.error;
|
|
398
|
+
return { ok: true, ts: "1737000000.000100" };
|
|
399
|
+
});
|
|
400
|
+
return { chat: { postMessage } } as unknown as SlackPostMessageClient & {
|
|
401
|
+
chat: { postMessage: ReturnType<typeof mock> };
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
it("builds a client from the agent's own bot token and posts the message", async () => {
|
|
406
|
+
const client = makeSlackClient();
|
|
407
|
+
const tokensSeen: string[] = [];
|
|
408
|
+
|
|
409
|
+
const sent = await sendTrialExpiryWarning(params, (botToken) => {
|
|
410
|
+
tokensSeen.push(botToken);
|
|
411
|
+
return client;
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(sent).toBe(true);
|
|
415
|
+
expect(tokensSeen).toEqual([params.botToken]);
|
|
416
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
|
|
417
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith({
|
|
418
|
+
channel: params.channel,
|
|
419
|
+
text: params.text,
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns false (never throws) when chat.postMessage rejects", async () => {
|
|
424
|
+
const client = makeSlackClient({
|
|
425
|
+
error: new Error("An API error occurred: channel_not_found"),
|
|
426
|
+
});
|
|
427
|
+
const errors: unknown[][] = [];
|
|
428
|
+
const originalError = console.error.bind(console);
|
|
429
|
+
console.error = (...args: unknown[]) => {
|
|
430
|
+
errors.push(args);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const sent = await sendTrialExpiryWarning(params, () => client);
|
|
435
|
+
expect(sent).toBe(false);
|
|
436
|
+
} finally {
|
|
437
|
+
console.error = originalError;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
|
|
441
|
+
// The failure is logged, not swallowed — a persistently failing agent has
|
|
442
|
+
// to stay visible in the admin service's logs (see the module header).
|
|
443
|
+
expect(errors).toHaveLength(1);
|
|
444
|
+
expect(String(errors[0][0])).toContain("chat.postMessage failed");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("returns false when the client factory itself throws (bad token)", async () => {
|
|
448
|
+
const originalError = console.error.bind(console);
|
|
449
|
+
console.error = () => {};
|
|
450
|
+
try {
|
|
451
|
+
const sent = await sendTrialExpiryWarning(params, () => {
|
|
452
|
+
throw new Error("invalid token");
|
|
453
|
+
});
|
|
454
|
+
expect(sent).toBe(false);
|
|
455
|
+
} finally {
|
|
456
|
+
console.error = originalError;
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("defaults to a real @slack/web-api WebClient built from the bot token", async () => {
|
|
461
|
+
// The default factory is the production path — assert it really returns a
|
|
462
|
+
// WebClient with a callable chat.postMessage, without making a network
|
|
463
|
+
// call (the doubles above cover the send/catch behaviour itself).
|
|
464
|
+
const client = defaultSlackClientFactory(params.botToken);
|
|
465
|
+
expect(client).toBeInstanceOf(WebClient);
|
|
466
|
+
expect(typeof client.chat.postMessage).toBe("function");
|
|
467
|
+
});
|
|
468
|
+
});
|