@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
package/src/agents.ts
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/agents.ts
|
|
3
|
+
* AgentService — CRUD/read access to the Agent model.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the sibling *Service modules (AgentEnvService, AgentTokenService,
|
|
6
|
+
* etc.) so route handlers never call prisma.agent.* directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { findRepoNameCollisions } from "@app-vitals/agent-lib/clone-plan";
|
|
10
|
+
import { DEFAULT_AGENT_ENV } from "@app-vitals/agent-lib/default-agent-env";
|
|
11
|
+
import { isGithubLogin } from "@app-vitals/agent-lib/github-login";
|
|
12
|
+
import { isOrgRepo } from "@app-vitals/agent-lib/org-repo";
|
|
13
|
+
import { SECRET_ENV_VARS } from "@app-vitals/agent-lib/secret-env-vars";
|
|
14
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
15
|
+
import type { AgentAnthropicIdentity } from "./agent-anthropic-identity.ts";
|
|
16
|
+
import type { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
17
|
+
import type { AgentEnvService } from "./agent-envs.ts";
|
|
18
|
+
import { AgentMemberService } from "./agent-members.ts";
|
|
19
|
+
import type { AgentPluginService } from "./agent-plugins.ts";
|
|
20
|
+
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
21
|
+
import type { AgentToolService } from "./agent-tools.ts";
|
|
22
|
+
import {
|
|
23
|
+
type SpecialtyManifestResolver,
|
|
24
|
+
SpecialtyRegistry,
|
|
25
|
+
} from "./specialty-manifest-loader.ts";
|
|
26
|
+
import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
|
|
27
|
+
import type { PrismaTransactionClient } from "./prisma-tx.ts";
|
|
28
|
+
|
|
29
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface CreateAgentInput {
|
|
32
|
+
name: string;
|
|
33
|
+
slackId?: string | null;
|
|
34
|
+
selfHosted?: boolean;
|
|
35
|
+
/** Specialty name. Omitted means Prisma's column default ("coding") applies. */
|
|
36
|
+
typeName?: string;
|
|
37
|
+
/** Initial repos[] — the manifest repos merged with any request-supplied repos. */
|
|
38
|
+
repos?: string[];
|
|
39
|
+
/** Initial reviewAuthorAllowlist[]. */
|
|
40
|
+
reviewAuthorAllowlist?: string[];
|
|
41
|
+
/** Initial patchAuthorAllowlist[]. Independent of reviewAuthorAllowlist. */
|
|
42
|
+
patchAuthorAllowlist?: string[];
|
|
43
|
+
/** Initial restrictSlackToMembers flag. Omitted means the column default (false) applies. */
|
|
44
|
+
restrictSlackToMembers?: boolean;
|
|
45
|
+
/** Owning Account (SSP-4.1). Omitted means the agent has no account (legacy behavior). */
|
|
46
|
+
accountId?: string;
|
|
47
|
+
/** Owner's email (PAI-1.2). Already normalized by createAgent(); omitted means no owner. */
|
|
48
|
+
ownerEmail?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentRecord {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
slackId: string | null;
|
|
55
|
+
selfHosted: boolean;
|
|
56
|
+
typeName: string;
|
|
57
|
+
createdAt: Date;
|
|
58
|
+
updatedAt: Date;
|
|
59
|
+
/** Owning account (SSP-5.2 admin Account column); null for legacy agents. */
|
|
60
|
+
accountId?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Optional here even though listAll()/listByIds()/searchByName() below
|
|
63
|
+
* always return the full Prisma row (repos included, since none of them
|
|
64
|
+
* pass a `select` clause) — kept optional so existing call sites/test
|
|
65
|
+
* doubles constructing a narrower AgentRecord literal don't need updating.
|
|
66
|
+
* Consumed by the merged fleet-wide queue-activity view (AAV-2.1) to build
|
|
67
|
+
* the repo -> agentId[] eligibility index (agent-work-queue-merge.ts's
|
|
68
|
+
* buildEligibilityIndex()).
|
|
69
|
+
*/
|
|
70
|
+
repos?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The owner and per-agent Anthropic identity IDs (inherited, all optional) are
|
|
75
|
+
* selected by list()/getSummary() so reconcile targets carry them; GET /agents
|
|
76
|
+
* strips them from its response.
|
|
77
|
+
*/
|
|
78
|
+
export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
selfHosted: boolean;
|
|
82
|
+
typeName: string;
|
|
83
|
+
/** Owning account; null/absent for platform agents. */
|
|
84
|
+
accountId?: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
slackId: string | null;
|
|
91
|
+
selfHosted: boolean;
|
|
92
|
+
repos: string[];
|
|
93
|
+
reviewAuthorAllowlist: string[];
|
|
94
|
+
patchAuthorAllowlist: string[];
|
|
95
|
+
restrictSlackToMembers: boolean;
|
|
96
|
+
typeName: string;
|
|
97
|
+
/**
|
|
98
|
+
* Owning account, null for unassigned (platform) agents. Optional here so
|
|
99
|
+
* existing test doubles constructing a narrower AgentDetail literal don't
|
|
100
|
+
* need updating.
|
|
101
|
+
*/
|
|
102
|
+
accountId?: string | null;
|
|
103
|
+
createdAt: Date;
|
|
104
|
+
updatedAt: Date;
|
|
105
|
+
/**
|
|
106
|
+
* Required env keys declared by the agent's type manifest that have no
|
|
107
|
+
* corresponding AgentEnv row yet — key names only, never values
|
|
108
|
+
* (secrets_in_logs). Always present (empty array when the type's required
|
|
109
|
+
* contract is empty or every required key is set). Informational only —
|
|
110
|
+
* not a create blocker or provisioning gate (ATS-4.2).
|
|
111
|
+
*/
|
|
112
|
+
missingRequiredEnv: string[];
|
|
113
|
+
/**
|
|
114
|
+
* ATE-1.1: trial expiry tracking, both additive/nullable. trialExpiresAt is
|
|
115
|
+
* settable via PATCH /agents/:id (see UpdateSelfHostedInput below).
|
|
116
|
+
* trialExpiryWarnedAt is written internally by ATE-2.1's warning check —
|
|
117
|
+
* read-only via this route, GET/PATCH responses only. Optional here (like
|
|
118
|
+
* `repos` above) so existing test doubles constructing a narrower
|
|
119
|
+
* AgentDetail literal don't need updating.
|
|
120
|
+
*/
|
|
121
|
+
trialExpiresAt?: Date | null;
|
|
122
|
+
trialExpiryWarnedAt?: Date | null;
|
|
123
|
+
/**
|
|
124
|
+
* APM-1.3: the six real agent-policy fields (APM-1.1 DB columns,
|
|
125
|
+
* Prisma-column defaults noted per field below). Optional here (like
|
|
126
|
+
* `repos`/`trialExpiresAt` above) so existing test doubles constructing a
|
|
127
|
+
* narrower AgentDetail literal don't need updating.
|
|
128
|
+
*/
|
|
129
|
+
autoPostReviews?: boolean;
|
|
130
|
+
allowSelfReview?: boolean;
|
|
131
|
+
minConfidence?: number;
|
|
132
|
+
maxFindings?: number;
|
|
133
|
+
cleanupMergedWorktrees?: boolean;
|
|
134
|
+
cleanupAfterDays?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface UpdateSelfHostedInput {
|
|
138
|
+
/** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
|
|
139
|
+
accountId?: string | null;
|
|
140
|
+
/**
|
|
141
|
+
* Optional to mirror Prisma's generated AgentUpdateInput shape (undefined
|
|
142
|
+
* means "leave unchanged"); callers such as PATCH /agents/:id currently
|
|
143
|
+
* always pass a value since selfHosted is treated as required at the route
|
|
144
|
+
* level, but the type stays permissive to match the underlying data layer.
|
|
145
|
+
*/
|
|
146
|
+
selfHosted?: boolean;
|
|
147
|
+
repos?: string[];
|
|
148
|
+
reviewAuthorAllowlist?: string[];
|
|
149
|
+
patchAuthorAllowlist?: string[];
|
|
150
|
+
restrictSlackToMembers?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Backfills agent.slackId (UAP-1.3) — nullable so it can also be
|
|
153
|
+
* explicitly cleared via PATCH /agents/:id.
|
|
154
|
+
*/
|
|
155
|
+
slackId?: string | null;
|
|
156
|
+
/**
|
|
157
|
+
* ATE-1.1: nullable so it can also be explicitly cleared via
|
|
158
|
+
* PATCH /agents/:id (omitted entirely means "leave unchanged").
|
|
159
|
+
*/
|
|
160
|
+
trialExpiresAt?: Date | null;
|
|
161
|
+
/** APM-1.3: see AgentDetail's doc comment for these six fields. */
|
|
162
|
+
autoPostReviews?: boolean;
|
|
163
|
+
allowSelfReview?: boolean;
|
|
164
|
+
minConfidence?: number;
|
|
165
|
+
maxFindings?: number;
|
|
166
|
+
cleanupMergedWorktrees?: boolean;
|
|
167
|
+
cleanupAfterDays?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface AgentIdAndRepos {
|
|
171
|
+
id: string;
|
|
172
|
+
repos: string[];
|
|
173
|
+
reviewAuthorAllowlist: string[];
|
|
174
|
+
patchAuthorAllowlist: string[];
|
|
175
|
+
restrictSlackToMembers: boolean;
|
|
176
|
+
memberEmails: string[];
|
|
177
|
+
/**
|
|
178
|
+
* ATE-3.1: read by the runtime config route so the agent process can gate
|
|
179
|
+
* Slack access once its trial has passed. Optional so pre-existing
|
|
180
|
+
* fixtures/doubles built against this interface before ATE-3.1 (e.g.
|
|
181
|
+
* agents.unit.test.ts's getById() `toEqual` fixtures) keep compiling
|
|
182
|
+
* unchanged.
|
|
183
|
+
*/
|
|
184
|
+
trialExpiresAt?: Date | null;
|
|
185
|
+
/**
|
|
186
|
+
* WIF-2.1: read by the runtime config route to decide whether Anthropic
|
|
187
|
+
* credentials are stripped under federation. Optional for fixture compat.
|
|
188
|
+
*/
|
|
189
|
+
selfHosted?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* SSP-8.3: status of the owning Account, or null when the agent has none.
|
|
192
|
+
* Read by the runtime config route so the agent process can pause Slack.
|
|
193
|
+
* Optional for the same fixture-compat reason as trialExpiresAt.
|
|
194
|
+
*/
|
|
195
|
+
accountStatus?: string | null;
|
|
196
|
+
/**
|
|
197
|
+
* ARD-6.1: the agent's Specialty name, surfaced on the runtime config
|
|
198
|
+
* response so the agent process can pick its workspace template directory.
|
|
199
|
+
* Optional for the same fixture-compat reason as trialExpiresAt.
|
|
200
|
+
*/
|
|
201
|
+
typeName?: string;
|
|
202
|
+
/**
|
|
203
|
+
* APM-1.5: the 6 agent-policy fields, read by the runtime GET /:id/config
|
|
204
|
+
* route for direct passthrough. Required (not optional) since the Prisma
|
|
205
|
+
* select below always returns them (each column has a schema default).
|
|
206
|
+
*/
|
|
207
|
+
autoPostReviews: boolean;
|
|
208
|
+
allowSelfReview: boolean;
|
|
209
|
+
minConfidence: number;
|
|
210
|
+
maxFindings: number;
|
|
211
|
+
cleanupMergedWorktrees: boolean;
|
|
212
|
+
cleanupAfterDays: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface AgentOption {
|
|
216
|
+
id: string;
|
|
217
|
+
name: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface UpdateAgentFieldsInput {
|
|
221
|
+
name?: string;
|
|
222
|
+
/** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
|
|
223
|
+
accountId?: string | null;
|
|
224
|
+
repos?: string[];
|
|
225
|
+
reviewAuthorAllowlist?: string[];
|
|
226
|
+
patchAuthorAllowlist?: string[];
|
|
227
|
+
restrictSlackToMembers?: boolean;
|
|
228
|
+
selfHosted?: boolean;
|
|
229
|
+
slackId?: string | null;
|
|
230
|
+
/**
|
|
231
|
+
* ATE-2.1: stamped internally by TrialExpiryWarningSweeper once a warning
|
|
232
|
+
* Slack alert has been sent — never accepted from PATCH /agents/:id (see
|
|
233
|
+
* agents-api.ts's PatchAgentBodySchema, which omits this field entirely).
|
|
234
|
+
*/
|
|
235
|
+
trialExpiryWarnedAt?: Date | null;
|
|
236
|
+
/** APM-1.3: see AgentDetail's doc comment for these six fields. */
|
|
237
|
+
autoPostReviews?: boolean;
|
|
238
|
+
allowSelfReview?: boolean;
|
|
239
|
+
minConfidence?: number;
|
|
240
|
+
maxFindings?: number;
|
|
241
|
+
cleanupMergedWorktrees?: boolean;
|
|
242
|
+
cleanupAfterDays?: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Select shapes ────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const SUMMARY_SELECT = {
|
|
248
|
+
id: true,
|
|
249
|
+
name: true,
|
|
250
|
+
selfHosted: true,
|
|
251
|
+
typeName: true,
|
|
252
|
+
accountId: true,
|
|
253
|
+
ownerEmail: true,
|
|
254
|
+
anthropicIdentityName: true,
|
|
255
|
+
anthropicServiceAccountId: true,
|
|
256
|
+
anthropicFederationRuleId: true,
|
|
257
|
+
k8sServiceAccountName: true,
|
|
258
|
+
} as const;
|
|
259
|
+
|
|
260
|
+
const DETAIL_SELECT = {
|
|
261
|
+
id: true,
|
|
262
|
+
name: true,
|
|
263
|
+
slackId: true,
|
|
264
|
+
selfHosted: true,
|
|
265
|
+
repos: true,
|
|
266
|
+
reviewAuthorAllowlist: true,
|
|
267
|
+
patchAuthorAllowlist: true,
|
|
268
|
+
restrictSlackToMembers: true,
|
|
269
|
+
typeName: true,
|
|
270
|
+
accountId: true,
|
|
271
|
+
createdAt: true,
|
|
272
|
+
updatedAt: true,
|
|
273
|
+
trialExpiresAt: true,
|
|
274
|
+
trialExpiryWarnedAt: true,
|
|
275
|
+
autoPostReviews: true,
|
|
276
|
+
allowSelfReview: true,
|
|
277
|
+
minConfidence: true,
|
|
278
|
+
maxFindings: true,
|
|
279
|
+
cleanupMergedWorktrees: true,
|
|
280
|
+
cleanupAfterDays: true,
|
|
281
|
+
// ownerEmail and the three Anthropic identity fields are returned by GET
|
|
282
|
+
// /agents/:id (serializeAgent picks them explicitly). k8sServiceAccountName
|
|
283
|
+
// stays out of the API response; the post-PATCH reconcile of the agent's
|
|
284
|
+
// workload needs it.
|
|
285
|
+
ownerEmail: true,
|
|
286
|
+
anthropicIdentityName: true,
|
|
287
|
+
anthropicServiceAccountId: true,
|
|
288
|
+
anthropicFederationRuleId: true,
|
|
289
|
+
k8sServiceAccountName: true,
|
|
290
|
+
} as const;
|
|
291
|
+
|
|
292
|
+
// ─── Service ──────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export class AgentService {
|
|
295
|
+
constructor(
|
|
296
|
+
private prisma: PrismaClient,
|
|
297
|
+
private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
|
|
298
|
+
private agentMemberService: Pick<
|
|
299
|
+
AgentMemberService,
|
|
300
|
+
"listByAgentId"
|
|
301
|
+
> = new AgentMemberService(prisma),
|
|
302
|
+
) {}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Create a new agent row, plus `missingRequiredEnv` (see
|
|
306
|
+
* computeMissingRequiredEnv) — a freshly created agent has no AgentEnv rows
|
|
307
|
+
* yet, so this always reflects the full set of the type's required keys.
|
|
308
|
+
*
|
|
309
|
+
* @param client - defaults to `this.prisma`; pass the `tx` argument from a
|
|
310
|
+
* caller's `prisma.$transaction(async (tx) => ...)` to make this write
|
|
311
|
+
* participate in that transaction (see createAgent() below).
|
|
312
|
+
*/
|
|
313
|
+
async create(
|
|
314
|
+
input: CreateAgentInput,
|
|
315
|
+
client: PrismaTransactionClient = this.prisma,
|
|
316
|
+
): Promise<AgentDetail> {
|
|
317
|
+
const row = await client.agent.create({
|
|
318
|
+
data: {
|
|
319
|
+
name: input.name,
|
|
320
|
+
slackId: input.slackId ?? null,
|
|
321
|
+
selfHosted: input.selfHosted ?? false,
|
|
322
|
+
...(input.typeName !== undefined ? { typeName: input.typeName } : {}),
|
|
323
|
+
...(input.repos !== undefined ? { repos: input.repos } : {}),
|
|
324
|
+
...(input.reviewAuthorAllowlist !== undefined
|
|
325
|
+
? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
|
|
326
|
+
: {}),
|
|
327
|
+
...(input.patchAuthorAllowlist !== undefined
|
|
328
|
+
? { patchAuthorAllowlist: input.patchAuthorAllowlist }
|
|
329
|
+
: {}),
|
|
330
|
+
...(input.restrictSlackToMembers !== undefined
|
|
331
|
+
? { restrictSlackToMembers: input.restrictSlackToMembers }
|
|
332
|
+
: {}),
|
|
333
|
+
...(input.accountId !== undefined
|
|
334
|
+
? { accountId: input.accountId }
|
|
335
|
+
: {}),
|
|
336
|
+
...(input.ownerEmail !== undefined
|
|
337
|
+
? { ownerEmail: input.ownerEmail }
|
|
338
|
+
: {}),
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
const missingRequiredEnv = await this.computeMissingRequiredEnv(
|
|
342
|
+
row.id,
|
|
343
|
+
row.typeName,
|
|
344
|
+
client,
|
|
345
|
+
);
|
|
346
|
+
return { ...row, missingRequiredEnv };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Delete an agent row by id. Used as the provisioning-failure rollback path
|
|
351
|
+
* on create(), and (out of scope here) internally by deleteAgentFully().
|
|
352
|
+
*
|
|
353
|
+
* @param client - defaults to `this.prisma`; pass the `tx` argument from a
|
|
354
|
+
* caller's `prisma.$transaction(async (tx) => ...)` to make this write
|
|
355
|
+
* participate in that transaction (see createAgent() below).
|
|
356
|
+
*/
|
|
357
|
+
async delete(
|
|
358
|
+
id: string,
|
|
359
|
+
client: PrismaTransactionClient = this.prisma,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
await client.agent.delete({ where: { id } });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Run a function inside a single interactive Prisma transaction — a thin
|
|
366
|
+
* wrapper over `this.prisma.$transaction()` so callers outside this class
|
|
367
|
+
* (e.g. createAgent() below, driven from admin-ui.ts with injected service
|
|
368
|
+
* doubles in tests) never need direct access to the raw PrismaClient.
|
|
369
|
+
*/
|
|
370
|
+
async runTransaction<T>(
|
|
371
|
+
fn: (tx: PrismaTransactionClient) => Promise<T>,
|
|
372
|
+
): Promise<T> {
|
|
373
|
+
return this.prisma.$transaction(fn);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* List all agents (id + name + selfHosted + typeName), ordered by name asc.
|
|
378
|
+
*/
|
|
379
|
+
async list(): Promise<AgentSummary[]> {
|
|
380
|
+
return this.prisma.agent.findMany({
|
|
381
|
+
select: SUMMARY_SELECT,
|
|
382
|
+
orderBy: { name: "asc" },
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get {id, name, selfHosted, typeName} for a single agent. Returns null if
|
|
388
|
+
* not found.
|
|
389
|
+
*/
|
|
390
|
+
async getSummary(id: string): Promise<AgentSummary | null> {
|
|
391
|
+
return this.prisma.agent.findUnique({
|
|
392
|
+
where: { id },
|
|
393
|
+
select: SUMMARY_SELECT,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The env-contract gap: the type manifest's required env keys minus the
|
|
399
|
+
* keys already present in this agent's AgentEnv rows (key names only,
|
|
400
|
+
* never values — the AgentEnv query below selects only `key`, never
|
|
401
|
+
* `value`, so decrypted secrets can never reach this computation).
|
|
402
|
+
* Skips the AgentEnv query entirely when the manifest declares no required
|
|
403
|
+
* keys. Purely informational: never blocks create or gates provisioning
|
|
404
|
+
* (ATS-4.2).
|
|
405
|
+
*/
|
|
406
|
+
private async computeMissingRequiredEnv(
|
|
407
|
+
agentId: string,
|
|
408
|
+
typeName: string,
|
|
409
|
+
client: PrismaTransactionClient = this.prisma,
|
|
410
|
+
): Promise<string[]> {
|
|
411
|
+
const requiredKeys = this.specialtyRegistry
|
|
412
|
+
.getManifest(typeName)
|
|
413
|
+
.env.required.map((entry) => entry.key);
|
|
414
|
+
|
|
415
|
+
if (requiredKeys.length === 0) return [];
|
|
416
|
+
|
|
417
|
+
// Key names only — never select/decrypt AgentEnv.value here.
|
|
418
|
+
const presentEnvRows = await client.agentEnv.findMany({
|
|
419
|
+
where: { agentId },
|
|
420
|
+
select: { key: true },
|
|
421
|
+
});
|
|
422
|
+
const presentKeys = new Set(presentEnvRows.map((r) => r.key));
|
|
423
|
+
return requiredKeys.filter((key) => !presentKeys.has(key));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get the full agent record (incl. repos/typeName/timestamps), plus
|
|
428
|
+
* `missingRequiredEnv` (see computeMissingRequiredEnv). Returns null if not
|
|
429
|
+
* found.
|
|
430
|
+
*/
|
|
431
|
+
async getDetail(id: string): Promise<AgentDetail | null> {
|
|
432
|
+
const row = await this.prisma.agent.findUnique({
|
|
433
|
+
where: { id },
|
|
434
|
+
select: DETAIL_SELECT,
|
|
435
|
+
});
|
|
436
|
+
if (!row) return null;
|
|
437
|
+
|
|
438
|
+
const missingRequiredEnv = await this.computeMissingRequiredEnv(
|
|
439
|
+
id,
|
|
440
|
+
row.typeName,
|
|
441
|
+
);
|
|
442
|
+
return { ...row, missingRequiredEnv };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Returns whether an agent with the given id exists.
|
|
447
|
+
*/
|
|
448
|
+
async exists(id: string): Promise<boolean> {
|
|
449
|
+
const agent = await this.prisma.agent.findUnique({
|
|
450
|
+
where: { id },
|
|
451
|
+
select: { id: true },
|
|
452
|
+
});
|
|
453
|
+
return agent !== null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Update selfHosted (and optionally repos) for an agent. Returns the full
|
|
458
|
+
* updated record.
|
|
459
|
+
*/
|
|
460
|
+
async updateSelfHosted(
|
|
461
|
+
id: string,
|
|
462
|
+
input: UpdateSelfHostedInput,
|
|
463
|
+
): Promise<AgentDetail> {
|
|
464
|
+
const row = await this.prisma.agent.update({
|
|
465
|
+
where: { id },
|
|
466
|
+
data: {
|
|
467
|
+
selfHosted: input.selfHosted,
|
|
468
|
+
...(input.accountId !== undefined
|
|
469
|
+
? { accountId: input.accountId }
|
|
470
|
+
: {}),
|
|
471
|
+
...(input.repos !== undefined ? { repos: input.repos } : {}),
|
|
472
|
+
...(input.reviewAuthorAllowlist !== undefined
|
|
473
|
+
? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
|
|
474
|
+
: {}),
|
|
475
|
+
...(input.patchAuthorAllowlist !== undefined
|
|
476
|
+
? { patchAuthorAllowlist: input.patchAuthorAllowlist }
|
|
477
|
+
: {}),
|
|
478
|
+
...(input.restrictSlackToMembers !== undefined
|
|
479
|
+
? { restrictSlackToMembers: input.restrictSlackToMembers }
|
|
480
|
+
: {}),
|
|
481
|
+
...(input.slackId !== undefined ? { slackId: input.slackId } : {}),
|
|
482
|
+
...(input.trialExpiresAt !== undefined
|
|
483
|
+
? { trialExpiresAt: input.trialExpiresAt }
|
|
484
|
+
: {}),
|
|
485
|
+
...(input.autoPostReviews !== undefined
|
|
486
|
+
? { autoPostReviews: input.autoPostReviews }
|
|
487
|
+
: {}),
|
|
488
|
+
...(input.allowSelfReview !== undefined
|
|
489
|
+
? { allowSelfReview: input.allowSelfReview }
|
|
490
|
+
: {}),
|
|
491
|
+
...(input.minConfidence !== undefined
|
|
492
|
+
? { minConfidence: input.minConfidence }
|
|
493
|
+
: {}),
|
|
494
|
+
...(input.maxFindings !== undefined
|
|
495
|
+
? { maxFindings: input.maxFindings }
|
|
496
|
+
: {}),
|
|
497
|
+
...(input.cleanupMergedWorktrees !== undefined
|
|
498
|
+
? { cleanupMergedWorktrees: input.cleanupMergedWorktrees }
|
|
499
|
+
: {}),
|
|
500
|
+
...(input.cleanupAfterDays !== undefined
|
|
501
|
+
? { cleanupAfterDays: input.cleanupAfterDays }
|
|
502
|
+
: {}),
|
|
503
|
+
},
|
|
504
|
+
select: DETAIL_SELECT,
|
|
505
|
+
});
|
|
506
|
+
const missingRequiredEnv = await this.computeMissingRequiredEnv(
|
|
507
|
+
id,
|
|
508
|
+
row.typeName,
|
|
509
|
+
);
|
|
510
|
+
return { ...row, missingRequiredEnv };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Get {id, repos, reviewAuthorAllowlist, patchAuthorAllowlist,
|
|
515
|
+
* restrictSlackToMembers, memberEmails, trialExpiresAt, accountStatus, and the 6 APM-1.5
|
|
516
|
+
* agent-policy fields} for a single agent — used by the runtime
|
|
517
|
+
* config/crons routes. Returns null if not found.
|
|
518
|
+
*/
|
|
519
|
+
async getById(id: string): Promise<AgentIdAndRepos | null> {
|
|
520
|
+
const row = await this.prisma.agent.findUnique({
|
|
521
|
+
where: { id },
|
|
522
|
+
select: {
|
|
523
|
+
id: true,
|
|
524
|
+
repos: true,
|
|
525
|
+
reviewAuthorAllowlist: true,
|
|
526
|
+
patchAuthorAllowlist: true,
|
|
527
|
+
restrictSlackToMembers: true,
|
|
528
|
+
trialExpiresAt: true,
|
|
529
|
+
selfHosted: true,
|
|
530
|
+
typeName: true,
|
|
531
|
+
account: { select: { status: true } },
|
|
532
|
+
autoPostReviews: true,
|
|
533
|
+
allowSelfReview: true,
|
|
534
|
+
minConfidence: true,
|
|
535
|
+
maxFindings: true,
|
|
536
|
+
cleanupMergedWorktrees: true,
|
|
537
|
+
cleanupAfterDays: true,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
if (!row) return null;
|
|
541
|
+
|
|
542
|
+
const members = await this.agentMemberService.listByAgentId(id);
|
|
543
|
+
const memberEmails = members.map((m) => m.email);
|
|
544
|
+
const { account, ...rest } = row;
|
|
545
|
+
return { ...rest, memberEmails, accountStatus: account?.status ?? null };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* List every agent, full record, no filtering — used by dashboard-style
|
|
550
|
+
* pages (e.g. /admin/agents isAdmin branch, /admin/provision, /admin/tasks,
|
|
551
|
+
* /admin/prs, /admin/chat) that want every field back in whatever default
|
|
552
|
+
* order Prisma returns.
|
|
553
|
+
*/
|
|
554
|
+
async listAll(): Promise<AgentRecord[]> {
|
|
555
|
+
return this.prisma.agent.findMany();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* List agents matching a given set of ids — used for batch-resolving
|
|
560
|
+
* agent ids to full records (e.g. the non-admin /admin/agents filter path
|
|
561
|
+
* and agentNames-resolution on the tasks/PRs pages).
|
|
562
|
+
*/
|
|
563
|
+
async listByIds(ids: string[]): Promise<AgentRecord[]> {
|
|
564
|
+
return this.prisma.agent.findMany({ where: { id: { in: ids } } });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Search agents by name, case-insensitive substring match — used by the
|
|
569
|
+
* /admin/tasks agent-name filter.
|
|
570
|
+
*/
|
|
571
|
+
async searchByName(query: string): Promise<AgentRecord[]> {
|
|
572
|
+
return this.prisma.agent.findMany({
|
|
573
|
+
where: { name: { contains: query, mode: "insensitive" } },
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* List {id, name} for all agents, ordered by name asc. Backs both the
|
|
579
|
+
* full-record-mapped-to-{id,name} call sites (chat page, provision pages)
|
|
580
|
+
* and the name-only autocomplete call site (tasks page).
|
|
581
|
+
*/
|
|
582
|
+
async listOptions(): Promise<AgentOption[]> {
|
|
583
|
+
return this.prisma.agent.findMany({
|
|
584
|
+
select: { id: true, name: true },
|
|
585
|
+
orderBy: { name: "asc" },
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Generic partial-field update for an agent's
|
|
591
|
+
* name/repos/reviewAuthorAllowlist/patchAuthorAllowlist/
|
|
592
|
+
* restrictSlackToMembers/selfHosted/slackId. Only fields present in the
|
|
593
|
+
* input are touched. Returns the full updated detail record.
|
|
594
|
+
*/
|
|
595
|
+
async updateFields(
|
|
596
|
+
id: string,
|
|
597
|
+
input: UpdateAgentFieldsInput,
|
|
598
|
+
client: PrismaTransactionClient = this.prisma,
|
|
599
|
+
): Promise<AgentDetail> {
|
|
600
|
+
const row = await client.agent.update({
|
|
601
|
+
where: { id },
|
|
602
|
+
data: {
|
|
603
|
+
...(input.name !== undefined && { name: input.name }),
|
|
604
|
+
...(input.accountId !== undefined && { accountId: input.accountId }),
|
|
605
|
+
...(input.repos !== undefined && { repos: input.repos }),
|
|
606
|
+
...(input.reviewAuthorAllowlist !== undefined && {
|
|
607
|
+
reviewAuthorAllowlist: input.reviewAuthorAllowlist,
|
|
608
|
+
}),
|
|
609
|
+
...(input.patchAuthorAllowlist !== undefined && {
|
|
610
|
+
patchAuthorAllowlist: input.patchAuthorAllowlist,
|
|
611
|
+
}),
|
|
612
|
+
...(input.restrictSlackToMembers !== undefined && {
|
|
613
|
+
restrictSlackToMembers: input.restrictSlackToMembers,
|
|
614
|
+
}),
|
|
615
|
+
...(input.selfHosted !== undefined && {
|
|
616
|
+
selfHosted: input.selfHosted,
|
|
617
|
+
}),
|
|
618
|
+
...(input.slackId !== undefined && { slackId: input.slackId }),
|
|
619
|
+
...(input.trialExpiryWarnedAt !== undefined && {
|
|
620
|
+
trialExpiryWarnedAt: input.trialExpiryWarnedAt,
|
|
621
|
+
}),
|
|
622
|
+
...(input.autoPostReviews !== undefined && {
|
|
623
|
+
autoPostReviews: input.autoPostReviews,
|
|
624
|
+
}),
|
|
625
|
+
...(input.allowSelfReview !== undefined && {
|
|
626
|
+
allowSelfReview: input.allowSelfReview,
|
|
627
|
+
}),
|
|
628
|
+
...(input.minConfidence !== undefined && {
|
|
629
|
+
minConfidence: input.minConfidence,
|
|
630
|
+
}),
|
|
631
|
+
...(input.maxFindings !== undefined && {
|
|
632
|
+
maxFindings: input.maxFindings,
|
|
633
|
+
}),
|
|
634
|
+
...(input.cleanupMergedWorktrees !== undefined && {
|
|
635
|
+
cleanupMergedWorktrees: input.cleanupMergedWorktrees,
|
|
636
|
+
}),
|
|
637
|
+
...(input.cleanupAfterDays !== undefined && {
|
|
638
|
+
cleanupAfterDays: input.cleanupAfterDays,
|
|
639
|
+
}),
|
|
640
|
+
},
|
|
641
|
+
select: DETAIL_SELECT,
|
|
642
|
+
});
|
|
643
|
+
const missingRequiredEnv = await this.computeMissingRequiredEnv(
|
|
644
|
+
id,
|
|
645
|
+
row.typeName,
|
|
646
|
+
client,
|
|
647
|
+
);
|
|
648
|
+
return { ...row, missingRequiredEnv };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ─── createAgent() ────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Raw form-shaped input for createAgent() — mirrors exactly what
|
|
656
|
+
* admin-ui.ts's POST /admin/agents handler extracts from the submitted
|
|
657
|
+
* FormData, before any parsing/validation. createAgent() owns all of the
|
|
658
|
+
* parsing (multi-line repo/allowlist/member lists, dedup, format validation)
|
|
659
|
+
* that the old inline handler used to do itself, so there is exactly one
|
|
660
|
+
* implementation of agent creation (APA-1.1).
|
|
661
|
+
*/
|
|
662
|
+
export interface CreateAgentFormInput {
|
|
663
|
+
name: string | undefined;
|
|
664
|
+
typeName: string | undefined;
|
|
665
|
+
/**
|
|
666
|
+
* "in-cluster" means K8s-provisioned; anything else (including absent)
|
|
667
|
+
* means self-hosted — matches the pre-APA-1.1 handler's fallback rule.
|
|
668
|
+
*/
|
|
669
|
+
runtime: string | undefined;
|
|
670
|
+
reposRaw: string | undefined;
|
|
671
|
+
authorAllowlistRaw: string | undefined;
|
|
672
|
+
patchAuthorAllowlistRaw: string | undefined;
|
|
673
|
+
memberEmailsRaw: string | undefined;
|
|
674
|
+
/** Raw "true"/"false" form value — only the literal string "true" enables it. */
|
|
675
|
+
restrictSlackToMembersRaw: string | undefined;
|
|
676
|
+
claudeCodeOauthToken: string | undefined;
|
|
677
|
+
anthropicApiKey: string | undefined;
|
|
678
|
+
/**
|
|
679
|
+
* Owning Account (SSP-4.1). Service-level input only — callers must
|
|
680
|
+
* resolve it from the authenticated principal, never from a request body.
|
|
681
|
+
* When set, the account's status and maxAgents quota are enforced
|
|
682
|
+
* race-safely inside the creation transaction.
|
|
683
|
+
*/
|
|
684
|
+
accountId?: string;
|
|
685
|
+
/**
|
|
686
|
+
* Agent owner (PAI-1.2). Service-level input only — callers must resolve it
|
|
687
|
+
* from the authenticated principal, never from a request body. createAgent()
|
|
688
|
+
* trims and lower-cases it; blank is treated as absent.
|
|
689
|
+
*/
|
|
690
|
+
ownerEmail?: string;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Deliberately simple: one @, no whitespace, a dotted domain. Enough to keep
|
|
694
|
+
// a non-email admin key name out of the ownerEmail column.
|
|
695
|
+
const OWNER_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Resolve the agent owner for the JSON API (PAI-1.2): the session user's
|
|
699
|
+
* email when present; otherwise the bearer caller's name, but only if it is
|
|
700
|
+
* itself a valid email address (an admin API key named like one). Anything
|
|
701
|
+
* else — a plain key name or an agent id — yields undefined so a non-email
|
|
702
|
+
* never lands in the email column. Always lower-cased.
|
|
703
|
+
*/
|
|
704
|
+
export function resolveOwnerEmail(
|
|
705
|
+
callerEmail: string | undefined,
|
|
706
|
+
caller: { name: string } | undefined,
|
|
707
|
+
): string | undefined {
|
|
708
|
+
const fromSession = callerEmail?.trim().toLowerCase();
|
|
709
|
+
if (fromSession) return fromSession;
|
|
710
|
+
const name = caller?.name.trim().toLowerCase();
|
|
711
|
+
return name && OWNER_EMAIL_RE.test(name) ? name : undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Error codes matching the `?error=` query-param values the pre-APA-1.1
|
|
716
|
+
* inline handler redirected with — preserved 1:1 so createAgent() is a
|
|
717
|
+
* drop-in replacement: admin-ui.ts maps `errorCode` straight into the
|
|
718
|
+
* redirect URL (`/admin/agents/new?error=${errorCode}`).
|
|
719
|
+
*/
|
|
720
|
+
type CreateAgentErrorCode =
|
|
721
|
+
| "missing_fields"
|
|
722
|
+
| "invalid_type"
|
|
723
|
+
| "provisioning_disabled"
|
|
724
|
+
| "seed_failed"
|
|
725
|
+
| "invalid_repo_format"
|
|
726
|
+
| "repo_name_collision"
|
|
727
|
+
| "invalid_author_allowlist_format"
|
|
728
|
+
| "provision_failed"
|
|
729
|
+
| "quota_exceeded"
|
|
730
|
+
| "account_inactive"
|
|
731
|
+
| typeof ANTHROPIC_CREDENTIALS_BLOCKED_CODE;
|
|
732
|
+
|
|
733
|
+
type CreateAgentResult =
|
|
734
|
+
| { ok: true; agent: AgentDetail; restrictSlackToMembers: boolean }
|
|
735
|
+
| { ok: false; errorCode: CreateAgentErrorCode; errorMessage?: string };
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Returns a message naming both repos of the first same-basename collision
|
|
739
|
+
* (different owners) in `repos`, or null when there is none. `repos/<name>`
|
|
740
|
+
* is keyed by basename alone, so such repos would share one clone folder.
|
|
741
|
+
*/
|
|
742
|
+
export function describeRepoNameCollision(repos: string[]): string | null {
|
|
743
|
+
const [collision] = findRepoNameCollisions(repos);
|
|
744
|
+
if (!collision) return null;
|
|
745
|
+
return `repos ${collision.collidesWith} and ${collision.repo} share the same name — repos/<name> clones would collide`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Injected service dependencies for createAgent() — the same instances
|
|
750
|
+
* admin-ui.ts already constructs/receives via AdminUIDeps, narrowed to just
|
|
751
|
+
* the methods this function calls.
|
|
752
|
+
*/
|
|
753
|
+
export interface CreateAgentDeps {
|
|
754
|
+
agentService: Pick<
|
|
755
|
+
AgentService,
|
|
756
|
+
"create" | "delete" | "updateFields" | "runTransaction"
|
|
757
|
+
>;
|
|
758
|
+
agentToolService: Pick<AgentToolService, "add">;
|
|
759
|
+
agentPluginService: Pick<AgentPluginService, "add">;
|
|
760
|
+
agentMemberService: Pick<AgentMemberService, "add">;
|
|
761
|
+
agentEnvService: Pick<AgentEnvService, "patch">;
|
|
762
|
+
agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
|
|
763
|
+
provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
|
|
764
|
+
specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
|
|
765
|
+
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
766
|
+
anthropicFederationEnabled?: boolean;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Parses a textarea's newline-separated lines into a trimmed, non-empty
|
|
771
|
+
* list — optionally lower-cased and/or deduped. Mirrors the ad-hoc parsing
|
|
772
|
+
* the pre-APA-1.1 inline handler did separately for each of
|
|
773
|
+
* repos/authorAllowlist/patchAuthorAllowlist/memberEmails (repos was never
|
|
774
|
+
* deduped; the other three were).
|
|
775
|
+
*/
|
|
776
|
+
function parseLines(
|
|
777
|
+
raw: string,
|
|
778
|
+
opts: { lower?: boolean; dedup?: boolean } = {},
|
|
779
|
+
): string[] {
|
|
780
|
+
const lines = raw
|
|
781
|
+
.split(/\r?\n/)
|
|
782
|
+
.map((l) => (opts.lower ? l.trim().toLowerCase() : l.trim()))
|
|
783
|
+
.filter((l) => l.length > 0);
|
|
784
|
+
return opts.dedup ? [...new Set(lines)] : lines;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Locks the account row (SELECT ... FOR UPDATE) so concurrent creates for the
|
|
789
|
+
* same account serialize, then checks it is active and under its maxAgents
|
|
790
|
+
* quota. Returns the failing error code, or null when a slot is available.
|
|
791
|
+
* The lock is held until the surrounding transaction commits, so the count
|
|
792
|
+
* below cannot be invalidated by a concurrent create.
|
|
793
|
+
*/
|
|
794
|
+
async function checkAccountQuota(
|
|
795
|
+
tx: PrismaTransactionClient,
|
|
796
|
+
accountId: string,
|
|
797
|
+
): Promise<"account_inactive" | "quota_exceeded" | null> {
|
|
798
|
+
const rows = await tx.$queryRaw<{ status: string; maxAgents: number }[]>`
|
|
799
|
+
SELECT "status", "maxAgents" FROM "Account" WHERE "id" = ${accountId} FOR UPDATE`;
|
|
800
|
+
const account = rows[0];
|
|
801
|
+
if (!account) return "account_inactive";
|
|
802
|
+
if (account.status !== "active") return "account_inactive";
|
|
803
|
+
const count = await tx.agent.count({ where: { accountId } });
|
|
804
|
+
return count >= account.maxAgents ? "quota_exceeded" : null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Discriminated outcome of the transactional portion of createAgent(). */
|
|
808
|
+
type CreateAgentTxOutcome =
|
|
809
|
+
| { ok: true; agent: AgentDetail }
|
|
810
|
+
| { ok: false; errorCode: CreateAgentErrorCode; errorMessage?: string };
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Atomically create an agent: validate name/typeName, create the Agent row,
|
|
814
|
+
* seed AgentTool/AgentPlugin from the resolved type manifest, attach
|
|
815
|
+
* repos/allowlists/members, patch Claude credentials, provision Kubernetes
|
|
816
|
+
* resources when requested, and best-effort reconcile system crons.
|
|
817
|
+
*
|
|
818
|
+
* Extracted from admin-ui.ts's POST /admin/agents form handler (APA-1.1) so
|
|
819
|
+
* there is exactly one implementation of agent creation.
|
|
820
|
+
*
|
|
821
|
+
* All DB-writing steps — create, tool/plugin seeding, and attaching
|
|
822
|
+
* repos/allowlists/members — run inside a single interactive Prisma
|
|
823
|
+
* transaction via `agentService.runTransaction()`. A mid-sequence failure
|
|
824
|
+
* (seeding throws, or a repo/allowlist fails format validation) is handled
|
|
825
|
+
* by an explicit compensating `agentService.delete()` call *inside that same
|
|
826
|
+
* transaction*, followed by a normal (non-throwing) return — the delete
|
|
827
|
+
* commits atomically alongside everything that preceded it, so external
|
|
828
|
+
* readers never observe the agent in a half-seeded state and zero rows
|
|
829
|
+
* persist once the transaction commits. This preserves the pre-APA-1.1
|
|
830
|
+
* behavior of explicitly calling delete() on failure (observable by
|
|
831
|
+
* callers/tests that inject their own AgentService) while still giving the
|
|
832
|
+
* whole sequence true single-transaction atomicity.
|
|
833
|
+
*
|
|
834
|
+
* Kubernetes provisioning is an external side effect that cannot join that
|
|
835
|
+
* transaction: it runs after the transaction has already committed, and on
|
|
836
|
+
* failure the just-created Agent row is explicitly deleted as a separate
|
|
837
|
+
* statement — matching the pre-APA-1.1 delete-on-failure behavior exactly.
|
|
838
|
+
*
|
|
839
|
+
* reconcileSystemCrons() runs last and is best-effort/non-blocking —
|
|
840
|
+
* failures are logged, never surfaced as an error (mirrors the same call at
|
|
841
|
+
* agent boot, agent/src/index.ts).
|
|
842
|
+
*/
|
|
843
|
+
export async function createAgent(
|
|
844
|
+
deps: CreateAgentDeps,
|
|
845
|
+
input: CreateAgentFormInput,
|
|
846
|
+
): Promise<CreateAgentResult> {
|
|
847
|
+
const name = input.name;
|
|
848
|
+
if (!name) {
|
|
849
|
+
return { ok: false, errorCode: "missing_fields" };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Resolve the requested type BEFORE creating any row — an unknown/missing
|
|
853
|
+
// type must fail with zero rows created. The resolved manifest is
|
|
854
|
+
// captured (not discarded) so its tools/plugins can be seeded below.
|
|
855
|
+
const typeName = input.typeName;
|
|
856
|
+
const manifest = typeName
|
|
857
|
+
? deps.specialtyRegistry.tryGetManifest(typeName)
|
|
858
|
+
: undefined;
|
|
859
|
+
if (!typeName || !manifest) {
|
|
860
|
+
return { ok: false, errorCode: "invalid_type" };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Absent/unrecognized runtime means self-hosted — the historical behavior
|
|
864
|
+
// of this form.
|
|
865
|
+
const inCluster = input.runtime === "in-cluster";
|
|
866
|
+
// Same "validate before creating any row" rule as the type check above: a
|
|
867
|
+
// no-op provisioner would let provision() succeed while creating nothing,
|
|
868
|
+
// leaving an agent row with no workload behind it.
|
|
869
|
+
if (inCluster && !deps.provisioner.canProvision) {
|
|
870
|
+
return { ok: false, errorCode: "provisioning_disabled" };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// WIF-2.1: federated (in-cluster) agents must never carry Anthropic
|
|
874
|
+
// credentials — reject before any row is created.
|
|
875
|
+
if (
|
|
876
|
+
deps.anthropicFederationEnabled &&
|
|
877
|
+
inCluster &&
|
|
878
|
+
(input.claudeCodeOauthToken || input.anthropicApiKey)
|
|
879
|
+
) {
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
errorCode: ANTHROPIC_CREDENTIALS_BLOCKED_CODE,
|
|
883
|
+
errorMessage: `${ANTHROPIC_CREDENTIALS_BLOCKED_CODE}: Claude credentials cannot be set on an agent that uses Anthropic workload identity federation`,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const restrictSlackToMembers = input.restrictSlackToMembersRaw === "true";
|
|
888
|
+
const ownerEmail = input.ownerEmail?.trim().toLowerCase() || undefined;
|
|
889
|
+
|
|
890
|
+
const txResult = await deps.agentService.runTransaction<CreateAgentTxOutcome>(
|
|
891
|
+
async (tx) => {
|
|
892
|
+
// Quota enforcement must precede the create so a rejected request
|
|
893
|
+
// writes nothing. Skipped when tx is undefined (admin-ui.ts test
|
|
894
|
+
// doubles without runTransaction pass no real transaction).
|
|
895
|
+
if (input.accountId !== undefined && tx) {
|
|
896
|
+
const denied = await checkAccountQuota(tx, input.accountId);
|
|
897
|
+
if (denied) return { ok: false, errorCode: denied };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const agent = await deps.agentService.create(
|
|
901
|
+
{
|
|
902
|
+
name,
|
|
903
|
+
selfHosted: !inCluster,
|
|
904
|
+
typeName,
|
|
905
|
+
restrictSlackToMembers,
|
|
906
|
+
...(input.accountId !== undefined
|
|
907
|
+
? { accountId: input.accountId }
|
|
908
|
+
: {}),
|
|
909
|
+
...(ownerEmail !== undefined ? { ownerEmail } : {}),
|
|
910
|
+
},
|
|
911
|
+
tx,
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
// Seed AgentTool/AgentPlugin rows from the resolved manifest. Roll the
|
|
915
|
+
// agent row back on any seeding failure so a retry with the same name
|
|
916
|
+
// doesn't collide with a half-seeded agent. Members/repos are NOT
|
|
917
|
+
// seeded from the manifest here — they have their own dedicated
|
|
918
|
+
// handling below, and the manifest's members/repos arrays are empty
|
|
919
|
+
// for every specialty that exists today.
|
|
920
|
+
try {
|
|
921
|
+
for (const pattern of manifest.tools) {
|
|
922
|
+
await deps.agentToolService.add(agent.id, pattern, tx);
|
|
923
|
+
}
|
|
924
|
+
for (const pluginName of manifest.plugins) {
|
|
925
|
+
await deps.agentPluginService.add(
|
|
926
|
+
agent.id,
|
|
927
|
+
pluginName,
|
|
928
|
+
undefined,
|
|
929
|
+
tx,
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
} catch (err) {
|
|
933
|
+
console.error(
|
|
934
|
+
"[agents] createAgent: tool/plugin seeding failed, rolling back:",
|
|
935
|
+
err,
|
|
936
|
+
);
|
|
937
|
+
await deps.agentService.delete(agent.id, tx).catch((cleanupErr) => {
|
|
938
|
+
console.error(
|
|
939
|
+
"[agents] createAgent: failed to roll back agent after seeding error:",
|
|
940
|
+
cleanupErr,
|
|
941
|
+
);
|
|
942
|
+
});
|
|
943
|
+
return { ok: false, errorCode: "seed_failed" };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Attach repos if provided.
|
|
947
|
+
if (input.reposRaw) {
|
|
948
|
+
const repos = parseLines(input.reposRaw);
|
|
949
|
+
const invalid = repos.filter((r) => !isOrgRepo(r));
|
|
950
|
+
if (invalid.length > 0) {
|
|
951
|
+
await deps.agentService.delete(agent.id, tx);
|
|
952
|
+
return { ok: false, errorCode: "invalid_repo_format" };
|
|
953
|
+
}
|
|
954
|
+
const collision = describeRepoNameCollision(repos);
|
|
955
|
+
if (collision) {
|
|
956
|
+
await deps.agentService.delete(agent.id, tx);
|
|
957
|
+
return {
|
|
958
|
+
ok: false,
|
|
959
|
+
errorCode: "repo_name_collision",
|
|
960
|
+
errorMessage: collision,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
if (repos.length > 0) {
|
|
964
|
+
await deps.agentService.updateFields(agent.id, { repos }, tx);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Attach authorAllowlist if provided.
|
|
969
|
+
if (input.authorAllowlistRaw) {
|
|
970
|
+
const authorAllowlist = parseLines(input.authorAllowlistRaw, {
|
|
971
|
+
dedup: true,
|
|
972
|
+
});
|
|
973
|
+
const invalid = authorAllowlist.filter((l) => !isGithubLogin(l));
|
|
974
|
+
if (invalid.length > 0) {
|
|
975
|
+
await deps.agentService.delete(agent.id, tx);
|
|
976
|
+
return { ok: false, errorCode: "invalid_author_allowlist_format" };
|
|
977
|
+
}
|
|
978
|
+
if (authorAllowlist.length > 0) {
|
|
979
|
+
await deps.agentService.updateFields(
|
|
980
|
+
agent.id,
|
|
981
|
+
{ reviewAuthorAllowlist: authorAllowlist },
|
|
982
|
+
tx,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Attach patchAuthorAllowlist if provided.
|
|
988
|
+
if (input.patchAuthorAllowlistRaw) {
|
|
989
|
+
const patchAuthorAllowlist = parseLines(input.patchAuthorAllowlistRaw, {
|
|
990
|
+
dedup: true,
|
|
991
|
+
});
|
|
992
|
+
const invalid = patchAuthorAllowlist.filter((l) => !isGithubLogin(l));
|
|
993
|
+
if (invalid.length > 0) {
|
|
994
|
+
await deps.agentService.delete(agent.id, tx);
|
|
995
|
+
return { ok: false, errorCode: "invalid_author_allowlist_format" };
|
|
996
|
+
}
|
|
997
|
+
if (patchAuthorAllowlist.length > 0) {
|
|
998
|
+
await deps.agentService.updateFields(
|
|
999
|
+
agent.id,
|
|
1000
|
+
{ patchAuthorAllowlist },
|
|
1001
|
+
tx,
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Attach member emails if provided — best-effort, mirrors the
|
|
1007
|
+
// single-add POST /admin/agents/:id/members route: no format
|
|
1008
|
+
// validation, and a failed/duplicate add is silently ignored rather
|
|
1009
|
+
// than rolling back the agent.
|
|
1010
|
+
if (input.memberEmailsRaw) {
|
|
1011
|
+
const memberEmails = parseLines(input.memberEmailsRaw, {
|
|
1012
|
+
lower: true,
|
|
1013
|
+
dedup: true,
|
|
1014
|
+
});
|
|
1015
|
+
for (const email of memberEmails) {
|
|
1016
|
+
try {
|
|
1017
|
+
await deps.agentMemberService.add(agent.id, email, tx);
|
|
1018
|
+
} catch {
|
|
1019
|
+
// unique constraint violation — already a member, ignore
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return { ok: true, agent };
|
|
1025
|
+
},
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
if (!txResult.ok) {
|
|
1029
|
+
return {
|
|
1030
|
+
ok: false,
|
|
1031
|
+
errorCode: txResult.errorCode,
|
|
1032
|
+
errorMessage: txResult.errorMessage,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
const agent = txResult.agent;
|
|
1036
|
+
|
|
1037
|
+
// Store the Claude credentials before provisioning so the pod comes up
|
|
1038
|
+
// with them already in its env bundle rather than failing its first turn.
|
|
1039
|
+
// Deliberately outside the transaction above (AgentEnvService.patch runs
|
|
1040
|
+
// its own internal transaction) and not rolled back on failure, matching
|
|
1041
|
+
// pre-APA-1.1 behavior: an uncaught error here propagates to the caller,
|
|
1042
|
+
// leaving the already-committed agent row in place.
|
|
1043
|
+
// Non-secret defaults (e.g. ANTHROPIC_MODEL) are seeded alongside so they
|
|
1044
|
+
// show up in the agent's env view; the secret credentials below are
|
|
1045
|
+
// operator-supplied only and never defaulted.
|
|
1046
|
+
const initialEnv: Record<string, string> = { ...DEFAULT_AGENT_ENV };
|
|
1047
|
+
if (input.claudeCodeOauthToken) {
|
|
1048
|
+
initialEnv.CLAUDE_CODE_OAUTH_TOKEN = input.claudeCodeOauthToken;
|
|
1049
|
+
}
|
|
1050
|
+
if (input.anthropicApiKey) {
|
|
1051
|
+
initialEnv.ANTHROPIC_API_KEY = input.anthropicApiKey;
|
|
1052
|
+
}
|
|
1053
|
+
await deps.agentEnvService.patch(
|
|
1054
|
+
agent.id,
|
|
1055
|
+
initialEnv,
|
|
1056
|
+
new Set(SECRET_ENV_VARS),
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (inCluster) {
|
|
1060
|
+
// Kubernetes provisioning is an external side effect that cannot join
|
|
1061
|
+
// the DB transaction above — it runs after that transaction has already
|
|
1062
|
+
// committed. Roll the row back explicitly on failure so a retry with the
|
|
1063
|
+
// same name doesn't collide with a half-created agent.
|
|
1064
|
+
try {
|
|
1065
|
+
await deps.provisioner.provision(agent.id, {
|
|
1066
|
+
slug: agent.name,
|
|
1067
|
+
accountId: agent.accountId,
|
|
1068
|
+
});
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
console.error(
|
|
1071
|
+
"[agents] createAgent: provisioning failed, rolling back:",
|
|
1072
|
+
err,
|
|
1073
|
+
);
|
|
1074
|
+
await deps.agentService.delete(agent.id).catch((cleanupErr) => {
|
|
1075
|
+
console.error(
|
|
1076
|
+
"[agents] createAgent: failed to roll back agent after provision error:",
|
|
1077
|
+
cleanupErr,
|
|
1078
|
+
);
|
|
1079
|
+
});
|
|
1080
|
+
return { ok: false, errorCode: "provision_failed" };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Best-effort, mirroring the same call at agent boot (agent/src/index.ts).
|
|
1085
|
+
// reconcileSystemCrons is a full three-pass reconcile, so a second run is a
|
|
1086
|
+
// no-op — and failing here would strand the operator next to a live agent.
|
|
1087
|
+
try {
|
|
1088
|
+
await deps.agentCronJobService.reconcileSystemCrons(agent.id);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.error(
|
|
1091
|
+
"[agents] createAgent: failed to seed system crons (non-fatal):",
|
|
1092
|
+
err,
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return { ok: true, agent, restrictSlackToMembers };
|
|
1097
|
+
}
|