@app-vitals/agent-admin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +64 -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,2826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/agents-api.ts
|
|
3
|
+
* Admin CRUD API — OpenAPIHono app factory.
|
|
4
|
+
*
|
|
5
|
+
* Routes mounted at /agents/*. Full CRUD for:
|
|
6
|
+
* - Agent (create/read/update/delete — POST /agents (APA-2.1) and the web
|
|
7
|
+
* UI form at /admin/agents/new (see admin-ui.ts) both call the same
|
|
8
|
+
* createAgent() implementation (agents.ts, APA-1.1), so there is exactly
|
|
9
|
+
* one implementation of agent creation with two callers)
|
|
10
|
+
* - AgentEnv
|
|
11
|
+
* - AgentCronJob
|
|
12
|
+
* - AgentTool
|
|
13
|
+
* - AgentToken
|
|
14
|
+
* - AgentPlugin
|
|
15
|
+
*
|
|
16
|
+
* Routes are declared via createRoute() with Zod request/response schemas
|
|
17
|
+
* (see openapi-schemas.ts). Malformed request bodies are rejected with 400 by
|
|
18
|
+
* the OpenAPIHono defaultHook before reaching handlers.
|
|
19
|
+
*
|
|
20
|
+
* Auth: admin key (SHIPWRIGHT_ADMIN_API_KEYS) OR per-agent bearer token OR
|
|
21
|
+
* session cookie (httpOnly JWT, SHIPWRIGHT_SESSION_SECRET).
|
|
22
|
+
* Cookie name: admin_session.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
isPoolTimeoutError,
|
|
27
|
+
type PoolLike,
|
|
28
|
+
poolErrorContext,
|
|
29
|
+
routeContextMiddleware,
|
|
30
|
+
} from "@app-vitals/agent-lib/pool-observability";
|
|
31
|
+
import { callerLabel } from "@app-vitals/agent-lib/request-context";
|
|
32
|
+
import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
|
|
33
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
34
|
+
import { HTTPException } from "hono/http-exception";
|
|
35
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
36
|
+
import {
|
|
37
|
+
ACCOUNT_CREATE_ERROR_MESSAGES,
|
|
38
|
+
accountIdFromScope,
|
|
39
|
+
getSelfServeSpecialty,
|
|
40
|
+
isAccountCreateErrorCode,
|
|
41
|
+
} from "./account-agent-create.ts";
|
|
42
|
+
import { reconcileAgentAfterAccountChange } from "./agent-account-assignment.ts";
|
|
43
|
+
import {
|
|
44
|
+
type AgentAskDeps,
|
|
45
|
+
type AskChatClient,
|
|
46
|
+
registerAgentAskRoutes,
|
|
47
|
+
} from "./agent-ask.ts";
|
|
48
|
+
import type { AgentChatTokenService } from "./agent-chat-tokens.ts";
|
|
49
|
+
import type {
|
|
50
|
+
AgentCronJobService,
|
|
51
|
+
AgentCronJobWithRunSummary,
|
|
52
|
+
} from "./agent-cron-jobs.ts";
|
|
53
|
+
import type { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
|
|
54
|
+
import type { AgentCronRunService } from "./agent-cron-runs.ts";
|
|
55
|
+
import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
|
|
56
|
+
import { deleteAgentFully } from "./agent-deletion.ts";
|
|
57
|
+
import type { AgentEnvService } from "./agent-envs.ts";
|
|
58
|
+
import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
|
|
59
|
+
import type { AgentMemberService } from "./agent-members.ts";
|
|
60
|
+
import type { AgentPluginService } from "./agent-plugins.ts";
|
|
61
|
+
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
62
|
+
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
63
|
+
import type { AgentToolService } from "./agent-tools.ts";
|
|
64
|
+
import type { AgentService } from "./agents.ts";
|
|
65
|
+
import {
|
|
66
|
+
createAgent,
|
|
67
|
+
describeRepoNameCollision,
|
|
68
|
+
resolveOwnerEmail,
|
|
69
|
+
} from "./agents.ts";
|
|
70
|
+
import {
|
|
71
|
+
ANTHROPIC_CREDENTIALS_BLOCKED_CODE,
|
|
72
|
+
AnthropicCredentialsBlockedError,
|
|
73
|
+
anthropicCredentialKeysIn,
|
|
74
|
+
} from "./anthropic-credential-guard.ts";
|
|
75
|
+
import type { AdminApiKey, AdminAuthEnv, AdminScope } from "./api-auth.ts";
|
|
76
|
+
import {
|
|
77
|
+
createAdminAuthMiddleware,
|
|
78
|
+
parseAdminApiKeys,
|
|
79
|
+
requireScope,
|
|
80
|
+
} from "./api-auth.ts";
|
|
81
|
+
import {
|
|
82
|
+
type CallerScopeResolver,
|
|
83
|
+
memberOnlyCallerScopeResolver,
|
|
84
|
+
} from "./caller-scope.ts";
|
|
85
|
+
import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
86
|
+
import {
|
|
87
|
+
ApiError,
|
|
88
|
+
BadRequestError,
|
|
89
|
+
ForbiddenError,
|
|
90
|
+
NotFoundError,
|
|
91
|
+
} from "./errors.ts";
|
|
92
|
+
import {
|
|
93
|
+
AddAgentReposBodySchema,
|
|
94
|
+
AgentChatTokenUsageDailySchema,
|
|
95
|
+
AgentCronJobSchema,
|
|
96
|
+
AgentCronRunSchema,
|
|
97
|
+
AgentEnvBodySchema,
|
|
98
|
+
AgentEnvPatchBodySchema,
|
|
99
|
+
AgentEnvResponseSchema,
|
|
100
|
+
AgentGitHubInstallationsSnapshotSchema,
|
|
101
|
+
AgentIdParamSchema,
|
|
102
|
+
AgentPluginSchema,
|
|
103
|
+
AgentRepoParamSchema,
|
|
104
|
+
AgentSummarySchema,
|
|
105
|
+
AgentTokenSchema,
|
|
106
|
+
AgentToolSchema,
|
|
107
|
+
ChatCredentialsWrapperSchema,
|
|
108
|
+
ChatTokenStatsSchema,
|
|
109
|
+
CreateAgentBodySchema,
|
|
110
|
+
CreateAgentCronJobBodySchema,
|
|
111
|
+
CreateAgentCronRunBodySchema,
|
|
112
|
+
CreateAgentPluginBodySchema,
|
|
113
|
+
CreateAgentTokenBodySchema,
|
|
114
|
+
CreateAgentTokenResponseSchema,
|
|
115
|
+
CreateAgentToolBodySchema,
|
|
116
|
+
CreateChatCredentialBodySchema,
|
|
117
|
+
CreateChatCredentialResponseSchema,
|
|
118
|
+
CronIdParamSchema,
|
|
119
|
+
CronRunIdParamSchema,
|
|
120
|
+
CronRunOutcomesSchema,
|
|
121
|
+
CronRunsListSchema,
|
|
122
|
+
CronRunTokenStatsSchema,
|
|
123
|
+
CronsWithSummaryWrapperSchema,
|
|
124
|
+
DeleteAgentBodySchema,
|
|
125
|
+
DeleteAgentResultSchema,
|
|
126
|
+
EnvKeyParamSchema,
|
|
127
|
+
ErrorSchema,
|
|
128
|
+
ListCronRunsQuerySchema,
|
|
129
|
+
type ModelBreakdownEntry,
|
|
130
|
+
OkSchema,
|
|
131
|
+
PatchAgentBodySchema,
|
|
132
|
+
PatchAgentCronJobBodySchema,
|
|
133
|
+
PatchAgentCronRunBodySchema,
|
|
134
|
+
PatchAgentPluginBodySchema,
|
|
135
|
+
PatchAgentToolBodySchema,
|
|
136
|
+
PluginNameQuerySchema,
|
|
137
|
+
PutGitHubInstallationsBodySchema,
|
|
138
|
+
TokenIdParamSchema,
|
|
139
|
+
ToolIdParamSchema,
|
|
140
|
+
UpsertChatTokenDailyBodySchema,
|
|
141
|
+
} from "./openapi-schemas.ts";
|
|
142
|
+
import type { SelfServeConfig } from "./self-serve-config.ts";
|
|
143
|
+
import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
|
|
144
|
+
import { managedReconcileTargets } from "./startup-policy-reconcile.ts";
|
|
145
|
+
|
|
146
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface AdminDeps {
|
|
149
|
+
agentService: Pick<
|
|
150
|
+
AgentService,
|
|
151
|
+
| "create"
|
|
152
|
+
| "delete"
|
|
153
|
+
| "list"
|
|
154
|
+
| "getSummary"
|
|
155
|
+
| "getDetail"
|
|
156
|
+
| "exists"
|
|
157
|
+
| "updateSelfHosted"
|
|
158
|
+
// "updateFields" and "runTransaction" are only needed by createAgent()
|
|
159
|
+
// (POST /agents, APA-2.1) — everything else in this file already went
|
|
160
|
+
// through the narrower Pick above.
|
|
161
|
+
| "updateFields"
|
|
162
|
+
| "runTransaction"
|
|
163
|
+
>;
|
|
164
|
+
agentEnvService: Pick<
|
|
165
|
+
AgentEnvService,
|
|
166
|
+
"upsert" | "patch" | "getByAgentId" | "deleteKey"
|
|
167
|
+
>;
|
|
168
|
+
agentCronJobService: Pick<
|
|
169
|
+
AgentCronJobService,
|
|
170
|
+
| "list"
|
|
171
|
+
| "listWithRunSummary"
|
|
172
|
+
| "create"
|
|
173
|
+
| "update"
|
|
174
|
+
| "delete"
|
|
175
|
+
| "reconcileSystemCrons"
|
|
176
|
+
| "get"
|
|
177
|
+
| "setEnabled"
|
|
178
|
+
| "updatePreCheck"
|
|
179
|
+
>;
|
|
180
|
+
agentCronRunService: Pick<AgentCronRunService, "create" | "list" | "patch">;
|
|
181
|
+
agentCronRunStatsService: Pick<
|
|
182
|
+
AgentCronRunStatsService,
|
|
183
|
+
"query" | "outcomes"
|
|
184
|
+
>;
|
|
185
|
+
agentToolService: Pick<
|
|
186
|
+
AgentToolService,
|
|
187
|
+
"list" | "add" | "remove" | "toggle"
|
|
188
|
+
>;
|
|
189
|
+
agentTokenService: Pick<
|
|
190
|
+
AgentTokenService,
|
|
191
|
+
"create" | "listForAgent" | "revoke" | "validate"
|
|
192
|
+
>;
|
|
193
|
+
agentPluginService: Pick<
|
|
194
|
+
AgentPluginService,
|
|
195
|
+
"list" | "add" | "remove" | "removeByName"
|
|
196
|
+
>;
|
|
197
|
+
agentMemberService: Pick<
|
|
198
|
+
AgentMemberService,
|
|
199
|
+
"add" | "listByAgentId" | "exists" | "listByEmail"
|
|
200
|
+
>;
|
|
201
|
+
/** Account-aware caller-scope resolver (SSP-2.1); defaults to AgentMember-only. */
|
|
202
|
+
callerScopeResolver?: CallerScopeResolver;
|
|
203
|
+
/**
|
|
204
|
+
* Resolves a specialty name to its parsed Specialty manifest. Consumed
|
|
205
|
+
* by POST /agents (createAgentRoute, APA-2.1) to seed AgentTool/AgentPlugin
|
|
206
|
+
* rows from the resolved manifest during agent creation — the same
|
|
207
|
+
* createAgent() call admin-ui.ts's POST /admin/agents form handler makes
|
|
208
|
+
* with its own independently-constructed specialtyRegistry instance.
|
|
209
|
+
* Required (not optional) because the route needs it unconditionally.
|
|
210
|
+
*/
|
|
211
|
+
specialtyRegistry: SpecialtyManifestResolver;
|
|
212
|
+
agentChatTokenService: Pick<
|
|
213
|
+
AgentChatTokenService,
|
|
214
|
+
"upsertDailyByModel" | "queryStats"
|
|
215
|
+
>;
|
|
216
|
+
agentGitHubInstallationsService: Pick<
|
|
217
|
+
AgentGitHubInstallationsService,
|
|
218
|
+
"push" | "get"
|
|
219
|
+
>;
|
|
220
|
+
/**
|
|
221
|
+
* Retained solely as a pass-through to deleteAgentFully() (DELETE /agents/:id),
|
|
222
|
+
* which has its own internal prisma.agent/prisma.agentEnv access — out of
|
|
223
|
+
* scope for the AgentService migration. All other route handlers in this
|
|
224
|
+
* file go through `agentService` instead of touching `prisma.agent.*`
|
|
225
|
+
* directly.
|
|
226
|
+
*/
|
|
227
|
+
prisma: Pick<PrismaClient, "agent" | "agentEnv">;
|
|
228
|
+
/**
|
|
229
|
+
* Provisions (and tears down) the workload backing an agent. Defaults to a
|
|
230
|
+
* no-op when Kubernetes provisioning is disabled (preserving create/delete
|
|
231
|
+
* behavior without a cluster). See `agent-provisioner.ts`.
|
|
232
|
+
*/
|
|
233
|
+
provisioner: AgentProvisioner;
|
|
234
|
+
/**
|
|
235
|
+
* Cleanup deps for the full delete-agent orchestration (DELETE /agents/:id
|
|
236
|
+
* → deleteAgentFully()). Matches DeleteAgentFullyDeps's shapes exactly so
|
|
237
|
+
* this app can pass them straight through. Production wiring in main.ts
|
|
238
|
+
* defaults chatService to a no-op client and passes the extension-registered
|
|
239
|
+
* serviceIntegrations (task-store: a no-op client) when their respective when their respective
|
|
240
|
+
* URL/token env vars are unset (independent of SHIPWRIGHT_K8S_PROVISIONING),
|
|
241
|
+
* so this stays required rather than optional — there is always a valid
|
|
242
|
+
* (possibly no-op) value to inject.
|
|
243
|
+
*/
|
|
244
|
+
serviceIntegrations: DeleteAgentFullyDeps["serviceIntegrations"];
|
|
245
|
+
chatService: DeleteAgentFullyDeps["chatService"];
|
|
246
|
+
slack: DeleteAgentFullyDeps["slack"];
|
|
247
|
+
decrypt: DeleteAgentFullyDeps["decrypt"];
|
|
248
|
+
sessionSecret: string;
|
|
249
|
+
/** Parsed SHIPWRIGHT_ADMIN_API_KEYS — optional; absent means env key auth is disabled. */
|
|
250
|
+
adminApiKeys?: Map<string, AdminApiKey>;
|
|
251
|
+
/**
|
|
252
|
+
* Optional Sentry client for reporting unhandled errors. Undefined means
|
|
253
|
+
* Sentry is not initialized (SENTRY_DSN unset) — onError simply skips the
|
|
254
|
+
* capture call. Production wiring in main.ts passes the real `Sentry` from
|
|
255
|
+
* `@sentry/bun` only when SENTRY_DSN is set.
|
|
256
|
+
*/
|
|
257
|
+
sentryClient?: ErrorCapturingClient;
|
|
258
|
+
/**
|
|
259
|
+
* Admin's pg pool (PLO-1.3). When present, a pool-timeout error captured in
|
|
260
|
+
* onError carries pool stats context plus route/server_name tags.
|
|
261
|
+
*/
|
|
262
|
+
pool?: PoolLike;
|
|
263
|
+
/** Resolved self-serve provisioning config (SSP-1.3). No consumers yet. */
|
|
264
|
+
selfServe?: SelfServeConfig;
|
|
265
|
+
/**
|
|
266
|
+
* Chat-credential mint flag (SHIPWRIGHT_CHAT_TOKEN_MINT_ENABLED === "enabled",
|
|
267
|
+
* see chat-credential-config.ts). Off (default) → the chat-credentials
|
|
268
|
+
* routes 404.
|
|
269
|
+
*/
|
|
270
|
+
chatCredentialsEnabled?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Chat-service client behind the chat-credentials routes. Absent (chat
|
|
273
|
+
* service not configured) → the routes 503. Distinct from `chatService`,
|
|
274
|
+
* which is the always-present (possibly no-op) deletion-cleanup client.
|
|
275
|
+
*/
|
|
276
|
+
chatCredentials?: Pick<
|
|
277
|
+
ChatServiceProvisioningClient,
|
|
278
|
+
"mintToken" | "revokeToken" | "listTokensForAgent"
|
|
279
|
+
>;
|
|
280
|
+
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
281
|
+
anthropicFederationEnabled?: boolean;
|
|
282
|
+
/**
|
|
283
|
+
* Agent-to-agent ask (COS-31.1): flag + allowlist config, the AgentAsk
|
|
284
|
+
* audit store and an injectable clock. Absent or flag off → the ask routes
|
|
285
|
+
* 404. See agent-ask.ts.
|
|
286
|
+
*/
|
|
287
|
+
agentAsk?: AgentAskDeps;
|
|
288
|
+
/**
|
|
289
|
+
* Chat-service thread/message client behind POST /agents/:id/ask
|
|
290
|
+
* (admin-boot wires the HttpChatClient the chat UI uses). Absent → the ask
|
|
291
|
+
* route 503s, even with the flag on.
|
|
292
|
+
*/
|
|
293
|
+
chatClient?: AskChatClient;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export type { AdminApiKey };
|
|
297
|
+
// Re-export for callers that need to build the map from an env string.
|
|
298
|
+
export { parseAdminApiKeys };
|
|
299
|
+
|
|
300
|
+
// ─── Response schema helpers ────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
const ReconcileCronResultSchema = z
|
|
303
|
+
.object({
|
|
304
|
+
created: z.number().int(),
|
|
305
|
+
updated: z.number().int(),
|
|
306
|
+
deleted: z.number().int(),
|
|
307
|
+
})
|
|
308
|
+
.openapi("ReconcileCronResult");
|
|
309
|
+
|
|
310
|
+
const ReconcileAgentsResultSchema = z
|
|
311
|
+
.object({
|
|
312
|
+
recreated: z.array(z.string()),
|
|
313
|
+
updated: z.array(z.string()),
|
|
314
|
+
orphans: z.array(z.string()),
|
|
315
|
+
failed: z.array(z.object({ agentId: z.string(), error: z.string() })),
|
|
316
|
+
migrated: z.array(z.string()).openapi({
|
|
317
|
+
description:
|
|
318
|
+
"perAgent identity mode: agents moved (or, in a dry run, that would be moved) onto their own newly created identity",
|
|
319
|
+
}),
|
|
320
|
+
noOwner: z.array(z.string()).openapi({
|
|
321
|
+
description:
|
|
322
|
+
"perAgent identity mode: agents skipped because they have no owner; left unmodified",
|
|
323
|
+
}),
|
|
324
|
+
dryRun: z.boolean(),
|
|
325
|
+
})
|
|
326
|
+
.openapi("ReconcileAgentsResult");
|
|
327
|
+
|
|
328
|
+
const ReconcileAgentsQuerySchema = z
|
|
329
|
+
.object({
|
|
330
|
+
dryRun: z.enum(["true", "false"]).default("false").openapi({
|
|
331
|
+
description:
|
|
332
|
+
"Defaults to false (reconcile for real). Pass true to report what would be recreated, updated and moved to per-agent identity without changing anything.",
|
|
333
|
+
}),
|
|
334
|
+
})
|
|
335
|
+
.openapi("ReconcileAgentsQuery");
|
|
336
|
+
|
|
337
|
+
const StripCredentialsQuerySchema = z
|
|
338
|
+
.object({
|
|
339
|
+
dryRun: z.enum(["true", "false"]).default("true").openapi({
|
|
340
|
+
description:
|
|
341
|
+
"Defaults to true (report only). Pass false to delete the keys.",
|
|
342
|
+
}),
|
|
343
|
+
})
|
|
344
|
+
.openapi("StripCredentialsQuery");
|
|
345
|
+
|
|
346
|
+
const StripCredentialsResultSchema = z
|
|
347
|
+
.object({
|
|
348
|
+
dryRun: z.boolean(),
|
|
349
|
+
agents: z.array(
|
|
350
|
+
z.object({ agentId: z.string(), keys: z.array(z.string()) }),
|
|
351
|
+
),
|
|
352
|
+
})
|
|
353
|
+
.openapi("StripCredentialsResult");
|
|
354
|
+
|
|
355
|
+
const ProvisionAgentResultSchema = z
|
|
356
|
+
.object({
|
|
357
|
+
resourceName: z.string(),
|
|
358
|
+
secretName: z.string(),
|
|
359
|
+
deploymentName: z.string(),
|
|
360
|
+
})
|
|
361
|
+
.openapi("ProvisionAgentResult");
|
|
362
|
+
|
|
363
|
+
const ProvisionSkippedResultSchema = z
|
|
364
|
+
.object({
|
|
365
|
+
skipped: z.literal(true),
|
|
366
|
+
reason: z.string(),
|
|
367
|
+
})
|
|
368
|
+
.openapi("ProvisionSkippedResult");
|
|
369
|
+
|
|
370
|
+
const GetAgentResultSchema = z
|
|
371
|
+
.object({
|
|
372
|
+
id: z.string(),
|
|
373
|
+
name: z.string(),
|
|
374
|
+
slackId: z.string().nullable().optional(),
|
|
375
|
+
selfHosted: z.boolean(),
|
|
376
|
+
repos: z.array(z.string()),
|
|
377
|
+
reviewAuthorAllowlist: z.array(z.string()),
|
|
378
|
+
patchAuthorAllowlist: z.array(z.string()),
|
|
379
|
+
restrictSlackToMembers: z.boolean(),
|
|
380
|
+
typeName: z.string(),
|
|
381
|
+
/** Owning account; null for unassigned agents (task-store maps null to its 'default' account). */
|
|
382
|
+
accountId: z.string().nullable(),
|
|
383
|
+
createdAt: z.string().datetime(),
|
|
384
|
+
updatedAt: z.string().datetime(),
|
|
385
|
+
/**
|
|
386
|
+
* Required env keys declared by the agent's type manifest that have no
|
|
387
|
+
* corresponding AgentEnv row yet — key names only, never values
|
|
388
|
+
* (secrets_in_logs). Always present; informational only (ATS-4.2).
|
|
389
|
+
*/
|
|
390
|
+
missingRequiredEnv: z.array(z.string()),
|
|
391
|
+
/**
|
|
392
|
+
* Non-blocking warning surfaced when restrictSlackToMembers is set to
|
|
393
|
+
* true on an agent with zero AgentMember rows — the save still succeeds,
|
|
394
|
+
* this is informational only.
|
|
395
|
+
*/
|
|
396
|
+
warning: z.string().optional(),
|
|
397
|
+
/**
|
|
398
|
+
* ATE-1.1: trial expiry tracking. trialExpiresAt is settable via
|
|
399
|
+
* PATCH /agents/:id (see PatchAgentBodySchema). trialExpiryWarnedAt is
|
|
400
|
+
* written internally by ATE-2.1's warning check — read-only via this
|
|
401
|
+
* route, GET/PATCH responses only.
|
|
402
|
+
*/
|
|
403
|
+
trialExpiresAt: z.string().datetime().nullable().optional(),
|
|
404
|
+
trialExpiryWarnedAt: z.string().datetime().nullable().optional(),
|
|
405
|
+
/** APM-1.3: the six real agent-policy fields (APM-1.1 DB columns). */
|
|
406
|
+
autoPostReviews: z.boolean().openapi({ example: true }),
|
|
407
|
+
allowSelfReview: z.boolean().openapi({ example: false }),
|
|
408
|
+
minConfidence: z.number().int().openapi({ example: 75 }),
|
|
409
|
+
maxFindings: z.number().int().openapi({ example: 5 }),
|
|
410
|
+
cleanupMergedWorktrees: z.boolean().openapi({ example: true }),
|
|
411
|
+
cleanupAfterDays: z.number().int().openapi({ example: 14 }),
|
|
412
|
+
/**
|
|
413
|
+
* PAI-2.5: owner and Anthropic identity, so Platform and IT can trace an
|
|
414
|
+
* agent to its Anthropic identity. Null when unset. k8sServiceAccountName
|
|
415
|
+
* is deliberately not part of this response.
|
|
416
|
+
*/
|
|
417
|
+
ownerEmail: z
|
|
418
|
+
.string()
|
|
419
|
+
.nullable()
|
|
420
|
+
.openapi({ example: "jane.doe@example.com" }),
|
|
421
|
+
anthropicIdentityName: z
|
|
422
|
+
.string()
|
|
423
|
+
.nullable()
|
|
424
|
+
.openapi({ example: "shipwright-my-agent" }),
|
|
425
|
+
anthropicServiceAccountId: z
|
|
426
|
+
.string()
|
|
427
|
+
.nullable()
|
|
428
|
+
.openapi({ example: "svac_01ABC" }),
|
|
429
|
+
anthropicFederationRuleId: z
|
|
430
|
+
.string()
|
|
431
|
+
.nullable()
|
|
432
|
+
.openapi({ example: "fdrl_01XYZ" }),
|
|
433
|
+
})
|
|
434
|
+
.openapi("GetAgentResult");
|
|
435
|
+
|
|
436
|
+
const CronWrapperSchema = z
|
|
437
|
+
.object({ cron: AgentCronJobSchema })
|
|
438
|
+
.openapi("CronWrapper");
|
|
439
|
+
|
|
440
|
+
const ToolWrapperSchema = z
|
|
441
|
+
.object({ tool: AgentToolSchema })
|
|
442
|
+
.openapi("ToolWrapper");
|
|
443
|
+
|
|
444
|
+
const ToolsWrapperSchema = z
|
|
445
|
+
.object({ tools: z.array(AgentToolSchema) })
|
|
446
|
+
.openapi("ToolsWrapper");
|
|
447
|
+
|
|
448
|
+
const TokensWrapperSchema = z
|
|
449
|
+
.object({ tokens: z.array(AgentTokenSchema) })
|
|
450
|
+
.openapi("TokensWrapper");
|
|
451
|
+
|
|
452
|
+
const PluginWrapperSchema = z
|
|
453
|
+
.object({ plugin: AgentPluginSchema })
|
|
454
|
+
.openapi("PluginWrapper");
|
|
455
|
+
|
|
456
|
+
const PluginsWrapperSchema = z
|
|
457
|
+
.object({ plugins: z.array(AgentPluginSchema) })
|
|
458
|
+
.openapi("PluginsWrapper");
|
|
459
|
+
|
|
460
|
+
const jsonError = {
|
|
461
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const GitHubInstallationsSnapshotWrapperSchema = z
|
|
465
|
+
.object({ snapshot: AgentGitHubInstallationsSnapshotSchema })
|
|
466
|
+
.openapi("GitHubInstallationsSnapshotWrapper");
|
|
467
|
+
|
|
468
|
+
// ─── Route definitions ──────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
const reconcileAgentsRoute = createRoute({
|
|
471
|
+
method: "post",
|
|
472
|
+
path: "/agents/reconcile",
|
|
473
|
+
summary: "Reconcile all agents' K8s state",
|
|
474
|
+
description:
|
|
475
|
+
"Admin-only. Reconciles Kubernetes Deployment state against all managed (non-self-hosted) agents in the DB, returning the recreated/updated deployments plus any orphaned deployments or per-agent failures. In perAgent Anthropic identity mode it also creates the missing identity of each existing agent that has an owner and moves its Deployment off the shared ServiceAccount (`migrated`); agents with no owner are listed in `noOwner` and left alone. Pass `dryRun=true` to only report: nothing is created, saved or patched.",
|
|
476
|
+
request: { query: ReconcileAgentsQuerySchema },
|
|
477
|
+
responses: {
|
|
478
|
+
200: {
|
|
479
|
+
description: "Reconciliation summary",
|
|
480
|
+
content: { "application/json": { schema: ReconcileAgentsResultSchema } },
|
|
481
|
+
},
|
|
482
|
+
403: { description: "Forbidden", ...jsonError },
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const stripFederationCredentialsRoute = createRoute({
|
|
487
|
+
method: "post",
|
|
488
|
+
path: "/agents/federation/strip-credentials",
|
|
489
|
+
summary: "Strip Anthropic credentials from agents' saved env bundles",
|
|
490
|
+
description:
|
|
491
|
+
"Admin-only, one-off migration (WIF-2.2) for moving a deployment to Anthropic workload identity federation. Deletes CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from every non-self-hosted agent's stored env bundle. `dryRun` defaults to true: nothing is deleted and the response lists what would be. Only agents holding at least one of the keys are listed, so a second applied call returns an empty list. Values are never returned or logged.",
|
|
492
|
+
request: { query: StripCredentialsQuerySchema },
|
|
493
|
+
responses: {
|
|
494
|
+
200: {
|
|
495
|
+
description: "Agents (and key names) removed, or that would be removed",
|
|
496
|
+
content: {
|
|
497
|
+
"application/json": { schema: StripCredentialsResultSchema },
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
501
|
+
403: { description: "Forbidden", ...jsonError },
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const provisionAgentRoute = createRoute({
|
|
506
|
+
method: "post",
|
|
507
|
+
path: "/agents/{id}/provision",
|
|
508
|
+
summary: "Provision an agent's K8s workload",
|
|
509
|
+
description:
|
|
510
|
+
'Admin-only. Provisions or re-provisions the Kubernetes workload for a single managed agent — idempotent, safe to call on an already-provisioned agent. For self-hosted agents, returns `{ skipped: true, reason: "self-hosted" }` with no K8s changes.',
|
|
511
|
+
request: { params: AgentIdParamSchema },
|
|
512
|
+
responses: {
|
|
513
|
+
200: {
|
|
514
|
+
description:
|
|
515
|
+
"Agent provisioned (or already-provisioned — idempotent), or skipped for self-hosted agents",
|
|
516
|
+
content: {
|
|
517
|
+
"application/json": {
|
|
518
|
+
schema: z.union([
|
|
519
|
+
ProvisionAgentResultSchema,
|
|
520
|
+
ProvisionSkippedResultSchema,
|
|
521
|
+
]),
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
403: { description: "Forbidden", ...jsonError },
|
|
526
|
+
404: { description: "Agent not found", ...jsonError },
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const listAgentsRoute = createRoute({
|
|
531
|
+
method: "get",
|
|
532
|
+
path: "/agents",
|
|
533
|
+
summary: "List all agents",
|
|
534
|
+
description:
|
|
535
|
+
"Admin-only. Returns every agent's `id`, `name`, `selfHosted`, and `typeName` — used for metrics name resolution.",
|
|
536
|
+
responses: {
|
|
537
|
+
200: {
|
|
538
|
+
description: "List of agents",
|
|
539
|
+
content: { "application/json": { schema: z.array(AgentSummarySchema) } },
|
|
540
|
+
},
|
|
541
|
+
403: { description: "Forbidden", ...jsonError },
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const createAgentRoute = createRoute({
|
|
546
|
+
method: "post",
|
|
547
|
+
path: "/agents",
|
|
548
|
+
summary: "Create an agent",
|
|
549
|
+
description:
|
|
550
|
+
'Admin-only. Validates name/typeName, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type manifest, attaches repos/allowlists/members, patches Claude credentials, and provisions Kubernetes when `runtime` is "in-cluster" — the same createAgent() implementation (APA-1.1) the web UI form at /admin/agents/new uses. Transactional: on any failure zero rows persist, and this route never returns a partial-success 200. Returns the created agent in the same shape as GET /agents/:id.',
|
|
551
|
+
request: {
|
|
552
|
+
body: {
|
|
553
|
+
content: { "application/json": { schema: CreateAgentBodySchema } },
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
responses: {
|
|
557
|
+
200: {
|
|
558
|
+
description: "Agent created",
|
|
559
|
+
content: { "application/json": { schema: GetAgentResultSchema } },
|
|
560
|
+
},
|
|
561
|
+
400: { description: "Bad request", ...jsonError },
|
|
562
|
+
403: { description: "Forbidden", ...jsonError },
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const getAgentRoute = createRoute({
|
|
567
|
+
method: "get",
|
|
568
|
+
path: "/agents/{id}",
|
|
569
|
+
summary: "Get an agent",
|
|
570
|
+
description:
|
|
571
|
+
"Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `typeName`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type manifest with no corresponding AgentEnv row yet (informational only).",
|
|
572
|
+
request: { params: AgentIdParamSchema },
|
|
573
|
+
responses: {
|
|
574
|
+
200: {
|
|
575
|
+
description: "Full agent record",
|
|
576
|
+
content: { "application/json": { schema: GetAgentResultSchema } },
|
|
577
|
+
},
|
|
578
|
+
403: { description: "Forbidden", ...jsonError },
|
|
579
|
+
404: { description: "Agent not found", ...jsonError },
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const patchAgentRoute = createRoute({
|
|
584
|
+
method: "patch",
|
|
585
|
+
path: "/agents/{id}",
|
|
586
|
+
summary: "Update an agent",
|
|
587
|
+
description:
|
|
588
|
+
"Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, and/or `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows). `typeName` and `trialExpiryWarnedAt` are not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
|
|
589
|
+
request: {
|
|
590
|
+
params: AgentIdParamSchema,
|
|
591
|
+
body: {
|
|
592
|
+
content: { "application/json": { schema: PatchAgentBodySchema } },
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
responses: {
|
|
596
|
+
200: {
|
|
597
|
+
description: "Agent updated",
|
|
598
|
+
content: { "application/json": { schema: GetAgentResultSchema } },
|
|
599
|
+
},
|
|
600
|
+
400: { description: "Bad request", ...jsonError },
|
|
601
|
+
403: { description: "Forbidden", ...jsonError },
|
|
602
|
+
404: { description: "Agent not found", ...jsonError },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const addAgentReposRoute = createRoute({
|
|
607
|
+
method: "post",
|
|
608
|
+
path: "/agents/{id}/repos",
|
|
609
|
+
summary: "Add repos to an agent",
|
|
610
|
+
description:
|
|
611
|
+
"Admin, the agent's own key, or platform.ops. Additive alternative to `PATCH /agents/{id}` (which replaces the whole `repos` array): unions the given `org/repo` entries into the agent's current list, preserving the order of existing entries and appending new ones. Idempotent — adding a repo the agent already has is a no-op. 400 when the resulting list has two repos sharing a basename. Returns the full updated agent.",
|
|
612
|
+
request: {
|
|
613
|
+
params: AgentIdParamSchema,
|
|
614
|
+
body: {
|
|
615
|
+
content: { "application/json": { schema: AddAgentReposBodySchema } },
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
responses: {
|
|
619
|
+
200: {
|
|
620
|
+
description: "Repos added (or already present)",
|
|
621
|
+
content: { "application/json": { schema: GetAgentResultSchema } },
|
|
622
|
+
},
|
|
623
|
+
400: { description: "Bad request", ...jsonError },
|
|
624
|
+
403: { description: "Forbidden", ...jsonError },
|
|
625
|
+
404: { description: "Agent not found", ...jsonError },
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const removeAgentRepoRoute = createRoute({
|
|
630
|
+
method: "delete",
|
|
631
|
+
path: "/agents/{id}/repos/{owner}/{name}",
|
|
632
|
+
summary: "Remove a repo from an agent",
|
|
633
|
+
description:
|
|
634
|
+
"Admin, the agent's own key, or platform.ops. Removes one `owner/name` repo from the agent's `repos`, preserving the order of the rest. 404 when the agent does not have that repo. Returns the full updated agent.",
|
|
635
|
+
request: { params: AgentRepoParamSchema },
|
|
636
|
+
responses: {
|
|
637
|
+
200: {
|
|
638
|
+
description: "Repo removed",
|
|
639
|
+
content: { "application/json": { schema: GetAgentResultSchema } },
|
|
640
|
+
},
|
|
641
|
+
403: { description: "Forbidden", ...jsonError },
|
|
642
|
+
404: { description: "Agent or repo not found", ...jsonError },
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const deleteAgentRoute = createRoute({
|
|
647
|
+
method: "delete",
|
|
648
|
+
path: "/agents/{id}",
|
|
649
|
+
summary: "Delete an agent",
|
|
650
|
+
description:
|
|
651
|
+
"Admin-only. Runs the full deleteAgentFully() teardown: deprovisions the K8s workload, removes the agent's per-agent Anthropic identity when it has one (federation rule, then Anthropic service account, then Kubernetes ServiceAccount), revokes task-store and chat-service tokens, deletes chat threads, and — if an `xoxpToken` is supplied — deletes the Slack app. The Agent row (and its cascade-deleted child records) is deleted last, only if every automatable step succeeded; `agentDeleted: false` means the row was preserved and the call is safe to retry.",
|
|
652
|
+
request: {
|
|
653
|
+
params: AgentIdParamSchema,
|
|
654
|
+
body: {
|
|
655
|
+
required: false,
|
|
656
|
+
content: { "application/json": { schema: DeleteAgentBodySchema } },
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
responses: {
|
|
660
|
+
200: {
|
|
661
|
+
description:
|
|
662
|
+
"Delete outcome — agentDeleted is false when a cleanup step failed; the Agent row is preserved for retry",
|
|
663
|
+
content: { "application/json": { schema: DeleteAgentResultSchema } },
|
|
664
|
+
},
|
|
665
|
+
403: { description: "Forbidden", ...jsonError },
|
|
666
|
+
404: { description: "Agent not found", ...jsonError },
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const upsertEnvsRoute = createRoute({
|
|
671
|
+
method: "post",
|
|
672
|
+
path: "/agents/{id}/envs",
|
|
673
|
+
summary: "Replace all env vars",
|
|
674
|
+
description:
|
|
675
|
+
"Bulk-replaces all env vars for the agent atomically. Values are stored encrypted (AES-256-GCM).",
|
|
676
|
+
request: {
|
|
677
|
+
params: AgentIdParamSchema,
|
|
678
|
+
body: { content: { "application/json": { schema: AgentEnvBodySchema } } },
|
|
679
|
+
},
|
|
680
|
+
responses: {
|
|
681
|
+
201: {
|
|
682
|
+
description: "Env vars replaced",
|
|
683
|
+
content: { "application/json": { schema: OkSchema } },
|
|
684
|
+
},
|
|
685
|
+
400: { description: "Bad request", ...jsonError },
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const getEnvsRoute = createRoute({
|
|
690
|
+
method: "get",
|
|
691
|
+
path: "/agents/{id}/envs",
|
|
692
|
+
summary: "Get env vars",
|
|
693
|
+
description:
|
|
694
|
+
"Returns the agent's env vars. Non-secret values are decrypted; values flagged secret are masked as `***` and their keys listed in `secretKeys`.",
|
|
695
|
+
request: { params: AgentIdParamSchema },
|
|
696
|
+
responses: {
|
|
697
|
+
200: {
|
|
698
|
+
description: "Decrypted env vars",
|
|
699
|
+
content: { "application/json": { schema: AgentEnvResponseSchema } },
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const patchEnvsRoute = createRoute({
|
|
705
|
+
method: "patch",
|
|
706
|
+
path: "/agents/{id}/envs",
|
|
707
|
+
summary: "Update specific env vars",
|
|
708
|
+
description:
|
|
709
|
+
"Updates only the specified keys, leaving other existing env vars untouched.",
|
|
710
|
+
request: {
|
|
711
|
+
params: AgentIdParamSchema,
|
|
712
|
+
body: {
|
|
713
|
+
content: { "application/json": { schema: AgentEnvPatchBodySchema } },
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
responses: {
|
|
717
|
+
200: {
|
|
718
|
+
description: "Env vars updated",
|
|
719
|
+
content: { "application/json": { schema: OkSchema } },
|
|
720
|
+
},
|
|
721
|
+
400: { description: "Bad request", ...jsonError },
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const deleteEnvKeyRoute = createRoute({
|
|
726
|
+
method: "delete",
|
|
727
|
+
path: "/agents/{id}/envs/{key}",
|
|
728
|
+
summary: "Delete an env var",
|
|
729
|
+
description: "Deletes a single env var by key.",
|
|
730
|
+
request: { params: EnvKeyParamSchema },
|
|
731
|
+
responses: {
|
|
732
|
+
204: { description: "Key deleted" },
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const createCronRoute = createRoute({
|
|
737
|
+
method: "post",
|
|
738
|
+
path: "/agents/{id}/crons",
|
|
739
|
+
summary: "Create a cron job",
|
|
740
|
+
description:
|
|
741
|
+
"Creates a cron job for the agent. `channel` and `user` are mutually exclusive delivery targets. Returns the created job including system-managed read-only fields (`id`, `system`, `parentCronId`, `createdAt`, `updatedAt`) — `parentCronId` is never settable by the caller.",
|
|
742
|
+
request: {
|
|
743
|
+
params: AgentIdParamSchema,
|
|
744
|
+
body: {
|
|
745
|
+
content: {
|
|
746
|
+
"application/json": { schema: CreateAgentCronJobBodySchema },
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
responses: {
|
|
751
|
+
201: {
|
|
752
|
+
description: "Cron job created",
|
|
753
|
+
content: { "application/json": { schema: CronWrapperSchema } },
|
|
754
|
+
},
|
|
755
|
+
400: { description: "Bad request", ...jsonError },
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const reconcileCronsRoute = createRoute({
|
|
760
|
+
method: "post",
|
|
761
|
+
path: "/agents/{id}/crons/reconcile",
|
|
762
|
+
summary: "Reconcile system crons",
|
|
763
|
+
description:
|
|
764
|
+
"Reconciles the agent's system crons against the cron list declared by its Specialty manifest. Runs in three passes within a single transaction: create-or-update matched-by-name system crons (preserving IDs so run history survives), link/unlink parent-child cron relationships, and delete orphaned system crons no longer in the manifest. Called automatically on agent startup; returns created/updated/deleted counts.",
|
|
765
|
+
request: { params: AgentIdParamSchema },
|
|
766
|
+
responses: {
|
|
767
|
+
200: {
|
|
768
|
+
description: "Reconciliation summary",
|
|
769
|
+
content: { "application/json": { schema: ReconcileCronResultSchema } },
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const patchCronRoute = createRoute({
|
|
775
|
+
method: "patch",
|
|
776
|
+
path: "/agents/{id}/crons/{cronId}",
|
|
777
|
+
summary: "Update a cron job",
|
|
778
|
+
description:
|
|
779
|
+
"Updates a cron job. `schedule` and `prompt` must be provided together for a content update; `enabled` and `preCheck` are orthogonal and may be sent alone or combined with any other field. At least one field must be present — an empty body returns 400. System crons (`system: true`) reject `schedule`, `prompt`, and/or `preCheck` changes with 403; an enabled-only body is allowed and toggles the cron via the same path used for non-system crons.",
|
|
780
|
+
request: {
|
|
781
|
+
params: CronIdParamSchema,
|
|
782
|
+
body: {
|
|
783
|
+
content: { "application/json": { schema: PatchAgentCronJobBodySchema } },
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
responses: {
|
|
787
|
+
200: {
|
|
788
|
+
description: "Cron job updated",
|
|
789
|
+
content: { "application/json": { schema: CronWrapperSchema } },
|
|
790
|
+
},
|
|
791
|
+
400: { description: "Bad request", ...jsonError },
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const deleteCronRoute = createRoute({
|
|
796
|
+
method: "delete",
|
|
797
|
+
path: "/agents/{id}/crons/{cronId}",
|
|
798
|
+
summary: "Delete a cron job",
|
|
799
|
+
description:
|
|
800
|
+
"Deletes a cron job. System crons (`system: true`) cannot be deleted and return 403.",
|
|
801
|
+
request: { params: CronIdParamSchema },
|
|
802
|
+
responses: {
|
|
803
|
+
204: { description: "Cron job deleted" },
|
|
804
|
+
403: { description: "Forbidden", ...jsonError },
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const listCronsRoute = createRoute({
|
|
809
|
+
method: "get",
|
|
810
|
+
path: "/agents/{id}/crons/summary",
|
|
811
|
+
summary: "List cron jobs with run summary",
|
|
812
|
+
description:
|
|
813
|
+
"Returns each cron job's last-run timestamp, outcome, and today's run count alongside its config — without full prompt text. Useful for dashboards.",
|
|
814
|
+
request: { params: AgentIdParamSchema },
|
|
815
|
+
responses: {
|
|
816
|
+
200: {
|
|
817
|
+
description: "List of cron jobs with run summary",
|
|
818
|
+
content: {
|
|
819
|
+
"application/json": { schema: CronsWithSummaryWrapperSchema },
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
403: { description: "Forbidden", ...jsonError },
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const createCronRunRoute = createRoute({
|
|
827
|
+
method: "post",
|
|
828
|
+
path: "/agents/{id}/crons/{cronId}/runs",
|
|
829
|
+
summary: "Create a cron run record",
|
|
830
|
+
description:
|
|
831
|
+
"Records the start of a cron job execution, including optional skip/outcome data and dispatch attribution (`itemType`/`itemId`, write-once at creation).",
|
|
832
|
+
request: {
|
|
833
|
+
params: CronIdParamSchema,
|
|
834
|
+
body: {
|
|
835
|
+
content: {
|
|
836
|
+
"application/json": { schema: CreateAgentCronRunBodySchema },
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
responses: {
|
|
841
|
+
201: {
|
|
842
|
+
description: "Run record created",
|
|
843
|
+
content: {
|
|
844
|
+
"application/json": {
|
|
845
|
+
schema: z
|
|
846
|
+
.object({ run: AgentCronRunSchema })
|
|
847
|
+
.openapi("CronRunWrapper"),
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
400: { description: "Bad request", ...jsonError },
|
|
852
|
+
404: { description: "Cron job not found", ...jsonError },
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const listCronRunsRoute = createRoute({
|
|
857
|
+
method: "get",
|
|
858
|
+
path: "/agents/{id}/crons/{cronId}/runs",
|
|
859
|
+
summary: "List cron runs",
|
|
860
|
+
description:
|
|
861
|
+
"Returns a paginated list of runs for a cron job (`limit` default 20, `offset` default 0). `itemId` and `phaseId` are optional server-side filters that combine with AND when both are supplied.",
|
|
862
|
+
request: {
|
|
863
|
+
params: CronIdParamSchema,
|
|
864
|
+
query: ListCronRunsQuerySchema,
|
|
865
|
+
},
|
|
866
|
+
responses: {
|
|
867
|
+
200: {
|
|
868
|
+
description: "Paginated list of cron runs",
|
|
869
|
+
content: { "application/json": { schema: CronRunsListSchema } },
|
|
870
|
+
},
|
|
871
|
+
404: { description: "Cron job not found", ...jsonError },
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const patchCronRunRoute = createRoute({
|
|
876
|
+
method: "patch",
|
|
877
|
+
path: "/agents/{id}/crons/{cronId}/runs/{runId}",
|
|
878
|
+
summary: "Update a cron run record",
|
|
879
|
+
description:
|
|
880
|
+
"Records completion data after a run finishes — `completedAt`, `outcome`, `error`, `skipped`, `skipReason`, `sessionId`, and `modelBreakdown` (per-model token/cost entries, upserted per `[cronRunId, model]`). At least one field must be provided.",
|
|
881
|
+
request: {
|
|
882
|
+
params: CronRunIdParamSchema,
|
|
883
|
+
body: {
|
|
884
|
+
content: {
|
|
885
|
+
"application/json": { schema: PatchAgentCronRunBodySchema },
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
responses: {
|
|
890
|
+
200: {
|
|
891
|
+
description: "Run record updated",
|
|
892
|
+
content: {
|
|
893
|
+
"application/json": {
|
|
894
|
+
schema: z
|
|
895
|
+
.object({ run: AgentCronRunSchema })
|
|
896
|
+
.openapi("PatchCronRunWrapper"),
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
400: { description: "Bad request", ...jsonError },
|
|
901
|
+
404: { description: "Run not found or not owned by agent", ...jsonError },
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const createToolRoute = createRoute({
|
|
906
|
+
method: "post",
|
|
907
|
+
path: "/agents/{id}/tools",
|
|
908
|
+
summary: "Add a tool pattern",
|
|
909
|
+
description:
|
|
910
|
+
'Adds an allowed-tool entry — a glob or exact Claude Code tool name (e.g. "Read", "Bash", "mcp__*") the agent can call.',
|
|
911
|
+
request: {
|
|
912
|
+
params: AgentIdParamSchema,
|
|
913
|
+
body: {
|
|
914
|
+
content: {
|
|
915
|
+
"application/json": { schema: CreateAgentToolBodySchema },
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
responses: {
|
|
920
|
+
201: {
|
|
921
|
+
description: "Tool added",
|
|
922
|
+
content: { "application/json": { schema: ToolWrapperSchema } },
|
|
923
|
+
},
|
|
924
|
+
400: { description: "Bad request", ...jsonError },
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const listToolsRoute = createRoute({
|
|
929
|
+
method: "get",
|
|
930
|
+
path: "/agents/{id}/tools",
|
|
931
|
+
summary: "List tool patterns",
|
|
932
|
+
description:
|
|
933
|
+
"Returns every allowed-tool entry for the agent, each with `id`, `pattern`, and `enabled`.",
|
|
934
|
+
request: { params: AgentIdParamSchema },
|
|
935
|
+
responses: {
|
|
936
|
+
200: {
|
|
937
|
+
description: "List of tool patterns",
|
|
938
|
+
content: { "application/json": { schema: ToolsWrapperSchema } },
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const patchToolRoute = createRoute({
|
|
944
|
+
method: "patch",
|
|
945
|
+
path: "/agents/{id}/tools/{toolId}",
|
|
946
|
+
summary: "Enable or disable a tool pattern",
|
|
947
|
+
description: "Updates a tool entry's `pattern` and/or `enabled` state.",
|
|
948
|
+
request: {
|
|
949
|
+
params: ToolIdParamSchema,
|
|
950
|
+
body: {
|
|
951
|
+
content: { "application/json": { schema: PatchAgentToolBodySchema } },
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
responses: {
|
|
955
|
+
200: {
|
|
956
|
+
description: "Tool toggled",
|
|
957
|
+
content: { "application/json": { schema: ToolWrapperSchema } },
|
|
958
|
+
},
|
|
959
|
+
400: { description: "Bad request", ...jsonError },
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
const deleteToolRoute = createRoute({
|
|
964
|
+
method: "delete",
|
|
965
|
+
path: "/agents/{id}/tools/{toolId}",
|
|
966
|
+
summary: "Remove a tool pattern",
|
|
967
|
+
description: "Deletes a tool entry from the agent's allowed-tools list.",
|
|
968
|
+
request: { params: ToolIdParamSchema },
|
|
969
|
+
responses: {
|
|
970
|
+
204: { description: "Tool removed" },
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const createTokenRoute = createRoute({
|
|
975
|
+
method: "post",
|
|
976
|
+
path: "/agents/{id}/tokens",
|
|
977
|
+
summary: "Create an API token",
|
|
978
|
+
description:
|
|
979
|
+
"Creates a per-agent bearer token for scoped API access. The raw token value is returned once in this response — only its SHA-256 hash is persisted, so it must be saved immediately.",
|
|
980
|
+
request: {
|
|
981
|
+
params: AgentIdParamSchema,
|
|
982
|
+
body: {
|
|
983
|
+
required: false,
|
|
984
|
+
content: {
|
|
985
|
+
"application/json": { schema: CreateAgentTokenBodySchema },
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
responses: {
|
|
990
|
+
201: {
|
|
991
|
+
description: "Token created (raw value returned once)",
|
|
992
|
+
content: {
|
|
993
|
+
"application/json": { schema: CreateAgentTokenResponseSchema },
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const listTokensRoute = createRoute({
|
|
1000
|
+
method: "get",
|
|
1001
|
+
path: "/agents/{id}/tokens",
|
|
1002
|
+
summary: "List API tokens",
|
|
1003
|
+
description:
|
|
1004
|
+
"Returns token metadata (hash excluded) for the agent — raw token values are never returned after creation.",
|
|
1005
|
+
request: { params: AgentIdParamSchema },
|
|
1006
|
+
responses: {
|
|
1007
|
+
200: {
|
|
1008
|
+
description: "List of token metadata",
|
|
1009
|
+
content: { "application/json": { schema: TokensWrapperSchema } },
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const deleteTokenRoute = createRoute({
|
|
1015
|
+
method: "delete",
|
|
1016
|
+
path: "/agents/{id}/tokens/{tokenId}",
|
|
1017
|
+
summary: "Revoke an API token",
|
|
1018
|
+
description: "Soft-deletes the token by setting `revokedAt`.",
|
|
1019
|
+
request: { params: TokenIdParamSchema },
|
|
1020
|
+
responses: {
|
|
1021
|
+
204: { description: "Token revoked" },
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const chatCredentialErrors = {
|
|
1026
|
+
403: { description: "Forbidden", ...jsonError },
|
|
1027
|
+
404: {
|
|
1028
|
+
description: "Not found, or chat-credential minting disabled",
|
|
1029
|
+
...jsonError,
|
|
1030
|
+
},
|
|
1031
|
+
503: { description: "Chat service not configured", ...jsonError },
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
const createChatCredentialRoute = createRoute({
|
|
1035
|
+
method: "post",
|
|
1036
|
+
path: "/agents/{id}/chat-credentials",
|
|
1037
|
+
summary: "Mint a chat credential",
|
|
1038
|
+
description:
|
|
1039
|
+
"Mints a chat-service token scoped to the agent in the path. `*` or `platform.ops` callers only — never the agent's own key. The body is closed: an `agentId` field is rejected with 400, so an unrestricted chat admin token cannot be minted here. The raw token is returned once and is never logged or stored by admin. 404 unless SHIPWRIGHT_CHAT_TOKEN_MINT_ENABLED is `enabled`.",
|
|
1040
|
+
request: {
|
|
1041
|
+
params: AgentIdParamSchema,
|
|
1042
|
+
body: {
|
|
1043
|
+
content: {
|
|
1044
|
+
"application/json": { schema: CreateChatCredentialBodySchema },
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
responses: {
|
|
1049
|
+
201: {
|
|
1050
|
+
description: "Credential minted (raw token returned once)",
|
|
1051
|
+
content: {
|
|
1052
|
+
"application/json": { schema: CreateChatCredentialResponseSchema },
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
400: { description: "Bad request", ...jsonError },
|
|
1056
|
+
...chatCredentialErrors,
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
const listChatCredentialsRoute = createRoute({
|
|
1061
|
+
method: "get",
|
|
1062
|
+
path: "/agents/{id}/chat-credentials",
|
|
1063
|
+
summary: "List chat credentials",
|
|
1064
|
+
description:
|
|
1065
|
+
"Lists the chat-service tokens scoped to the agent (id and label only — raw values are never returned). `*` or `platform.ops` callers only.",
|
|
1066
|
+
request: { params: AgentIdParamSchema },
|
|
1067
|
+
responses: {
|
|
1068
|
+
200: {
|
|
1069
|
+
description: "Chat credential metadata",
|
|
1070
|
+
content: {
|
|
1071
|
+
"application/json": { schema: ChatCredentialsWrapperSchema },
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
...chatCredentialErrors,
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
const deleteChatCredentialRoute = createRoute({
|
|
1079
|
+
method: "delete",
|
|
1080
|
+
path: "/agents/{id}/chat-credentials/{tokenId}",
|
|
1081
|
+
summary: "Revoke a chat credential",
|
|
1082
|
+
description:
|
|
1083
|
+
"Revokes a chat-service token. 404 when the token is not scoped to the agent in the path. `*` or `platform.ops` callers only.",
|
|
1084
|
+
request: { params: TokenIdParamSchema },
|
|
1085
|
+
responses: {
|
|
1086
|
+
204: { description: "Credential revoked" },
|
|
1087
|
+
...chatCredentialErrors,
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
const createPluginRoute = createRoute({
|
|
1092
|
+
method: "post",
|
|
1093
|
+
path: "/agents/{id}/plugins",
|
|
1094
|
+
summary: "Install a plugin",
|
|
1095
|
+
description: "Adds a Claude Code marketplace plugin for the agent.",
|
|
1096
|
+
request: {
|
|
1097
|
+
params: AgentIdParamSchema,
|
|
1098
|
+
body: {
|
|
1099
|
+
content: {
|
|
1100
|
+
"application/json": { schema: CreateAgentPluginBodySchema },
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
responses: {
|
|
1105
|
+
201: {
|
|
1106
|
+
description: "Plugin added",
|
|
1107
|
+
content: { "application/json": { schema: PluginWrapperSchema } },
|
|
1108
|
+
},
|
|
1109
|
+
400: { description: "Bad request", ...jsonError },
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
const listPluginsRoute = createRoute({
|
|
1114
|
+
method: "get",
|
|
1115
|
+
path: "/agents/{id}/plugins",
|
|
1116
|
+
summary: "List plugins",
|
|
1117
|
+
description: "Returns every plugin installed for the agent.",
|
|
1118
|
+
request: { params: AgentIdParamSchema },
|
|
1119
|
+
responses: {
|
|
1120
|
+
200: {
|
|
1121
|
+
description: "List of plugins",
|
|
1122
|
+
content: { "application/json": { schema: PluginsWrapperSchema } },
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
const patchPluginRoute = createRoute({
|
|
1128
|
+
method: "patch",
|
|
1129
|
+
path: "/agents/{id}/plugins",
|
|
1130
|
+
summary: "Update a plugin",
|
|
1131
|
+
description:
|
|
1132
|
+
'Re-upserts the plugin identified by the required `name` query param with a new `version` and/or `enabled` state. Uses a query param rather than a path segment because a canonical plugin spec like "my-plugin@org/my-marketplace" can contain a literal "/" that would break path matching.',
|
|
1133
|
+
request: {
|
|
1134
|
+
params: AgentIdParamSchema,
|
|
1135
|
+
query: PluginNameQuerySchema,
|
|
1136
|
+
body: {
|
|
1137
|
+
content: { "application/json": { schema: PatchAgentPluginBodySchema } },
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
responses: {
|
|
1141
|
+
200: {
|
|
1142
|
+
description: "Plugin version updated",
|
|
1143
|
+
content: { "application/json": { schema: PluginWrapperSchema } },
|
|
1144
|
+
},
|
|
1145
|
+
400: { description: "Bad request", ...jsonError },
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const deletePluginRoute = createRoute({
|
|
1150
|
+
method: "delete",
|
|
1151
|
+
path: "/agents/{id}/plugins",
|
|
1152
|
+
summary: "Remove a plugin",
|
|
1153
|
+
description:
|
|
1154
|
+
"Removes the plugin identified by the required `name` query param.",
|
|
1155
|
+
request: {
|
|
1156
|
+
params: AgentIdParamSchema,
|
|
1157
|
+
query: PluginNameQuerySchema,
|
|
1158
|
+
},
|
|
1159
|
+
responses: {
|
|
1160
|
+
204: { description: "Plugin removed" },
|
|
1161
|
+
400: { description: "Bad request", ...jsonError },
|
|
1162
|
+
},
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
const upsertChatTokenDailyRoute = createRoute({
|
|
1166
|
+
method: "post",
|
|
1167
|
+
path: "/agents/{id}/chat-tokens/daily",
|
|
1168
|
+
summary: "Record daily chat token usage",
|
|
1169
|
+
description:
|
|
1170
|
+
"Atomically accumulates Slack chat token usage into the existing rows for each `(agentId, date, model)` tuple, creating them if absent. Supply a `modelBreakdown` array to split a single day's usage across multiple models. Returns the updated daily rows, one per model.",
|
|
1171
|
+
request: {
|
|
1172
|
+
params: AgentIdParamSchema,
|
|
1173
|
+
body: {
|
|
1174
|
+
content: {
|
|
1175
|
+
"application/json": { schema: UpsertChatTokenDailyBodySchema },
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
responses: {
|
|
1180
|
+
200: {
|
|
1181
|
+
description: "Updated daily chat token usage rows (one per model)",
|
|
1182
|
+
content: {
|
|
1183
|
+
"application/json": { schema: z.array(AgentChatTokenUsageDailySchema) },
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
400: { description: "Bad request", ...jsonError },
|
|
1187
|
+
404: { description: "Agent not found", ...jsonError },
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
const putGitHubInstallationsRoute = createRoute({
|
|
1192
|
+
method: "put",
|
|
1193
|
+
path: "/agents/{id}/github-installations",
|
|
1194
|
+
summary: "Replace the agent's GitHub installations snapshot",
|
|
1195
|
+
description:
|
|
1196
|
+
"Upserts the agent's single GitHub App installations snapshot (owner, installation id, state, sanitized lastError), replacing the prior list wholesale — an installation absent from the body is dropped. The body schema is closed: unknown fields (e.g. tokens) are rejected with 400.",
|
|
1197
|
+
request: {
|
|
1198
|
+
params: AgentIdParamSchema,
|
|
1199
|
+
body: {
|
|
1200
|
+
content: {
|
|
1201
|
+
"application/json": { schema: PutGitHubInstallationsBodySchema },
|
|
1202
|
+
},
|
|
1203
|
+
},
|
|
1204
|
+
},
|
|
1205
|
+
responses: {
|
|
1206
|
+
200: {
|
|
1207
|
+
description: "Snapshot replaced",
|
|
1208
|
+
content: {
|
|
1209
|
+
"application/json": {
|
|
1210
|
+
schema: GitHubInstallationsSnapshotWrapperSchema,
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
400: { description: "Bad request", ...jsonError },
|
|
1215
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1216
|
+
403: {
|
|
1217
|
+
description: "Forbidden — token does not own this agent",
|
|
1218
|
+
...jsonError,
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
const getGitHubInstallationsRoute = createRoute({
|
|
1224
|
+
method: "get",
|
|
1225
|
+
path: "/agents/{id}/github-installations",
|
|
1226
|
+
summary: "Get the latest GitHub installations snapshot",
|
|
1227
|
+
description:
|
|
1228
|
+
"Returns the most recently reported GitHub installations snapshot, or 404 if the agent has never reported one.",
|
|
1229
|
+
request: { params: AgentIdParamSchema },
|
|
1230
|
+
responses: {
|
|
1231
|
+
200: {
|
|
1232
|
+
description: "Latest GitHub installations snapshot",
|
|
1233
|
+
content: {
|
|
1234
|
+
"application/json": {
|
|
1235
|
+
schema: GitHubInstallationsSnapshotWrapperSchema,
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1240
|
+
403: {
|
|
1241
|
+
description: "Forbidden — token does not own this agent",
|
|
1242
|
+
...jsonError,
|
|
1243
|
+
},
|
|
1244
|
+
404: { description: "No snapshot reported yet", ...jsonError },
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
/** `from`/`to` ISO datetime bounds shared by the cron-run report routes. */
|
|
1249
|
+
export const cronRunStatsQuerySchema = z
|
|
1250
|
+
.object({
|
|
1251
|
+
from: z
|
|
1252
|
+
.string()
|
|
1253
|
+
.datetime()
|
|
1254
|
+
.optional()
|
|
1255
|
+
.openapi({ example: "2026-01-01T00:00:00Z" }),
|
|
1256
|
+
to: z
|
|
1257
|
+
.string()
|
|
1258
|
+
.datetime()
|
|
1259
|
+
.optional()
|
|
1260
|
+
.openapi({ example: "2026-02-01T00:00:00Z" }),
|
|
1261
|
+
})
|
|
1262
|
+
.openapi("CronRunStatsQuery");
|
|
1263
|
+
|
|
1264
|
+
const cronRunTokenStatsRoute = createRoute({
|
|
1265
|
+
method: "get",
|
|
1266
|
+
path: "/agents/all/cron-runs/stats",
|
|
1267
|
+
summary: "Get aggregated cron-run token stats",
|
|
1268
|
+
description:
|
|
1269
|
+
"Admin-only. Returns token usage and cost aggregated across all agents' cron runs, broken down by agent, cron, model, day, and pipeline phase. Optional `from`/`to` ISO datetime query params bound the range.",
|
|
1270
|
+
request: {
|
|
1271
|
+
query: cronRunStatsQuerySchema,
|
|
1272
|
+
},
|
|
1273
|
+
responses: {
|
|
1274
|
+
200: {
|
|
1275
|
+
description: "Aggregated cron-run token stats across all agents",
|
|
1276
|
+
content: { "application/json": { schema: CronRunTokenStatsSchema } },
|
|
1277
|
+
},
|
|
1278
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1279
|
+
403: { description: "Forbidden — requires admin scope", ...jsonError },
|
|
1280
|
+
},
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
const agentCronRunTokenStatsRoute = createRoute({
|
|
1284
|
+
method: "get",
|
|
1285
|
+
path: "/agents/{id}/cron-runs/stats",
|
|
1286
|
+
summary: "Get aggregated cron-run token stats for one agent",
|
|
1287
|
+
description:
|
|
1288
|
+
"Same shape as GET /agents/all/cron-runs/stats, scoped to the given agent's runs. An agent's own bearer token may read its own stats; other agents' tokens are rejected. Optional `from`/`to` ISO datetime query params bound the range.",
|
|
1289
|
+
request: {
|
|
1290
|
+
params: z.object({ id: z.string() }),
|
|
1291
|
+
query: cronRunStatsQuerySchema,
|
|
1292
|
+
},
|
|
1293
|
+
responses: {
|
|
1294
|
+
200: {
|
|
1295
|
+
description: "Aggregated cron-run token stats for the agent",
|
|
1296
|
+
content: { "application/json": { schema: CronRunTokenStatsSchema } },
|
|
1297
|
+
},
|
|
1298
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1299
|
+
403: { description: "Forbidden", ...jsonError },
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const cronRunOutcomesRoute = createRoute({
|
|
1304
|
+
method: "get",
|
|
1305
|
+
path: "/agents/all/cron-runs/outcomes",
|
|
1306
|
+
summary: "Get cron-run outcomes per phase and context fingerprint",
|
|
1307
|
+
description:
|
|
1308
|
+
"Admin-only. Returns, per (phase, contextFingerprint): run, completed, failed, and skipped counts, a skipReasons histogram, avg and p50 duration, avg turns, avg tool calls, and avg first-turn context tokens. Optional `from`/`to` ISO datetime query params bound the range on startedAt.",
|
|
1309
|
+
request: {
|
|
1310
|
+
query: cronRunStatsQuerySchema,
|
|
1311
|
+
},
|
|
1312
|
+
responses: {
|
|
1313
|
+
200: {
|
|
1314
|
+
description: "Outcome series per (phase, contextFingerprint)",
|
|
1315
|
+
content: { "application/json": { schema: CronRunOutcomesSchema } },
|
|
1316
|
+
},
|
|
1317
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1318
|
+
403: { description: "Forbidden — requires admin scope", ...jsonError },
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
const agentCronRunOutcomesRoute = createRoute({
|
|
1323
|
+
method: "get",
|
|
1324
|
+
path: "/agents/{id}/cron-runs/outcomes",
|
|
1325
|
+
summary:
|
|
1326
|
+
"Get cron-run outcomes per phase and context fingerprint for one agent",
|
|
1327
|
+
description:
|
|
1328
|
+
"Same shape as GET /agents/all/cron-runs/outcomes, scoped to the given agent's runs. An agent's own bearer token may read its own outcomes; other agents' tokens are rejected. Optional `from`/`to` ISO datetime query params bound the range on startedAt.",
|
|
1329
|
+
request: {
|
|
1330
|
+
params: z.object({ id: z.string() }),
|
|
1331
|
+
query: cronRunStatsQuerySchema,
|
|
1332
|
+
},
|
|
1333
|
+
responses: {
|
|
1334
|
+
200: {
|
|
1335
|
+
description:
|
|
1336
|
+
"Outcome series per (phase, contextFingerprint) for the agent",
|
|
1337
|
+
content: { "application/json": { schema: CronRunOutcomesSchema } },
|
|
1338
|
+
},
|
|
1339
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1340
|
+
403: { description: "Forbidden", ...jsonError },
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
const chatTokenDailyStatsQuerySchema = z
|
|
1345
|
+
.object({
|
|
1346
|
+
from: z.string().date().optional().openapi({ example: "2026-01-01" }),
|
|
1347
|
+
to: z.string().date().optional().openapi({ example: "2026-02-01" }),
|
|
1348
|
+
})
|
|
1349
|
+
.openapi("ChatTokenDailyStatsQuery");
|
|
1350
|
+
|
|
1351
|
+
const chatTokenDailyStatsRoute = createRoute({
|
|
1352
|
+
method: "get",
|
|
1353
|
+
path: "/agents/chat-tokens/daily/stats",
|
|
1354
|
+
summary: "Get aggregated chat token daily stats",
|
|
1355
|
+
description:
|
|
1356
|
+
"Admin-only. Returns Slack chat token usage aggregated across all agents, broken down by agent, model, and day. Optional `from`/`to` YYYY-MM-DD query params bound the range.",
|
|
1357
|
+
request: {
|
|
1358
|
+
query: chatTokenDailyStatsQuerySchema,
|
|
1359
|
+
},
|
|
1360
|
+
responses: {
|
|
1361
|
+
200: {
|
|
1362
|
+
description: "Aggregated chat token daily stats across all agents",
|
|
1363
|
+
content: { "application/json": { schema: ChatTokenStatsSchema } },
|
|
1364
|
+
},
|
|
1365
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1366
|
+
403: { description: "Forbidden — requires admin scope", ...jsonError },
|
|
1367
|
+
},
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
const selfChatTokenDailyStatsRoute = createRoute({
|
|
1371
|
+
method: "get",
|
|
1372
|
+
path: "/agents/{id}/chat-tokens/daily/stats",
|
|
1373
|
+
summary: "Get this agent's chat token daily stats",
|
|
1374
|
+
description:
|
|
1375
|
+
"Self-serve variant of the fleet-wide chat token daily stats, scoped to the agent in the path. Access is gated by the generic agent-route auth (an agent's own bearer token, an admin, or a member session) — no admin scope required. Same response shape and optional `from`/`to` params as GET /agents/chat-tokens/daily/stats.",
|
|
1376
|
+
request: {
|
|
1377
|
+
params: AgentIdParamSchema,
|
|
1378
|
+
query: chatTokenDailyStatsQuerySchema,
|
|
1379
|
+
},
|
|
1380
|
+
responses: {
|
|
1381
|
+
200: {
|
|
1382
|
+
description: "Chat token daily stats for this agent only",
|
|
1383
|
+
content: { "application/json": { schema: ChatTokenStatsSchema } },
|
|
1384
|
+
},
|
|
1385
|
+
401: { description: "Unauthorized", ...jsonError },
|
|
1386
|
+
403: { description: "Forbidden", ...jsonError },
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// ─── App factory ──────────────────────────────────────────────────────────────
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* The admin API's OpenAPIHono app: a 400 for request-validation failures
|
|
1394
|
+
* (defaultHook) and the shared error mapping (onError). Exported so extension
|
|
1395
|
+
* API apps (AEP-3.2) answer validation failures and errors exactly like the
|
|
1396
|
+
* platform API. `pool`, when given, attaches pg pool stats to pool-timeout
|
|
1397
|
+
* captures (PLO-1.3).
|
|
1398
|
+
*/
|
|
1399
|
+
export function createAdminApiHono(
|
|
1400
|
+
sentryClient?: ErrorCapturingClient,
|
|
1401
|
+
pool?: PoolLike,
|
|
1402
|
+
): OpenAPIHono<AdminAuthEnv> {
|
|
1403
|
+
const app = new OpenAPIHono<AdminAuthEnv>({
|
|
1404
|
+
// Fires when Zod request validation fails — surface a 400 with the issues.
|
|
1405
|
+
defaultHook: (result, c) => {
|
|
1406
|
+
if (!result.success) {
|
|
1407
|
+
const message = result.error.issues
|
|
1408
|
+
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
1409
|
+
.join(", ");
|
|
1410
|
+
return c.json({ error: message }, 400);
|
|
1411
|
+
}
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// Record method + matched route so pool diagnostics can name the route.
|
|
1416
|
+
app.use("*", routeContextMiddleware());
|
|
1417
|
+
|
|
1418
|
+
// Capture an unhandled error. Pool-timeout errors additionally carry pool
|
|
1419
|
+
// stats + route/server_name (PLO-1.3); every other error is captured as-is.
|
|
1420
|
+
const captureUnhandled = (err: unknown) => {
|
|
1421
|
+
if (!sentryClient) return;
|
|
1422
|
+
if (pool && sentryClient.withScope && isPoolTimeoutError(err)) {
|
|
1423
|
+
const ctx = poolErrorContext(pool);
|
|
1424
|
+
void sentryClient.withScope(async (scope) => {
|
|
1425
|
+
scope.setContext?.("pg_pool", ctx);
|
|
1426
|
+
scope.setTag("server_name", "admin");
|
|
1427
|
+
if (typeof ctx.route === "string") scope.setTag("route", ctx.route);
|
|
1428
|
+
sentryClient.captureException(err);
|
|
1429
|
+
});
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
sentryClient.captureException(err);
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
app.onError((err, c) => {
|
|
1436
|
+
if (err instanceof ApiError && err.statusCode < 500) {
|
|
1437
|
+
return c.json(
|
|
1438
|
+
{
|
|
1439
|
+
error: err.message,
|
|
1440
|
+
...(err instanceof AnthropicCredentialsBlockedError && {
|
|
1441
|
+
code: err.code,
|
|
1442
|
+
}),
|
|
1443
|
+
},
|
|
1444
|
+
err.statusCode as 400 | 401 | 403 | 404 | 409 | 422,
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
if (err instanceof ApiError) {
|
|
1448
|
+
captureUnhandled(err);
|
|
1449
|
+
return c.json({ error: err.message }, err.statusCode as 500 | 502);
|
|
1450
|
+
}
|
|
1451
|
+
// zod-openapi 1.x throws an HTTPException for request-shape problems —
|
|
1452
|
+
// 415 for a non-JSON Content-Type on a JSON-body route, 400 for an
|
|
1453
|
+
// unparseable body. Client errors: respond with the exception's status.
|
|
1454
|
+
if (err instanceof HTTPException && err.status < 500) {
|
|
1455
|
+
return c.json({ error: err.message }, err.status as 400 | 415);
|
|
1456
|
+
}
|
|
1457
|
+
captureUnhandled(err);
|
|
1458
|
+
console.error(
|
|
1459
|
+
`[agents-api] unhandled error (caller: ${callerLabel(c.get("caller"), c.get("scope")?.kind)}):`,
|
|
1460
|
+
err,
|
|
1461
|
+
);
|
|
1462
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
return app;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
1469
|
+
const {
|
|
1470
|
+
agentService,
|
|
1471
|
+
agentEnvService,
|
|
1472
|
+
agentCronJobService,
|
|
1473
|
+
agentCronRunService,
|
|
1474
|
+
agentCronRunStatsService,
|
|
1475
|
+
agentToolService,
|
|
1476
|
+
agentTokenService,
|
|
1477
|
+
agentPluginService,
|
|
1478
|
+
agentMemberService,
|
|
1479
|
+
specialtyRegistry,
|
|
1480
|
+
agentChatTokenService,
|
|
1481
|
+
agentGitHubInstallationsService,
|
|
1482
|
+
prisma,
|
|
1483
|
+
provisioner,
|
|
1484
|
+
serviceIntegrations,
|
|
1485
|
+
chatService,
|
|
1486
|
+
slack,
|
|
1487
|
+
decrypt,
|
|
1488
|
+
sessionSecret,
|
|
1489
|
+
adminApiKeys,
|
|
1490
|
+
sentryClient,
|
|
1491
|
+
pool,
|
|
1492
|
+
} = deps;
|
|
1493
|
+
const callerScopeResolver =
|
|
1494
|
+
deps.callerScopeResolver ??
|
|
1495
|
+
memberOnlyCallerScopeResolver(agentMemberService);
|
|
1496
|
+
|
|
1497
|
+
const app = createAdminApiHono(sentryClient, pool);
|
|
1498
|
+
|
|
1499
|
+
const authMiddleware = createAdminAuthMiddleware({
|
|
1500
|
+
sessionSecret,
|
|
1501
|
+
agentTokenService,
|
|
1502
|
+
adminApiKeys,
|
|
1503
|
+
agentMemberService,
|
|
1504
|
+
callerScopeResolver,
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// Platform readers/operators (platform.read / platform.ops admin keys): the
|
|
1508
|
+
// auth middleware has already enforced the method and PLATFORM_OPS_ROUTES
|
|
1509
|
+
// allowlist, so handlers only widen their reader checks for them.
|
|
1510
|
+
const isPlatformCaller = (c: { get: (k: "scope") => unknown }) => {
|
|
1511
|
+
const kind = (c.get("scope") as AdminScope | undefined)?.kind;
|
|
1512
|
+
return kind === "platform-read" || kind === "platform-ops";
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
// Admin, a platform reader/operator, or a session-cookie caller (membership
|
|
1516
|
+
// of the route's agent already enforced by the auth middleware). Bearer
|
|
1517
|
+
// non-admins stay denied.
|
|
1518
|
+
const isAdminOrSessionMember = (c: {
|
|
1519
|
+
get: (k: "isAdmin" | "callerEmail" | "scope") => unknown;
|
|
1520
|
+
}) =>
|
|
1521
|
+
c.get("isAdmin") === true ||
|
|
1522
|
+
c.get("callerEmail") !== undefined ||
|
|
1523
|
+
isPlatformCaller(c);
|
|
1524
|
+
|
|
1525
|
+
// Apply combined auth (bearer token OR session cookie) to all /agents/* routes.
|
|
1526
|
+
app.use("/agents/*", authMiddleware);
|
|
1527
|
+
|
|
1528
|
+
// /agents/* covers all /agents/… paths with at least one trailing segment
|
|
1529
|
+
// (e.g. /agents/:id, /agents/:id/crons). Only the zero-segment paths
|
|
1530
|
+
// (/agents and /agents/reconcile) are not covered above — apply auth to them
|
|
1531
|
+
// explicitly.
|
|
1532
|
+
app.use("/agents", authMiddleware);
|
|
1533
|
+
app.use("/agents/reconcile", authMiddleware);
|
|
1534
|
+
|
|
1535
|
+
// ─── Agents ────────────────────────────────────────────────────────────────
|
|
1536
|
+
|
|
1537
|
+
// POST /agents/reconcile — reconcile K8s Deployment state against the DB (admin only)
|
|
1538
|
+
app.openapi(reconcileAgentsRoute, async (c) => {
|
|
1539
|
+
if (c.get("isAdmin") !== true) {
|
|
1540
|
+
throw new ForbiddenError(
|
|
1541
|
+
"Only admin bearers and session users can reconcile agents",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const dryRun = c.req.valid("query").dryRun === "true";
|
|
1545
|
+
const result = await provisioner.reconcile(
|
|
1546
|
+
managedReconcileTargets(await agentService.list()),
|
|
1547
|
+
{ dryRun },
|
|
1548
|
+
);
|
|
1549
|
+
return c.json(result, 200);
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
// POST /agents/federation/strip-credentials — WIF-2.2 migration (admin only).
|
|
1553
|
+
// Static path "federation" must be registered before any /agents/:id/* routes
|
|
1554
|
+
// so the literal is never matched as an agentId.
|
|
1555
|
+
app.openapi(stripFederationCredentialsRoute, async (c) => {
|
|
1556
|
+
if (c.get("isAdmin") !== true) {
|
|
1557
|
+
throw new ForbiddenError(
|
|
1558
|
+
"Only admin bearers and session users can strip federation credentials",
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
const dryRun = c.req.valid("query").dryRun !== "false";
|
|
1562
|
+
const agents: Array<{ agentId: string; keys: string[] }> = [];
|
|
1563
|
+
for (const agent of await agentService.list()) {
|
|
1564
|
+
if (agent.selfHosted) continue;
|
|
1565
|
+
const bundle = await agentEnvService.getByAgentId(agent.id);
|
|
1566
|
+
if (bundle === null) continue;
|
|
1567
|
+
const keys = anthropicCredentialKeysIn(Object.keys(bundle.env));
|
|
1568
|
+
if (keys.length === 0) continue;
|
|
1569
|
+
if (!dryRun) {
|
|
1570
|
+
for (const key of keys) await agentEnvService.deleteKey(agent.id, key);
|
|
1571
|
+
console.log(
|
|
1572
|
+
`[agents-api] stripped Anthropic credentials from agent ${agent.id}: ${keys.join(", ")}`,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
agents.push({ agentId: agent.id, keys });
|
|
1576
|
+
}
|
|
1577
|
+
return c.json({ dryRun, agents }, 200);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
// POST /agents/:id/provision — provision a single agent's K8s workload (admin only).
|
|
1581
|
+
// Idempotent: safe to call on an already-provisioned agent.
|
|
1582
|
+
app.openapi(provisionAgentRoute, async (c) => {
|
|
1583
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1584
|
+
throw new ForbiddenError(
|
|
1585
|
+
"Only admin bearers and session users can provision agents",
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
const { id: agentId } = c.req.valid("param");
|
|
1589
|
+
|
|
1590
|
+
const agent = await agentService.getSummary(agentId);
|
|
1591
|
+
if (!agent) {
|
|
1592
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (agent.selfHosted) {
|
|
1596
|
+
return c.json({ skipped: true as const, reason: "self-hosted" }, 200);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const { resourceName, secretName, deploymentName } =
|
|
1600
|
+
await provisioner.provision(agent.id, {
|
|
1601
|
+
slug: agent.name,
|
|
1602
|
+
accountId: agent.accountId,
|
|
1603
|
+
});
|
|
1604
|
+
return c.json({ resourceName, secretName, deploymentName }, 200);
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
// GET /agents/:id — get full agent record including selfHosted and repos
|
|
1608
|
+
app.openapi(getAgentRoute, async (c) => {
|
|
1609
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1610
|
+
throw new ForbiddenError("Admin access required to get agent");
|
|
1611
|
+
}
|
|
1612
|
+
const { id: agentId } = c.req.valid("param");
|
|
1613
|
+
const agent = await agentService.getDetail(agentId);
|
|
1614
|
+
if (!agent) {
|
|
1615
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
1616
|
+
}
|
|
1617
|
+
return c.json(serializeAgent(agent), 200);
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
// PATCH /agents/:id — update agent fields (selfHosted, repos)
|
|
1621
|
+
app.openapi(patchAgentRoute, async (c) => {
|
|
1622
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1623
|
+
throw new ForbiddenError("Admin access required to update agent");
|
|
1624
|
+
}
|
|
1625
|
+
requireScope(c, "ops");
|
|
1626
|
+
const { id: agentId } = c.req.valid("param");
|
|
1627
|
+
const body = c.req.valid("json");
|
|
1628
|
+
|
|
1629
|
+
const existing = await agentService.exists(agentId);
|
|
1630
|
+
if (!existing) {
|
|
1631
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
1632
|
+
}
|
|
1633
|
+
// SSP-5.3: assigning an agent to an account is platform-admin only —
|
|
1634
|
+
// session members (admitted by isAdminOrSessionMember) must not move
|
|
1635
|
+
// agents between tenants.
|
|
1636
|
+
if (body.accountId !== undefined) {
|
|
1637
|
+
if (c.get("isAdmin") !== true) {
|
|
1638
|
+
throw new ForbiddenError("Admin access required to change accountId");
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
const collision =
|
|
1642
|
+
body.repos !== undefined ? describeRepoNameCollision(body.repos) : null;
|
|
1643
|
+
if (collision) {
|
|
1644
|
+
throw new BadRequestError(collision);
|
|
1645
|
+
}
|
|
1646
|
+
const agent = await agentService
|
|
1647
|
+
.updateSelfHosted(agentId, {
|
|
1648
|
+
selfHosted: body.selfHosted,
|
|
1649
|
+
...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
|
|
1650
|
+
...(body.repos !== undefined ? { repos: body.repos } : {}),
|
|
1651
|
+
...(body.reviewAuthorAllowlist !== undefined
|
|
1652
|
+
? { reviewAuthorAllowlist: body.reviewAuthorAllowlist }
|
|
1653
|
+
: {}),
|
|
1654
|
+
...(body.patchAuthorAllowlist !== undefined
|
|
1655
|
+
? { patchAuthorAllowlist: body.patchAuthorAllowlist }
|
|
1656
|
+
: {}),
|
|
1657
|
+
...(body.restrictSlackToMembers !== undefined
|
|
1658
|
+
? { restrictSlackToMembers: body.restrictSlackToMembers }
|
|
1659
|
+
: {}),
|
|
1660
|
+
...(body.slackId !== undefined ? { slackId: body.slackId } : {}),
|
|
1661
|
+
...(body.trialExpiresAt !== undefined
|
|
1662
|
+
? {
|
|
1663
|
+
trialExpiresAt: body.trialExpiresAt
|
|
1664
|
+
? new Date(body.trialExpiresAt)
|
|
1665
|
+
: null,
|
|
1666
|
+
}
|
|
1667
|
+
: {}),
|
|
1668
|
+
...(body.autoPostReviews !== undefined
|
|
1669
|
+
? { autoPostReviews: body.autoPostReviews }
|
|
1670
|
+
: {}),
|
|
1671
|
+
...(body.allowSelfReview !== undefined
|
|
1672
|
+
? { allowSelfReview: body.allowSelfReview }
|
|
1673
|
+
: {}),
|
|
1674
|
+
...(body.minConfidence !== undefined
|
|
1675
|
+
? { minConfidence: body.minConfidence }
|
|
1676
|
+
: {}),
|
|
1677
|
+
...(body.maxFindings !== undefined
|
|
1678
|
+
? { maxFindings: body.maxFindings }
|
|
1679
|
+
: {}),
|
|
1680
|
+
...(body.cleanupMergedWorktrees !== undefined
|
|
1681
|
+
? { cleanupMergedWorktrees: body.cleanupMergedWorktrees }
|
|
1682
|
+
: {}),
|
|
1683
|
+
...(body.cleanupAfterDays !== undefined
|
|
1684
|
+
? { cleanupAfterDays: body.cleanupAfterDays }
|
|
1685
|
+
: {}),
|
|
1686
|
+
})
|
|
1687
|
+
.catch((err: unknown) => {
|
|
1688
|
+
// FK violation: Agent.accountId references a nonexistent Account.
|
|
1689
|
+
if ((err as { code?: string } | null)?.code === "P2003") {
|
|
1690
|
+
throw new NotFoundError(`account ${body.accountId} not found`);
|
|
1691
|
+
}
|
|
1692
|
+
throw err;
|
|
1693
|
+
});
|
|
1694
|
+
const slackWarning = await computeRestrictSlackToMembersWarning(
|
|
1695
|
+
agentMemberService,
|
|
1696
|
+
agentId,
|
|
1697
|
+
agent.restrictSlackToMembers,
|
|
1698
|
+
);
|
|
1699
|
+
const reconcileWarning =
|
|
1700
|
+
body.accountId !== undefined
|
|
1701
|
+
? await reconcileAgentAfterAccountChange(provisioner, agent)
|
|
1702
|
+
: undefined;
|
|
1703
|
+
const warning =
|
|
1704
|
+
[slackWarning, reconcileWarning].filter(Boolean).join("; ") || undefined;
|
|
1705
|
+
return c.json(serializeAgent(agent, warning), 200);
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
// POST /agents/:id/repos — additive repo attach (same gating as PATCH).
|
|
1709
|
+
// Registered with the other /agents/:id routes; no static /agents/repos
|
|
1710
|
+
// route exists, and "/agents/{id}/repos" has an extra segment so it cannot
|
|
1711
|
+
// shadow or be shadowed by the single-segment "/agents/{id}".
|
|
1712
|
+
app.openapi(addAgentReposRoute, async (c) => {
|
|
1713
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1714
|
+
throw new ForbiddenError("Admin access required to update agent");
|
|
1715
|
+
}
|
|
1716
|
+
requireScope(c, "ops");
|
|
1717
|
+
const { id: agentId } = c.req.valid("param");
|
|
1718
|
+
const { repos: toAdd } = c.req.valid("json");
|
|
1719
|
+
const current = await agentService.getDetail(agentId);
|
|
1720
|
+
if (!current) {
|
|
1721
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
1722
|
+
}
|
|
1723
|
+
const merged = [...new Set([...current.repos, ...toAdd])];
|
|
1724
|
+
const collision = describeRepoNameCollision(merged);
|
|
1725
|
+
if (collision) {
|
|
1726
|
+
throw new BadRequestError(collision);
|
|
1727
|
+
}
|
|
1728
|
+
const agent = await agentService.updateSelfHosted(agentId, {
|
|
1729
|
+
selfHosted: current.selfHosted,
|
|
1730
|
+
repos: merged,
|
|
1731
|
+
});
|
|
1732
|
+
return c.json(serializeAgent(agent), 200);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
// DELETE /agents/:id/repos/:owner/:name — remove one repo.
|
|
1736
|
+
app.openapi(removeAgentRepoRoute, async (c) => {
|
|
1737
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1738
|
+
throw new ForbiddenError("Admin access required to update agent");
|
|
1739
|
+
}
|
|
1740
|
+
requireScope(c, "ops");
|
|
1741
|
+
const { id: agentId, owner, name } = c.req.valid("param");
|
|
1742
|
+
const current = await agentService.getDetail(agentId);
|
|
1743
|
+
if (!current) {
|
|
1744
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
1745
|
+
}
|
|
1746
|
+
const repo = `${owner}/${name}`;
|
|
1747
|
+
if (!current.repos.includes(repo)) {
|
|
1748
|
+
throw new NotFoundError(`agent ${agentId} does not have repo ${repo}`);
|
|
1749
|
+
}
|
|
1750
|
+
const agent = await agentService.updateSelfHosted(agentId, {
|
|
1751
|
+
selfHosted: current.selfHosted,
|
|
1752
|
+
repos: current.repos.filter((r) => r !== repo),
|
|
1753
|
+
});
|
|
1754
|
+
return c.json(serializeAgent(agent), 200);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// DELETE /agents/:id — delete an agent and tear down its workload (admin only)
|
|
1758
|
+
app.openapi(deleteAgentRoute, async (c) => {
|
|
1759
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1760
|
+
throw new ForbiddenError(
|
|
1761
|
+
"Only admin bearers and session users can delete agents",
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
const { id: agentId } = c.req.valid("param");
|
|
1765
|
+
|
|
1766
|
+
// Body is optional — an absent/empty body is the common case (most
|
|
1767
|
+
// current callers don't send one) and simply means no xoxpToken.
|
|
1768
|
+
const body = c.req.valid("json");
|
|
1769
|
+
|
|
1770
|
+
// Full teardown: K8s workload, task-store + chat-service tokens/threads,
|
|
1771
|
+
// optional Slack app deletion, then the Agent row itself (deleted last,
|
|
1772
|
+
// only if every automatable step succeeded). deleteAgentFully() does its
|
|
1773
|
+
// own findUnique and throws NotFoundError for an unknown id (→ 404 via
|
|
1774
|
+
// the existing onError handler), so no separate pre-check is needed here.
|
|
1775
|
+
// It catches per-step teardown failures internally and reports them in
|
|
1776
|
+
// the result rather than throwing, so this call is intentionally NOT
|
|
1777
|
+
// wrapped in try/catch — a 500 here would only mean a genuinely
|
|
1778
|
+
// unexpected error, which should propagate like any other ApiError.
|
|
1779
|
+
const result = await deleteAgentFully(
|
|
1780
|
+
agentId,
|
|
1781
|
+
{
|
|
1782
|
+
prisma,
|
|
1783
|
+
provisioner,
|
|
1784
|
+
serviceIntegrations,
|
|
1785
|
+
chatService,
|
|
1786
|
+
slack,
|
|
1787
|
+
decrypt,
|
|
1788
|
+
},
|
|
1789
|
+
{ xoxpToken: body?.xoxpToken },
|
|
1790
|
+
);
|
|
1791
|
+
|
|
1792
|
+
return c.json(result, 200);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
// GET /agents — list all agents (id + name + selfHosted) for metrics name resolution.
|
|
1796
|
+
app.openapi(listAgentsRoute, async (c) => {
|
|
1797
|
+
const email = c.get("callerEmail");
|
|
1798
|
+
if (!isAdminOrSessionMember(c)) {
|
|
1799
|
+
throw new ForbiddenError("Admin access required to list agents");
|
|
1800
|
+
}
|
|
1801
|
+
// list() also selects the owner and identity IDs for reconcile targets;
|
|
1802
|
+
// they are not part of this response.
|
|
1803
|
+
const agents = (await agentService.list()).map(
|
|
1804
|
+
({
|
|
1805
|
+
ownerEmail: _o,
|
|
1806
|
+
anthropicIdentityName: _n,
|
|
1807
|
+
anthropicServiceAccountId: _s,
|
|
1808
|
+
anthropicFederationRuleId: _r,
|
|
1809
|
+
k8sServiceAccountName: _k,
|
|
1810
|
+
...summary
|
|
1811
|
+
}) => summary,
|
|
1812
|
+
);
|
|
1813
|
+
if (c.get("isAdmin") === true || email === undefined) {
|
|
1814
|
+
return c.json(agents, 200);
|
|
1815
|
+
}
|
|
1816
|
+
// Non-admin session: only agents the caller has an AgentMember row for.
|
|
1817
|
+
const scope = await callerScopeResolver(email, false);
|
|
1818
|
+
const memberAgentIds = new Set(scope.kind === "all" ? [] : scope.agentIds);
|
|
1819
|
+
return c.json(
|
|
1820
|
+
agents.filter((a) => memberAgentIds.has(a.id)),
|
|
1821
|
+
200,
|
|
1822
|
+
);
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
// POST /agents — create a new agent (admin only). Delegates to createAgent()
|
|
1826
|
+
// (agents.ts, APA-1.1) — the same implementation admin-ui.ts's POST
|
|
1827
|
+
// /admin/agents form handler calls, so this route is a second caller, not a
|
|
1828
|
+
// second implementation.
|
|
1829
|
+
app.openapi(createAgentRoute, async (c) => {
|
|
1830
|
+
const body = c.req.valid("json");
|
|
1831
|
+
|
|
1832
|
+
// SSP-4.2: platform admins keep today's behavior (and may create
|
|
1833
|
+
// unassigned agents). A session user with an account (flag on) may create
|
|
1834
|
+
// agents in THEIR account only: accountId comes from the resolved scope —
|
|
1835
|
+
// never the body — and the type is forced to "coding".
|
|
1836
|
+
let accountId: string | undefined;
|
|
1837
|
+
if (c.get("isAdmin") !== true) {
|
|
1838
|
+
const email = c.get("callerEmail");
|
|
1839
|
+
const callerAccountId =
|
|
1840
|
+
email === undefined
|
|
1841
|
+
? null
|
|
1842
|
+
: accountIdFromScope(await callerScopeResolver(email, false));
|
|
1843
|
+
if (callerAccountId === null) {
|
|
1844
|
+
throw new ForbiddenError("Admin access required to create agent");
|
|
1845
|
+
}
|
|
1846
|
+
accountId = callerAccountId;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const result = await createAgent(
|
|
1850
|
+
{
|
|
1851
|
+
// Bind each method explicitly rather than spreading `agentService`:
|
|
1852
|
+
// in production this is a real AgentService instance whose methods
|
|
1853
|
+
// live on the prototype, and object spread copies only own
|
|
1854
|
+
// enumerable properties — it would silently drop
|
|
1855
|
+
// create/delete/updateFields/runTransaction and throw at the first
|
|
1856
|
+
// call. Mirrors admin-ui.ts's POST /admin/agents wiring exactly.
|
|
1857
|
+
agentService: {
|
|
1858
|
+
create: agentService.create.bind(agentService),
|
|
1859
|
+
delete: agentService.delete.bind(agentService),
|
|
1860
|
+
updateFields: agentService.updateFields.bind(agentService),
|
|
1861
|
+
runTransaction: agentService.runTransaction.bind(agentService),
|
|
1862
|
+
},
|
|
1863
|
+
agentToolService,
|
|
1864
|
+
agentPluginService,
|
|
1865
|
+
agentMemberService,
|
|
1866
|
+
agentEnvService,
|
|
1867
|
+
agentCronJobService,
|
|
1868
|
+
provisioner,
|
|
1869
|
+
specialtyRegistry,
|
|
1870
|
+
anthropicFederationEnabled: deps.anthropicFederationEnabled,
|
|
1871
|
+
},
|
|
1872
|
+
{
|
|
1873
|
+
name: body.name,
|
|
1874
|
+
typeName:
|
|
1875
|
+
accountId !== undefined ? getSelfServeSpecialty() : body.typeName,
|
|
1876
|
+
...(accountId !== undefined ? { accountId } : {}),
|
|
1877
|
+
// PAI-1.2: owner comes from the authenticated caller only; the body
|
|
1878
|
+
// schema has no owner field, so a client-supplied one is stripped.
|
|
1879
|
+
ownerEmail: resolveOwnerEmail(c.get("callerEmail"), c.get("caller")),
|
|
1880
|
+
runtime: body.runtime,
|
|
1881
|
+
reposRaw: body.reposRaw,
|
|
1882
|
+
authorAllowlistRaw: body.authorAllowlistRaw,
|
|
1883
|
+
patchAuthorAllowlistRaw: body.patchAuthorAllowlistRaw,
|
|
1884
|
+
memberEmailsRaw: body.memberEmailsRaw,
|
|
1885
|
+
restrictSlackToMembersRaw: body.restrictSlackToMembersRaw,
|
|
1886
|
+
claudeCodeOauthToken: body.claudeCodeOauthToken,
|
|
1887
|
+
anthropicApiKey: body.anthropicApiKey,
|
|
1888
|
+
},
|
|
1889
|
+
);
|
|
1890
|
+
|
|
1891
|
+
if (!result.ok) {
|
|
1892
|
+
// createAgent()'s transactional design guarantees zero rows persist on
|
|
1893
|
+
// any of these failure codes — no partial-success 200, no redundant
|
|
1894
|
+
// cleanup needed here.
|
|
1895
|
+
if (isAccountCreateErrorCode(result.errorCode)) {
|
|
1896
|
+
throw new ForbiddenError(
|
|
1897
|
+
ACCOUNT_CREATE_ERROR_MESSAGES[result.errorCode],
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
if (result.errorCode === ANTHROPIC_CREDENTIALS_BLOCKED_CODE) {
|
|
1901
|
+
throw new AnthropicCredentialsBlockedError([
|
|
1902
|
+
...(body.claudeCodeOauthToken ? ["CLAUDE_CODE_OAUTH_TOKEN"] : []),
|
|
1903
|
+
...(body.anthropicApiKey ? ["ANTHROPIC_API_KEY"] : []),
|
|
1904
|
+
]);
|
|
1905
|
+
}
|
|
1906
|
+
throw new BadRequestError(
|
|
1907
|
+
result.errorMessage ?? `create agent failed: ${result.errorCode}`,
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
return c.json(serializeAgent(result.agent), 200);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
// ─── Env vars ──────────────────────────────────────────────────────────────
|
|
1915
|
+
|
|
1916
|
+
// POST /agents/:id/envs — replace all env vars (bulk upsert)
|
|
1917
|
+
app.openapi(upsertEnvsRoute, async (c) => {
|
|
1918
|
+
const { id: agentId } = c.req.valid("param");
|
|
1919
|
+
const body = c.req.valid("json");
|
|
1920
|
+
await agentEnvService.upsert(agentId, body);
|
|
1921
|
+
return c.json({ ok: true } as const, 201);
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
// GET /agents/:id/envs — get all env vars (decrypted, secret values masked)
|
|
1925
|
+
app.openapi(getEnvsRoute, async (c) => {
|
|
1926
|
+
const { id: agentId } = c.req.valid("param");
|
|
1927
|
+
const result = await agentEnvService.getByAgentId(agentId);
|
|
1928
|
+
return c.json(
|
|
1929
|
+
{ env: result?.env ?? {}, secretKeys: result?.secretKeys ?? [] },
|
|
1930
|
+
200,
|
|
1931
|
+
);
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// PATCH /agents/:id/envs — update specific keys (without replacing all)
|
|
1935
|
+
app.openapi(patchEnvsRoute, async (c) => {
|
|
1936
|
+
const { id: agentId } = c.req.valid("param");
|
|
1937
|
+
const { env, secretKeys } = c.req.valid("json");
|
|
1938
|
+
await agentEnvService.patch(
|
|
1939
|
+
agentId,
|
|
1940
|
+
env,
|
|
1941
|
+
secretKeys ? new Set(secretKeys) : undefined,
|
|
1942
|
+
);
|
|
1943
|
+
return c.json({ ok: true } as const, 200);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// DELETE /agents/:id/envs/:key — delete a single key
|
|
1947
|
+
app.openapi(deleteEnvKeyRoute, async (c) => {
|
|
1948
|
+
const { id: agentId, key } = c.req.valid("param");
|
|
1949
|
+
await agentEnvService.deleteKey(agentId, key);
|
|
1950
|
+
return c.body(null, 204);
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// ─── Cron jobs ─────────────────────────────────────────────────────────────
|
|
1954
|
+
|
|
1955
|
+
// POST /agents/:id/crons — create a cron job
|
|
1956
|
+
app.openapi(createCronRoute, async (c) => {
|
|
1957
|
+
const { id: agentId } = c.req.valid("param");
|
|
1958
|
+
const body = c.req.valid("json");
|
|
1959
|
+
const cron = await agentCronJobService.create(agentId, body);
|
|
1960
|
+
return c.json({ cron: serializeCron(cron) }, 201);
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
// POST /agents/:id/crons/reconcile — static path must precede /:cronId routes
|
|
1964
|
+
app.openapi(reconcileCronsRoute, async (c) => {
|
|
1965
|
+
const { id: agentId } = c.req.valid("param");
|
|
1966
|
+
const result = await agentCronJobService.reconcileSystemCrons(agentId);
|
|
1967
|
+
return c.json(result, 200);
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
// PATCH /agents/:id/crons/:cronId — update a cron job
|
|
1971
|
+
//
|
|
1972
|
+
// schedule and prompt must be provided together (content update).
|
|
1973
|
+
// enabled and preCheck are orthogonal — each may be sent alone or combined
|
|
1974
|
+
// with a content update or with each other. Empty body returns 400.
|
|
1975
|
+
// System crons (system: true) reject schedule/prompt/preCheck changes with
|
|
1976
|
+
// 403, but an enabled-only body is allowed through to setEnabled().
|
|
1977
|
+
// These are business-logic checks (not schema-level), so they remain manual.
|
|
1978
|
+
app.openapi(patchCronRoute, async (c) => {
|
|
1979
|
+
const { id: agentId, cronId } = c.req.valid("param");
|
|
1980
|
+
const body = c.req.valid("json");
|
|
1981
|
+
|
|
1982
|
+
const hasSchedule = body.schedule != null;
|
|
1983
|
+
const hasPrompt = body.prompt != null;
|
|
1984
|
+
const hasEnabled = body.enabled !== undefined;
|
|
1985
|
+
const hasPreCheck = body.preCheck !== undefined;
|
|
1986
|
+
|
|
1987
|
+
if (!hasSchedule && !hasPrompt && !hasEnabled && !hasPreCheck) {
|
|
1988
|
+
throw new BadRequestError(
|
|
1989
|
+
"provide at least one field: schedule+prompt, enabled, or preCheck",
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
if (hasSchedule !== hasPrompt) {
|
|
1993
|
+
throw new BadRequestError(
|
|
1994
|
+
"schedule and prompt must be provided together",
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const existing = await agentCronJobService.get(agentId, cronId);
|
|
1999
|
+
if (existing.system && (hasSchedule || hasPrompt || hasPreCheck)) {
|
|
2000
|
+
throw new ForbiddenError(
|
|
2001
|
+
"system crons cannot have schedule, prompt, or preCheck updated",
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
let cron: Awaited<ReturnType<AgentCronJobService["update"]>> | undefined;
|
|
2006
|
+
if (hasSchedule && hasPrompt) {
|
|
2007
|
+
// Content update — preCheck and enabled are folded into update()
|
|
2008
|
+
cron = await agentCronJobService.update(agentId, cronId, {
|
|
2009
|
+
schedule: body.schedule as string,
|
|
2010
|
+
prompt: body.prompt as string,
|
|
2011
|
+
channel: body.channel,
|
|
2012
|
+
user: body.user,
|
|
2013
|
+
silent: body.silent,
|
|
2014
|
+
preCheck: body.preCheck,
|
|
2015
|
+
enabled: body.enabled,
|
|
2016
|
+
});
|
|
2017
|
+
} else {
|
|
2018
|
+
// Orthogonal-field-only update — apply each independently
|
|
2019
|
+
if (hasPreCheck) {
|
|
2020
|
+
cron = await agentCronJobService.updatePreCheck(
|
|
2021
|
+
agentId,
|
|
2022
|
+
cronId,
|
|
2023
|
+
body.preCheck as string | null,
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
if (hasEnabled) {
|
|
2027
|
+
cron = await agentCronJobService.setEnabled(
|
|
2028
|
+
agentId,
|
|
2029
|
+
cronId,
|
|
2030
|
+
body.enabled as boolean,
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
// When both ran, fetch final state so the response reflects all writes.
|
|
2034
|
+
if (hasPreCheck && hasEnabled) {
|
|
2035
|
+
cron = await agentCronJobService.get(agentId, cronId);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// Narrowing: cron is always set here since at least one update ran (guarded
|
|
2040
|
+
// above), but TypeScript cannot prove it through the disjoint if-blocks.
|
|
2041
|
+
if (cron === undefined) {
|
|
2042
|
+
throw new BadRequestError(
|
|
2043
|
+
"provide at least one field: schedule+prompt, enabled, or preCheck",
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
return c.json({ cron: serializeCron(cron) }, 200);
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
// DELETE /agents/:id/crons/:cronId — delete a cron job
|
|
2051
|
+
app.openapi(deleteCronRoute, async (c) => {
|
|
2052
|
+
const { id: agentId, cronId } = c.req.valid("param");
|
|
2053
|
+
const cron = await agentCronJobService.get(agentId, cronId);
|
|
2054
|
+
if (cron.system) {
|
|
2055
|
+
throw new ForbiddenError("system crons cannot be deleted");
|
|
2056
|
+
}
|
|
2057
|
+
await agentCronJobService.delete(agentId, cronId);
|
|
2058
|
+
return c.body(null, 204);
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
// GET /agents/:id/crons — list cron jobs with run summary
|
|
2062
|
+
app.openapi(listCronsRoute, async (c) => {
|
|
2063
|
+
const { id: agentId } = c.req.valid("param");
|
|
2064
|
+
const jobs = await agentCronJobService.listWithRunSummary(agentId);
|
|
2065
|
+
return c.json({ crons: jobs.map(serializeCronWithSummary) }, 200);
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
// POST /agents/:id/crons/:cronId/runs — create a cron run record
|
|
2069
|
+
// Static path /runs suffix must follow the :cronId param routes
|
|
2070
|
+
app.openapi(createCronRunRoute, async (c) => {
|
|
2071
|
+
const { id: agentId, cronId } = c.req.valid("param");
|
|
2072
|
+
const body = c.req.valid("json");
|
|
2073
|
+
const run = await agentCronRunService.create(cronId, agentId, {
|
|
2074
|
+
startedAt: new Date(body.startedAt),
|
|
2075
|
+
completedAt: body.completedAt ? new Date(body.completedAt) : null,
|
|
2076
|
+
skipped: body.skipped,
|
|
2077
|
+
skipReason: body.skipReason,
|
|
2078
|
+
outcome: body.outcome,
|
|
2079
|
+
error: body.error,
|
|
2080
|
+
phaseId: body.phaseId,
|
|
2081
|
+
itemType: body.itemType,
|
|
2082
|
+
itemId: body.itemId,
|
|
2083
|
+
commandVariant: body.commandVariant,
|
|
2084
|
+
});
|
|
2085
|
+
return c.json({ run: serializeCronRun(run) }, 201);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
// GET /agents/:id/crons/:cronId/runs — list cron runs (paginated)
|
|
2089
|
+
app.openapi(listCronRunsRoute, async (c) => {
|
|
2090
|
+
const { id: agentId, cronId } = c.req.valid("param");
|
|
2091
|
+
const { limit, offset, itemId, phaseId } = c.req.valid("query");
|
|
2092
|
+
const result = await agentCronRunService.list(cronId, agentId, {
|
|
2093
|
+
limit,
|
|
2094
|
+
offset,
|
|
2095
|
+
itemId,
|
|
2096
|
+
phaseId,
|
|
2097
|
+
});
|
|
2098
|
+
return c.json(
|
|
2099
|
+
{
|
|
2100
|
+
items: result.items.map(serializeCronRun),
|
|
2101
|
+
total: result.total,
|
|
2102
|
+
limit: result.limit,
|
|
2103
|
+
offset: result.offset,
|
|
2104
|
+
},
|
|
2105
|
+
200,
|
|
2106
|
+
);
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
// PATCH /agents/:id/crons/:cronId/runs/:runId — update a cron run record
|
|
2110
|
+
app.openapi(patchCronRunRoute, async (c) => {
|
|
2111
|
+
const { id: agentId, cronId, runId } = c.req.valid("param");
|
|
2112
|
+
const body = c.req.valid("json");
|
|
2113
|
+
|
|
2114
|
+
// At least one field must be provided
|
|
2115
|
+
if (Object.keys(body).length === 0) {
|
|
2116
|
+
throw new BadRequestError("provide at least one field to update");
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const run = await agentCronRunService.patch(runId, agentId, cronId, {
|
|
2120
|
+
...(body.completedAt !== undefined && {
|
|
2121
|
+
completedAt: body.completedAt ? new Date(body.completedAt) : null,
|
|
2122
|
+
}),
|
|
2123
|
+
...(body.outcome !== undefined && { outcome: body.outcome }),
|
|
2124
|
+
...(body.error !== undefined && { error: body.error }),
|
|
2125
|
+
...(body.skipped !== undefined && { skipped: body.skipped }),
|
|
2126
|
+
...(body.skipReason !== undefined && { skipReason: body.skipReason }),
|
|
2127
|
+
...(body.inputTokens !== undefined && { inputTokens: body.inputTokens }),
|
|
2128
|
+
...(body.outputTokens !== undefined && {
|
|
2129
|
+
outputTokens: body.outputTokens,
|
|
2130
|
+
}),
|
|
2131
|
+
...(body.cacheReadTokens !== undefined && {
|
|
2132
|
+
cacheReadTokens: body.cacheReadTokens,
|
|
2133
|
+
}),
|
|
2134
|
+
...(body.cacheCreationTokens !== undefined && {
|
|
2135
|
+
cacheCreationTokens: body.cacheCreationTokens,
|
|
2136
|
+
}),
|
|
2137
|
+
...(body.sessionId !== undefined && { sessionId: body.sessionId }),
|
|
2138
|
+
...(body.lastHeartbeatAt !== undefined && {
|
|
2139
|
+
lastHeartbeatAt: body.lastHeartbeatAt
|
|
2140
|
+
? new Date(body.lastHeartbeatAt)
|
|
2141
|
+
: null,
|
|
2142
|
+
}),
|
|
2143
|
+
...(body.modelBreakdown !== undefined && {
|
|
2144
|
+
modelBreakdown: body.modelBreakdown,
|
|
2145
|
+
}),
|
|
2146
|
+
...(body.contextBaseline !== undefined && {
|
|
2147
|
+
contextBaseline: body.contextBaseline,
|
|
2148
|
+
}),
|
|
2149
|
+
...(body.turns !== undefined && { turns: body.turns }),
|
|
2150
|
+
...(body.toolCalls !== undefined && { toolCalls: body.toolCalls }),
|
|
2151
|
+
...(body.contextFingerprint !== undefined && {
|
|
2152
|
+
contextFingerprint: body.contextFingerprint,
|
|
2153
|
+
}),
|
|
2154
|
+
...(body.pluginVersion !== undefined && {
|
|
2155
|
+
pluginVersion: body.pluginVersion,
|
|
2156
|
+
}),
|
|
2157
|
+
...(body.claudeCodeVersion !== undefined && {
|
|
2158
|
+
claudeCodeVersion: body.claudeCodeVersion,
|
|
2159
|
+
}),
|
|
2160
|
+
...(body.skillUsage !== undefined && { skillUsage: body.skillUsage }),
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
return c.json({ run: serializeCronRun(run) }, 200);
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
// ─── Tools ─────────────────────────────────────────────────────────────────
|
|
2167
|
+
|
|
2168
|
+
// POST /agents/:id/tools — add a tool pattern
|
|
2169
|
+
app.openapi(createToolRoute, async (c) => {
|
|
2170
|
+
const { id: agentId } = c.req.valid("param");
|
|
2171
|
+
const body = c.req.valid("json");
|
|
2172
|
+
const tool = await agentToolService.add(agentId, body.pattern);
|
|
2173
|
+
return c.json({ tool: serializeTool(tool) }, 201);
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
// GET /agents/:id/tools — list tool patterns
|
|
2177
|
+
app.openapi(listToolsRoute, async (c) => {
|
|
2178
|
+
const { id: agentId } = c.req.valid("param");
|
|
2179
|
+
const tools = await agentToolService.list(agentId);
|
|
2180
|
+
return c.json({ tools: tools.map(serializeTool) }, 200);
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
// PATCH /agents/:id/tools/:toolId — enable or disable a tool pattern
|
|
2184
|
+
app.openapi(patchToolRoute, async (c) => {
|
|
2185
|
+
const { id: agentId, toolId } = c.req.valid("param");
|
|
2186
|
+
const body = c.req.valid("json");
|
|
2187
|
+
const tool = await agentToolService.toggle(agentId, toolId, body.enabled);
|
|
2188
|
+
return c.json({ tool: serializeTool(tool) }, 200);
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
// DELETE /agents/:id/tools/:toolId — remove a tool pattern
|
|
2192
|
+
app.openapi(deleteToolRoute, async (c) => {
|
|
2193
|
+
const { id: agentId, toolId } = c.req.valid("param");
|
|
2194
|
+
await agentToolService.remove(agentId, toolId);
|
|
2195
|
+
return c.body(null, 204);
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
// ─── Tokens ────────────────────────────────────────────────────────────────
|
|
2199
|
+
|
|
2200
|
+
// POST /agents/:id/tokens — create a token (returns raw once)
|
|
2201
|
+
app.openapi(createTokenRoute, async (c) => {
|
|
2202
|
+
const { id: agentId } = c.req.valid("param");
|
|
2203
|
+
const body = c.req.valid("json");
|
|
2204
|
+
const { token, rawToken } = await agentTokenService.create(
|
|
2205
|
+
agentId,
|
|
2206
|
+
body?.label,
|
|
2207
|
+
);
|
|
2208
|
+
// Return the token record (without the hashed token value) + rawToken
|
|
2209
|
+
const { token: _hash, ...tokenMeta } = token;
|
|
2210
|
+
return c.json(
|
|
2211
|
+
{
|
|
2212
|
+
token: {
|
|
2213
|
+
...tokenMeta,
|
|
2214
|
+
createdAt: tokenMeta.createdAt.toISOString(),
|
|
2215
|
+
revokedAt: tokenMeta.revokedAt
|
|
2216
|
+
? tokenMeta.revokedAt.toISOString()
|
|
2217
|
+
: null,
|
|
2218
|
+
},
|
|
2219
|
+
rawToken,
|
|
2220
|
+
},
|
|
2221
|
+
201,
|
|
2222
|
+
);
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
// GET /agents/:id/tokens — list tokens (hash metadata only)
|
|
2226
|
+
app.openapi(listTokensRoute, async (c) => {
|
|
2227
|
+
const { id: agentId } = c.req.valid("param");
|
|
2228
|
+
const records = await agentTokenService.listForAgent(agentId);
|
|
2229
|
+
// Never expose the stored hash — return only metadata
|
|
2230
|
+
const tokens = records.map(({ token: _hash, ...meta }) => ({
|
|
2231
|
+
...meta,
|
|
2232
|
+
createdAt: meta.createdAt.toISOString(),
|
|
2233
|
+
revokedAt: meta.revokedAt ? meta.revokedAt.toISOString() : null,
|
|
2234
|
+
}));
|
|
2235
|
+
return c.json({ tokens }, 200);
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
// DELETE /agents/:id/tokens/:tokenId — revoke a token
|
|
2239
|
+
app.openapi(deleteTokenRoute, async (c) => {
|
|
2240
|
+
const { tokenId } = c.req.valid("param");
|
|
2241
|
+
await agentTokenService.revoke(tokenId);
|
|
2242
|
+
return c.body(null, 204);
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
// ─── Chat credentials ──────────────────────────────────────────────────────
|
|
2246
|
+
|
|
2247
|
+
// Shared gate: flag (404 when off) → caller (`*` / platform.ops only, never
|
|
2248
|
+
// an agent's own key) → client wired (503). Returns the client.
|
|
2249
|
+
const chatCredentialClient = (c: {
|
|
2250
|
+
get: (k: "isAdmin" | "scope") => unknown;
|
|
2251
|
+
}) => {
|
|
2252
|
+
if (!deps.chatCredentialsEnabled) throw new NotFoundError();
|
|
2253
|
+
const kind = (c.get("scope") as AdminScope | undefined)?.kind;
|
|
2254
|
+
if (c.get("isAdmin") !== true && kind !== "platform-ops") {
|
|
2255
|
+
throw new ForbiddenError(
|
|
2256
|
+
"Only * and platform.ops callers can manage chat credentials",
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
return deps.chatCredentials ?? null;
|
|
2260
|
+
};
|
|
2261
|
+
const chatCredentialsUnavailable = { error: "Chat service not configured" };
|
|
2262
|
+
|
|
2263
|
+
// POST /agents/:id/chat-credentials — mint a chat token scoped to the agent
|
|
2264
|
+
app.openapi(createChatCredentialRoute, async (c) => {
|
|
2265
|
+
const client = chatCredentialClient(c);
|
|
2266
|
+
if (!client) return c.json(chatCredentialsUnavailable, 503);
|
|
2267
|
+
const { id: agentId } = c.req.valid("param");
|
|
2268
|
+
const { label } = c.req.valid("json");
|
|
2269
|
+
if (!(await agentService.exists(agentId))) {
|
|
2270
|
+
throw new NotFoundError(`agent ${agentId} not found`);
|
|
2271
|
+
}
|
|
2272
|
+
const { id: tokenId, rawToken } = await client.mintToken(label, agentId);
|
|
2273
|
+
// Never log rawToken.
|
|
2274
|
+
console.log(
|
|
2275
|
+
JSON.stringify({
|
|
2276
|
+
event: "chat-credential.mint",
|
|
2277
|
+
agentId,
|
|
2278
|
+
tokenId,
|
|
2279
|
+
label,
|
|
2280
|
+
callerLabel: callerLabel(c.get("caller"), c.get("scope")?.kind),
|
|
2281
|
+
}),
|
|
2282
|
+
);
|
|
2283
|
+
return c.json({ tokenId, rawToken, label, agentId }, 201);
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
// GET /agents/:id/chat-credentials — list (no raw values)
|
|
2287
|
+
app.openapi(listChatCredentialsRoute, async (c) => {
|
|
2288
|
+
const client = chatCredentialClient(c);
|
|
2289
|
+
if (!client) return c.json(chatCredentialsUnavailable, 503);
|
|
2290
|
+
const { id: agentId } = c.req.valid("param");
|
|
2291
|
+
const tokens = await client.listTokensForAgent(agentId);
|
|
2292
|
+
return c.json(
|
|
2293
|
+
{
|
|
2294
|
+
credentials: tokens.map((t) => ({
|
|
2295
|
+
tokenId: t.id,
|
|
2296
|
+
label: t.label ?? null,
|
|
2297
|
+
agentId,
|
|
2298
|
+
})),
|
|
2299
|
+
},
|
|
2300
|
+
200,
|
|
2301
|
+
);
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
// DELETE /agents/:id/chat-credentials/:tokenId — revoke
|
|
2305
|
+
app.openapi(deleteChatCredentialRoute, async (c) => {
|
|
2306
|
+
const client = chatCredentialClient(c);
|
|
2307
|
+
if (!client) return c.json(chatCredentialsUnavailable, 503);
|
|
2308
|
+
const { id: agentId, tokenId } = c.req.valid("param");
|
|
2309
|
+
// Only tokens scoped to this agent may be revoked through its path.
|
|
2310
|
+
const tokens = await client.listTokensForAgent(agentId);
|
|
2311
|
+
const token = tokens.find((t) => t.id === tokenId);
|
|
2312
|
+
if (!token) {
|
|
2313
|
+
throw new NotFoundError(
|
|
2314
|
+
`chat credential ${tokenId} not found for agent ${agentId}`,
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
await client.revokeToken(tokenId);
|
|
2318
|
+
console.log(
|
|
2319
|
+
JSON.stringify({
|
|
2320
|
+
event: "chat-credential.revoke",
|
|
2321
|
+
agentId,
|
|
2322
|
+
tokenId,
|
|
2323
|
+
label: token.label ?? null,
|
|
2324
|
+
callerLabel: callerLabel(c.get("caller"), c.get("scope")?.kind),
|
|
2325
|
+
}),
|
|
2326
|
+
);
|
|
2327
|
+
return c.body(null, 204);
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
// ─── Agent ask (COS-31.1) ─────────────────────────────────────────────────
|
|
2331
|
+
|
|
2332
|
+
registerAgentAskRoutes(app, {
|
|
2333
|
+
agentAsk: deps.agentAsk,
|
|
2334
|
+
chatClient: deps.chatClient,
|
|
2335
|
+
agentService,
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
// ─── Plugins ───────────────────────────────────────────────────────────────
|
|
2339
|
+
|
|
2340
|
+
// POST /agents/:id/plugins — add a plugin
|
|
2341
|
+
app.openapi(createPluginRoute, async (c) => {
|
|
2342
|
+
const { id: agentId } = c.req.valid("param");
|
|
2343
|
+
const body = c.req.valid("json");
|
|
2344
|
+
const plugin = await agentPluginService.add(
|
|
2345
|
+
agentId,
|
|
2346
|
+
body.name,
|
|
2347
|
+
body.version,
|
|
2348
|
+
);
|
|
2349
|
+
return c.json({ plugin: serializePlugin(plugin) }, 201);
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
// GET /agents/:id/plugins — list plugins
|
|
2353
|
+
app.openapi(listPluginsRoute, async (c) => {
|
|
2354
|
+
const { id: agentId } = c.req.valid("param");
|
|
2355
|
+
const plugins = await agentPluginService.list(agentId);
|
|
2356
|
+
return c.json({ plugins: plugins.map(serializePlugin) }, 200);
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
// PATCH /agents/:id/plugins?name=<name> — update plugin version (re-upsert)
|
|
2360
|
+
// Uses a query param rather than a path segment because a canonical spec like
|
|
2361
|
+
// "my-plugin@org/my-marketplace" can contain a literal "/" that breaks path
|
|
2362
|
+
// matching. The name param is validated by Zod (PluginNameQuerySchema).
|
|
2363
|
+
app.openapi(patchPluginRoute, async (c) => {
|
|
2364
|
+
const { id: agentId } = c.req.valid("param");
|
|
2365
|
+
const { name } = c.req.valid("query");
|
|
2366
|
+
const body = c.req.valid("json");
|
|
2367
|
+
const plugin = await agentPluginService.add(agentId, name, body.version);
|
|
2368
|
+
return c.json({ plugin: serializePlugin(plugin) }, 200);
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
// DELETE /agents/:id/plugins?name=<name> — remove a plugin by name
|
|
2372
|
+
app.openapi(deletePluginRoute, async (c) => {
|
|
2373
|
+
const { id: agentId } = c.req.valid("param");
|
|
2374
|
+
const { name } = c.req.valid("query");
|
|
2375
|
+
await agentPluginService.removeByName(agentId, name);
|
|
2376
|
+
return c.body(null, 204);
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
// ─── Cron-run token stats ──────────────────────────────────────────────────
|
|
2380
|
+
|
|
2381
|
+
// GET /agents/all/cron-runs/stats — aggregated token stats across all agents
|
|
2382
|
+
// Static path "all" must be registered before any /:id routes to prevent
|
|
2383
|
+
// the literal "all" being matched as an agentId.
|
|
2384
|
+
app.openapi(cronRunTokenStatsRoute, async (c) => {
|
|
2385
|
+
if (c.get("isAdmin") !== true) {
|
|
2386
|
+
throw new ForbiddenError(
|
|
2387
|
+
"Only admin bearers and session users can access cross-agent stats",
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
const { from, to } = c.req.valid("query");
|
|
2391
|
+
const stats = await agentCronRunStatsService.query(from, to);
|
|
2392
|
+
return c.json(stats, 200);
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
// GET /agents/all/cron-runs/outcomes — per-(phase, contextFingerprint) outcomes
|
|
2396
|
+
app.openapi(cronRunOutcomesRoute, async (c) => {
|
|
2397
|
+
if (c.get("isAdmin") !== true) {
|
|
2398
|
+
throw new ForbiddenError(
|
|
2399
|
+
"Only admin bearers and session users can access cross-agent stats",
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
const { from, to } = c.req.valid("query");
|
|
2403
|
+
const outcomes = await agentCronRunStatsService.outcomes(from, to);
|
|
2404
|
+
return c.json(outcomes, 200);
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
// GET /agents/:id/cron-runs/stats — self-serve stats for one agent. Auth is the
|
|
2408
|
+
// generic agentId-match middleware (api-auth.ts); registered after the static
|
|
2409
|
+
// /agents/all/... routes so it cannot swallow them.
|
|
2410
|
+
app.openapi(agentCronRunTokenStatsRoute, async (c) => {
|
|
2411
|
+
const { id } = c.req.valid("param");
|
|
2412
|
+
const { from, to } = c.req.valid("query");
|
|
2413
|
+
const stats = await agentCronRunStatsService.query(from, to, id);
|
|
2414
|
+
return c.json(stats, 200);
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
// GET /agents/:id/cron-runs/outcomes — self-serve outcomes for one agent. Auth is
|
|
2418
|
+
// the generic agentId-match middleware (api-auth.ts); registered after the
|
|
2419
|
+
// static /agents/all/... routes so it cannot swallow them.
|
|
2420
|
+
app.openapi(agentCronRunOutcomesRoute, async (c) => {
|
|
2421
|
+
const { id } = c.req.valid("param");
|
|
2422
|
+
const { from, to } = c.req.valid("query");
|
|
2423
|
+
const outcomes = await agentCronRunStatsService.outcomes(from, to, id);
|
|
2424
|
+
return c.json(outcomes, 200);
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
// GET /agents/chat-tokens/daily/stats — aggregated daily chat token stats
|
|
2428
|
+
// Static path "chat-tokens" must be registered before any /:id routes to
|
|
2429
|
+
// prevent "chat-tokens" from being matched as an agentId.
|
|
2430
|
+
app.openapi(chatTokenDailyStatsRoute, async (c) => {
|
|
2431
|
+
if (c.get("isAdmin") !== true) {
|
|
2432
|
+
throw new ForbiddenError(
|
|
2433
|
+
"Only admin bearers and session users can access cross-agent stats",
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
const { from, to } = c.req.valid("query");
|
|
2437
|
+
const stats = await agentChatTokenService.queryStats(from, to);
|
|
2438
|
+
return c.json(stats, 200);
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
// GET /agents/{id}/chat-tokens/daily/stats — same stats scoped to one agent.
|
|
2442
|
+
// No isAdmin check: the generic agent-route auth middleware already rejects
|
|
2443
|
+
// a bearer whose agentId doesn't match the path. Registered after the static
|
|
2444
|
+
// /agents/chat-tokens/... route above.
|
|
2445
|
+
app.openapi(selfChatTokenDailyStatsRoute, async (c) => {
|
|
2446
|
+
const { id: agentId } = c.req.valid("param");
|
|
2447
|
+
const { from, to } = c.req.valid("query");
|
|
2448
|
+
const stats = await agentChatTokenService.queryStats(from, to, agentId);
|
|
2449
|
+
return c.json(stats, 200);
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
// ─── Chat token usage ──────────────────────────────────────────────────────
|
|
2453
|
+
|
|
2454
|
+
// POST /agents/:id/chat-tokens/daily — atomically accumulate daily chat token usage per model
|
|
2455
|
+
app.openapi(upsertChatTokenDailyRoute, async (c) => {
|
|
2456
|
+
const { id: agentId } = c.req.valid("param");
|
|
2457
|
+
const body = c.req.valid("json");
|
|
2458
|
+
const rows = await Promise.all(
|
|
2459
|
+
body.modelBreakdown.map((entry) =>
|
|
2460
|
+
agentChatTokenService.upsertDailyByModel(
|
|
2461
|
+
agentId,
|
|
2462
|
+
body.date,
|
|
2463
|
+
entry.model,
|
|
2464
|
+
{
|
|
2465
|
+
inputTokens: entry.inputTokens,
|
|
2466
|
+
outputTokens: entry.outputTokens,
|
|
2467
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
2468
|
+
cacheCreationTokens: entry.cacheCreationTokens,
|
|
2469
|
+
costUsd: entry.costUsd,
|
|
2470
|
+
},
|
|
2471
|
+
),
|
|
2472
|
+
),
|
|
2473
|
+
);
|
|
2474
|
+
return c.json(rows.map(serializeChatTokenDaily), 200);
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
// ─── GitHub installations snapshot ─────────────────────────────────────────
|
|
2478
|
+
|
|
2479
|
+
// PUT /agents/:id/github-installations — replace-all upsert of the single row
|
|
2480
|
+
app.openapi(putGitHubInstallationsRoute, async (c) => {
|
|
2481
|
+
const { id: agentId } = c.req.valid("param");
|
|
2482
|
+
const body = c.req.valid("json");
|
|
2483
|
+
const snapshot = await agentGitHubInstallationsService.push(agentId, {
|
|
2484
|
+
reportedAt: new Date(body.reportedAt),
|
|
2485
|
+
installations: body.installations,
|
|
2486
|
+
});
|
|
2487
|
+
return c.json(
|
|
2488
|
+
{ snapshot: serializeGitHubInstallationsSnapshot(snapshot) },
|
|
2489
|
+
200,
|
|
2490
|
+
);
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
// GET /agents/:id/github-installations — latest snapshot, 404 if none yet
|
|
2494
|
+
app.openapi(getGitHubInstallationsRoute, async (c) => {
|
|
2495
|
+
const { id: agentId } = c.req.valid("param");
|
|
2496
|
+
const snapshot = await agentGitHubInstallationsService.get(agentId);
|
|
2497
|
+
if (!snapshot) {
|
|
2498
|
+
throw new NotFoundError(
|
|
2499
|
+
`no github-installations snapshot for agent ${agentId}`,
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
return c.json(
|
|
2503
|
+
{ snapshot: serializeGitHubInstallationsSnapshot(snapshot) },
|
|
2504
|
+
200,
|
|
2505
|
+
);
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
return app;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// ─── Serializers ────────────────────────────────────────────────────────────────
|
|
2512
|
+
//
|
|
2513
|
+
// Prisma returns Date objects; the Zod response schemas expect ISO strings.
|
|
2514
|
+
// These helpers normalize date fields so c.json() output matches the schema.
|
|
2515
|
+
|
|
2516
|
+
function serializeCron(cron: {
|
|
2517
|
+
createdAt: Date;
|
|
2518
|
+
updatedAt: Date;
|
|
2519
|
+
[k: string]: unknown;
|
|
2520
|
+
}): z.infer<typeof AgentCronJobSchema> {
|
|
2521
|
+
return {
|
|
2522
|
+
...cron,
|
|
2523
|
+
createdAt: cron.createdAt.toISOString(),
|
|
2524
|
+
updatedAt: cron.updatedAt.toISOString(),
|
|
2525
|
+
} as z.infer<typeof AgentCronJobSchema>;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function serializeCronWithSummary(job: AgentCronJobWithRunSummary): z.infer<
|
|
2529
|
+
typeof AgentCronJobSchema
|
|
2530
|
+
> & {
|
|
2531
|
+
lastRun: {
|
|
2532
|
+
startedAt: string;
|
|
2533
|
+
completedAt: string | null;
|
|
2534
|
+
skipped: boolean;
|
|
2535
|
+
outcome: string | null;
|
|
2536
|
+
} | null;
|
|
2537
|
+
runCountToday: number;
|
|
2538
|
+
} {
|
|
2539
|
+
return {
|
|
2540
|
+
id: job.id,
|
|
2541
|
+
agentId: job.agentId,
|
|
2542
|
+
schedule: job.schedule,
|
|
2543
|
+
prompt: job.prompt,
|
|
2544
|
+
channel: job.channel,
|
|
2545
|
+
user: job.user,
|
|
2546
|
+
silent: job.silent,
|
|
2547
|
+
enabled: job.enabled,
|
|
2548
|
+
preCheck: job.preCheck,
|
|
2549
|
+
name: job.name,
|
|
2550
|
+
system: job.system,
|
|
2551
|
+
parentCronId: job.parentCronId,
|
|
2552
|
+
createdAt: job.createdAt.toISOString(),
|
|
2553
|
+
updatedAt: job.updatedAt.toISOString(),
|
|
2554
|
+
lastRun: job.lastRun
|
|
2555
|
+
? {
|
|
2556
|
+
startedAt: job.lastRun.startedAt.toISOString(),
|
|
2557
|
+
completedAt: job.lastRun.completedAt
|
|
2558
|
+
? job.lastRun.completedAt.toISOString()
|
|
2559
|
+
: null,
|
|
2560
|
+
skipped: job.lastRun.skipped,
|
|
2561
|
+
outcome: job.lastRun.outcome,
|
|
2562
|
+
}
|
|
2563
|
+
: null,
|
|
2564
|
+
runCountToday: job.runCountToday,
|
|
2565
|
+
} as ReturnType<typeof serializeCronWithSummary>;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function serializeCronRun(run: {
|
|
2569
|
+
id: string;
|
|
2570
|
+
cronId: string;
|
|
2571
|
+
agentId: string;
|
|
2572
|
+
startedAt: Date;
|
|
2573
|
+
completedAt: Date | null;
|
|
2574
|
+
skipped: boolean;
|
|
2575
|
+
skipReason: string | null;
|
|
2576
|
+
outcome: string | null;
|
|
2577
|
+
error: string | null;
|
|
2578
|
+
phaseId: string | null;
|
|
2579
|
+
itemType: string | null;
|
|
2580
|
+
itemId: string | null;
|
|
2581
|
+
commandVariant: string | null;
|
|
2582
|
+
sessionId: string | null;
|
|
2583
|
+
lastHeartbeatAt: Date | null;
|
|
2584
|
+
createdAt: Date;
|
|
2585
|
+
modelBreakdown?: ModelBreakdownEntry[];
|
|
2586
|
+
baselineModel?: string | null;
|
|
2587
|
+
baselineContextTokens?: number | null;
|
|
2588
|
+
baselineInputTokens?: number | null;
|
|
2589
|
+
baselineCacheCreationTokens?: number | null;
|
|
2590
|
+
baselineCacheReadTokens?: number | null;
|
|
2591
|
+
turns?: number | null;
|
|
2592
|
+
toolCalls?: number | null;
|
|
2593
|
+
contextFingerprint?: string | null;
|
|
2594
|
+
pluginVersion?: string | null;
|
|
2595
|
+
claudeCodeVersion?: string | null;
|
|
2596
|
+
}): z.infer<typeof AgentCronRunSchema> {
|
|
2597
|
+
// The baseline is reported as one object; null unless every column is set
|
|
2598
|
+
// (an older agent build, or a resumed session, leaves them all null).
|
|
2599
|
+
const contextBaseline =
|
|
2600
|
+
run.baselineModel != null &&
|
|
2601
|
+
run.baselineContextTokens != null &&
|
|
2602
|
+
run.baselineInputTokens != null &&
|
|
2603
|
+
run.baselineCacheCreationTokens != null &&
|
|
2604
|
+
run.baselineCacheReadTokens != null
|
|
2605
|
+
? {
|
|
2606
|
+
model: run.baselineModel,
|
|
2607
|
+
contextTokens: run.baselineContextTokens,
|
|
2608
|
+
inputTokens: run.baselineInputTokens,
|
|
2609
|
+
cacheCreationTokens: run.baselineCacheCreationTokens,
|
|
2610
|
+
cacheReadTokens: run.baselineCacheReadTokens,
|
|
2611
|
+
}
|
|
2612
|
+
: null;
|
|
2613
|
+
// Token totals are no longer columns on AgentCronRun — they're summed from
|
|
2614
|
+
// the per-model breakdown, the sole source of truth post-consolidation.
|
|
2615
|
+
// A run with no breakdown rows reports null (unknown), not zero.
|
|
2616
|
+
const breakdown = run.modelBreakdown ?? [];
|
|
2617
|
+
const tokenTotals =
|
|
2618
|
+
breakdown.length === 0
|
|
2619
|
+
? {
|
|
2620
|
+
inputTokens: null,
|
|
2621
|
+
outputTokens: null,
|
|
2622
|
+
cacheReadTokens: null,
|
|
2623
|
+
cacheCreationTokens: null,
|
|
2624
|
+
}
|
|
2625
|
+
: {
|
|
2626
|
+
inputTokens: breakdown.reduce((sum, m) => sum + m.inputTokens, 0),
|
|
2627
|
+
outputTokens: breakdown.reduce((sum, m) => sum + m.outputTokens, 0),
|
|
2628
|
+
cacheReadTokens: breakdown.reduce(
|
|
2629
|
+
(sum, m) => sum + m.cacheReadTokens,
|
|
2630
|
+
0,
|
|
2631
|
+
),
|
|
2632
|
+
cacheCreationTokens: breakdown.reduce(
|
|
2633
|
+
(sum, m) => sum + m.cacheCreationTokens,
|
|
2634
|
+
0,
|
|
2635
|
+
),
|
|
2636
|
+
};
|
|
2637
|
+
|
|
2638
|
+
return {
|
|
2639
|
+
id: run.id,
|
|
2640
|
+
cronId: run.cronId,
|
|
2641
|
+
agentId: run.agentId,
|
|
2642
|
+
startedAt: run.startedAt.toISOString(),
|
|
2643
|
+
completedAt: run.completedAt ? run.completedAt.toISOString() : null,
|
|
2644
|
+
skipped: run.skipped,
|
|
2645
|
+
skipReason: run.skipReason,
|
|
2646
|
+
outcome: run.outcome,
|
|
2647
|
+
error: run.error,
|
|
2648
|
+
phaseId: run.phaseId,
|
|
2649
|
+
itemType: run.itemType,
|
|
2650
|
+
itemId: run.itemId,
|
|
2651
|
+
commandVariant: run.commandVariant,
|
|
2652
|
+
sessionId: run.sessionId,
|
|
2653
|
+
lastHeartbeatAt: run.lastHeartbeatAt
|
|
2654
|
+
? run.lastHeartbeatAt.toISOString()
|
|
2655
|
+
: null,
|
|
2656
|
+
...tokenTotals,
|
|
2657
|
+
createdAt: run.createdAt.toISOString(),
|
|
2658
|
+
contextBaseline,
|
|
2659
|
+
turns: run.turns ?? null,
|
|
2660
|
+
toolCalls: run.toolCalls ?? null,
|
|
2661
|
+
contextFingerprint: run.contextFingerprint ?? null,
|
|
2662
|
+
pluginVersion: run.pluginVersion ?? null,
|
|
2663
|
+
claudeCodeVersion: run.claudeCodeVersion ?? null,
|
|
2664
|
+
...(run.modelBreakdown !== undefined && {
|
|
2665
|
+
modelBreakdown: run.modelBreakdown.map((entry) => ({
|
|
2666
|
+
model: entry.model,
|
|
2667
|
+
inputTokens: entry.inputTokens,
|
|
2668
|
+
outputTokens: entry.outputTokens,
|
|
2669
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
2670
|
+
cacheCreationTokens: entry.cacheCreationTokens,
|
|
2671
|
+
costUsd: entry.costUsd,
|
|
2672
|
+
})),
|
|
2673
|
+
}),
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function serializeTool(tool: {
|
|
2678
|
+
createdAt: Date;
|
|
2679
|
+
[k: string]: unknown;
|
|
2680
|
+
}): z.infer<typeof AgentToolSchema> {
|
|
2681
|
+
return {
|
|
2682
|
+
...tool,
|
|
2683
|
+
createdAt: tool.createdAt.toISOString(),
|
|
2684
|
+
} as z.infer<typeof AgentToolSchema>;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
function serializePlugin(plugin: {
|
|
2688
|
+
createdAt: Date;
|
|
2689
|
+
updatedAt: Date;
|
|
2690
|
+
[k: string]: unknown;
|
|
2691
|
+
}): z.infer<typeof AgentPluginSchema> {
|
|
2692
|
+
return {
|
|
2693
|
+
...plugin,
|
|
2694
|
+
createdAt: plugin.createdAt.toISOString(),
|
|
2695
|
+
updatedAt: plugin.updatedAt.toISOString(),
|
|
2696
|
+
} as z.infer<typeof AgentPluginSchema>;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function serializeAgent(
|
|
2700
|
+
agent: {
|
|
2701
|
+
id: string;
|
|
2702
|
+
name: string;
|
|
2703
|
+
slackId: string | null | undefined;
|
|
2704
|
+
selfHosted: boolean;
|
|
2705
|
+
repos?: string[];
|
|
2706
|
+
reviewAuthorAllowlist?: string[];
|
|
2707
|
+
patchAuthorAllowlist?: string[];
|
|
2708
|
+
restrictSlackToMembers?: boolean;
|
|
2709
|
+
typeName: string;
|
|
2710
|
+
accountId?: string | null;
|
|
2711
|
+
createdAt: Date;
|
|
2712
|
+
updatedAt: Date;
|
|
2713
|
+
missingRequiredEnv?: string[];
|
|
2714
|
+
trialExpiresAt?: Date | null;
|
|
2715
|
+
trialExpiryWarnedAt?: Date | null;
|
|
2716
|
+
autoPostReviews?: boolean;
|
|
2717
|
+
allowSelfReview?: boolean;
|
|
2718
|
+
minConfidence?: number;
|
|
2719
|
+
maxFindings?: number;
|
|
2720
|
+
cleanupMergedWorktrees?: boolean;
|
|
2721
|
+
cleanupAfterDays?: number;
|
|
2722
|
+
ownerEmail?: string | null;
|
|
2723
|
+
anthropicIdentityName?: string | null;
|
|
2724
|
+
anthropicServiceAccountId?: string | null;
|
|
2725
|
+
anthropicFederationRuleId?: string | null;
|
|
2726
|
+
},
|
|
2727
|
+
warning?: string,
|
|
2728
|
+
): z.infer<typeof GetAgentResultSchema> {
|
|
2729
|
+
return {
|
|
2730
|
+
id: agent.id,
|
|
2731
|
+
name: agent.name,
|
|
2732
|
+
slackId: agent.slackId,
|
|
2733
|
+
selfHosted: agent.selfHosted,
|
|
2734
|
+
repos: agent.repos ?? [],
|
|
2735
|
+
reviewAuthorAllowlist: agent.reviewAuthorAllowlist ?? [],
|
|
2736
|
+
patchAuthorAllowlist: agent.patchAuthorAllowlist ?? [],
|
|
2737
|
+
restrictSlackToMembers: agent.restrictSlackToMembers ?? false,
|
|
2738
|
+
typeName: agent.typeName,
|
|
2739
|
+
accountId: agent.accountId ?? null,
|
|
2740
|
+
createdAt: agent.createdAt.toISOString(),
|
|
2741
|
+
updatedAt: agent.updatedAt.toISOString(),
|
|
2742
|
+
missingRequiredEnv: agent.missingRequiredEnv ?? [],
|
|
2743
|
+
trialExpiresAt: agent.trialExpiresAt
|
|
2744
|
+
? agent.trialExpiresAt.toISOString()
|
|
2745
|
+
: null,
|
|
2746
|
+
trialExpiryWarnedAt: agent.trialExpiryWarnedAt
|
|
2747
|
+
? agent.trialExpiryWarnedAt.toISOString()
|
|
2748
|
+
: null,
|
|
2749
|
+
autoPostReviews: agent.autoPostReviews ?? true,
|
|
2750
|
+
allowSelfReview: agent.allowSelfReview ?? false,
|
|
2751
|
+
minConfidence: agent.minConfidence ?? 75,
|
|
2752
|
+
maxFindings: agent.maxFindings ?? 5,
|
|
2753
|
+
cleanupMergedWorktrees: agent.cleanupMergedWorktrees ?? true,
|
|
2754
|
+
cleanupAfterDays: agent.cleanupAfterDays ?? 14,
|
|
2755
|
+
ownerEmail: agent.ownerEmail ?? null,
|
|
2756
|
+
anthropicIdentityName: agent.anthropicIdentityName ?? null,
|
|
2757
|
+
anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
|
|
2758
|
+
anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
|
|
2759
|
+
...(warning !== undefined ? { warning } : {}),
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
/**
|
|
2764
|
+
* Computes the non-blocking "no members" warning surfaced when
|
|
2765
|
+
* restrictSlackToMembers is true on an agent with zero AgentMember rows.
|
|
2766
|
+
* Never blocks the create/update it's called after — callers await this
|
|
2767
|
+
* strictly to enrich the response, not to gate persistence. Returns
|
|
2768
|
+
* undefined (no warning) when the flag is false or the agent has at least
|
|
2769
|
+
* one member.
|
|
2770
|
+
*/
|
|
2771
|
+
async function computeRestrictSlackToMembersWarning(
|
|
2772
|
+
agentMemberService: Pick<AgentMemberService, "listByAgentId">,
|
|
2773
|
+
agentId: string,
|
|
2774
|
+
restrictSlackToMembers: boolean,
|
|
2775
|
+
): Promise<string | undefined> {
|
|
2776
|
+
if (!restrictSlackToMembers) return undefined;
|
|
2777
|
+
const members = await agentMemberService.listByAgentId(agentId);
|
|
2778
|
+
if (members.length > 0) return undefined;
|
|
2779
|
+
return "this agent has no members — enabling this will block all Slack senders";
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function serializeGitHubInstallationsSnapshot(snapshot: {
|
|
2783
|
+
id: string;
|
|
2784
|
+
agentId: string;
|
|
2785
|
+
reportedAt: Date;
|
|
2786
|
+
installations: unknown;
|
|
2787
|
+
createdAt: Date;
|
|
2788
|
+
}): z.infer<typeof AgentGitHubInstallationsSnapshotSchema> {
|
|
2789
|
+
return {
|
|
2790
|
+
id: snapshot.id,
|
|
2791
|
+
agentId: snapshot.agentId,
|
|
2792
|
+
reportedAt: snapshot.reportedAt.toISOString(),
|
|
2793
|
+
installations: snapshot.installations as z.infer<
|
|
2794
|
+
typeof AgentGitHubInstallationsSnapshotSchema
|
|
2795
|
+
>["installations"],
|
|
2796
|
+
createdAt: snapshot.createdAt.toISOString(),
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function serializeChatTokenDaily(row: {
|
|
2801
|
+
id: string;
|
|
2802
|
+
agentId: string;
|
|
2803
|
+
date: string;
|
|
2804
|
+
model: string;
|
|
2805
|
+
inputTokens: number;
|
|
2806
|
+
outputTokens: number;
|
|
2807
|
+
cacheReadTokens: number;
|
|
2808
|
+
cacheCreationTokens: number;
|
|
2809
|
+
costUsd: number;
|
|
2810
|
+
createdAt: Date;
|
|
2811
|
+
updatedAt: Date;
|
|
2812
|
+
}): z.infer<typeof AgentChatTokenUsageDailySchema> {
|
|
2813
|
+
return {
|
|
2814
|
+
id: row.id,
|
|
2815
|
+
agentId: row.agentId,
|
|
2816
|
+
date: row.date,
|
|
2817
|
+
model: row.model,
|
|
2818
|
+
inputTokens: row.inputTokens,
|
|
2819
|
+
outputTokens: row.outputTokens,
|
|
2820
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
2821
|
+
cacheCreationTokens: row.cacheCreationTokens,
|
|
2822
|
+
costUsd: row.costUsd,
|
|
2823
|
+
createdAt: row.createdAt.toISOString(),
|
|
2824
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
2825
|
+
};
|
|
2826
|
+
}
|