@app-vitals/agent-admin 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. 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
+ }