@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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-identity-name.integration.test.ts
|
|
3
|
+
* Integration tests for AnthropicIdentityNameAllocator (PAI-1.3) against a
|
|
4
|
+
* real PostgreSQL DB — sequential numbering and concurrent uniqueness.
|
|
5
|
+
*
|
|
6
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
11
|
+
import { AnthropicIdentityNameAllocator } from "./anthropic-identity-name.ts";
|
|
12
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
13
|
+
|
|
14
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
15
|
+
|
|
16
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
17
|
+
|
|
18
|
+
function makePrisma(): PrismaClient {
|
|
19
|
+
// TEST_DB is guaranteed set — the describe block is skipped otherwise.
|
|
20
|
+
return createAdminPrismaClient(TEST_DB as string);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describeOrSkip("AnthropicIdentityNameAllocator (integration)", () => {
|
|
24
|
+
let prisma: PrismaClient;
|
|
25
|
+
let allocator: AnthropicIdentityNameAllocator;
|
|
26
|
+
const email = "jane.doe@example.com";
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
prisma = makePrisma();
|
|
30
|
+
await prisma.agentPhaseMethodology.deleteMany();
|
|
31
|
+
await prisma.agentToken.deleteMany();
|
|
32
|
+
await prisma.agentCronJob.deleteMany();
|
|
33
|
+
await prisma.agentTool.deleteMany();
|
|
34
|
+
await prisma.agentEnv.deleteMany();
|
|
35
|
+
await prisma.agent.deleteMany();
|
|
36
|
+
allocator = new AnthropicIdentityNameAllocator(prisma);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await prisma.$disconnect();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allocates a1 then a2 for the same owner and persists them", async () => {
|
|
44
|
+
const a = await prisma.agent.create({ data: { name: "A" } });
|
|
45
|
+
const b = await prisma.agent.create({ data: { name: "B" } });
|
|
46
|
+
expect(await allocator.allocate(a.id, email)).toBe("jane-doe-a1");
|
|
47
|
+
expect(await allocator.allocate(b.id, email)).toBe("jane-doe-a2");
|
|
48
|
+
const row = await prisma.agent.findUnique({ where: { id: b.id } });
|
|
49
|
+
expect(row?.anthropicIdentityName).toBe("jane-doe-a2");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("applies the configured suffix", async () => {
|
|
53
|
+
const a = await prisma.agent.create({ data: { name: "A" } });
|
|
54
|
+
const suffixed = new AnthropicIdentityNameAllocator(prisma, {
|
|
55
|
+
suffix: "dev",
|
|
56
|
+
});
|
|
57
|
+
expect(await suffixed.allocate(a.id, email)).toBe("jane-doe-a1-dev");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("gives concurrent allocations different names", async () => {
|
|
61
|
+
const agents = await Promise.all(
|
|
62
|
+
["A", "B", "C", "D"].map((name) =>
|
|
63
|
+
prisma.agent.create({ data: { name } }),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
const names = await Promise.all(
|
|
67
|
+
agents.map((a) => allocator.allocate(a.id, email)),
|
|
68
|
+
);
|
|
69
|
+
expect(new Set(names).size).toBe(agents.length);
|
|
70
|
+
for (const n of names) expect(n).toMatch(/^jane-doe-a\d+$/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("concurrent allocations for the same agent agree on one name", async () => {
|
|
74
|
+
const a = await prisma.agent.create({ data: { name: "A" } });
|
|
75
|
+
const names = await Promise.all([
|
|
76
|
+
allocator.allocate(a.id, email),
|
|
77
|
+
allocator.allocate(a.id, email),
|
|
78
|
+
]);
|
|
79
|
+
expect(names[0]).toBe(names[1]);
|
|
80
|
+
const row = await prisma.agent.findUnique({ where: { id: a.id } });
|
|
81
|
+
expect(row?.anthropicIdentityName).toBe(names[0]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-identity-name.ts
|
|
3
|
+
* Anthropic identity name allocator (PAI-1.3). Names are built from the
|
|
4
|
+
* owner's email: jane.doe@example.com becomes jane-doe, then -a{n} with the
|
|
5
|
+
* next free number for that owner, then an optional per-environment suffix.
|
|
6
|
+
* Names match ^[a-z0-9-]+$, are at most 255 characters, and are unique
|
|
7
|
+
* (Agent.anthropicIdentityName unique index).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
11
|
+
import { BadRequestError, ConflictError, NotFoundError } from "./errors.ts";
|
|
12
|
+
|
|
13
|
+
export const MAX_IDENTITY_NAME_LENGTH = 255;
|
|
14
|
+
|
|
15
|
+
const NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
16
|
+
const MAX_N_DIGITS = 10;
|
|
17
|
+
const MAX_N = 10 ** MAX_N_DIGITS - 1;
|
|
18
|
+
/** Maximum unique-conflict retries before giving up. */
|
|
19
|
+
const MAX_ATTEMPTS = 10;
|
|
20
|
+
|
|
21
|
+
function normalizeSuffix(suffix: string | undefined): string {
|
|
22
|
+
if (suffix === undefined || suffix === "") return "";
|
|
23
|
+
if (!NAME_PATTERN.test(suffix)) {
|
|
24
|
+
throw new BadRequestError(
|
|
25
|
+
"Anthropic identity name suffix must match ^[a-z0-9-]+$",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return suffix;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Length of everything the name needs besides the base: -a{n}[-suffix]. */
|
|
32
|
+
function reservedLength(suffix: string): number {
|
|
33
|
+
return "-a".length + MAX_N_DIGITS + (suffix ? `-${suffix}`.length : 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base name from an owner email: local part (before the last @) without a
|
|
38
|
+
* +tag, lowercased, anything outside [a-z0-9] mapped to `-`, repeated `-`
|
|
39
|
+
* collapsed and ends trimmed. Truncated (independently of n) so that
|
|
40
|
+
* `identityName(base, n, suffix)` always fits in 255 characters.
|
|
41
|
+
* @throws BadRequestError for a malformed email, an empty result or a bad suffix
|
|
42
|
+
*/
|
|
43
|
+
export function identityBaseName(email: string, suffix?: string): string {
|
|
44
|
+
const sfx = normalizeSuffix(suffix);
|
|
45
|
+
const at = email.lastIndexOf("@");
|
|
46
|
+
if (at <= 0 || at === email.length - 1) {
|
|
47
|
+
throw new BadRequestError("Owner email is malformed");
|
|
48
|
+
}
|
|
49
|
+
const maxBase = MAX_IDENTITY_NAME_LENGTH - reservedLength(sfx);
|
|
50
|
+
if (maxBase < 1) {
|
|
51
|
+
throw new BadRequestError("Anthropic identity name suffix is too long");
|
|
52
|
+
}
|
|
53
|
+
const local = (email.slice(0, at).split("+")[0] ?? "").toLowerCase();
|
|
54
|
+
const base = local
|
|
55
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
+
.replace(/^-+/, "")
|
|
57
|
+
.slice(0, maxBase)
|
|
58
|
+
.replace(/-+$/, "");
|
|
59
|
+
if (!base) {
|
|
60
|
+
throw new BadRequestError("Owner email has no usable local part");
|
|
61
|
+
}
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `${base}-a${n}` plus `-${suffix}` when given.
|
|
67
|
+
* @throws BadRequestError if n, base or suffix is invalid or the name is too long
|
|
68
|
+
*/
|
|
69
|
+
export function identityName(base: string, n: number, suffix?: string): string {
|
|
70
|
+
const sfx = normalizeSuffix(suffix);
|
|
71
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_N) {
|
|
72
|
+
throw new BadRequestError("Identity number must be a positive integer");
|
|
73
|
+
}
|
|
74
|
+
const name = `${base}-a${n}${sfx ? `-${sfx}` : ""}`;
|
|
75
|
+
if (!NAME_PATTERN.test(name) || name.length > MAX_IDENTITY_NAME_LENGTH) {
|
|
76
|
+
throw new BadRequestError("Invalid Anthropic identity name");
|
|
77
|
+
}
|
|
78
|
+
return name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function escapeRegExp(s: string): string {
|
|
82
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function prismaCode(err: unknown): unknown {
|
|
86
|
+
return (err as { code?: unknown } | null)?.code;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AnthropicIdentityNameAllocatorOptions {
|
|
90
|
+
/** Per-environment suffix appended to every allocated name. */
|
|
91
|
+
suffix?: string | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class AnthropicIdentityNameAllocator {
|
|
95
|
+
private readonly suffix: string;
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private prisma: PrismaClient,
|
|
99
|
+
options: AnthropicIdentityNameAllocatorOptions = {},
|
|
100
|
+
) {
|
|
101
|
+
this.suffix = normalizeSuffix(options.suffix);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Allocate the next free `-a{n}` name for the owner and save it on the
|
|
106
|
+
* agent. Idempotent: an agent that already has a name keeps it.
|
|
107
|
+
* Concurrent allocations are serialized by the unique index — the loser
|
|
108
|
+
* of a race rescans and retries with the next number.
|
|
109
|
+
* @throws NotFoundError if the agent does not exist
|
|
110
|
+
* @throws BadRequestError if the email is malformed
|
|
111
|
+
* @throws ConflictError if retries are exhausted
|
|
112
|
+
*/
|
|
113
|
+
async allocate(agentId: string, ownerEmail: string): Promise<string> {
|
|
114
|
+
const base = identityBaseName(ownerEmail, this.suffix);
|
|
115
|
+
const agent = await this.prisma.agent.findUnique({
|
|
116
|
+
where: { id: agentId },
|
|
117
|
+
select: { anthropicIdentityName: true },
|
|
118
|
+
});
|
|
119
|
+
if (!agent) throw new NotFoundError(`Agent ${agentId} not found`);
|
|
120
|
+
if (agent.anthropicIdentityName) return agent.anthropicIdentityName;
|
|
121
|
+
|
|
122
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
123
|
+
const name = identityName(base, await this.nextNumber(base), this.suffix);
|
|
124
|
+
try {
|
|
125
|
+
// Only write while the agent has no name, so a concurrent allocate
|
|
126
|
+
// for the same agent cannot overwrite a name that is already taken.
|
|
127
|
+
const { count } = await this.prisma.agent.updateMany({
|
|
128
|
+
where: { id: agentId, anthropicIdentityName: null },
|
|
129
|
+
data: { anthropicIdentityName: name },
|
|
130
|
+
});
|
|
131
|
+
if (count === 1) return name;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (prismaCode(err) !== "P2002") throw err;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const current = await this.prisma.agent.findUnique({
|
|
137
|
+
where: { id: agentId },
|
|
138
|
+
select: { anthropicIdentityName: true },
|
|
139
|
+
});
|
|
140
|
+
if (!current) throw new NotFoundError(`Agent ${agentId} not found`);
|
|
141
|
+
if (current.anthropicIdentityName) return current.anthropicIdentityName;
|
|
142
|
+
}
|
|
143
|
+
throw new ConflictError("Could not allocate a unique identity name");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** max(n) + 1 over this owner's existing names (1 if none). */
|
|
147
|
+
private async nextNumber(base: string): Promise<number> {
|
|
148
|
+
const rows = await this.prisma.agent.findMany({
|
|
149
|
+
where: { anthropicIdentityName: { startsWith: `${base}-a` } },
|
|
150
|
+
select: { anthropicIdentityName: true },
|
|
151
|
+
});
|
|
152
|
+
const sfx = this.suffix ? `-${escapeRegExp(this.suffix)}` : "";
|
|
153
|
+
const pattern = new RegExp(
|
|
154
|
+
`^${escapeRegExp(base)}-a([1-9][0-9]{0,${MAX_N_DIGITS - 1}})${sfx}$`,
|
|
155
|
+
);
|
|
156
|
+
let max = 0;
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
const m = pattern.exec(row.anthropicIdentityName ?? "");
|
|
159
|
+
if (m?.[1]) max = Math.max(max, Number(m[1]));
|
|
160
|
+
}
|
|
161
|
+
return max + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-identity-name.unit.test.ts
|
|
3
|
+
* Unit tests for the pure Anthropic identity name functions and the
|
|
4
|
+
* allocator logic (PAI-1.3) against an injected prisma test double.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
AnthropicIdentityNameAllocator,
|
|
10
|
+
identityBaseName,
|
|
11
|
+
identityName,
|
|
12
|
+
MAX_IDENTITY_NAME_LENGTH,
|
|
13
|
+
} from "./anthropic-identity-name.ts";
|
|
14
|
+
import { BadRequestError, ConflictError, NotFoundError } from "./errors.ts";
|
|
15
|
+
|
|
16
|
+
const VALID = /^[a-z0-9-]+$/;
|
|
17
|
+
|
|
18
|
+
describe("identityBaseName", () => {
|
|
19
|
+
it("maps dots to dashes", () => {
|
|
20
|
+
expect(identityBaseName("jane.doe@example.com")).toBe("jane-doe");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("drops a +tag", () => {
|
|
24
|
+
expect(identityBaseName("jane.doe+agents@example.com")).toBe("jane-doe");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("lowercases", () => {
|
|
28
|
+
expect(identityBaseName("Jane.DOE@Example.COM")).toBe("jane-doe");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("replaces invalid chars, collapses and trims dashes", () => {
|
|
32
|
+
expect(identityBaseName("_a..b__c_@x.io")).toBe("a-b-c");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("uses the part before the last @", () => {
|
|
36
|
+
expect(identityBaseName('"a@b"@x.io')).toBe("a-b");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("truncates a very long local part so the final name fits", () => {
|
|
40
|
+
const email = `${"a".repeat(400)}@x.io`;
|
|
41
|
+
const base = identityBaseName(email);
|
|
42
|
+
expect(base.length).toBeLessThan(MAX_IDENTITY_NAME_LENGTH);
|
|
43
|
+
const name = identityName(base, 9_999_999_999);
|
|
44
|
+
expect(name.length).toBeLessThanOrEqual(MAX_IDENTITY_NAME_LENGTH);
|
|
45
|
+
expect(name).toMatch(VALID);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("truncation leaves room for the suffix and re-trims dashes", () => {
|
|
49
|
+
const email = `${"a".repeat(300)}.${"b".repeat(300)}@x.io`;
|
|
50
|
+
const suffix = "s".repeat(30);
|
|
51
|
+
const base = identityBaseName(email, suffix);
|
|
52
|
+
expect(base.endsWith("-")).toBe(false);
|
|
53
|
+
const name = identityName(base, 9_999_999_999, suffix);
|
|
54
|
+
expect(name.length).toBeLessThanOrEqual(MAX_IDENTITY_NAME_LENGTH);
|
|
55
|
+
expect(name.endsWith(`-a9999999999-${suffix}`)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("re-trims a dash exposed by truncation", () => {
|
|
59
|
+
const email = `${"a".repeat(230)}.bbb@x.io`;
|
|
60
|
+
for (let i = 0; i < 40; i++) {
|
|
61
|
+
const base = identityBaseName(`${"a".repeat(i)}.${email}`);
|
|
62
|
+
expect(base.endsWith("-")).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
"",
|
|
68
|
+
"nope",
|
|
69
|
+
"@example.com",
|
|
70
|
+
"jane@",
|
|
71
|
+
"+tag@example.com",
|
|
72
|
+
"...@example.com",
|
|
73
|
+
"___@example.com",
|
|
74
|
+
])("rejects malformed or empty-result email %p", (email) => {
|
|
75
|
+
expect(() => identityBaseName(email)).toThrow(BadRequestError);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects an invalid suffix", () => {
|
|
79
|
+
expect(() => identityBaseName("a@x.io", "Bad_Suffix")).toThrow(
|
|
80
|
+
BadRequestError,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects a suffix too long to leave room for a base", () => {
|
|
85
|
+
expect(() => identityBaseName("a@x.io", "s".repeat(300))).toThrow(
|
|
86
|
+
BadRequestError,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("identityName", () => {
|
|
92
|
+
it("builds base-a{n}", () => {
|
|
93
|
+
expect(identityName("jane-doe", 3)).toBe("jane-doe-a3");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("appends the suffix", () => {
|
|
97
|
+
expect(identityName("jane-doe", 1, "prod")).toBe("jane-doe-a1-prod");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("treats an empty suffix as none", () => {
|
|
101
|
+
expect(identityName("jane", 2, "")).toBe("jane-a2");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it.each([0, -1, 1.5, Number.NaN, 1e10])("rejects n=%p", (n) => {
|
|
105
|
+
expect(() => identityName("jane", n)).toThrow(BadRequestError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects an invalid suffix", () => {
|
|
109
|
+
expect(() => identityName("jane", 1, "UP")).toThrow(BadRequestError);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects an invalid base", () => {
|
|
113
|
+
expect(() => identityName("Jane!", 1)).toThrow(BadRequestError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects a name over the length limit", () => {
|
|
117
|
+
expect(() => identityName("a".repeat(255), 1)).toThrow(BadRequestError);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
type Row = { id: string; anthropicIdentityName: string | null };
|
|
122
|
+
|
|
123
|
+
/** Minimal in-memory prisma double; update enforces the unique index. */
|
|
124
|
+
function makeFakePrisma(
|
|
125
|
+
rows: Row[],
|
|
126
|
+
opts: { conflictsBeforeSuccess?: number } = {},
|
|
127
|
+
) {
|
|
128
|
+
let conflicts = opts.conflictsBeforeSuccess ?? 0;
|
|
129
|
+
const calls = { findMany: 0, update: 0 };
|
|
130
|
+
const agent = {
|
|
131
|
+
async findUnique({ where }: { where: { id: string } }) {
|
|
132
|
+
return rows.find((r) => r.id === where.id) ?? null;
|
|
133
|
+
},
|
|
134
|
+
async findMany({
|
|
135
|
+
where,
|
|
136
|
+
}: {
|
|
137
|
+
where: { anthropicIdentityName: { startsWith: string } };
|
|
138
|
+
}) {
|
|
139
|
+
calls.findMany++;
|
|
140
|
+
const prefix = where.anthropicIdentityName.startsWith;
|
|
141
|
+
return rows
|
|
142
|
+
.filter((r) => r.anthropicIdentityName?.startsWith(prefix))
|
|
143
|
+
.map((r) => ({ anthropicIdentityName: r.anthropicIdentityName }));
|
|
144
|
+
},
|
|
145
|
+
async updateMany({
|
|
146
|
+
where,
|
|
147
|
+
data,
|
|
148
|
+
}: {
|
|
149
|
+
where: { id: string; anthropicIdentityName: null };
|
|
150
|
+
data: { anthropicIdentityName: string };
|
|
151
|
+
}) {
|
|
152
|
+
calls.update++;
|
|
153
|
+
if (conflicts > 0) {
|
|
154
|
+
conflicts--;
|
|
155
|
+
// Simulate a concurrent writer grabbing the name we computed.
|
|
156
|
+
rows.push({ id: `other${calls.update}`, ...data });
|
|
157
|
+
throw { code: "P2002" };
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
rows.some((r) => r.anthropicIdentityName === data.anthropicIdentityName)
|
|
161
|
+
)
|
|
162
|
+
throw { code: "P2002" };
|
|
163
|
+
const row = rows.find(
|
|
164
|
+
(r) => r.id === where.id && r.anthropicIdentityName === null,
|
|
165
|
+
);
|
|
166
|
+
if (!row) return { count: 0 };
|
|
167
|
+
row.anthropicIdentityName = data.anthropicIdentityName;
|
|
168
|
+
return { count: 1 };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal prisma double
|
|
172
|
+
return { prisma: { agent } as any, calls, rows };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe("AnthropicIdentityNameAllocator", () => {
|
|
176
|
+
const email = "jane.doe@example.com";
|
|
177
|
+
|
|
178
|
+
it("starts at a1", async () => {
|
|
179
|
+
const { prisma } = makeFakePrisma([
|
|
180
|
+
{ id: "x", anthropicIdentityName: null },
|
|
181
|
+
]);
|
|
182
|
+
const name = await new AnthropicIdentityNameAllocator(prisma).allocate(
|
|
183
|
+
"x",
|
|
184
|
+
email,
|
|
185
|
+
);
|
|
186
|
+
expect(name).toBe("jane-doe-a1");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("picks max+1, ignoring gaps", async () => {
|
|
190
|
+
const { prisma } = makeFakePrisma([
|
|
191
|
+
{ id: "x", anthropicIdentityName: null },
|
|
192
|
+
{ id: "p", anthropicIdentityName: "jane-doe-a1" },
|
|
193
|
+
{ id: "q", anthropicIdentityName: "jane-doe-a7" },
|
|
194
|
+
]);
|
|
195
|
+
expect(
|
|
196
|
+
await new AnthropicIdentityNameAllocator(prisma).allocate("x", email),
|
|
197
|
+
).toBe("jane-doe-a8");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("does not count other owners sharing a prefix", async () => {
|
|
201
|
+
const { prisma } = makeFakePrisma([
|
|
202
|
+
{ id: "x", anthropicIdentityName: null },
|
|
203
|
+
{ id: "p", anthropicIdentityName: "jane-doe-a3" },
|
|
204
|
+
{ id: "q", anthropicIdentityName: "jane-doe-a3-prod" },
|
|
205
|
+
{ id: "r", anthropicIdentityName: "jane-doe-alpha" },
|
|
206
|
+
]);
|
|
207
|
+
expect(
|
|
208
|
+
await new AnthropicIdentityNameAllocator(prisma).allocate(
|
|
209
|
+
"x",
|
|
210
|
+
"jane@example.com",
|
|
211
|
+
),
|
|
212
|
+
).toBe("jane-a1");
|
|
213
|
+
const second = makeFakePrisma([
|
|
214
|
+
{ id: "x", anthropicIdentityName: null },
|
|
215
|
+
{ id: "p", anthropicIdentityName: "jane-doe-a3-prod" },
|
|
216
|
+
{ id: "q", anthropicIdentityName: "jane-doe-a9" },
|
|
217
|
+
]);
|
|
218
|
+
expect(
|
|
219
|
+
await new AnthropicIdentityNameAllocator(second.prisma).allocate(
|
|
220
|
+
"x",
|
|
221
|
+
email,
|
|
222
|
+
),
|
|
223
|
+
).toBe("jane-doe-a10");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("applies the suffix and only counts same-suffix names", async () => {
|
|
227
|
+
const { prisma } = makeFakePrisma([
|
|
228
|
+
{ id: "x", anthropicIdentityName: null },
|
|
229
|
+
{ id: "p", anthropicIdentityName: "jane-doe-a5" },
|
|
230
|
+
{ id: "q", anthropicIdentityName: "jane-doe-a2-prod" },
|
|
231
|
+
{ id: "r", anthropicIdentityName: "jane-doe-a4-dev" },
|
|
232
|
+
]);
|
|
233
|
+
expect(
|
|
234
|
+
await new AnthropicIdentityNameAllocator(prisma, {
|
|
235
|
+
suffix: "prod",
|
|
236
|
+
}).allocate("x", email),
|
|
237
|
+
).toBe("jane-doe-a3-prod");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("persists the name on the agent row", async () => {
|
|
241
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }]);
|
|
242
|
+
await new AnthropicIdentityNameAllocator(f.prisma).allocate("x", email);
|
|
243
|
+
expect(f.rows[0]?.anthropicIdentityName).toBe("jane-doe-a1");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("is idempotent: returns an existing name unchanged", async () => {
|
|
247
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: "custom-a1" }]);
|
|
248
|
+
const name = await new AnthropicIdentityNameAllocator(f.prisma).allocate(
|
|
249
|
+
"x",
|
|
250
|
+
email,
|
|
251
|
+
);
|
|
252
|
+
expect(name).toBe("custom-a1");
|
|
253
|
+
expect(f.calls.update).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("throws NotFoundError for an unknown agent", async () => {
|
|
257
|
+
const { prisma } = makeFakePrisma([]);
|
|
258
|
+
await expect(
|
|
259
|
+
new AnthropicIdentityNameAllocator(prisma).allocate("nope", email),
|
|
260
|
+
).rejects.toBeInstanceOf(NotFoundError);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("rejects a malformed email before touching the db", async () => {
|
|
264
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }]);
|
|
265
|
+
await expect(
|
|
266
|
+
new AnthropicIdentityNameAllocator(f.prisma).allocate("x", "bad"),
|
|
267
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
268
|
+
expect(f.calls.findMany).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("rejects an invalid suffix at construction", () => {
|
|
272
|
+
const { prisma } = makeFakePrisma([]);
|
|
273
|
+
expect(
|
|
274
|
+
() => new AnthropicIdentityNameAllocator(prisma, { suffix: "No!" }),
|
|
275
|
+
).toThrow(BadRequestError);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("retries with a fresh scan after a unique conflict", async () => {
|
|
279
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }], {
|
|
280
|
+
conflictsBeforeSuccess: 2,
|
|
281
|
+
});
|
|
282
|
+
const name = await new AnthropicIdentityNameAllocator(f.prisma).allocate(
|
|
283
|
+
"x",
|
|
284
|
+
email,
|
|
285
|
+
);
|
|
286
|
+
expect(name).toBe("jane-doe-a3");
|
|
287
|
+
expect(f.calls.findMany).toBe(3);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("throws ConflictError once retries are exhausted", async () => {
|
|
291
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }], {
|
|
292
|
+
conflictsBeforeSuccess: 100,
|
|
293
|
+
});
|
|
294
|
+
await expect(
|
|
295
|
+
new AnthropicIdentityNameAllocator(f.prisma).allocate("x", email),
|
|
296
|
+
).rejects.toBeInstanceOf(ConflictError);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("throws NotFoundError if the agent is deleted mid-allocation", async () => {
|
|
300
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }]);
|
|
301
|
+
const findUnique = f.prisma.agent.findUnique;
|
|
302
|
+
f.prisma.agent.findUnique = async (args: { where: { id: string } }) => {
|
|
303
|
+
const row = await findUnique(args);
|
|
304
|
+
f.rows.splice(0, f.rows.length);
|
|
305
|
+
return row;
|
|
306
|
+
};
|
|
307
|
+
await expect(
|
|
308
|
+
new AnthropicIdentityNameAllocator(f.prisma).allocate("x", email),
|
|
309
|
+
).rejects.toBeInstanceOf(NotFoundError);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps a name written concurrently for the same agent", async () => {
|
|
313
|
+
const f = makeFakePrisma([{ id: "x", anthropicIdentityName: null }]);
|
|
314
|
+
const findUnique = f.prisma.agent.findUnique;
|
|
315
|
+
let first = true;
|
|
316
|
+
f.prisma.agent.findUnique = async (args: { where: { id: string } }) => {
|
|
317
|
+
const row = await findUnique(args);
|
|
318
|
+
const snapshot = row && { ...row };
|
|
319
|
+
if (first && f.rows[0]) {
|
|
320
|
+
// Another allocate for this agent wins the race after our read.
|
|
321
|
+
f.rows[0].anthropicIdentityName = "jane-doe-a1";
|
|
322
|
+
first = false;
|
|
323
|
+
}
|
|
324
|
+
return snapshot;
|
|
325
|
+
};
|
|
326
|
+
const name = await new AnthropicIdentityNameAllocator(f.prisma).allocate(
|
|
327
|
+
"x",
|
|
328
|
+
email,
|
|
329
|
+
);
|
|
330
|
+
expect(name).toBe("jane-doe-a1");
|
|
331
|
+
expect(f.rows[0]?.anthropicIdentityName).toBe("jane-doe-a1");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("rethrows unexpected db errors", async () => {
|
|
335
|
+
const { prisma } = makeFakePrisma([
|
|
336
|
+
{ id: "x", anthropicIdentityName: null },
|
|
337
|
+
]);
|
|
338
|
+
prisma.agent.updateMany = async () => {
|
|
339
|
+
throw new Error("boom");
|
|
340
|
+
};
|
|
341
|
+
await expect(
|
|
342
|
+
new AnthropicIdentityNameAllocator(prisma).allocate("x", email),
|
|
343
|
+
).rejects.toThrow("boom");
|
|
344
|
+
});
|
|
345
|
+
});
|