@app-vitals/agent-admin 0.16.1 → 1.0.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 (47) hide show
  1. package/e2e/agents-page.e2e.ts +2 -2
  2. package/openapi.json +3 -3
  3. package/openapi.platform.json +7 -7
  4. package/package.json +3 -5
  5. package/prisma/client/internal/class.ts +1 -1
  6. package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
  7. package/prisma/schema/schema.prisma +1 -1
  8. package/src/account-agent-create.ts +1 -1
  9. package/src/admin-boot.ts +12 -6
  10. package/src/admin-extension.ts +51 -5
  11. package/src/admin-extension.unit.test.ts +61 -0
  12. package/src/admin-local-agent.smoke.test.ts +8 -5
  13. package/src/admin-ui-pages.ts +4 -4
  14. package/src/admin-ui.smoke.test.ts +54 -34
  15. package/src/admin-ui.ts +12 -12
  16. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  17. package/src/agent-cron-jobs.integration.test.ts +23 -21
  18. package/src/agent-cron-jobs.ts +30 -40
  19. package/src/agent-cron-jobs.unit.test.ts +51 -39
  20. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  21. package/src/agent-manifest.ts +2 -6
  22. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  23. package/src/agent-specialty-backfill.integration.test.ts +30 -0
  24. package/src/agents-api.integration.test.ts +29 -40
  25. package/src/agents-api.sentry.smoke.test.ts +2 -2
  26. package/src/agents-api.smoke.test.ts +17 -30
  27. package/src/agents-api.ts +13 -13
  28. package/src/agents.integration.test.ts +23 -33
  29. package/src/agents.ts +35 -34
  30. package/src/agents.unit.test.ts +35 -55
  31. package/src/api.smoke.test.ts +2 -2
  32. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  33. package/src/openapi-schemas.ts +4 -4
  34. package/src/server.smoke.test.ts +2 -2
  35. package/src/specialty-defaults.ts +4 -4
  36. package/src/specialty-types.ts +92 -0
  37. package/src/specialty-types.unit.test.ts +67 -0
  38. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  39. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  40. package/src/coding-task-store-provisioning-client.ts +0 -156
  41. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  42. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  43. package/src/specialty-manifest-loader.ts +0 -245
  44. package/src/specialty-manifest-loader.unit.test.ts +0 -299
  45. package/src/specialty-registry.schema.unit.test.ts +0 -35
  46. package/src/specialty-registry.ts +0 -236
  47. package/src/specialty-registry.unit.test.ts +0 -272
@@ -304,7 +304,7 @@ test.describe("GET /admin/agents/new — type picker", () => {
304
304
  await expect(typeSelect).toHaveAttribute("required", "");
305
305
  });
306
306
 
