@app-vitals/agent-admin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +64 -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,255 @@
1
+ /**
2
+ * admin/src/specialty-manifest-loader.ts
3
+ *
4
+ * SpecialtyRegistry — resolves an agent's stored `typeName` to its parsed
5
+ * Specialty manifest by loading `specialties/{typeName}/manifest.yaml` from
6
+ * disk (relative to the repo root) and validating it via
7
+ * parseSpecialtyManifest().
8
+ *
9
+ * This is the runtime source of truth for an agent's default system crons
10
+ * (and, in time, its default plugins/tools/env contract). It replaces the
11
+ * former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
12
+ *
13
+ * Fallback contract: an unknown `typeName` (a value with no matching
14
+ * directory under specialties/) resolves to the "coding" manifest with a
15
+ * logged warning — never throws. This keeps the boot-path
16
+ * POST /agents/:id/crons/reconcile route from beginning to 5xx for an agent
17
+ * whose type was renamed or removed.
18
+ *
19
+ * Hard-failure contract: if the "coding" manifest itself (the universal
20
+ * fallback) cannot be loaded or fails validation, that is a configuration error
21
+ * (it names SHIPWRIGHT_SPECIALTIES_DIR / SHIPWRIGHT_DEFAULT_AGENT_TYPE) and the error propagates — we never silently degrade the fallback into a
22
+ * no-op that would strip every agent's system crons.
23
+ *
24
+ * The class takes an injectable reader/warner so unit tests can drive it
25
+ * without touching the filesystem (see agent-cron-jobs.unit.test.ts).
26
+ */
27
+
28
+ import { readdirSync, readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import {
31
+ type SpecialtyManifest,
32
+ parseSpecialtyManifest,
33
+ } from "./specialty-registry.ts";
34
+
35
+ /** The universal fallback type — every agent degrades to this manifest. */
36
+ export const DEFAULT_SPECIALTY_NAME = "coding";
37
+
38
+ /** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
39
+ type ManifestReader = (typeName: string) => string;
40
+
41
+ /** Sink for the unknown-typeName warning (defaults to console.warn). */
42
+ type WarnFn = (message: string) => void;
43
+
44
+ /**
45
+ * Lists the typeName of every discoverable specialty directory (e.g. the
46
+ * names of the subdirectories under specialties/). Used by listTypes() to
47
+ * discover candidates before resolving each one's manifest via `read`.
48
+ */
49
+ type TypeDirLister = () => string[];
50
+
51
+ /** A discovered specialty, as surfaced to the new-specialty picker. */
52
+ export interface SpecialtyOption {
53
+ name: string;
54
+ displayName: string;
55
+ }
56
+
57
+ /**
58
+ * Repo root resolved relative to this module (admin/src/ → ../../). The
59
+ * specialties/ directory lives at the repo root alongside admin/.
60
+ */
61
+ const REPO_ROOT = join(import.meta.dir, "..", "..");
62
+ const BUILT_IN_SPECIALTIES_DIR = join(REPO_ROOT, "specialties");
63
+
64
+ /**
65
+ * The specialties directory: SHIPWRIGHT_SPECIALTIES_DIR when set, else the
66
+ * repo-root specialties/ (today's location).
67
+ */
68
+ export function resolveSpecialtiesDir(
69
+ env: Record<string, string | undefined> = process.env,
70
+ ): string {
71
+ return env.SHIPWRIGHT_SPECIALTIES_DIR || BUILT_IN_SPECIALTIES_DIR;
72
+ }
73
+
74
+ /**
75
+ * The universal fallback type: SHIPWRIGHT_DEFAULT_AGENT_TYPE when set, else
76
+ * "coding".
77
+ */
78
+ /**
79
+ * The actionable error for a default specialty that cannot be loaded. The
80
+ * platform ships no specialty of its own, so this is a configuration problem:
81
+ * the operator must point the two env vars at a real specialties directory.
82
+ */
83
+ export function missingDefaultSpecialtyError(
84
+ defaultTypeName: string,
85
+ typesDir: string,
86
+ cause?: unknown,
87
+ ): Error {
88
+ const detail = cause instanceof Error ? ` (${cause.message})` : "";
89
+ return new Error(
90
+ `SpecialtyRegistry: the default specialty "${defaultTypeName}" could not be loaded from "${typesDir}"${detail}. ` +
91
+ "Set SHIPWRIGHT_SPECIALTIES_DIR to a directory containing <type>/manifest.yaml and " +
92
+ "SHIPWRIGHT_DEFAULT_AGENT_TYPE to the name of a specialty in it.",
93
+ );
94
+ }
95
+
96
+ export function resolveDefaultSpecialtyName(
97
+ env: Record<string, string | undefined> = process.env,
98
+ ): string {
99
+ return env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || DEFAULT_SPECIALTY_NAME;
100
+ }
101
+
102
+ /** Default reader: loads {typesDir}/{typeName}/manifest.yaml from disk. */
103
+ export function defaultManifestReader(
104
+ typeName: string,
105
+ typesDir: string = resolveSpecialtiesDir(),
106
+ ): string {
107
+ return readFileSync(join(typesDir, typeName, "manifest.yaml"), "utf-8");
108
+ }
109
+
110
+ /** Default lister: the directory names under the specialties dir on disk. */
111
+ export function defaultTypeDirLister(
112
+ typesDir: string = resolveSpecialtiesDir(),
113
+ ): string[] {
114
+ return readdirSync(typesDir, { withFileTypes: true })
115
+ .filter((entry) => entry.isDirectory())
116
+ .map((entry) => entry.name);
117
+ }
118
+
119
+ /**
120
+ * Interface the reconcile path depends on — kept narrow so tests can supply a
121
+ * trivial double without constructing the real disk-backed registry.
122
+ */
123
+ export interface SpecialtyManifestResolver {
124
+ /**
125
+ * Resolve `typeName` to its parsed manifest. Falls back to the "coding"
126
+ * manifest (with a warning) when the type is unknown. Never throws for an
127
+ * unknown type — only if the "coding" fallback itself fails to load.
128
+ */
129
+ getManifest(typeName: string): SpecialtyManifest;
130
+ /**
131
+ * Resolve `typeName` to its parsed manifest WITHOUT falling back to
132
+ * "coding" — returns undefined for an unknown type instead. No warning is
133
+ * logged (an unknown type here is an expected, callers-handle-it outcome,
134
+ * not a degraded-boot condition). Used by POST /admin/agents to reject an
135
+ * unknown requested `type` with 400 rather than silently seeding a
136
+ * "coding" agent under a different label.
137
+ */
138
+ tryGetManifest(typeName: string): SpecialtyManifest | undefined;
139
+ /**
140
+ * Discover every specialty directory (via the injected `list` fn) and
141
+ * resolve each to its {name, displayName} for populating the new-agent
142
+ * type picker. A directory whose manifest fails to load or parse is
143
+ * skipped (not thrown) — the picker still renders every other valid type
144
+ * rather than 500ing the whole /admin/agents/new page over one bad
145
+ * manifest. Order matches the underlying `list` fn's order.
146
+ */
147
+ listTypes(): SpecialtyOption[];
148
+ }
149
+
150
+ export class SpecialtyRegistry implements SpecialtyManifestResolver {
151
+ private readonly read: ManifestReader;
152
+ private readonly warn: WarnFn;
153
+ private readonly list: TypeDirLister;
154
+ private readonly defaultTypeName: string;
155
+ private readonly typesDir: string;
156
+ private readonly cache = new Map<string, SpecialtyManifest>();
157
+
158
+ /**
159
+ * `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
160
+ * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to today's repo-root specialties/
161
+ * and "coding". An injected `read`/`list` takes precedence over `typesDir`.
162
+ */
163
+ constructor(
164
+ opts: {
165
+ read?: ManifestReader;
166
+ warn?: WarnFn;
167
+ list?: TypeDirLister;
168
+ typesDir?: string;
169
+ defaultTypeName?: string;
170
+ } = {},
171
+ ) {
172
+ const typesDir = opts.typesDir ?? resolveSpecialtiesDir();
173
+ this.typesDir = typesDir;
174
+ this.read = opts.read ?? ((name) => defaultManifestReader(name, typesDir));
175
+ this.warn = opts.warn ?? ((m) => console.warn(m));
176
+ this.list = opts.list ?? (() => defaultTypeDirLister(typesDir));
177
+ this.defaultTypeName =
178
+ opts.defaultTypeName ?? resolveDefaultSpecialtyName();
179
+ }
180
+
181
+ getManifest(typeName: string): SpecialtyManifest {
182
+ const resolved = this.tryLoad(typeName);
183
+ if (resolved) return resolved;
184
+
185
+ // Unknown type — warn once and fall back to "coding". The fallback load is
186
+ // deliberately NOT wrapped in tryLoad: if "coding" can't load, that's a
187
+ // hard startup bug and must surface, not silently become a no-op.
188
+ this.warn(
189
+ `SpecialtyRegistry: unknown agent typeName "${typeName}" — ` +
190
+ `falling back to "${this.defaultTypeName}" manifest`,
191
+ );
192
+ if (typeName === this.defaultTypeName) {
193
+ // Guard against infinite intent: the default itself failed to load.
194
+ throw missingDefaultSpecialtyError(this.defaultTypeName, this.typesDir);
195
+ }
196
+ try {
197
+ return this.loadOrThrow(this.defaultTypeName);
198
+ } catch (err) {
199
+ throw missingDefaultSpecialtyError(
200
+ this.defaultTypeName,
201
+ this.typesDir,
202
+ err,
203
+ );
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Resolve `typeName` without falling back to "coding" — see the interface
209
+ * doc comment. Thin public wrapper around the existing tryLoad() used
210
+ * internally by getManifest().
211
+ */
212
+ tryGetManifest(typeName: string): SpecialtyManifest | undefined {
213
+ return this.tryLoad(typeName);
214
+ }
215
+
216
+ listTypes(): SpecialtyOption[] {
217
+ const typeNames = this.list();
218
+ const options: SpecialtyOption[] = [];
219
+ for (const typeName of typeNames) {
220
+ const manifest = this.tryLoad(typeName);
221
+ if (!manifest) continue;
222
+ options.push({
223
+ name: manifest.metadata.name,
224
+ displayName: manifest.metadata.displayName,
225
+ });
226
+ }
227
+ return options;
228
+ }
229
+
230
+ /** Load + cache a manifest, returning undefined only if the file is missing. */
231
+ private tryLoad(typeName: string): SpecialtyManifest | undefined {
232
+ const cached = this.cache.get(typeName);
233
+ if (cached) return cached;
234
+ let raw: string;
235
+ try {
236
+ raw = this.read(typeName);
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ return this.parseAndCache(typeName, raw);
241
+ }
242
+
243
+ /** Load + cache a manifest, propagating any read/parse error. */
244
+ private loadOrThrow(typeName: string): SpecialtyManifest {
245
+ const cached = this.cache.get(typeName);
246
+ if (cached) return cached;
247
+ return this.parseAndCache(typeName, this.read(typeName));
248
+ }
249
+
250
+ private parseAndCache(typeName: string, raw: string): SpecialtyManifest {
251
+ const manifest = parseSpecialtyManifest(raw);
252
+ this.cache.set(typeName, manifest);
253
+ return manifest;
254
+ }
255
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * admin/src/specialty-manifest-loader.unit.test.ts
3
+ * Unit tests for SpecialtyRegistry — the disk-backed resolver from an agent's
4
+ * typeName to its parsed manifest, with the unknown-type "coding" fallback.
5
+ *
6
+ * Pure logic against an injected reader/warner — no filesystem, no DB. See
7
+ * docs/testing.md for the unit-layer contract.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ SpecialtyRegistry,
13
+ DEFAULT_SPECIALTY_NAME,
14
+ resolveSpecialtiesDir,
15
+ resolveDefaultSpecialtyName,
16
+ } from "./specialty-manifest-loader.ts";
17
+
18
+ /** A minimal valid Specialty manifest YAML for a given type name. */
19
+ function manifestYaml(name: string): string {
20
+ return `apiVersion: shipwright.dev/v1alpha1
21
+ kind: Specialty
22
+ metadata:
23
+ name: ${name}
24
+ displayName: ${name}
25
+ description: test manifest
26
+ version: 1.0.0
27
+ skills: []
28
+ identity:
29
+ templatesDir: workspace/
30
+ crons:
31
+ - name: ${name}-cron
32
+ schedule: "* * * * *"
33
+ prompt: /noop
34
+ plugins: []
35
+ tools: []
36
+ env:
37
+ required: []
38
+ optional: []
39
+ members: []
40
+ repos: []
41
+ chat: true
42
+ voice: true
43
+ `;
44
+ }
45
+
46
+ describe("SpecialtyRegistry", () => {
47
+ it("resolves a known typeName to its parsed manifest", () => {
48
+ const registry = new SpecialtyRegistry({
49
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
50
+ read: (t) => manifestYaml(t),
51
+ });
52
+ const manifest = registry.getManifest("coding");
53
+ expect(manifest.metadata.name).toBe("coding");
54
+ expect(manifest.crons[0]?.name).toBe("coding-cron");
55
+ });
56
+
57
+ it("falls back to the coding manifest with a warning for an unknown typeName", () => {
58
+ const warnings: string[] = [];
59
+ const registry = new SpecialtyRegistry({
60
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
61
+ read: (t) => {
62
+ if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("coding");
63
+ throw new Error(`ENOENT: no manifest for ${t}`);
64
+ },
65
+ warn: (m) => warnings.push(m),
66
+ });
67
+
68
+ const manifest = registry.getManifest("renamed-type");
69
+
70
+ expect(manifest.metadata.name).toBe("coding");
71
+ expect(warnings).toHaveLength(1);
72
+ expect(warnings[0]).toContain("renamed-type");
73
+ expect(warnings[0]).toContain("coding");
74
+ });
75
+
76
+ it("throws (does not silently no-op) when the coding fallback itself cannot load", () => {
77
+ const registry = new SpecialtyRegistry({
78
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
79
+ read: () => {
80
+ throw new Error("ENOENT: coding manifest missing");
81
+ },
82
+ warn: () => {},
83
+ });
84
+
85
+ // Requesting the default type when it can't load must surface a hard error
86
+ // rather than degrade into an empty cron set that would strip system crons.
87
+ expect(() => registry.getManifest("coding")).toThrow();
88
+ });
89
+
90
+ it("names SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE when the default specialty is missing", () => {
91
+ const registry = new SpecialtyRegistry({
92
+ read: () => {
93
+ throw new Error("ENOENT: no manifest");
94
+ },
95
+ warn: () => {},
96
+ typesDir: "/nonexistent/specialties",
97
+ defaultTypeName: "absent",
98
+ });
99
+
100
+ for (const typeName of ["absent", "some-other-type"]) {
101
+ let message = "";
102
+ try {
103
+ registry.getManifest(typeName);
104
+ } catch (err) {
105
+ message = (err as Error).message;
106
+ }
107
+ expect(message).toContain('default specialty "absent"');
108
+ expect(message).toContain("/nonexistent/specialties");
109
+ expect(message).toContain("SHIPWRIGHT_SPECIALTIES_DIR");
110
+ expect(message).toContain("SHIPWRIGHT_DEFAULT_AGENT_TYPE");
111
+ expect(message).not.toContain("startup-time bug");
112
+ }
113
+ });
114
+
115
+ it("caches a resolved manifest — the reader is called once per type", () => {
116
+ let reads = 0;
117
+ const registry = new SpecialtyRegistry({
118
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
119
+ read: (t) => {
120
+ reads++;
121
+ return manifestYaml(t);
122
+ },
123
+ });
124
+
125
+ registry.getManifest("coding");
126
+ registry.getManifest("coding");
127
+
128
+ expect(reads).toBe(1);
129
+ });
130
+
131
+ // ─── tryGetManifest — non-fallback lookup ────────────────────────────────
132
+ //
133
+ // Unlike getManifest(), tryGetManifest() must NOT fall back to "coding" for
134
+ // an unknown typeName — it returns undefined instead. This is what lets
135
+ // POST /agents distinguish "resolved to the exact requested type" from
136
+ // "silently degraded", which the cron-reconcile boot path (getManifest())
137
+ // deliberately does not need to distinguish.
138
+
139
+ it("tryGetManifest resolves a known typeName to its parsed manifest", () => {
140
+ const registry = new SpecialtyRegistry({
141
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
142
+ read: (t) => manifestYaml(t),
143
+ });
144
+ const manifest = registry.tryGetManifest("coding");
145
+ expect(manifest?.metadata.name).toBe("coding");
146
+ });
147
+
148
+ it("tryGetManifest returns undefined for an unknown typeName — no fallback, no warning", () => {
149
+ const warnings: string[] = [];
150
+ const registry = new SpecialtyRegistry({
151
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
152
+ read: (t) => {
153
+ if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("coding");
154
+ throw new Error(`ENOENT: no manifest for ${t}`);
155
+ },
156
+ warn: (m) => warnings.push(m),
157
+ });
158
+
159
+ const manifest = registry.tryGetManifest("nonexistent-type");
160
+
161
+ expect(manifest).toBeUndefined();
162
+ expect(warnings).toHaveLength(0);
163
+ });
164
+
165
+ it("tryGetManifest shares the cache with getManifest — reader called once per type", () => {
166
+ let reads = 0;
167
+ const registry = new SpecialtyRegistry({
168
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
169
+ read: (t) => {
170
+ reads++;
171
+ return manifestYaml(t);
172
+ },
173
+ });
174
+
175
+ registry.getManifest("coding");
176
+ registry.tryGetManifest("coding");
177
+
178
+ expect(reads).toBe(1);
179
+ });
180
+
181
+ // ─── listTypes — populates the new-specialty picker ─────────────────────
182
+ //
183
+ // Discovers every directory under specialties/ via the injected `list` fn,
184
+ // then resolves each one's manifest via the existing `read` path — so
185
+ // listTypes() shares the same disk-free-in-tests DI contract as
186
+ // getManifest()/tryGetManifest().
187
+
188
+ it("listTypes lists every discovered type as {name, displayName}", () => {
189
+ const registry = new SpecialtyRegistry({
190
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
191
+ list: () => ["coding"],
192
+ read: (t) => manifestYaml(t),
193
+ });
194
+
195
+ const types = registry.listTypes();
196
+
197
+ expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
198
+ });
199
+
200
+ it("listTypes resolves displayName from each manifest's metadata.displayName", () => {
201
+ const registry = new SpecialtyRegistry({
202
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
203
+ list: () => ["coding"],
204
+ read: () => `apiVersion: shipwright.dev/v1alpha1
205
+ kind: Specialty
206
+ metadata:
207
+ name: coding
208
+ displayName: Coding Agent
209
+ description: test manifest
210
+ version: 1.0.0
211
+ skills: []
212
+ identity:
213
+ templatesDir: workspace/
214
+ crons: []
215
+ plugins: []
216
+ tools: []
217
+ env:
218
+ required: []
219
+ optional: []
220
+ members: []
221
+ repos: []
222
+ chat: true
223
+ voice: true
224
+ `,
225
+ });
226
+
227
+ const types = registry.listTypes();
228
+
229
+ expect(types).toEqual([{ name: "coding", displayName: "Coding Agent" }]);
230
+ });
231
+
232
+ it("listTypes lists multiple discovered types", () => {
233
+ const registry = new SpecialtyRegistry({
234
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
235
+ list: () => ["coding", "research"],
236
+ read: (t) => manifestYaml(t),
237
+ });
238
+
239
+ const types = registry.listTypes();
240
+
241
+ expect(types).toEqual([
242
+ { name: "coding", displayName: "coding" },
243
+ { name: "research", displayName: "research" },
244
+ ]);
245
+ });
246
+
247
+ it("listTypes skips a directory whose manifest fails to load or parse", () => {
248
+ const warnings: string[] = [];
249
+ const registry = new SpecialtyRegistry({
250
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
251
+ list: () => ["coding", "broken"],
252
+ read: (t) => {
253
+ if (t === "broken") throw new Error("ENOENT: broken manifest");
254
+ return manifestYaml(t);
255
+ },
256
+ warn: (m) => warnings.push(m),
257
+ });
258
+
259
+ const types = registry.listTypes();
260
+
261
+ expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
262
+ });
263
+
264
+ it("listTypes returns an empty array when no type directories are discovered", () => {
265
+ const registry = new SpecialtyRegistry({
266
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
267
+ list: () => [],
268
+ read: (t) => manifestYaml(t),
269
+ });
270
+
271
+ expect(registry.listTypes()).toEqual([]);
272
+ });
273
+ });
274
+
275
+ describe("specialties option resolution", () => {
276
+ it("defaults to coding and the repo-root specialties/ with no env set", () => {
277
+ expect(resolveDefaultSpecialtyName({})).toBe("coding");
278
+ expect(resolveSpecialtiesDir({})).toMatch(/specialties$/);
279
+ });
280
+
281
+ it("honours SHIPWRIGHT_DEFAULT_AGENT_TYPE and SHIPWRIGHT_SPECIALTIES_DIR", () => {
282
+ expect(
283
+ resolveDefaultSpecialtyName({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "base" }),
284
+ ).toBe("base");
285
+ expect(
286
+ resolveSpecialtiesDir({ SHIPWRIGHT_SPECIALTIES_DIR: "/tmp/types" }),
287
+ ).toBe("/tmp/types");
288
+ });
289
+
290
+ it("falls back to the configured defaultTypeName for an unknown type", () => {
291
+ const registry = new SpecialtyRegistry({
292
+ read: (name) => {
293
+ if (name === "base") return manifestYaml("base");
294
+ throw new Error("ENOENT");
295
+ },
296
+ warn: () => {},
297
+ defaultTypeName: "base",
298
+ });
299
+ expect(registry.getManifest("missing").metadata.name).toBe("base");
300
+ });
301
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * admin/src/specialty-registry.schema.unit.test.ts
3
+ * Regenerates the Specialty JSON Schema in-memory from its Zod source and
4
+ * diffs it (deep-equal, not string-equal) against the committed
5
+ * docs/schemas/specialty.schema.json artifact. This guarantees the
6
+ * committed artifact can never silently drift from the Zod source —
7
+ * `scripts/generate-specialty-schema.ts` must be re-run (and its output
8
+ * re-committed) whenever SpecialtyManifestSchema changes.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { describe, expect, it } from "bun:test";
14
+ import { buildSpecialtyJsonSchema } from "./specialty-registry.ts";
15
+
16
+ // docs/schemas/ in the monorepo, agent-types/ in the extracted layout.
17
+ const committedSchemaPath = [
18
+ join(import.meta.dir, "..", "..", "docs", "schemas", "specialty.schema.json"),
19
+ join(import.meta.dir, "..", "..", "agent-types", "specialty.schema.json"),
20
+ ].find((candidate) => existsSync(candidate)) as string;
21
+
22
+ describe("docs/schemas/specialty.schema.json", () => {
23
+ it("matches the JSON Schema regenerated from SpecialtyManifestSchema", () => {
24
+ const regenerated = buildSpecialtyJsonSchema();
25
+ const committedRaw = readFileSync(committedSchemaPath, "utf-8");
26
+ const committed = JSON.parse(committedRaw);
27
+ expect(regenerated).toEqual(committed);
28
+ });
29
+
30
+ it("ends with exactly one trailing newline (matches the generator's write format)", () => {
31
+ const committedRaw = readFileSync(committedSchemaPath, "utf-8");
32
+ expect(committedRaw.endsWith("\n")).toBe(true);
33
+ expect(committedRaw.endsWith("\n\n")).toBe(false);
34
+ });
35
+ });