@app-vitals/agent-admin 0.16.1 → 0.16.2
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/openapi.platform.json +4 -4
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +1 -1
- package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
- package/prisma/schema/schema.prisma +1 -1
- package/src/admin-boot.ts +7 -1
- package/src/admin-extension.ts +45 -0
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-ui.smoke.test.ts +33 -1
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +9 -9
- package/src/agents.integration.test.ts +2 -2
- package/src/agents.ts +11 -3
- package/src/openapi-schemas.ts +4 -4
- package/src/specialty-manifest-loader.ts +44 -4
- package/src/specialty-manifest-loader.unit.test.ts +67 -2
package/openapi.platform.json
CHANGED
|
@@ -537,7 +537,7 @@
|
|
|
537
537
|
},
|
|
538
538
|
"specialty": {
|
|
539
539
|
"type": "string",
|
|
540
|
-
"example": "
|
|
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": "
|
|
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": "
|
|
3094
|
+
"example": "specialist"
|
|
3095
3095
|
},
|
|
3096
3096
|
"typeName": {
|
|
3097
3097
|
"type": "string",
|
|
3098
|
-
"example": "
|
|
3098
|
+
"example": "specialist",
|
|
3099
3099
|
"deprecated": true
|
|
3100
3100
|
},
|
|
3101
3101
|
"phaseMethodology": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.16.
|
|
46
|
+
"@app-vitals/agent-lib": "0.16.2",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
|
@@ -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("
|
|
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)
|
package/src/admin-boot.ts
CHANGED
|
@@ -69,6 +69,7 @@ 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";
|
|
@@ -182,6 +183,10 @@ export async function startAdmin(
|
|
|
182
183
|
// Extension-supplied specialty defaults (crons/tools/plugins), consulted by
|
|
183
184
|
// the cron reconcile and create-time seeding before the manifest.
|
|
184
185
|
const specialtyDefaults = collectSpecialtyDefaults(extensions);
|
|
186
|
+
const specialtyRegistry = withExtensionAgentTypes(
|
|
187
|
+
new SpecialtyRegistry(),
|
|
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
|
|
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,
|
package/src/admin-extension.ts
CHANGED
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* reconcile and create-time seeding consult it
|
|
31
31
|
* before the specialty manifest, which remains the
|
|
32
32
|
* 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 with no manifest on disk. Extension entries
|
|
36
|
+
* win over a manifest 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
|
+
SpecialtyManifestResolver,
|
|
72
|
+
SpecialtyOption,
|
|
73
|
+
} from "./specialty-manifest-loader.ts";
|
|
65
74
|
|
|
66
75
|
/** The platform services an extension may build on. */
|
|
67
76
|
export interface AdminPlatformServices {
|
|
@@ -180,6 +189,8 @@ export interface AdminExtension {
|
|
|
180
189
|
serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
|
|
181
190
|
/** See the header: undefined means "no opinion" — the manifest applies. */
|
|
182
191
|
specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
|
|
192
|
+
/** See the header: additive; merged over the manifest-discovered types. */
|
|
193
|
+
agentTypes?(): SpecialtyOption[];
|
|
183
194
|
services?(ctx: AdminExtensionContext): AdminExtensionServices;
|
|
184
195
|
apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
|
|
185
196
|
adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
|
|
@@ -373,6 +384,40 @@ export function collectSpecialtyDefaults(
|
|
|
373
384
|
};
|
|
374
385
|
}
|
|
375
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Merges every extension's agentTypes() over the manifest-discovered `base`
|
|
389
|
+
* list. Order is base first, then new extension names; on a name collision the
|
|
390
|
+
* extension's entry replaces the base entry in place.
|
|
391
|
+
*/
|
|
392
|
+
export function mergeAgentTypes(
|
|
393
|
+
base: readonly SpecialtyOption[],
|
|
394
|
+
extensions: readonly AdminExtension[],
|
|
395
|
+
): SpecialtyOption[] {
|
|
396
|
+
const byName = new Map(base.map((type) => [type.name, type]));
|
|
397
|
+
for (const ext of extensions) {
|
|
398
|
+
for (const type of ext.agentTypes?.() ?? []) byName.set(type.name, type);
|
|
399
|
+
}
|
|
400
|
+
return [...byName.values()];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Wraps the registry so the new-agent picker lists extension types and
|
|
405
|
+
* POST /admin/agents accepts them. With no extension declaring agentTypes()
|
|
406
|
+
* the wrapped registry behaves exactly like `registry`.
|
|
407
|
+
*/
|
|
408
|
+
export function withExtensionAgentTypes(
|
|
409
|
+
registry: SpecialtyManifestResolver,
|
|
410
|
+
extensions: readonly AdminExtension[],
|
|
411
|
+
): SpecialtyManifestResolver {
|
|
412
|
+
return {
|
|
413
|
+
getManifest: (specialty) => registry.getManifest(specialty),
|
|
414
|
+
tryGetManifest: (specialty) => registry.tryGetManifest(specialty),
|
|
415
|
+
listTypes: () => mergeAgentTypes(registry.listTypes(), extensions),
|
|
416
|
+
hasExtensionType: (specialty) =>
|
|
417
|
+
mergeAgentTypes([], extensions).some((type) => type.name === specialty),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
376
421
|
/** Mounts each contribution on root, in order. */
|
|
377
422
|
export function mountRouteContributions(
|
|
378
423
|
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
|
+
getManifest: () => {
|
|
595
|
+
throw new Error("unused");
|
|
596
|
+
},
|
|
597
|
+
tryGetManifest: () => 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
|
+
});
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeAll, describe, expect, it } from "bun:test";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { sign } from "hono/jwt";
|
|
14
|
+
import { withExtensionAgentTypes } from "./admin-extension.ts";
|
|
13
15
|
import { createAdminUIApp } from "./admin-ui.ts";
|
|
14
16
|
import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
|
|
15
17
|
import {
|
|
@@ -5666,6 +5668,36 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
|
|
|
5666
5668
|
expect(seededPlugins).toEqual(["fixture-plugin"]);
|
|
5667
5669
|
});
|
|
5668
5670
|
|
|
5671
|
+
it("an extension-contributed type (ETP-1.2) shows in the picker and POST /admin/agents accepts it with no manifest", async () => {
|
|
5672
|
+
const emptyDir = mkdtempSync(join(tmpdir(), "no-specialties-"));
|
|
5673
|
+
const registry = withExtensionAgentTypes(
|
|
5674
|
+
new SpecialtyRegistry({ typesDir: emptyDir }),
|
|
5675
|
+
[{ agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] }],
|
|
5676
|
+
);
|
|
5677
|
+
const deps = makeMockDeps();
|
|
5678
|
+
deps.specialtyRegistry = registry;
|
|
5679
|
+
const app = createAdminUIApp(deps);
|
|
5680
|
+
const form = await app.request("/admin/agents/new", {
|
|
5681
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
5682
|
+
});
|
|
5683
|
+
expect(await form.text()).toContain(
|
|
5684
|
+
'<option value="coding">Coding Agent</option>',
|
|
5685
|
+
);
|
|
5686
|
+
const res = await app.request("/admin/agents", {
|
|
5687
|
+
method: "POST",
|
|
5688
|
+
body: new URLSearchParams({
|
|
5689
|
+
name: "Ext Agent",
|
|
5690
|
+
type: "coding",
|
|
5691
|
+
}).toString(),
|
|
5692
|
+
headers: {
|
|
5693
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
5694
|
+
Cookie: `admin_session=${cookie}`,
|
|
5695
|
+
},
|
|
5696
|
+
});
|
|
5697
|
+
expect(res.status).toBe(302);
|
|
5698
|
+
expect(res.headers.get("Location")).toBe(`/admin/agents/${AGENT_ID}`);
|
|
5699
|
+
});
|
|
5700
|
+
|
|
5669
5701
|
it("POST /admin/agents rolls back the agent and redirects with error when tool seeding fails", async () => {
|
|
5670
5702
|
let deletedId: string | undefined;
|
|
5671
5703
|
const deps = makeMockDeps();
|
|
@@ -40,6 +40,9 @@ function migrationSql(dir: string): string {
|
|
|
40
40
|
const ADD_TYPE_NAME = migrationSql("20260724000000_add_agent_type_name");
|
|
41
41
|
const ADD_SPECIALTY = migrationSql("20261029000000_add_agent_specialty");
|
|
42
42
|
const DROP_TYPE_NAME = migrationSql("20261030000000_drop_agent_type_name");
|
|
43
|
+
const SET_DEFAULT_SPECIALIST = migrationSql(
|
|
44
|
+
"20261030100000_set_agent_specialty_default_specialist",
|
|
45
|
+
);
|
|
43
46
|
|
|
44
47
|
describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
|
|
45
48
|
const schema = `agr63_${process.pid}_${Date.now()}`;
|
|
@@ -123,4 +126,31 @@ describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
|
|
|
123
126
|
);
|
|
124
127
|
expect(def.rows[0]?.specialty).toBe("coding");
|
|
125
128
|
});
|
|
129
|
+
|
|
130
|
+
it("set_agent_specialty_default_specialist changes only the default and leaves rows unchanged (PCD-3.4)", async () => {
|
|
131
|
+
await client.query(ADD_TYPE_NAME);
|
|
132
|
+
await client.query(ADD_SPECIALTY);
|
|
133
|
+
await client.query(DROP_TYPE_NAME);
|
|
134
|
+
for (const [i, s] of LIVE_SPECIALTIES.entries()) {
|
|
135
|
+
await client.query(
|
|
136
|
+
`INSERT INTO "Agent" ("id","name","specialty") VALUES ($1,$2,$3)`,
|
|
137
|
+
[`a${i}`, `agent-${i}`, s],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await client.query(SET_DEFAULT_SPECIALIST);
|
|
142
|
+
|
|
143
|
+
const { rows } = await client.query<{ specialty: string }>(
|
|
144
|
+
`SELECT "specialty" FROM "Agent" ORDER BY "id"`,
|
|
145
|
+
);
|
|
146
|
+
expect(rows.map((r) => r.specialty)).toEqual([...LIVE_SPECIALTIES]);
|
|
147
|
+
|
|
148
|
+
await client.query(
|
|
149
|
+
`INSERT INTO "Agent" ("id","name") VALUES ('a9','agent-9')`,
|
|
150
|
+
);
|
|
151
|
+
const def = await client.query<{ specialty: string }>(
|
|
152
|
+
`SELECT "specialty" FROM "Agent" WHERE "id" = 'a9'`,
|
|
153
|
+
);
|
|
154
|
+
expect(def.rows[0]?.specialty).toBe("specialist");
|
|
155
|
+
});
|
|
126
156
|
});
|
|
@@ -311,7 +311,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
311
311
|
|
|
312
312
|
// ─── specialty field ─────────────────────────────────────────────────────────
|
|
313
313
|
|
|
314
|
-
it("GET /agents includes specialty:'
|
|
314
|
+
it("GET /agents includes specialty:'specialist' for an agent created before specialty existed", async () => {
|
|
315
315
|
const res = await app.request("/agents", {
|
|
316
316
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
317
317
|
});
|
|
@@ -319,28 +319,28 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
319
319
|
const body = await res.json();
|
|
320
320
|
const entry = body.find((a: { id: string }) => a.id === agentId);
|
|
321
321
|
expect(entry).toBeDefined();
|
|
322
|
-
expect(entry.specialty).toBe("
|
|
322
|
+
expect(entry.specialty).toBe("specialist");
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
it("GET /agents/:id includes specialty:'
|
|
325
|
+
it("GET /agents/:id includes specialty:'specialist' by default", async () => {
|
|
326
326
|
const res = await app.request(`/agents/${agentId}`, {
|
|
327
327
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
328
328
|
});
|
|
329
329
|
expect(res.status).toBe(200);
|
|
330
330
|
const body = await res.json();
|
|
331
|
-
expect(body.specialty).toBe("
|
|
331
|
+
expect(body.specialty).toBe("specialist");
|
|
332
332
|
});
|
|
333
333
|
|
|
334
334
|
// ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
|
|
335
335
|
//
|
|
336
336
|
// Simulates the exact scenario the 20260724000000_add_agent_type_name
|
|
337
337
|
// migration must handle: rows that existed *before* the specialty column was
|
|
338
|
-
// added (never received an explicit value) must read back specialty="
|
|
338
|
+
// added (never received an explicit value) must read back specialty="specialist"
|
|
339
339
|
// via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
|
|
340
340
|
// must be completely untouched — same count, same updatedAt — since the
|
|
341
341
|
// migration only ALTERs the Agent table.
|
|
342
342
|
|
|
343
|
-
it("migration backfill: pre-existing agent rows read back specialty='
|
|
343
|
+
it("migration backfill: pre-existing agent rows read back specialty='specialist' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
|
|
344
344
|
// Seed a second "pre-existing" agent with one row in every child table,
|
|
345
345
|
// simulating data that existed prior to the specialty migration landing.
|
|
346
346
|
const preExisting = await prisma.agent.create({
|
|
@@ -383,7 +383,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
383
383
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
384
384
|
where: { id: preExisting.id },
|
|
385
385
|
});
|
|
386
|
-
expect(rawAgent.specialty).toBe("
|
|
386
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
387
387
|
|
|
388
388
|
// Read back via the HTTP API too — end-to-end confirmation.
|
|
389
389
|
const res = await app.request(`/agents/${preExisting.id}`, {
|
|
@@ -391,7 +391,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
391
391
|
});
|
|
392
392
|
expect(res.status).toBe(200);
|
|
393
393
|
const body = await res.json();
|
|
394
|
-
expect(body.specialty).toBe("
|
|
394
|
+
expect(body.specialty).toBe("specialist");
|
|
395
395
|
|
|
396
396
|
// Child row counts are unchanged — exactly one of each, still linked to
|
|
397
397
|
// the same agent.
|
|
@@ -455,7 +455,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
455
455
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
456
456
|
where: { id: agentId },
|
|
457
457
|
});
|
|
458
|
-
expect(rawAgent.specialty).toBe("
|
|
458
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
459
459
|
});
|
|
460
460
|
|
|
461
461
|
// ─── repos field round-trip ───────────────────────────────────────────────────
|
|
@@ -574,7 +574,7 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
|
|
|
574
574
|
).toBe("research");
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
-
it("specialty is required: NULL is rejected and omitting it applies the '
|
|
577
|
+
it("specialty is required: NULL is rejected and omitting it applies the 'specialist' default", async () => {
|
|
578
578
|
await expect(
|
|
579
579
|
(async () =>
|
|
580
580
|
prisma.agent.create({
|
|
@@ -584,6 +584,6 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
|
|
|
584
584
|
|
|
585
585
|
const created = await agentService.create({ name: "No Specialty" });
|
|
586
586
|
createdIds.push(created.id);
|
|
587
|
-
expect(created.specialty).toBe("
|
|
587
|
+
expect(created.specialty).toBe("specialist");
|
|
588
588
|
});
|
|
589
589
|
});
|
package/src/agents.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface CreateAgentInput {
|
|
|
33
33
|
name: string;
|
|
34
34
|
slackId?: string | null;
|
|
35
35
|
selfHosted?: boolean;
|
|
36
|
-
/** Specialty name. Omitted means Prisma's column default ("
|
|
36
|
+
/** Specialty name. Omitted means Prisma's column default ("specialist") applies. */
|
|
37
37
|
specialty?: string;
|
|
38
38
|
/** Initial repos[] — the manifest repos merged with any request-supplied repos. */
|
|
39
39
|
repos?: string[];
|
|
@@ -784,7 +784,10 @@ export interface CreateAgentDeps {
|
|
|
784
784
|
agentEnvService: Pick<AgentEnvService, "patch">;
|
|
785
785
|
agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
|
|
786
786
|
provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
|
|
787
|
-
specialtyRegistry: Pick<
|
|
787
|
+
specialtyRegistry: Pick<
|
|
788
|
+
SpecialtyManifestResolver,
|
|
789
|
+
"tryGetManifest" | "hasExtensionType"
|
|
790
|
+
>;
|
|
788
791
|
/** Extension-supplied tools/plugins, consulted before the manifest's. */
|
|
789
792
|
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
790
793
|
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
@@ -884,7 +887,12 @@ export async function createAgent(
|
|
|
884
887
|
const extensionDefaults = specialty
|
|
885
888
|
? deps.specialtyDefaults?.(specialty)
|
|
886
889
|
: undefined;
|
|
887
|
-
if (
|
|
890
|
+
if (
|
|
891
|
+
!specialty ||
|
|
892
|
+
(!manifest &&
|
|
893
|
+
!extensionDefaults &&
|
|
894
|
+
!deps.specialtyRegistry.hasExtensionType?.(specialty))
|
|
895
|
+
) {
|
|
888
896
|
return { ok: false, errorCode: "invalid_type" };
|
|
889
897
|
}
|
|
890
898
|
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const AgentSummarySchema = z
|
|
|
34
34
|
id: z.string().openapi({ example: "clx1234567890" }),
|
|
35
35
|
name: z.string().openapi({ example: "Bodhi" }),
|
|
36
36
|
selfHosted: z.boolean().openapi({ example: false }),
|
|
37
|
-
specialty: z.string().openapi({ example: "
|
|
37
|
+
specialty: z.string().openapi({ example: "specialist" }),
|
|
38
38
|
})
|
|
39
39
|
.openapi("AgentSummary");
|
|
40
40
|
|
|
@@ -147,7 +147,7 @@ export const PatchAgentBodySchema = z
|
|
|
147
147
|
export const CreateAgentBodySchema = z
|
|
148
148
|
.object({
|
|
149
149
|
name: z.string().min(1).openapi({ example: "Bodhi" }),
|
|
150
|
-
specialty: z.string().min(1).openapi({ example: "
|
|
150
|
+
specialty: z.string().min(1).openapi({ example: "specialist" }),
|
|
151
151
|
/**
|
|
152
152
|
* "in-cluster" means K8s-provisioned; anything else (including absent)
|
|
153
153
|
* means self-hosted.
|
|
@@ -1343,7 +1343,7 @@ export const AgentConfigResponseSchema = z
|
|
|
1343
1343
|
* ARD-6.1: the agent's Specialty name, used by the runtime to pick its
|
|
1344
1344
|
* workspace template directory.
|
|
1345
1345
|
*/
|
|
1346
|
-
specialty: z.string().openapi({ example: "
|
|
1346
|
+
specialty: z.string().openapi({ example: "specialist" }),
|
|
1347
1347
|
/**
|
|
1348
1348
|
* DEPRECATED back-compat alias of `specialty` (always equal to it). Kept
|
|
1349
1349
|
* only for pre-AGR-6.3 runtime images, whose entrypoint reads
|
|
@@ -1353,7 +1353,7 @@ export const AgentConfigResponseSchema = z
|
|
|
1353
1353
|
typeName: z
|
|
1354
1354
|
.string()
|
|
1355
1355
|
.optional()
|
|
1356
|
-
.openapi({ example: "
|
|
1356
|
+
.openapi({ example: "specialist", deprecated: true }),
|
|
1357
1357
|
/**
|
|
1358
1358
|
* PMC-1.1: phase -> subagentType (or null) map, covering all six pipeline
|
|
1359
1359
|
* phases (see AGENT_PHASES) regardless of whether the agent has an
|
|
@@ -121,7 +121,9 @@ export interface SpecialtyManifestResolver {
|
|
|
121
121
|
getManifest(specialty: string): SpecialtyManifest;
|
|
122
122
|
/**
|
|
123
123
|
* Resolve `specialty` to its parsed manifest WITHOUT falling back to
|
|
124
|
-
* the default type — returns undefined for an unknown type instead
|
|
124
|
+
* the default type — returns undefined for an unknown type instead (the one exception
|
|
125
|
+
* is the built-in default type when no specialty is discoverable, matching the
|
|
126
|
+
* picker's fallback option). No warning is
|
|
125
127
|
* logged (an unknown type here is an expected, callers-handle-it outcome,
|
|
126
128
|
* not a degraded-boot condition). Used by POST /admin/agents to reject an
|
|
127
129
|
* unknown requested `type` with 400 rather than silently seeding a
|
|
@@ -134,9 +136,17 @@ export interface SpecialtyManifestResolver {
|
|
|
134
136
|
* type picker. A directory whose manifest fails to load or parse is
|
|
135
137
|
* skipped (not thrown) — the picker still renders every other valid type
|
|
136
138
|
* rather than 500ing the whole /admin/agents/new page over one bad
|
|
137
|
-
* manifest. Order matches the underlying `list` fn's order.
|
|
139
|
+
* manifest. Order matches the underlying `list` fn's order. Never throws:
|
|
140
|
+
* a missing type dir is treated as empty (with a warning), and when no type
|
|
141
|
+
* is discoverable the built-in default option is returned.
|
|
138
142
|
*/
|
|
139
143
|
listTypes(): SpecialtyOption[];
|
|
144
|
+
/**
|
|
145
|
+
* True when an AdminExtension declares `specialty` via agentTypes() — a type
|
|
146
|
+
* with no manifest that POST /admin/agents must still accept. Optional: the
|
|
147
|
+
* disk-backed registry has none.
|
|
148
|
+
*/
|
|
149
|
+
hasExtensionType?(specialty: string): boolean;
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
@@ -198,11 +208,41 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
198
208
|
* internally by getManifest().
|
|
199
209
|
*/
|
|
200
210
|
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
201
|
-
|
|
211
|
+
const manifest = this.tryLoad(specialty);
|
|
212
|
+
if (manifest || specialty !== DEFAULT_SPECIALTY_NAME) return manifest;
|
|
213
|
+
// The picker offers the built-in default when nothing is discoverable, so
|
|
214
|
+
// creating an agent with it must resolve too.
|
|
215
|
+
return this.discoverOptions().length === 0
|
|
216
|
+
? parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML)
|
|
217
|
+
: undefined;
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
listTypes(): SpecialtyOption[] {
|
|
205
|
-
const
|
|
221
|
+
const options = this.discoverOptions();
|
|
222
|
+
if (options.length > 0) return options;
|
|
223
|
+
// Nothing discoverable: offer the built-in default so the picker is never empty.
|
|
224
|
+
const fallback = parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
name: fallback.metadata.name,
|
|
228
|
+
displayName: fallback.metadata.displayName,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every loadable specialty found on disk; empty when the dir is missing or has none. */
|
|
234
|
+
private discoverOptions(): SpecialtyOption[] {
|
|
235
|
+
let specialties: string[];
|
|
236
|
+
try {
|
|
237
|
+
specialties = this.list();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// Missing dir (e.g. npm consumers, where agent-types/ is not shipped).
|
|
240
|
+
this.warn(
|
|
241
|
+
`SpecialtyRegistry: could not list specialties under "${this.typesDir}" — ` +
|
|
242
|
+
`treating as empty (${err instanceof Error ? err.message : String(err)})`,
|
|
243
|
+
);
|
|
244
|
+
specialties = [];
|
|
245
|
+
}
|
|
206
246
|
const options: SpecialtyOption[] = [];
|
|
207
247
|
for (const specialty of specialties) {
|
|
208
248
|
const manifest = this.tryLoad(specialty);
|
|
@@ -160,6 +160,38 @@ describe("SpecialtyRegistry", () => {
|
|
|
160
160
|
expect(warnings).toHaveLength(0);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
it("tryGetManifest resolves the built-in default type when no specialty is discoverable", () => {
|
|
164
|
+
const registry = new SpecialtyRegistry({
|
|
165
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
166
|
+
list: () => {
|
|
167
|
+
throw new Error("ENOENT: no such file or directory");
|
|
168
|
+
},
|
|
169
|
+
read: (t) => {
|
|
170
|
+
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
171
|
+
},
|
|
172
|
+
warn: () => {},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)?.metadata.name).toBe(
|
|
176
|
+
DEFAULT_SPECIALTY_NAME,
|
|
177
|
+
);
|
|
178
|
+
expect(registry.tryGetManifest("nonexistent-type")).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("tryGetManifest does not synthesize the default type when other specialties are discoverable", () => {
|
|
182
|
+
const registry = new SpecialtyRegistry({
|
|
183
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
184
|
+
list: () => ["coding"],
|
|
185
|
+
read: (t) => {
|
|
186
|
+
if (t === "coding") return manifestYaml("coding");
|
|
187
|
+
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
188
|
+
},
|
|
189
|
+
warn: () => {},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
163
195
|
it("tryGetManifest shares the cache with getManifest — reader called once per type", () => {
|
|
164
196
|
let reads = 0;
|
|
165
197
|
const registry = new SpecialtyRegistry({
|
|
@@ -259,14 +291,47 @@ voice: true
|
|
|
259
291
|
expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
|
|
260
292
|
});
|
|
261
293
|
|
|
262
|
-
it("listTypes
|
|
294
|
+
it("listTypes falls back to the built-in default option when no type directories are discovered", () => {
|
|
263
295
|
const registry = new SpecialtyRegistry({
|
|
264
296
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
265
297
|
list: () => [],
|
|
266
298
|
read: (t) => manifestYaml(t),
|
|
267
299
|
});
|
|
268
300
|
|
|
269
|
-
expect(registry.listTypes()).toEqual([
|
|
301
|
+
expect(registry.listTypes()).toEqual([
|
|
302
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("listTypes warns and returns the built-in default when the list fn throws (missing dir)", () => {
|
|
307
|
+
const warnings: string[] = [];
|
|
308
|
+
const registry = new SpecialtyRegistry({
|
|
309
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
310
|
+
warn: (m) => warnings.push(m),
|
|
311
|
+
list: () => {
|
|
312
|
+
throw new Error("ENOENT: no such file or directory");
|
|
313
|
+
},
|
|
314
|
+
read: (t) => manifestYaml(t),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(registry.listTypes()).toEqual([
|
|
318
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
319
|
+
]);
|
|
320
|
+
expect(warnings).toHaveLength(1);
|
|
321
|
+
expect(warnings[0]).toContain("ENOENT");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("listTypes does not throw on a nonexistent typesDir with the real lister", () => {
|
|
325
|
+
const warnings: string[] = [];
|
|
326
|
+
const registry = new SpecialtyRegistry({
|
|
327
|
+
typesDir: "/nonexistent/agent-types",
|
|
328
|
+
warn: (m) => warnings.push(m),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(registry.listTypes()).toEqual([
|
|
332
|
+
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
333
|
+
]);
|
|
334
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
270
335
|
});
|
|
271
336
|
});
|
|
272
337
|
|