307
- test('the select lists "Coding Agent" (value coding)', async ({
307
+ test('the select lists the configured default type "coding"', async ({
308
308
  page,
309
309
  context,
310
310
  }) => {
@@ -313,7 +313,7 @@ test.describe("GET /admin/agents/new — type picker", () => {
313
313
  'select[name="type"] option[value="coding"]',
314
314
  );
315
315
  await expect(codingOption).toBeAttached();
316
- await expect(codingOption).toHaveText("Coding Agent");
316
+ await expect(codingOption).toHaveText("coding");
317
317
  });
318
318
  });
319
319
 
package/openapi.json CHANGED
@@ -3521,7 +3521,7 @@
3521
3521
  "/agents/{id}": {
3522
3522
  "get": {
3523
3523
  "summary": "Get an agent",
3524
- "description": "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `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).",
3524
+ "description": "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type with no corresponding AgentEnv row yet (informational only).",
3525
3525
  "parameters": [
3526
3526
  {
3527
3527
  "schema": {
@@ -3742,7 +3742,7 @@
3742
3742
  },
3743
3743
  "post": {
3744
3744
  "summary": "Create an agent",
3745
- "description": "Admin-only. Validates name/specialty, 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.",
3745
+ "description": "Admin-only. Validates name/specialty, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type, 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.",
3746
3746
  "requestBody": {
3747
3747
  "content": {
3748
3748
  "application/json": {
@@ -4047,7 +4047,7 @@
4047
4047
  "/agents/{id}/crons/reconcile": {
4048
4048
  "post": {
4049
4049
  "summary": "Reconcile system crons",
4050
- "description": "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.",
4050
+ "description": "Reconciles the agent's system crons against the cron list supplied by the AdminExtension for its specialty. 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 defaults. Called automatically on agent startup; returns created/updated/deleted counts.",
4051
4051
  "parameters": [
4052
4052
  {
4053
4053
  "schema": {
@@ -537,7 +537,7 @@
537
537
  },
538
538
  "specialty": {
539
539
  "type": "string",
540
- "example": "coding"
540
+ "example": "specialist"
541
541
  }
542
542
  },
543
543
  "required": [
@@ -558,7 +558,7 @@
558
558
  "specialty": {
559
559
  "type": "string",
560
560
  "minLength": 1,
561
- "example": "coding"
561
+ "example": "specialist"
562
562
  },
563
563
  "runtime": {
564
564
  "type": "string",
@@ -3091,11 +3091,11 @@
3091
3091
  },
3092
3092
  "specialty": {
3093
3093
  "type": "string",
3094
- "example": "coding"
3094
+ "example": "specialist"
3095
3095
  },
3096
3096
  "typeName": {
3097
3097
  "type": "string",
3098
- "example": "coding",
3098
+ "example": "specialist",
3099
3099
  "deprecated": true
3100
3100
  },
3101
3101
  "phaseMethodology": {
@@ -3349,7 +3349,7 @@
3349
3349
  "/agents/{id}": {
3350
3350
  "get": {
3351
3351
  "summary": "Get an agent",
3352
- "description": "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `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).",
3352
+ "description": "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `specialty`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type with no corresponding AgentEnv row yet (informational only).",
3353
3353
  "parameters": [
3354
3354
  {
3355
3355
  "schema": {
@@ -3705,7 +3705,7 @@
3705
3705
  },
3706
3706
  "post": {
3707
3707
  "summary": "Create an agent",
3708
- "description": "Admin-only. Validates name/specialty, 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.",
3708
+ "description": "Admin-only. Validates name/specialty, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type, 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.",
3709
3709
  "requestBody": {
3710
3710
  "content": {
3711
3711
  "application/json": {
@@ -4010,7 +4010,7 @@
4010
4010
  "/agents/{id}/crons/reconcile": {
4011
4011
  "post": {
4012
4012
  "summary": "Reconcile system crons",
4013
- "description": "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.",
4013
+ "description": "Reconciles the agent's system crons against the cron list supplied by the AdminExtension for its specialty. 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 defaults. Called automatically on agent startup; returns created/updated/deleted counts.",
4014
4014
  "parameters": [
4015
4015
  {
4016
4016
  "schema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.16.1",
3
+ "version": "1.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -21,8 +21,7 @@
21
21
  "./main": "./src/main.ts",
22
22
  "./openapi-schemas": "./src/openapi-schemas.ts",
23
23
  "./prisma-client": "./src/prisma-client.ts",
24
- "./specialty-manifest-loader": "./src/specialty-manifest-loader.ts",
25
- "./specialty-registry": "./src/specialty-registry.ts",
24
+ "./specialty-types": "./src/specialty-types.ts",
26
25
  "./theme-tokens.json": "./src/theme-tokens.json",
27
26
  "./*": "./src/*.ts",
28
27
  "./prisma/client/*": "./prisma/client/*",
@@ -43,7 +42,7 @@
43
42
  },
44
43
  "dependencies": {
45
44
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.16.1",
45
+ "@app-vitals/agent-lib": "1.0.0",
47
46
  "@hono/zod-openapi": "^1.0.0",
48
47
  "@prisma/adapter-pg": "^7.10.0",
49
48
  "@prisma/client": "^7.10.0",
@@ -52,7 +51,6 @@
52
51
  "@slack/web-api": "^8.1.0",
53
52
  "hono": "^4.13.1",
54
53
  "pg": "^8.16.3",
55
- "yaml": "^2.9.0",
56
54
  "zod": "^4"
57
55
  },
58
56
  "devDependencies": {
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
20
20
  "clientVersion": "7.10.0",
21
21
  "engineVersion": "0edf323efd1d98336f3f0a68684b56f689b900d3",
22
22
  "activeProvider": "postgresql",
23
- "inlineSchema": "// Shipwright — Admin Service Schema: coding-owned models (AEP-3.4)\n//\n// Part of the admin Prisma multi-file schema (prisma/schema/). These models\n// belong to the coding feature surface and are isolated here so the coding\n// code can later be extracted. They share the single `db` datasource and\n// generator declared in schema.prisma, so the generated client and the\n// migrations are unchanged by this split.\n//\n// Coding-owned columns that live on the shared Agent model (schema.prisma)\n// and are grouped/commented there: reviewAuthorAllowlist, patchAuthorAllowlist,\n// autoPostReviews, allowSelfReview, minConfidence, maxFindings.\n\n// ─── Agent Work Queue Snapshot ────────────────────────────────────────────────\n// One row per agent, holding the agent's most recently pushed work-queue\n// snapshot (its ranked view of pending tasks/PRs across pipeline phases).\n// POST /agents/:id/work-queue upserts this single row, overwriting any prior\n// snapshot — there is no history, only the latest state.\n\nmodel AgentWorkQueueSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n computedAt DateTime\n items Json // RankedWorkItem[]\n createdAt DateTime @default(now())\n}\n\n// ─── Agent Phase Methodology ──────────────────────────────────────────────────\n// Per-phase subagent-type override. Each row records which subagent type\n// (e.g. a custom Specialty name) should handle one pipeline phase — prd,\n// plan-session, review, patch, deploy, or dev-task — for a given agent,\n// overriding that phase's default methodology. `subagentType` is nullable:\n// a row with a null value (or the absence of a row) means \"use the default\".\n// The `phase` value is constrained to the six-value enum at the service/route\n// layer (Zod), not here — Postgres/Prisma has no native enum-column\n// requirement in this schema style (mirrors AgentTool.pattern / AgentCronJob\n// validation living at the service layer). The unique constraint on\n// [agentId, phase] makes upsert-based writes idempotent (one override row per\n// phase per agent), mirroring AgentTool's [agentId, pattern] and AgentPlugin's\n// [agentId, name].\n\nmodel AgentPhaseMethodology {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n phase String\n subagentType String?\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, phase])\n}\n\n// ─── Session Follow ───────────────────────────────────────────────────────────\n// Records that a user is \"following\" a planning session (identified by its\n// slug) so they can receive reminders/notifications about it. `muted` lets a\n// user stay following (kept in listByUser) while suppressing alerts, distinct\n// from unfollowing (row deleted entirely). The unique constraint on\n// [userEmail, sessionSlug] makes follow() idempotent via upsert.\nmodel SessionFollow {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n muted Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n @@index([sessionSlug])\n}\n\n// ─── Session Alert State ─────────────────────────────────────────────────────\n// Tracks the last time a user was alerted about a given session, so a\n// reminder job can avoid re-alerting the same user/session pair within a\n// cooldown window. The unique constraint on [userEmail, sessionSlug] allows\n// upsert semantics (one state row per user per session).\nmodel SessionAlertState {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n lastAlertedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n}\n\n// Shipwright — Admin Service Schema\n// Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,\n// plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,\n// AgentPhaseMethodology, SessionFollow, SessionAlertState).\n// Agent is a first-class model (standalone, not embedded in a User model).\n//\n// WARNING: This schema uses DATABASE_URL_SHIPWRIGHT_ADMIN — a dedicated database\n// for the admin service. Do NOT point this at a shared database.\n\n// Prisma 7: the `prisma-client` generator (ESM, driver-adapter based) replaces\n// `prisma-client-js`, and the datasource block no longer accepts `url` — the\n// connection string for CLI/Migrate lives in ../../prisma.config.ts, and the\n// runtime client connects through a PrismaPg driver adapter (see\n// admin/src/prisma-client.ts).\ngenerator client {\n provider = \"prisma-client\"\n output = \"../client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// ─── Agent ────────────────────────────────────────────────────────────────────\n// A Shipwright agent: an autonomous runner that picks tasks, builds, and ships PRs.\n\nmodel Agent {\n id String @id @default(cuid())\n name String\n slackId String? @unique\n slackBotToken String? // AES-256-GCM encrypted at the service layer\n anthropicApiKey String? // AES-256-GCM encrypted at the service layer\n selfHosted Boolean @default(false)\n repos String[] @default([])\n // DBR-2.1→2.4: renamed from authorAllowlist. DBR-2.1 added this column as\n // a dual-written/dual-read twin of the old authorAllowlist column and\n // backfilled it; DBR-2.2/2.3 migrated the admin UI and the agent's\n // runtime read path onto this field exclusively; DBR-2.4 dropped the old\n // authorAllowlist column now that nothing reads or writes it.\n // CODING-OWNED (AEP-3.4): reviewAuthorAllowlist and patchAuthorAllowlist are\n // read by the coding review/patch flows. Kept on Agent (no column move).\n reviewAuthorAllowlist String[] @default([])\n patchAuthorAllowlist String[] @default([])\n restrictSlackToMembers Boolean @default(false)\n // The agent's Specialty name (manifest key). AGR-6.3 made it required and\n // dropped the legacy typeName column.\n specialty String @default(\"coding\")\n // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.\n accountId String?\n account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)\n // ATE-1.1: trial expiry tracking. Both nullable/additive — null means \"no\n // trial configured\" / \"no warning sent yet\". trialExpiresAt is settable via\n // PATCH /agents/:id like other nullable scalar fields; trialExpiryWarnedAt\n // is written internally by ATE-2.1's warning check (not user-editable via\n // the PATCH route) but is included in GET/PATCH responses.\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n // PAI-1.1: owner and Anthropic identity. All nullable/additive — existing\n // agents keep NULL. anthropicIdentityName is unique across agents.\n ownerEmail String?\n anthropicIdentityName String? @unique\n anthropicServiceAccountId String?\n anthropicFederationRuleId String?\n k8sServiceAccountName String?\n // COS-48.1: the pod's own Kubernetes ServiceAccount (cluster identity). A\n // separate column from k8sServiceAccountName, which is the Anthropic-\n // federation KSA. Only rendered when the pod-SA flag is on.\n podServiceAccountName String?\n // APM-1.1: the 6 agent policy fields (auto_post_reviews, allow_self_review,\n // min_confidence, max_findings, cleanup_merged_worktrees,\n // cleanup_after_days). Originally mirrored from the legacy\n // state/agent-policy.md; the DB is now the only source and each column\n // carries its documented default.\n // CODING-OWNED (AEP-3.4): autoPostReviews, allowSelfReview, minConfidence,\n // maxFindings configure the coding review flow; cleanupMergedWorktrees and\n // cleanupAfterDays below are runtime-owned.\n autoPostReviews Boolean @default(true)\n allowSelfReview Boolean @default(false)\n minConfidence Int @default(75)\n maxFindings Int @default(5)\n cleanupMergedWorktrees Boolean @default(true)\n cleanupAfterDays Int @default(14)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n envVars AgentEnv[]\n cronJobs AgentCronJob[]\n tools AgentTool[]\n tokens AgentToken[]\n plugins AgentPlugin[]\n members AgentMember[]\n chatTokenUsage AgentChatTokenUsageDailyByModel[]\n workQueueSnapshot AgentWorkQueueSnapshot?\n githubInstallationsSnapshot AgentGitHubInstallationsSnapshot?\n phaseMethodologies AgentPhaseMethodology[]\n\n @@index([accountId])\n}\n\n// ─── Agent Env ────────────────────────────────────────────────────────────────\n// Generic key/value env var store for deployed agents.\n// Keys are env var names (e.g. ANTHROPIC_API_KEY). Values are AES-256-GCM encrypted.\n\nmodel AgentEnv {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n key String // env var name\n value String // AES-256-GCM encrypted\n secret Boolean @default(false) // when true, GET /agents/:id/envs masks the value as ***\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, key])\n}\n\n// ─── Agent Cron Jobs ──────────────────────────────────────────────────────────\n// Scheduled prompts per agent.\n// Delivery target is either a Slack channel ID or a Slack user ID (DM).\n// Validation that exactly one of channel/user is set happens in the service layer.\n\nmodel AgentCronJob {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n schedule String // cron expression e.g. \"0 9 * * 1-5\"\n prompt String\n channel String? // Slack channel ID (mutually exclusive with user)\n user String? // Slack user ID for DMs\n silent Boolean @default(false)\n enabled Boolean @default(true)\n preCheck String? // optional pre-check script e.g. \"shipwright:check-docs-freshness.ts\"\n name String? // stable key for system crons; null for user-created crons\n system Boolean @default(false)\n // Set when the trial-expiry lockdown (not the user) disabled this cron, so\n // restoreLockdownDisabled() can re-enable exactly those crons. Null for\n // enabled crons and for crons the user disabled manually.\n lockdownDisabledAt DateTime?\n // Run-now request (COS-42.1): stamped by POST .../run-now, cleared when the\n // runtime creates the run (commandVariant \"run-now\"). The runtime honours a\n // stamp at most once and only while it is fresh (15 minutes).\n runRequestedAt DateTime?\n runRequestedBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Self-relation: a parent cron can have child \"phase\" crons (e.g. dev-task/\n // review/patch/deploy phases of a single logical loop). Null for top-level\n // crons. Deleting a parent cascades to its phase children.\n parentCronId String?\n parentCron AgentCronJob? @relation(\"CronPhases\", fields: [parentCronId], references: [id], onDelete: Cascade)\n phases AgentCronJob[] @relation(\"CronPhases\")\n\n runs AgentCronRun[]\n phaseRuns AgentCronRun[] @relation(\"PhaseRuns\")\n\n @@index([parentCronId])\n}\n\n// ─── Agent Tools ──────────────────────────────────────────────────────────────\n// Allowed tool patterns per agent. Each row grants an agent permission to use\n// a specific tool pattern (e.g. \"Read\", \"Bash\", \"Write\"). The unique constraint\n// on [agentId, pattern] prevents duplicates.\n\nmodel AgentTool {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n pattern String\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n\n @@unique([agentId, pattern])\n}\n\n// ─── Agent Tokens ─────────────────────────────────────────────────────────────\n// Scoped API tokens for agent authentication. The raw token is returned once at\n// creation; only the SHA-256 hash is stored. Token validation is O(1) via the\n// hash index.\n\nmodel AgentToken {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n token String @unique // SHA-256 hash of the raw token (hex)\n label String?\n createdAt DateTime @default(now())\n revokedAt DateTime?\n}\n\n// ─── Agent Plugins ────────────────────────────────────────────────────────────\n// Claude Code plugins installed per agent. Each row represents one installed\n// plugin package. The unique constraint on [agentId, name] prevents duplicates.\n\nmodel AgentPlugin {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n name String // plugin package name e.g. \"@shipwright/plugin\"\n version String? // pinned version; null = latest\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, name])\n}\n\n// ─── Agent Members ────────────────────────────────────────────────────────────\n// Non-admin users granted web UI access to a specific agent.\n// Admins (SHIPWRIGHT_ADMIN_ALLOWED_EMAILS) bypass this table entirely.\n// Members can view and edit only the agents they are listed on.\n\nmodel AgentMember {\n id String @id @default(cuid())\n agentId String\n email String\n createdAt DateTime @default(now())\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@unique([agentId, email])\n @@index([email])\n}\n\n// ─── Accounts ─────────────────────────────────────────────────────────────────\n// SSP-1.2: self-serve agent provisioning. An Account owns agents and has\n// members (email-keyed) and pending invites. Phase 1: one account per email\n// (UNIQUE(email) on AccountMember). Enum-like strings are validated in the\n// service layer, matching the rest of this schema.\n\nmodel Account {\n id String @id @default(cuid())\n name String\n status String @default(\"active\") // 'active' | 'suspended' | 'trial_expired'\n maxAgents Int @default(0)\n plan String?\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n agents Agent[]\n members AccountMember[]\n invites AccountInvite[]\n}\n\nmodel AccountMember {\n id String @id @default(cuid())\n accountId String\n email String @unique // lowercased; one account per email in Phase 1\n role String // 'owner' | 'member'\n createdAt DateTime @default(now())\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n}\n\nmodel AccountInvite {\n id String @id @default(cuid())\n accountId String\n email String // lowercased\n invitedBy String\n createdAt DateTime @default(now())\n acceptedAt DateTime?\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n @@index([email])\n}\n\n// ─── Agent Cron Runs ──────────────────────────────────────────────────────────\n// Execution history for AgentCronJob firings. Each row records one cron invocation:\n// whether it was skipped (pre-check returned false), the outcome, and any error.\n// agentId is denormalized for efficient agent-level history queries.\n\nmodel AgentCronRun {\n id String @id @default(cuid())\n cronId String\n cron AgentCronJob @relation(fields: [cronId], references: [id], onDelete: Cascade)\n agentId String // denormalized from cron.agentId for efficient queries\n startedAt DateTime\n completedAt DateTime?\n skipped Boolean @default(false)\n skipReason String?\n outcome String?\n error String?\n itemType String? // work item type this run was dispatched against (\"task\" | \"pr\");\n // null when the tick had no dispatch (skipped tick, empty queue)\n itemId String? // work item id this run was dispatched against (e.g. \"WLS-2.2\" or\n // \"acme/x#123\"); null when the tick had no dispatch\n commandVariant String? // which slash-command variant a loop dispatch ran (\"default\" | \"alternate\");\n // null for runs with no variant attribution\n sessionId String? // Claude session id this cron run corresponds to\n lastHeartbeatAt DateTime? // Most recent debounced progress-push (recordProgress())\n // timestamp, sourced from the agent's injected Clock. Null\n // for runs that never reported progress (short/legacy runs).\n // Lets the admin UI show a \"last checked in\" signal — and a\n // best-effort in-progress duration — for a run that stalls\n // or crashes before reaching completedAt. Additive/nullable;\n // no backfill for existing rows.\n createdAt DateTime @default(now())\n\n // Secondary/denormalized pointer to the phase cron (a child AgentCronJob)\n // this run was dispatched by, distinct from cronId which always points at\n // the top-level cron. Null until phase crons are wired up (LPC-1.2+).\n // SetNull (not Cascade) on delete: deleting a phase cron shouldn't delete\n // the parent cron's run history, which cronId's own Cascade already owns.\n phaseId String?\n phaseCron AgentCronJob? @relation(\"PhaseRuns\", fields: [phaseId], references: [id], onDelete: SetNull)\n\n // ── Run telemetry (prompt-audit metrics; all additive/nullable) ──────────\n // First-turn context baseline: the usage of the run's first assistant\n // message. baselineContextTokens = input + cacheCreation + cacheRead of\n // that turn — the full always-loaded context (system prompt, tool schemas,\n // CLAUDE.md/rules, skill listing, cron prompt) independent of cache\n // warmth. Null for resumed sessions (their first turn replays history)\n // and for runs reported by older agent builds.\n baselineModel String?\n baselineContextTokens Int?\n baselineInputTokens Int?\n baselineCacheCreationTokens Int?\n baselineCacheReadTokens Int?\n // Distinct usage-bearing assistant turns / distinct tool_use blocks.\n turns Int?\n toolCalls Int?\n // sha256[:12] over the workspace's always-loaded markdown (CLAUDE.md, its\n // @imports, no-`paths` rules) + plugin version — groups runs by what the\n // model was given, so before/after series for a markdown change key on\n // content rather than merge date. Computed agent-side.\n contextFingerprint String?\n pluginVersion String?\n claudeCodeVersion String?\n\n modelBreakdown AgentCronRunModelBreakdown[]\n skillUsage AgentCronRunSkillUsage[]\n\n @@index([cronId])\n @@index([agentId, startedAt])\n @@index([phaseId])\n @@index([startedAt])\n @@index([contextFingerprint])\n}\n\n// ─── Agent Cron Run Skill Usage ──────────────────────────────────────────────\n// Per-skill / per-subagent token attribution for a cron run, derived from the\n// Claude CLI stream by the agent. `kind` is \"skill\" (a plugin\n// skill invoked via the Skill tool), \"agent\" (a subagent type invoked via the\n// Agent tool), or \"root\" (turns before any skill was invoked). Attribution is\n// last-wins: every turn after an invoke is charged to the most recently\n// invoked skill. invokeContextDelta is input + cacheCreation of the first\n// turn after the skill's first invoke — the new context admitted when its\n// body loaded. The unique constraint on [cronRunId, kind, name] allows upsert.\n\nmodel AgentCronRunSkillUsage {\n id String @id @default(cuid())\n cronRunId String\n kind String\n name String\n invocations Int @default(0)\n turns Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n invokeContextDelta Int?\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, kind, name])\n @@index([kind, name])\n}\n\n// ─── Agent Cron Run Model Breakdown ──────────────────────────────────────────\n// Per-model token breakdown for a cron run. Populated when sub-agents use\n// different models in a single run. The unique constraint on [cronRunId, model]\n// allows upsert semantics.\n\nmodel AgentCronRunModelBreakdown {\n id String @id @default(cuid())\n cronRunId String\n model String\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n costUsd Float @default(0)\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, model])\n}\n\n// ─── Agent Chat Token Usage (Daily, per Model) ───────────────────────────────\n// Daily rollup table for Slack/chat session token usage per agent per model.\n// Accumulated atomically via INSERT ... ON CONFLICT ... DO UPDATE (no read-modify-write).\n// Totals and byAgent aggregations are computed by summing across models.\n\nmodel AgentChatTokenUsageDailyByModel {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n date String // YYYY-MM-DD\n model String // e.g. \"claude-sonnet-4-5\"\n inputTokens BigInt @default(0)\n outputTokens BigInt @default(0)\n cacheReadTokens BigInt @default(0)\n cacheCreationTokens BigInt @default(0)\n costUsd Float @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, date, model])\n}\n\n// ─── Agent Ask ──────────────────────────────────────────────────────────────\n// Audit row per agent-to-agent ask (COS-31.1, POST /agents/:id/ask). Written\n// before the chat call and updated after it. The question text is never\n// stored. No relation to Agent on purpose: the audit trail outlives either\n// agent. threadId is null until the chat thread is created (and stays null\n// when that call fails). status: created | pending | replied | failed.\n\nmodel AgentAsk {\n id String @id @default(cuid())\n fromAgentId String\n toAgentId String\n threadId String?\n depth Int\n waitMs Int\n ttlSeconds Int\n status String\n callerLabel String\n createdAt DateTime @default(now())\n repliedAt DateTime?\n\n @@index([toAgentId, createdAt])\n @@index([fromAgentId, createdAt])\n}\n\n// ─── Agent Cron Run Requests ────────────────────────────────────────────────\n// Audit row per POST /agents/{id}/crons/{cronId}/run-now (COS-42.1), written\n// before the 202 returns. status: requested | honoured | expired. Holds no\n// prompt text. honouredRunId links the AgentCronRun the runtime created.\n\nmodel AgentCronRunRequest {\n id String @id @default(cuid())\n cronId String\n agentId String\n requestedBy String\n requestedAt DateTime\n status String @default(\"requested\")\n honouredRunId String?\n createdAt DateTime @default(now())\n\n @@index([cronId, requestedAt])\n @@index([agentId, requestedAt])\n}\n\n// ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────\n// One redacted session transcript per cron run, uploaded by the agent runtime\n// (already redacted + tail-truncated to 1 MiB) and served only to admin /\n// platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:\n// the retention sweep (newest 200 per agent) is the only lifecycle. The body\n// is never logged. AgentTranscriptRead is the audit row written before every\n// transcript read.\n\nmodel AgentCronRunTranscript {\n id String @id @default(cuid())\n runId String @unique\n agentId String\n cronId String\n sessionId String?\n bytes Int\n truncated Boolean\n redactions Int\n body String @db.Text\n createdAt DateTime @default(now())\n\n @@index([agentId, createdAt])\n}\n\nmodel AgentTranscriptRead {\n id String @id @default(cuid())\n runId String\n agentId String\n reader String\n scopeKind String\n at DateTime @default(now())\n\n @@index([agentId, at])\n}\n\n// ─── Agent GitHub Installations Snapshot ────────────────────────────────────\n// One row per agent holding the agent's most recently reported GitHub App\n// installations (owner, installation id, state, sanitized lastError). PUT\n// replaces the whole list — a dropped installation disappears. No tokens or\n// other credentials are ever stored here.\n\nmodel AgentGitHubInstallationsSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n reportedAt DateTime\n installations Json // GitHubInstallation[]\n repoAccess Json? // RepoAccessEntry[] — per-repo read/write probe (COS-44.1); null until a flagged runtime reports it\n createdAt DateTime @default(now())\n}\n\n// ─── Push Subscription ──────────────────────────────────────────────────────\n// A single browser/device Web Push subscription (RFC 8291) belonging to a\n// user. One user may have many rows (multi-device). Keyed by the unique push\n// `endpoint`; `p256dh` and `auth` are the subscription's public key + auth\n// secret used to encrypt the payload. `detailOptIn` is the USER'S opt-in for\n// how much notification detail they want on a locked screen — the effective\n// level is min(operator ceiling SHIPWRIGHT_ADMIN_PUSH_MAX_DETAIL, detailOptIn).\n// Values: \"generic\" | \"title\" | \"preview\". Never carries an agentId — targeting\n// is by userEmail via ChatThreadWatch, so a subscription is agent-agnostic.\nmodel PushSubscription {\n id String @id @default(cuid())\n userEmail String\n endpoint String @unique\n p256dh String // base64url of the UA public key\n auth String // base64url of the UA auth secret\n detailOptIn String @default(\"generic\") // user opt-in: generic | title | preview\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userEmail])\n}\n\n// ─── Chat Thread Watch ──────────────────────────────────────────────────────\n// Records that a user is \"watching\" a chat thread so the agent's reply can be\n// targeted to exactly the right subscriber(s) without fanning out to every\n// member of the agent. Upserted whenever a user sends a message in a thread.\n// The chat Message model has no author column, and admin is the only web front\n// door that knows both userEmail and threadId at send time — so authorship\n// targeting lives here, not in the chat service.\nmodel ChatThreadWatch {\n id String @id @default(cuid())\n userEmail String\n threadId String\n agentId String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, threadId])\n @@index([threadId])\n}\n\n// ─── User Notification Prefs ─────────────────────────────────────────────────\n// One row per user, holding their notification preferences. `userEmail` is\n// the primary key (one row per user, not per session). `autoFollowSessions`\n// controls whether sessions *visible to* the user (any session in an agent or\n// repo they can see — not only sessions they started) are auto-followed by\n// session-alert-sweeper.ts; `autoFollowSince` records when that behavior was\n// turned on (null if never explicitly set), and the sweeper skips\n// auto-following sessions that were already waiting before that moment. When\n// it is null the sweeper falls back to this row's `createdAt` as the boundary\n// — `autoFollowSessions` defaults to true and a row is created just by loading\n// the settings page, so \"null\" must not mean \"no boundary\", or the first sweep\n// would back-follow and immediately push the whole waiting backlog. Rows that\n// predate the sweeper were stamped once by\n// 20260911000000_backfill_auto_follow_since.\n// `reminderHourLocal` is validated to be an integer in\n// [0, 23] at the service layer (session-follow-service.ts), not here — Prisma\n// has no native CHECK constraint support in this schema style.\nmodel UserNotificationPrefs {\n userEmail String @id\n autoFollowSessions Boolean @default(true)\n reminderHourLocal Int @default(9)\n autoFollowSince DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
23
+ "inlineSchema": "// Shipwright — Admin Service Schema: coding-owned models (AEP-3.4)\n//\n// Part of the admin Prisma multi-file schema (prisma/schema/). These models\n// belong to the coding feature surface and are isolated here so the coding\n// code can later be extracted. They share the single `db` datasource and\n// generator declared in schema.prisma, so the generated client and the\n// migrations are unchanged by this split.\n//\n// Coding-owned columns that live on the shared Agent model (schema.prisma)\n// and are grouped/commented there: reviewAuthorAllowlist, patchAuthorAllowlist,\n// autoPostReviews, allowSelfReview, minConfidence, maxFindings.\n\n// ─── Agent Work Queue Snapshot ────────────────────────────────────────────────\n// One row per agent, holding the agent's most recently pushed work-queue\n// snapshot (its ranked view of pending tasks/PRs across pipeline phases).\n// POST /agents/:id/work-queue upserts this single row, overwriting any prior\n// snapshot — there is no history, only the latest state.\n\nmodel AgentWorkQueueSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n computedAt DateTime\n items Json // RankedWorkItem[]\n createdAt DateTime @default(now())\n}\n\n// ─── Agent Phase Methodology ──────────────────────────────────────────────────\n// Per-phase subagent-type override. Each row records which subagent type\n// (e.g. a custom Specialty name) should handle one pipeline phase — prd,\n// plan-session, review, patch, deploy, or dev-task — for a given agent,\n// overriding that phase's default methodology. `subagentType` is nullable:\n// a row with a null value (or the absence of a row) means \"use the default\".\n// The `phase` value is constrained to the six-value enum at the service/route\n// layer (Zod), not here — Postgres/Prisma has no native enum-column\n// requirement in this schema style (mirrors AgentTool.pattern / AgentCronJob\n// validation living at the service layer). The unique constraint on\n// [agentId, phase] makes upsert-based writes idempotent (one override row per\n// phase per agent), mirroring AgentTool's [agentId, pattern] and AgentPlugin's\n// [agentId, name].\n\nmodel AgentPhaseMethodology {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n phase String\n subagentType String?\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, phase])\n}\n\n// ─── Session Follow ───────────────────────────────────────────────────────────\n// Records that a user is \"following\" a planning session (identified by its\n// slug) so they can receive reminders/notifications about it. `muted` lets a\n// user stay following (kept in listByUser) while suppressing alerts, distinct\n// from unfollowing (row deleted entirely). The unique constraint on\n// [userEmail, sessionSlug] makes follow() idempotent via upsert.\nmodel SessionFollow {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n muted Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n @@index([sessionSlug])\n}\n\n// ─── Session Alert State ─────────────────────────────────────────────────────\n// Tracks the last time a user was alerted about a given session, so a\n// reminder job can avoid re-alerting the same user/session pair within a\n// cooldown window. The unique constraint on [userEmail, sessionSlug] allows\n// upsert semantics (one state row per user per session).\nmodel SessionAlertState {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n lastAlertedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n}\n\n// Shipwright — Admin Service Schema\n// Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,\n// plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,\n// AgentPhaseMethodology, SessionFollow, SessionAlertState).\n// Agent is a first-class model (standalone, not embedded in a User model).\n//\n// WARNING: This schema uses DATABASE_URL_SHIPWRIGHT_ADMIN — a dedicated database\n// for the admin service. Do NOT point this at a shared database.\n\n// Prisma 7: the `prisma-client` generator (ESM, driver-adapter based) replaces\n// `prisma-client-js`, and the datasource block no longer accepts `url` — the\n// connection string for CLI/Migrate lives in ../../prisma.config.ts, and the\n// runtime client connects through a PrismaPg driver adapter (see\n// admin/src/prisma-client.ts).\ngenerator client {\n provider = \"prisma-client\"\n output = \"../client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// ─── Agent ────────────────────────────────────────────────────────────────────\n// A Shipwright agent: an autonomous runner that picks tasks, builds, and ships PRs.\n\nmodel Agent {\n id String @id @default(cuid())\n name String\n slackId String? @unique\n slackBotToken String? // AES-256-GCM encrypted at the service layer\n anthropicApiKey String? // AES-256-GCM encrypted at the service layer\n selfHosted Boolean @default(false)\n repos String[] @default([])\n // DBR-2.1→2.4: renamed from authorAllowlist. DBR-2.1 added this column as\n // a dual-written/dual-read twin of the old authorAllowlist column and\n // backfilled it; DBR-2.2/2.3 migrated the admin UI and the agent's\n // runtime read path onto this field exclusively; DBR-2.4 dropped the old\n // authorAllowlist column now that nothing reads or writes it.\n // CODING-OWNED (AEP-3.4): reviewAuthorAllowlist and patchAuthorAllowlist are\n // read by the coding review/patch flows. Kept on Agent (no column move).\n reviewAuthorAllowlist String[] @default([])\n patchAuthorAllowlist String[] @default([])\n restrictSlackToMembers Boolean @default(false)\n // The agent's Specialty name (manifest key). AGR-6.3 made it required and\n // dropped the legacy typeName column.\n specialty String @default(\"specialist\")\n // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.\n accountId String?\n account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)\n // ATE-1.1: trial expiry tracking. Both nullable/additive — null means \"no\n // trial configured\" / \"no warning sent yet\". trialExpiresAt is settable via\n // PATCH /agents/:id like other nullable scalar fields; trialExpiryWarnedAt\n // is written internally by ATE-2.1's warning check (not user-editable via\n // the PATCH route) but is included in GET/PATCH responses.\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n // PAI-1.1: owner and Anthropic identity. All nullable/additive — existing\n // agents keep NULL. anthropicIdentityName is unique across agents.\n ownerEmail String?\n anthropicIdentityName String? @unique\n anthropicServiceAccountId String?\n anthropicFederationRuleId String?\n k8sServiceAccountName String?\n // COS-48.1: the pod's own Kubernetes ServiceAccount (cluster identity). A\n // separate column from k8sServiceAccountName, which is the Anthropic-\n // federation KSA. Only rendered when the pod-SA flag is on.\n podServiceAccountName String?\n // APM-1.1: the 6 agent policy fields (auto_post_reviews, allow_self_review,\n // min_confidence, max_findings, cleanup_merged_worktrees,\n // cleanup_after_days). Originally mirrored from the legacy\n // state/agent-policy.md; the DB is now the only source and each column\n // carries its documented default.\n // CODING-OWNED (AEP-3.4): autoPostReviews, allowSelfReview, minConfidence,\n // maxFindings configure the coding review flow; cleanupMergedWorktrees and\n // cleanupAfterDays below are runtime-owned.\n autoPostReviews Boolean @default(true)\n allowSelfReview Boolean @default(false)\n minConfidence Int @default(75)\n maxFindings Int @default(5)\n cleanupMergedWorktrees Boolean @default(true)\n cleanupAfterDays Int @default(14)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n envVars AgentEnv[]\n cronJobs AgentCronJob[]\n tools AgentTool[]\n tokens AgentToken[]\n plugins AgentPlugin[]\n members AgentMember[]\n chatTokenUsage AgentChatTokenUsageDailyByModel[]\n workQueueSnapshot AgentWorkQueueSnapshot?\n githubInstallationsSnapshot AgentGitHubInstallationsSnapshot?\n phaseMethodologies AgentPhaseMethodology[]\n\n @@index([accountId])\n}\n\n// ─── Agent Env ────────────────────────────────────────────────────────────────\n// Generic key/value env var store for deployed agents.\n// Keys are env var names (e.g. ANTHROPIC_API_KEY). Values are AES-256-GCM encrypted.\n\nmodel AgentEnv {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n key String // env var name\n value String // AES-256-GCM encrypted\n secret Boolean @default(false) // when true, GET /agents/:id/envs masks the value as ***\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, key])\n}\n\n// ─── Agent Cron Jobs ──────────────────────────────────────────────────────────\n// Scheduled prompts per agent.\n// Delivery target is either a Slack channel ID or a Slack user ID (DM).\n// Validation that exactly one of channel/user is set happens in the service layer.\n\nmodel AgentCronJob {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n schedule String // cron expression e.g. \"0 9 * * 1-5\"\n prompt String\n channel String? // Slack channel ID (mutually exclusive with user)\n user String? // Slack user ID for DMs\n silent Boolean @default(false)\n enabled Boolean @default(true)\n preCheck String? // optional pre-check script e.g. \"shipwright:check-docs-freshness.ts\"\n name String? // stable key for system crons; null for user-created crons\n system Boolean @default(false)\n // Set when the trial-expiry lockdown (not the user) disabled this cron, so\n // restoreLockdownDisabled() can re-enable exactly those crons. Null for\n // enabled crons and for crons the user disabled manually.\n lockdownDisabledAt DateTime?\n // Run-now request (COS-42.1): stamped by POST .../run-now, cleared when the\n // runtime creates the run (commandVariant \"run-now\"). The runtime honours a\n // stamp at most once and only while it is fresh (15 minutes).\n runRequestedAt DateTime?\n runRequestedBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Self-relation: a parent cron can have child \"phase\" crons (e.g. dev-task/\n // review/patch/deploy phases of a single logical loop). Null for top-level\n // crons. Deleting a parent cascades to its phase children.\n parentCronId String?\n parentCron AgentCronJob? @relation(\"CronPhases\", fields: [parentCronId], references: [id], onDelete: Cascade)\n phases AgentCronJob[] @relation(\"CronPhases\")\n\n runs AgentCronRun[]\n phaseRuns AgentCronRun[] @relation(\"PhaseRuns\")\n\n @@index([parentCronId])\n}\n\n// ─── Agent Tools ──────────────────────────────────────────────────────────────\n// Allowed tool patterns per agent. Each row grants an agent permission to use\n// a specific tool pattern (e.g. \"Read\", \"Bash\", \"Write\"). The unique constraint\n// on [agentId, pattern] prevents duplicates.\n\nmodel AgentTool {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n pattern String\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n\n @@unique([agentId, pattern])\n}\n\n// ─── Agent Tokens ─────────────────────────────────────────────────────────────\n// Scoped API tokens for agent authentication. The raw token is returned once at\n// creation; only the SHA-256 hash is stored. Token validation is O(1) via the\n// hash index.\n\nmodel AgentToken {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n token String @unique // SHA-256 hash of the raw token (hex)\n label String?\n createdAt DateTime @default(now())\n revokedAt DateTime?\n}\n\n// ─── Agent Plugins ────────────────────────────────────────────────────────────\n// Claude Code plugins installed per agent. Each row represents one installed\n// plugin package. The unique constraint on [agentId, name] prevents duplicates.\n\nmodel AgentPlugin {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n name String // plugin package name e.g. \"@shipwright/plugin\"\n version String? // pinned version; null = latest\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, name])\n}\n\n// ─── Agent Members ────────────────────────────────────────────────────────────\n// Non-admin users granted web UI access to a specific agent.\n// Admins (SHIPWRIGHT_ADMIN_ALLOWED_EMAILS) bypass this table entirely.\n// Members can view and edit only the agents they are listed on.\n\nmodel AgentMember {\n id String @id @default(cuid())\n agentId String\n email String\n createdAt DateTime @default(now())\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@unique([agentId, email])\n @@index([email])\n}\n\n// ─── Accounts ─────────────────────────────────────────────────────────────────\n// SSP-1.2: self-serve agent provisioning. An Account owns agents and has\n// members (email-keyed) and pending invites. Phase 1: one account per email\n// (UNIQUE(email) on AccountMember). Enum-like strings are validated in the\n// service layer, matching the rest of this schema.\n\nmodel Account {\n id String @id @default(cuid())\n name String\n status String @default(\"active\") // 'active' | 'suspended' | 'trial_expired'\n maxAgents Int @default(0)\n plan String?\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n agents Agent[]\n members AccountMember[]\n invites AccountInvite[]\n}\n\nmodel AccountMember {\n id String @id @default(cuid())\n accountId String\n email String @unique // lowercased; one account per email in Phase 1\n role String // 'owner' | 'member'\n createdAt DateTime @default(now())\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n}\n\nmodel AccountInvite {\n id String @id @default(cuid())\n accountId String\n email String // lowercased\n invitedBy String\n createdAt DateTime @default(now())\n acceptedAt DateTime?\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n @@index([email])\n}\n\n// ─── Agent Cron Runs ──────────────────────────────────────────────────────────\n// Execution history for AgentCronJob firings. Each row records one cron invocation:\n// whether it was skipped (pre-check returned false), the outcome, and any error.\n// agentId is denormalized for efficient agent-level history queries.\n\nmodel AgentCronRun {\n id String @id @default(cuid())\n cronId String\n cron AgentCronJob @relation(fields: [cronId], references: [id], onDelete: Cascade)\n agentId String // denormalized from cron.agentId for efficient queries\n startedAt DateTime\n completedAt DateTime?\n skipped Boolean @default(false)\n skipReason String?\n outcome String?\n error String?\n itemType String? // work item type this run was dispatched against (\"task\" | \"pr\");\n // null when the tick had no dispatch (skipped tick, empty queue)\n itemId String? // work item id this run was dispatched against (e.g. \"WLS-2.2\" or\n // \"acme/x#123\"); null when the tick had no dispatch\n commandVariant String? // which slash-command variant a loop dispatch ran (\"default\" | \"alternate\");\n // null for runs with no variant attribution\n sessionId String? // Claude session id this cron run corresponds to\n lastHeartbeatAt DateTime? // Most recent debounced progress-push (recordProgress())\n // timestamp, sourced from the agent's injected Clock. Null\n // for runs that never reported progress (short/legacy runs).\n // Lets the admin UI show a \"last checked in\" signal — and a\n // best-effort in-progress duration — for a run that stalls\n // or crashes before reaching completedAt. Additive/nullable;\n // no backfill for existing rows.\n createdAt DateTime @default(now())\n\n // Secondary/denormalized pointer to the phase cron (a child AgentCronJob)\n // this run was dispatched by, distinct from cronId which always points at\n // the top-level cron. Null until phase crons are wired up (LPC-1.2+).\n // SetNull (not Cascade) on delete: deleting a phase cron shouldn't delete\n // the parent cron's run history, which cronId's own Cascade already owns.\n phaseId String?\n phaseCron AgentCronJob? @relation(\"PhaseRuns\", fields: [phaseId], references: [id], onDelete: SetNull)\n\n // ── Run telemetry (prompt-audit metrics; all additive/nullable) ──────────\n // First-turn context baseline: the usage of the run's first assistant\n // message. baselineContextTokens = input + cacheCreation + cacheRead of\n // that turn — the full always-loaded context (system prompt, tool schemas,\n // CLAUDE.md/rules, skill listing, cron prompt) independent of cache\n // warmth. Null for resumed sessions (their first turn replays history)\n // and for runs reported by older agent builds.\n baselineModel String?\n baselineContextTokens Int?\n baselineInputTokens Int?\n baselineCacheCreationTokens Int?\n baselineCacheReadTokens Int?\n // Distinct usage-bearing assistant turns / distinct tool_use blocks.\n turns Int?\n toolCalls Int?\n // sha256[:12] over the workspace's always-loaded markdown (CLAUDE.md, its\n // @imports, no-`paths` rules) + plugin version — groups runs by what the\n // model was given, so before/after series for a markdown change key on\n // content rather than merge date. Computed agent-side.\n contextFingerprint String?\n pluginVersion String?\n claudeCodeVersion String?\n\n modelBreakdown AgentCronRunModelBreakdown[]\n skillUsage AgentCronRunSkillUsage[]\n\n @@index([cronId])\n @@index([agentId, startedAt])\n @@index([phaseId])\n @@index([startedAt])\n @@index([contextFingerprint])\n}\n\n// ─── Agent Cron Run Skill Usage ──────────────────────────────────────────────\n// Per-skill / per-subagent token attribution for a cron run, derived from the\n// Claude CLI stream by the agent. `kind` is \"skill\" (a plugin\n// skill invoked via the Skill tool), \"agent\" (a subagent type invoked via the\n// Agent tool), or \"root\" (turns before any skill was invoked). Attribution is\n// last-wins: every turn after an invoke is charged to the most recently\n// invoked skill. invokeContextDelta is input + cacheCreation of the first\n// turn after the skill's first invoke — the new context admitted when its\n// body loaded. The unique constraint on [cronRunId, kind, name] allows upsert.\n\nmodel AgentCronRunSkillUsage {\n id String @id @default(cuid())\n cronRunId String\n kind String\n name String\n invocations Int @default(0)\n turns Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n invokeContextDelta Int?\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, kind, name])\n @@index([kind, name])\n}\n\n// ─── Agent Cron Run Model Breakdown ──────────────────────────────────────────\n// Per-model token breakdown for a cron run. Populated when sub-agents use\n// different models in a single run. The unique constraint on [cronRunId, model]\n// allows upsert semantics.\n\nmodel AgentCronRunModelBreakdown {\n id String @id @default(cuid())\n cronRunId String\n model String\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n costUsd Float @default(0)\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, model])\n}\n\n// ─── Agent Chat Token Usage (Daily, per Model) ───────────────────────────────\n// Daily rollup table for Slack/chat session token usage per agent per model.\n// Accumulated atomically via INSERT ... ON CONFLICT ... DO UPDATE (no read-modify-write).\n// Totals and byAgent aggregations are computed by summing across models.\n\nmodel AgentChatTokenUsageDailyByModel {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n date String // YYYY-MM-DD\n model String // e.g. \"claude-sonnet-4-5\"\n inputTokens BigInt @default(0)\n outputTokens BigInt @default(0)\n cacheReadTokens BigInt @default(0)\n cacheCreationTokens BigInt @default(0)\n costUsd Float @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, date, model])\n}\n\n// ─── Agent Ask ──────────────────────────────────────────────────────────────\n// Audit row per agent-to-agent ask (COS-31.1, POST /agents/:id/ask). Written\n// before the chat call and updated after it. The question text is never\n// stored. No relation to Agent on purpose: the audit trail outlives either\n// agent. threadId is null until the chat thread is created (and stays null\n// when that call fails). status: created | pending | replied | failed.\n\nmodel AgentAsk {\n id String @id @default(cuid())\n fromAgentId String\n toAgentId String\n threadId String?\n depth Int\n waitMs Int\n ttlSeconds Int\n status String\n callerLabel String\n createdAt DateTime @default(now())\n repliedAt DateTime?\n\n @@index([toAgentId, createdAt])\n @@index([fromAgentId, createdAt])\n}\n\n// ─── Agent Cron Run Requests ────────────────────────────────────────────────\n// Audit row per POST /agents/{id}/crons/{cronId}/run-now (COS-42.1), written\n// before the 202 returns. status: requested | honoured | expired. Holds no\n// prompt text. honouredRunId links the AgentCronRun the runtime created.\n\nmodel AgentCronRunRequest {\n id String @id @default(cuid())\n cronId String\n agentId String\n requestedBy String\n requestedAt DateTime\n status String @default(\"requested\")\n honouredRunId String?\n createdAt DateTime @default(now())\n\n @@index([cronId, requestedAt])\n @@index([agentId, requestedAt])\n}\n\n// ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────\n// One redacted session transcript per cron run, uploaded by the agent runtime\n// (already redacted + tail-truncated to 1 MiB) and served only to admin /\n// platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:\n// the retention sweep (newest 200 per agent) is the only lifecycle. The body\n// is never logged. AgentTranscriptRead is the audit row written before every\n// transcript read.\n\nmodel AgentCronRunTranscript {\n id String @id @default(cuid())\n runId String @unique\n agentId String\n cronId String\n sessionId String?\n bytes Int\n truncated Boolean\n redactions Int\n body String @db.Text\n createdAt DateTime @default(now())\n\n @@index([agentId, createdAt])\n}\n\nmodel AgentTranscriptRead {\n id String @id @default(cuid())\n runId String\n agentId String\n reader String\n scopeKind String\n at DateTime @default(now())\n\n @@index([agentId, at])\n}\n\n// ─── Agent GitHub Installations Snapshot ────────────────────────────────────\n// One row per agent holding the agent's most recently reported GitHub App\n// installations (owner, installation id, state, sanitized lastError). PUT\n// replaces the whole list — a dropped installation disappears. No tokens or\n// other credentials are ever stored here.\n\nmodel AgentGitHubInstallationsSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n reportedAt DateTime\n installations Json // GitHubInstallation[]\n repoAccess Json? // RepoAccessEntry[] — per-repo read/write probe (COS-44.1); null until a flagged runtime reports it\n createdAt DateTime @default(now())\n}\n\n// ─── Push Subscription ──────────────────────────────────────────────────────\n// A single browser/device Web Push subscription (RFC 8291) belonging to a\n// user. One user may have many rows (multi-device). Keyed by the unique push\n// `endpoint`; `p256dh` and `auth` are the subscription's public key + auth\n// secret used to encrypt the payload. `detailOptIn` is the USER'S opt-in for\n// how much notification detail they want on a locked screen — the effective\n// level is min(operator ceiling SHIPWRIGHT_ADMIN_PUSH_MAX_DETAIL, detailOptIn).\n// Values: \"generic\" | \"title\" | \"preview\". Never carries an agentId — targeting\n// is by userEmail via ChatThreadWatch, so a subscription is agent-agnostic.\nmodel PushSubscription {\n id String @id @default(cuid())\n userEmail String\n endpoint String @unique\n p256dh String // base64url of the UA public key\n auth String // base64url of the UA auth secret\n detailOptIn String @default(\"generic\") // user opt-in: generic | title | preview\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userEmail])\n}\n\n// ─── Chat Thread Watch ──────────────────────────────────────────────────────\n// Records that a user is \"watching\" a chat thread so the agent's reply can be\n// targeted to exactly the right subscriber(s) without fanning out to every\n// member of the agent. Upserted whenever a user sends a message in a thread.\n// The chat Message model has no author column, and admin is the only web front\n// door that knows both userEmail and threadId at send time — so authorship\n// targeting lives here, not in the chat service.\nmodel ChatThreadWatch {\n id String @id @default(cuid())\n userEmail String\n threadId String\n agentId String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, threadId])\n @@index([threadId])\n}\n\n// ─── User Notification Prefs ─────────────────────────────────────────────────\n// One row per user, holding their notification preferences. `userEmail` is\n// the primary key (one row per user, not per session). `autoFollowSessions`\n// controls whether sessions *visible to* the user (any session in an agent or\n// repo they can see — not only sessions they started) are auto-followed by\n// session-alert-sweeper.ts; `autoFollowSince` records when that behavior was\n// turned on (null if never explicitly set), and the sweeper skips\n// auto-following sessions that were already waiting before that moment. When\n// it is null the sweeper falls back to this row's `createdAt` as the boundary\n// — `autoFollowSessions` defaults to true and a row is created just by loading\n// the settings page, so \"null\" must not mean \"no boundary\", or the first sweep\n// would back-follow and immediately push the whole waiting backlog. Rows that\n// predate the sweeper were stamped once by\n// 20260911000000_backfill_auto_follow_since.\n// `reminderHourLocal` is validated to be an integer in\n// [0, 23] at the service layer (session-follow-service.ts), not here — Prisma\n// has no native CHECK constraint support in this schema style.\nmodel UserNotificationPrefs {\n userEmail String @id\n autoFollowSessions Boolean @default(true)\n reminderHourLocal Int @default(9)\n autoFollowSince DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
24
24
  "runtimeDataModel": {
25
25
  "models": {},
26
26
  "enums": {},
@@ -0,0 +1,4 @@
1
+ -- PCD-3.4: change the Agent.specialty column default from 'coding' to 'specialist'.
2
+ -- Default only: no row rewrites, no constraint, no backfill, so every existing
3
+ -- agent keeps its stored value.
4
+ ALTER TABLE "Agent" ALTER COLUMN "specialty" SET DEFAULT 'specialist';
@@ -44,7 +44,7 @@ model Agent {
44
44
  restrictSlackToMembers Boolean @default(false)
45
45
  // The agent's Specialty name (manifest key). AGR-6.3 made it required and
46
46
  // dropped the legacy typeName column.
47
- specialty String @default("coding")
47
+ specialty String @default("specialist")
48
48
  // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.
49
49
  accountId String?
50
50
  account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { CallerScope } from "./caller-scope.ts";
12
- import { resolveDefaultSpecialtyName } from "./specialty-manifest-loader.ts";
12
+ import { resolveDefaultSpecialtyName } from "./specialty-types.ts";
13
13
 
14
14
  /**
15
15
  * The only specialty account users may create: SHIPWRIGHT_SELF_SERVE_AGENT_TYPE
package/src/admin-boot.ts CHANGED
@@ -69,13 +69,11 @@ import {
69
69
  mountRouteContributions,
70
70
  resolveAdminExtensions,
71
71
  startBackgroundPasses,
72
+ withExtensionAgentTypes,
72
73
  } from "./admin-extension.ts";
73
74
  import { createAdminUIApp } from "./admin-ui.ts";
74
75
  import { AgentAskService } from "./agent-ask.ts";
75
76
  import { parseAgentAskConfig } from "./agent-ask-config.ts";
76
- import { CronRunNowService } from "./cron-run-now.ts";
77
- import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
78
- import { parsePodServiceAccountConfig } from "./pod-service-account-config.ts";
79
77
  import { AgentChatTokenService } from "./agent-chat-tokens.ts";
80
78
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
81
79
  import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
@@ -96,6 +94,8 @@ import {
96
94
  } from "./caller-scope.ts";
97
95
  import { parseChatCredentialConfig } from "./chat-credential-config.ts";
98
96
  import { HttpChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
97
+ import { CronRunNowService } from "./cron-run-now.ts";
98
+ import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
99
99
  import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
100
100
  import { isDevAuthAllowed } from "./dev-auth-guard.ts";
101
101
  import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
@@ -103,13 +103,14 @@ import { HttpGoogleAuthClient } from "./google-auth-client.ts";
103
103
  import { HttpChatClient } from "./http-chat-client.ts";
104
104
  import { HttpOktaAuthClient } from "./okta-auth-client.ts";
105
105
  import { mountPlatformDoc } from "./platform-doc-route.ts";
106
+ import { parsePodServiceAccountConfig } from "./pod-service-account-config.ts";
106
107
  import { createAdminPrismaClient } from "./prisma-client.ts";
107
108
  import { isPushEnabled } from "./push-sender.ts";
108
109
  import { PushService } from "./push-service.ts";
109
110
  import { parseRepoAccessConfig } from "./repo-access-config.ts";
110
111
  import { parseSelfServeConfig } from "./self-serve-config.ts";
111
112
  import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
112
- import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
113
+ import { SpecialtyTypes } from "./specialty-types.ts";
113
114
  import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
114
115
  import { makeTokenCrypto } from "./token-crypto.ts";
115
116
  import { parseTranscriptConfig } from "./transcript-config.ts";
@@ -180,8 +181,12 @@ export async function startAdmin(
180
181
  anthropicFederationEnabled,
181
182
  );
182
183
  // Extension-supplied specialty defaults (crons/tools/plugins), consulted by
183
- // the cron reconcile and create-time seeding before the manifest.
184
+ // the cron reconcile and create-time seeding before the built-in type.
184
185
  const specialtyDefaults = collectSpecialtyDefaults(extensions);
186
+ const specialtyRegistry = withExtensionAgentTypes(
187
+ new SpecialtyTypes(),
188
+ extensions,
189
+ );
185
190
  const agentCronJobService = new AgentCronJobService(
186
191
  prisma,
187
192
  undefined,
@@ -458,7 +463,7 @@ export async function startAdmin(
458
463
  agentPluginService,
459
464
  agentMemberService,
460
465
  callerScopeResolver,
461
- specialtyRegistry: new SpecialtyRegistry(),
466
+ specialtyRegistry,
462
467
  specialtyDefaults,
463
468
  agentChatTokenService,
464
469
  agentGitHubInstallationsService,
@@ -551,6 +556,7 @@ export async function startAdmin(
551
556
  agentService,
552
557
  provisioner,
553
558
  anthropicFederationEnabled,
559
+ specialtyRegistry,
554
560
  specialtyDefaults,
555
561
  serviceIntegrations,
556
562
  chatService: deletionChatService,
@@ -28,8 +28,13 @@
28
28
  * serviceIntegrations it needs no context and is
29
29
  * collected before the services are built; the cron
30
30
  * reconcile and create-time seeding consult it
31
- * before the specialty manifest, which remains the
32
- * fallback when no extension returns anything
31
+ * before the built-in specialist type, which remains
32
+ * the fallback when no extension returns anything
33
+ * agentTypes() — agent types this extension offers in the new-agent
34
+ * picker and accepts in POST /admin/agents, for
35
+ * types beyond the built-in specialist. Extension
36
+ * entries win over a built-in of the same name; a type's
37
+ * crons still come from specialtyDefaults(name)
33
38
  *
34
39
  * openApi() is static (no context): scripts/generate-admin-spec.ts calls it to
35
40
  * fold extension routes/schemas into admin/openapi.json without booting.
@@ -62,6 +67,10 @@ import type {
62
67
  SpecialtyDefaults,
63
68
  SpecialtyDefaultsResolver,
64
69
  } from "./specialty-defaults.ts";
70
+ import type {
71
+ SpecialtyOption,
72
+ SpecialtyTypeResolver,
73
+ } from "./specialty-types.ts";
65
74
 
66
75
  /** The platform services an extension may build on. */
67
76
  export interface AdminPlatformServices {
@@ -178,8 +187,10 @@ export interface AdminExtension {
178
187
  name?: string;
179
188
  /** See the header: env-only, resolved before the provisioner is built. */
180
189
  serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
181
- /** See the header: undefined means "no opinion" — the manifest applies. */
190
+ /** See the header: undefined means "no opinion" — the built-in type applies. */
182
191
  specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
192
+ /** See the header: additive; merged over the built-in types. */
193
+ agentTypes?(): SpecialtyOption[];
183
194
  services?(ctx: AdminExtensionContext): AdminExtensionServices;
184
195
  apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
185
196
  adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
@@ -358,8 +369,9 @@ export function collectServiceIntegrations(
358
369
 
359
370
  /**
360
371
  * Builds the resolver the cron reconcile and create-time seeding consult
361
- * before the manifest. The first extension returning defaults wins; with none
362
- * registered (or none answering) it returns undefined and the manifest applies.
372
+ * before the built-in type. The first extension returning defaults wins; with none
373
+ * registered (or none answering) it returns undefined and the built-in type's
374
+ * tools floor applies.
363
375
  */
364
376
  export function collectSpecialtyDefaults(
365
377
  extensions: readonly AdminExtension[],
@@ -373,6 +385,40 @@ export function collectSpecialtyDefaults(
373
385
  };
374
386
  }
375
387
 
388
+ /**
389
+ * Merges every extension's agentTypes() over the built-in `base`
390
+ * list. Order is base first, then new extension names; on a name collision the
391
+ * extension's entry replaces the base entry in place.
392
+ */
393
+ export function mergeAgentTypes(
394
+ base: readonly SpecialtyOption[],
395
+ extensions: readonly AdminExtension[],
396
+ ): SpecialtyOption[] {
397
+ const byName = new Map(base.map((type) => [type.name, type]));
398
+ for (const ext of extensions) {
399
+ for (const type of ext.agentTypes?.() ?? []) byName.set(type.name, type);
400
+ }
401
+ return [...byName.values()];
402
+ }
403
+
404
+ /**
405
+ * Wraps the registry so the new-agent picker lists extension types and
406
+ * POST /admin/agents accepts them. With no extension declaring agentTypes()
407
+ * the wrapped registry behaves exactly like `registry`.
408
+ */
409
+ export function withExtensionAgentTypes(
410
+ registry: SpecialtyTypeResolver,
411
+ extensions: readonly AdminExtension[],
412
+ ): SpecialtyTypeResolver {
413
+ return {
414
+ getType: (specialty) => registry.getType(specialty),
415
+ tryGetType: (specialty) => registry.tryGetType(specialty),
416
+ listTypes: () => mergeAgentTypes(registry.listTypes(), extensions),
417
+ hasExtensionType: (specialty) =>
418
+ mergeAgentTypes([], extensions).some((type) => type.name === specialty),
419
+ };
420
+ }
421
+
376
422
  /** Mounts each contribution on root, in order. */
377
423
  export function mountRouteContributions(
378
424
  root: AnyHono,
@@ -10,11 +10,13 @@ import {
10
10
  collectOpenApiContributions,
11
11
  collectServiceIntegrations,
12
12
  collectSpecialtyDefaults,
13
+ mergeAgentTypes,
13
14
  mountRouteContributions,
14
15
  renderAgentDetailCardSlots,
15
16
  renderAgentDetailCards,
16
17
  resolveAdminExtensions,
17
18
  startBackgroundPasses,
19
+ withExtensionAgentTypes,
18
20
  } from "./admin-extension.ts";
19
21
 
20
22
  // The seam only hands the context through to hooks, so an opaque stand-in is
@@ -549,3 +551,62 @@ describe("collectSpecialtyDefaults", () => {
549
551
  expect(resolve("b")).toEqual({ tools: ["B"] });
550
552
  });
551
553
  });
554
+
555
+ describe("mergeAgentTypes", () => {
556
+ const base = [
557
+ { name: "specialist", displayName: "Specialist" },
558
+ { name: "coding", displayName: "Manifest Coding" },
559
+ ];
560
+
561
+ it("returns the base list unchanged with no or hookless extensions", () => {
562
+ expect(mergeAgentTypes(base, [])).toEqual(base);
563
+ expect(mergeAgentTypes(base, [{ name: "bare" }])).toEqual(base);
564
+ expect(mergeAgentTypes([], [])).toEqual([]);
565
+ });
566
+
567
+ it("appends extension types, across multiple extensions", () => {
568
+ const merged = mergeAgentTypes(base, [
569
+ { agentTypes: () => [{ name: "a", displayName: "A" }] },
570
+ { agentTypes: () => [{ name: "b", displayName: "B" }] },
571
+ ]);
572
+ expect(merged.map((t) => t.name)).toEqual([
573
+ "specialist",
574
+ "coding",
575
+ "a",
576
+ "b",
577
+ ]);
578
+ });
579
+
580
+ it("lets the extension win on a name collision, keeping position", () => {
581
+ const merged = mergeAgentTypes(base, [
582
+ { agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] },
583
+ { agentTypes: () => [{ name: "coding", displayName: "Later Wins" }] },
584
+ ]);
585
+ expect(merged).toEqual([
586
+ { name: "specialist", displayName: "Specialist" },
587
+ { name: "coding", displayName: "Later Wins" },
588
+ ]);
589
+ });
590
+ });
591
+
592
+ describe("withExtensionAgentTypes", () => {
593
+ const registry = {
594
+ getType: () => {
595
+ throw new Error("unused");
596
+ },
597
+ tryGetType: () => undefined,
598
+ listTypes: () => [{ name: "specialist", displayName: "Specialist" }],
599
+ };
600
+
601
+ it("merges listTypes and reports extension-only types", () => {
602
+ const wrapped = withExtensionAgentTypes(registry, [
603
+ { agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] },
604
+ ]);
605
+ expect(wrapped.listTypes().map((t) => t.name)).toEqual([
606
+ "specialist",
607
+ "coding",
608
+ ]);
609
+ expect(wrapped.hasExtensionType?.("coding")).toBe(true);
610
+ expect(wrapped.hasExtensionType?.("specialist")).toBe(false);
611
+ });
612
+ });
@@ -20,15 +20,18 @@ import type {
20
20
  AdminUISlackClient,
21
21
  } from "./admin-ui.ts";
22
22
  import { createAdminUIApp } from "./admin-ui.ts";
23
- import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
24
23
  import type { GoogleAuthClient } from "./google-auth-client.ts";
24
+ import {
25
+ resolveDefaultSpecialtyName,
26
+ SpecialtyTypes,
27
+ } from "./specialty-types.ts";
25
28
 
26
29
  // The configured default specialty (the fixture under the platform test run,
27
30
  // "coding" in the monorepo).
28
- const TYPE_META = new SpecialtyRegistry().getManifest(
29
- process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || "coding",
30
- ).metadata;
31
- const TYPE = TYPE_META.name;
31
+ const TYPE = resolveDefaultSpecialtyName();
32
+ const TYPE_META = new SpecialtyTypes()
33
+ .listTypes()
34
+ .find((t) => t.name === TYPE) as { name: string; displayName: string };
32
35
 
33
36
  // ─── Constants ────────────────────────────────────────────────────────────────
34
37