@app-vitals/agent-admin 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -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
|
+
});
|