@app-vitals/agent-admin 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/e2e/test-server.ts +5 -5
  2. package/openapi.json +12 -19
  3. package/openapi.platform.json +11 -18
  4. package/package.json +2 -2
  5. package/prisma/client/internal/class.ts +4 -4
  6. package/prisma/client/internal/prismaNamespace.ts +0 -1
  7. package/prisma/client/internal/prismaNamespaceBrowser.ts +0 -1
  8. package/prisma/client/models/Agent.ts +62 -137
  9. package/prisma/migrations/20261030000000_drop_agent_type_name/migration.sql +8 -0
  10. package/prisma/schema/schema.prisma +3 -4
  11. package/src/account-lifecycle.integration.test.ts +1 -1
  12. package/src/account-trial-warning-sweeper.integration.test.ts +1 -1
  13. package/src/admin-local-agent.smoke.test.ts +12 -12
  14. package/src/admin-ui-pages.ts +2 -2
  15. package/src/admin-ui-pages.unit.test.ts +2 -2
  16. package/src/admin-ui.fixtures.ts +6 -6
  17. package/src/admin-ui.smoke.test.ts +39 -39
  18. package/src/admin-ui.ts +8 -8
  19. package/src/agent-cron-jobs.integration.test.ts +8 -7
  20. package/src/agent-cron-jobs.ts +4 -5
  21. package/src/agent-cron-jobs.unit.test.ts +23 -23
  22. package/src/agent-patch-author-allowlist.integration.test.ts +4 -4
  23. package/src/agent-specialty-backfill.integration.test.ts +98 -53
  24. package/src/agents-api.integration.test.ts +20 -20
  25. package/src/agents-api.smoke.test.ts +76 -77
  26. package/src/agents-api.ts +9 -14
  27. package/src/agents.integration.test.ts +40 -53
  28. package/src/agents.ts +33 -46
  29. package/src/agents.unit.test.ts +28 -35
  30. package/src/api.smoke.test.ts +24 -2
  31. package/src/api.ts +15 -16
  32. package/src/openapi-schemas.ts +14 -7
  33. package/src/slack-provisioning-service.integration.test.ts +2 -2
  34. package/src/slack-provisioning.integration.test.ts +2 -2
  35. package/src/specialty-manifest-loader.ts +32 -32
  36. package/src/specialty-manifest-loader.unit.test.ts +8 -8
  37. package/src/startup-policy-reconcile.unit.test.ts +1 -1
  38. package/src/trial-expiry-sweeper.integration.test.ts +1 -1
  39. package/src/trial-expiry-warning-sweeper.integration.test.ts +1 -1
  40. package/src/agent-specialty.ts +0 -22
  41. package/src/agent-specialty.unit.test.ts +0 -43
package/src/agents-api.ts CHANGED
@@ -65,7 +65,6 @@ import type { AgentEnvService } from "./agent-envs.ts";
65
65
  import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
66
66
  import type { AgentMemberService } from "./agent-members.ts";
67
67
  import type { AgentPluginService } from "./agent-plugins.ts";
68
- import { resolveSpecialty } from "./agent-specialty.ts";
69
68
  import {
70
69
  type AgentProvisioner,
71
70
  hasPerAgentIdentity,
@@ -411,8 +410,6 @@ const GetAgentResultSchema = z
411
410
  reviewAuthorAllowlist: z.array(z.string()),
412
411
  patchAuthorAllowlist: z.array(z.string()),
413
412
  restrictSlackToMembers: z.boolean(),
414
- typeName: z.string(),
415
- /** AGR-6.1: specialty (specialty ?? typeName); additive next to typeName. */
416
413
  specialty: z.string(),
417
414
  /** COS-49.1: the pod ServiceAccount; null when unset. */
418
415
  podServiceAccountName: z.string().nullable(),
@@ -570,7 +567,7 @@ const listAgentsRoute = createRoute({
570
567
  path: "/agents",
571
568
  summary: "List all agents",
572
569
  description:
573
- "Admin-only. Returns every agent's `id`, `name`, `selfHosted`, and `typeName` — used for metrics name resolution.",
570
+ "Admin-only. Returns every agent's `id`, `name`, `selfHosted`, and `specialty` — used for metrics name resolution.",
574
571
  responses: {
575
572
  200: {
576
573
  description: "List of agents",
@@ -585,7 +582,7 @@ const createAgentRoute = createRoute({
585
582
  path: "/agents",
586
583
  summary: "Create an agent",
587
584
  description:
588
- 'Admin-only. Validates name/typeName, creates the Agent row, seeds AgentTool/AgentPlugin rows from the resolved type manifest, attaches repos/allowlists/members, patches Claude credentials, and provisions Kubernetes when `runtime` is "in-cluster" — the same createAgent() implementation (APA-1.1) the web UI form at /admin/agents/new uses. Transactional: on any failure zero rows persist, and this route never returns a partial-success 200. Returns the created agent in the same shape as GET /agents/:id.',
585
+ '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.',
589
586
  request: {
590
587
  body: {
591
588
  content: { "application/json": { schema: CreateAgentBodySchema } },
@@ -606,7 +603,7 @@ const getAgentRoute = createRoute({
606
603
  path: "/agents/{id}",
607
604
  summary: "Get an agent",
608
605
  description:
609
- "Admin-only. Returns the full agent record including `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `typeName`, `accountId` (null for unassigned agents), and `missingRequiredEnv` — required env keys declared by the agent's type manifest with no corresponding AgentEnv row yet (informational only).",
606
+ "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).",
610
607
  request: { params: AgentIdParamSchema },
611
608
  responses: {
612
609
  200: {
@@ -623,7 +620,7 @@ const patchAgentRoute = createRoute({
623
620
  path: "/agents/{id}",
624
621
  summary: "Update an agent",
625
622
  description:
626
- "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `typeName` and `trialExpiryWarnedAt` are not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
623
+ "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `specialty` and `trialExpiryWarnedAt` are not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
627
624
  request: {
628
625
  params: AgentIdParamSchema,
629
626
  body: {
@@ -1908,7 +1905,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1908
1905
  anthropicFederationRuleId: _r,
1909
1906
  k8sServiceAccountName: _k,
1910
1907
  ...summary
1911
- }) => ({ ...summary, specialty: resolveSpecialty(summary) }),
1908
+ }) => summary,
1912
1909
  );
1913
1910
  if (c.get("isAdmin") === true || email === undefined) {
1914
1911
  return c.json(agents, 200);
@@ -1971,8 +1968,8 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1971
1968
  },
1972
1969
  {
1973
1970
  name: body.name,
1974
- typeName:
1975
- accountId !== undefined ? getSelfServeSpecialty() : body.typeName,
1971
+ specialty:
1972
+ accountId !== undefined ? getSelfServeSpecialty() : body.specialty,
1976
1973
  ...(accountId !== undefined ? { accountId } : {}),
1977
1974
  // PAI-1.2: owner comes from the authenticated caller only; the body
1978
1975
  // schema has no owner field, so a client-supplied one is stripped.
@@ -2818,8 +2815,7 @@ function serializeAgent(
2818
2815
  reviewAuthorAllowlist?: string[];
2819
2816
  patchAuthorAllowlist?: string[];
2820
2817
  restrictSlackToMembers?: boolean;
2821
- typeName: string;
2822
- specialty?: string | null;
2818
+ specialty: string;
2823
2819
  accountId?: string | null;
2824
2820
  podServiceAccountName?: string | null;
2825
2821
  createdAt: Date;
@@ -2849,8 +2845,7 @@ function serializeAgent(
2849
2845
  reviewAuthorAllowlist: agent.reviewAuthorAllowlist ?? [],
2850
2846
  patchAuthorAllowlist: agent.patchAuthorAllowlist ?? [],
2851
2847
  restrictSlackToMembers: agent.restrictSlackToMembers ?? false,
2852
- typeName: agent.typeName,
2853
- specialty: resolveSpecialty(agent),
2848
+ specialty: agent.specialty,
2854
2849
  accountId: agent.accountId ?? null,
2855
2850
  podServiceAccountName: agent.podServiceAccountName ?? null,
2856
2851
  createdAt: agent.createdAt.toISOString(),
@@ -67,11 +67,11 @@ function fakeSpecialtyRegistry(
67
67
  byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
68
68
  ): SpecialtyManifestResolver {
69
69
  return {
70
- getManifest(typeName: string): SpecialtyManifest {
71
- return byType[typeName] ?? (byType.coding as SpecialtyManifest);
70
+ getManifest(specialty: string): SpecialtyManifest {
71
+ return byType[specialty] ?? (byType.coding as SpecialtyManifest);
72
72
  },
73
- tryGetManifest(typeName: string): SpecialtyManifest | undefined {
74
- return byType[typeName];
73
+ tryGetManifest(specialty: string): SpecialtyManifest | undefined {
74
+ return byType[specialty];
75
75
  },
76
76
  listTypes() {
77
77
  return Object.keys(byType).map((name) => ({ name, displayName: name }));
@@ -106,7 +106,7 @@ function makeProvisioner(
106
106
  function baseInput(overrides: Partial<Parameters<typeof createAgent>[1]> = {}) {
107
107
  return {
108
108
  name: "Test Agent",
109
- typeName: "coding",
109
+ specialty: "coding",
110
110
  runtime: undefined,
111
111
  reposRaw: undefined,
112
112
  authorAllowlistRaw: undefined,
@@ -511,7 +511,7 @@ describeOrSkip("createAgent() — integration (real Postgres)", () => {
511
511
  });
512
512
  });
513
513
 
514
- describeOrSkip("Agent.specialty — dual-write + fallback read (AGR-6.1)", () => {
514
+ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
515
515
  let prisma: PrismaClient;
516
516
  let agentService: AgentService;
517
517
  const createdIds: string[] = [];
@@ -527,76 +527,63 @@ describeOrSkip("Agent.specialty — dual-write + fallback read (AGR-6.1)", () =>
527
527
  await prisma.$disconnect();
528
528
  });
529
529
 
530
- it("create() writes specialty and typeName with the same value", async () => {
530
+ it("create() persists specialty and exposes no typeName anywhere", async () => {
531
531
  const created = await agentService.create({
532
- name: "Dual Write",
533
- typeName: "coding",
532
+ name: "Final Shape",
533
+ specialty: "research",
534
534
  });
535
535
  createdIds.push(created.id);
536
536
 
537
537
  const row = await prisma.agent.findUniqueOrThrow({
538
538
  where: { id: created.id },
539
539
  });
540
- expect(row.typeName).toBe("coding");
541
- expect(row.specialty).toBe("coding");
542
- expect(created.typeName).toBe("coding");
543
- expect(created.specialty).toBe("coding");
540
+ expect(row.specialty).toBe("research");
541
+ expect("typeName" in row).toBe(false);
542
+ expect(created.specialty).toBe("research");
543
+ expect("typeName" in created).toBe(false);
544
+
545
+ const columns = await prisma.$queryRaw<{ column_name: string }[]>`
546
+ SELECT column_name FROM information_schema.columns
547
+ WHERE table_name = 'Agent' AND table_schema = current_schema()`;
548
+ const names = columns.map((c) => c.column_name);
549
+ expect(names).toContain("specialty");
550
+ expect(names).not.toContain("typeName");
544
551
  });
545
552
 
546
- it("reads prefer specialty over typeName", async () => {
553
+ it("every read path returns the stored specialty", async () => {
547
554
  const created = await agentService.create({
548
- name: "Prefers Specialty",
549
- typeName: "coding",
555
+ name: "Reads Specialty",
556
+ specialty: "research",
550
557
  });
551
558
  createdIds.push(created.id);
552
- await prisma.agent.update({
553
- where: { id: created.id },
554
- data: { specialty: "research" },
555
- });
556
559
 
557
- const detail = await agentService.getDetail(created.id);
558
- expect(detail?.typeName).toBe("coding");
559
- expect(detail?.specialty).toBe("research");
560
+ expect((await agentService.getDetail(created.id))?.specialty).toBe(
561
+ "research",
562
+ );
560
563
  expect((await agentService.getSummary(created.id))?.specialty).toBe(
561
564
  "research",
562
565
  );
566
+ expect((await agentService.getById(created.id))?.specialty).toBe(
567
+ "research",
568
+ );
569
+ expect((await agentService.listByIds([created.id]))[0]?.specialty).toBe(
570
+ "research",
571
+ );
563
572
  expect(
564
573
  (await agentService.list()).find((a) => a.id === created.id)?.specialty,
565
574
  ).toBe("research");
566
575
  });
567
576
 
568
- it("falls back to typeName when specialty is NULL (pre-migration row)", async () => {
569
- const created = await agentService.create({
570
- name: "Legacy Row",
571
- typeName: "coding",
572
- });
573
- createdIds.push(created.id);
574
- await prisma.agent.update({
575
- where: { id: created.id },
576
- data: { specialty: null },
577
- });
578
-
579
- const detail = await agentService.getDetail(created.id);
580
- expect(detail?.specialty).toBe("coding");
581
- expect((await agentService.getSummary(created.id))?.specialty).toBe(
582
- "coding",
583
- );
584
- expect((await agentService.getById(created.id))?.specialty).toBe("coding");
585
- expect((await agentService.listByIds([created.id]))[0]?.specialty).toBe(
586
- "coding",
587
- );
588
- });
577
+ it("specialty is required: NULL is rejected and omitting it applies the 'coding' default", async () => {
578
+ await expect(
579
+ (async () =>
580
+ prisma.agent.create({
581
+ data: { name: "Null Specialty", specialty: null as never },
582
+ }))(),
583
+ ).rejects.toThrow();
589
584
 
590
- it("createAgent() leaves both columns populated", async () => {
591
- const created = await agentService.create({ name: "No Type" });
585
+ const created = await agentService.create({ name: "No Specialty" });
592
586
  createdIds.push(created.id);
593
- const row = await prisma.agent.findUniqueOrThrow({
594
- where: { id: created.id },
595
- });
596
- // typeName omitted: column default applies and specialty stays NULL,
597
- // so the fallback read still yields the default.
598
- expect(row.typeName).toBe("coding");
599
- expect(row.specialty).toBeNull();
600
587
  expect(created.specialty).toBe("coding");
601
588
  });
602
589
  });
package/src/agents.ts CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  SpecialtyRegistry,
25
25
  } from "./specialty-manifest-loader.ts";
26
26
  import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
27
- import { resolveSpecialty, withResolvedSpecialty } from "./agent-specialty.ts";
28
27
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
29
28
 
30
29
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -34,7 +33,7 @@ interface CreateAgentInput {
34
33
  slackId?: string | null;
35
34
  selfHosted?: boolean;
36
35
  /** Specialty name. Omitted means Prisma's column default ("coding") applies. */
37
- typeName?: string;
36
+ specialty?: string;
38
37
  /** Initial repos[] — the manifest repos merged with any request-supplied repos. */
39
38
  repos?: string[];
40
39
  /** Initial reviewAuthorAllowlist[]. */
@@ -54,9 +53,7 @@ export interface AgentRecord {
54
53
  name: string;
55
54
  slackId: string | null;
56
55
  selfHosted: boolean;
57
- typeName: string;
58
- /** AGR-6.1: resolved specialty (specialty ?? typeName). */
59
- specialty?: string;
56
+ specialty: string;
60
57
  createdAt: Date;
61
58
  updatedAt: Date;
62
59
  /** Owning account (SSP-5.2 admin Account column); null for legacy agents. */
@@ -84,9 +81,7 @@ export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
84
81
  id: string;
85
82
  name: string;
86
83
  selfHosted: boolean;
87
- typeName: string;
88
- /** AGR-6.1: resolved specialty (specialty ?? typeName). */
89
- specialty?: string;
84
+ specialty: string;
90
85
  /** Owning account; null/absent for platform agents. */
91
86
  accountId?: string | null;
92
87
  }
@@ -102,9 +97,7 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
102
97
  reviewAuthorAllowlist: string[];
103
98
  patchAuthorAllowlist: string[];
104
99
  restrictSlackToMembers: boolean;
105
- typeName: string;
106
- /** AGR-6.1: resolved specialty (specialty ?? typeName). */
107
- specialty?: string;
100
+ specialty: string;
108
101
  /**
109
102
  * Owning account, null for unassigned (platform) agents. Optional here so
110
103
  * existing test doubles constructing a narrower AgentDetail literal don't
@@ -209,11 +202,8 @@ interface AgentIdAndRepos {
209
202
  /**
210
203
  * ARD-6.1: the agent's Specialty name, surfaced on the runtime config
211
204
  * response so the agent process can pick its workspace template directory.
212
- * Optional for the same fixture-compat reason as trialExpiresAt.
213
205
  */
214
- typeName?: string;
215
- /** AGR-6.1: resolved specialty (specialty ?? typeName). */
216
- specialty?: string;
206
+ specialty: string;
217
207
  /**
218
208
  * APM-1.5: the 6 agent-policy fields, read by the runtime GET /:id/config
219
209
  * route for direct passthrough. Required (not optional) since the Prisma
@@ -263,7 +253,6 @@ const SUMMARY_SELECT = {
263
253
  id: true,
264
254
  name: true,
265
255
  selfHosted: true,
266
- typeName: true,
267
256
  specialty: true,
268
257
  accountId: true,
269
258
  ownerEmail: true,
@@ -283,7 +272,6 @@ const DETAIL_SELECT = {
283
272
  reviewAuthorAllowlist: true,
284
273
  patchAuthorAllowlist: true,
285
274
  restrictSlackToMembers: true,
286
- typeName: true,
287
275
  specialty: true,
288
276
  accountId: true,
289
277
  createdAt: true,
@@ -338,8 +326,8 @@ export class AgentService {
338
326
  name: input.name,
339
327
  slackId: input.slackId ?? null,
340
328
  selfHosted: input.selfHosted ?? false,
341
- ...(input.typeName !== undefined
342
- ? { typeName: input.typeName, specialty: input.typeName }
329
+ ...(input.specialty !== undefined
330
+ ? { specialty: input.specialty }
343
331
  : {}),
344
332
  ...(input.repos !== undefined ? { repos: input.repos } : {}),
345
333
  ...(input.reviewAuthorAllowlist !== undefined
@@ -361,10 +349,10 @@ export class AgentService {
361
349
  });
362
350
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
363
351
  row.id,
364
- resolveSpecialty(row),
352
+ row.specialty,
365
353
  client,
366
354
  );
367
- return { ...withResolvedSpecialty(row), missingRequiredEnv };
355
+ return { ...row, missingRequiredEnv };
368
356
  }
369
357
 
370
358
  /**
@@ -395,18 +383,18 @@ export class AgentService {
395
383
  }
396
384
 
397
385
  /**
398
- * List all agents (id + name + selfHosted + typeName), ordered by name asc.
386
+ * List all agents (id + name + selfHosted + specialty), ordered by name asc.
399
387
  */
400
388
  async list(): Promise<AgentSummary[]> {
401
389
  const rows = await this.prisma.agent.findMany({
402
390
  select: SUMMARY_SELECT,
403
391
  orderBy: { name: "asc" },
404
392
  });
405
- return rows.map(withResolvedSpecialty);
393
+ return rows;
406
394
  }
407
395
 
408
396
  /**
409
- * Get {id, name, selfHosted, typeName} for a single agent. Returns null if
397
+ * Get {id, name, selfHosted, specialty} for a single agent. Returns null if
410
398
  * not found.
411
399
  */
412
400
  async getSummary(id: string): Promise<AgentSummary | null> {
@@ -414,7 +402,7 @@ export class AgentService {
414
402
  where: { id },
415
403
  select: SUMMARY_SELECT,
416
404
  });
417
- return row ? withResolvedSpecialty(row) : null;
405
+ return row;
418
406
  }
419
407
 
420
408
  /**
@@ -428,11 +416,11 @@ export class AgentService {
428
416
  */
429
417
  private async computeMissingRequiredEnv(
430
418
  agentId: string,
431
- typeName: string,
419
+ specialty: string,
432
420
  client: PrismaTransactionClient = this.prisma,
433
421
  ): Promise<string[]> {
434
422
  const requiredKeys = this.specialtyRegistry
435
- .getManifest(typeName)
423
+ .getManifest(specialty)
436
424
  .env.required.map((entry) => entry.key);
437
425
 
438
426
  if (requiredKeys.length === 0) return [];
@@ -447,7 +435,7 @@ export class AgentService {
447
435
  }
448
436
 
449
437
  /**
450
- * Get the full agent record (incl. repos/typeName/timestamps), plus
438
+ * Get the full agent record (incl. repos/specialty/timestamps), plus
451
439
  * `missingRequiredEnv` (see computeMissingRequiredEnv). Returns null if not
452
440
  * found.
453
441
  */
@@ -460,9 +448,9 @@ export class AgentService {
460
448
 
461
449
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
462
450
  id,
463
- resolveSpecialty(row),
451
+ row.specialty,
464
452
  );
465
- return { ...withResolvedSpecialty(row), missingRequiredEnv };
453
+ return { ...row, missingRequiredEnv };
466
454
  }
467
455
 
468
456
  /**
@@ -531,9 +519,9 @@ export class AgentService {
531
519
  });
532
520
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
533
521
  id,
534
- resolveSpecialty(row),
522
+ row.specialty,
535
523
  );
536
- return { ...withResolvedSpecialty(row), missingRequiredEnv };
524
+ return { ...row, missingRequiredEnv };
537
525
  }
538
526
 
539
527
  /**
@@ -553,7 +541,6 @@ export class AgentService {
553
541
  restrictSlackToMembers: true,
554
542
  trialExpiresAt: true,
555
543
  selfHosted: true,
556
- typeName: true,
557
544
  specialty: true,
558
545
  account: { select: { status: true } },
559
546
  autoPostReviews: true,
@@ -570,7 +557,7 @@ export class AgentService {
570
557
  const memberEmails = members.map((m) => m.email);
571
558
  const { account, ...rest } = row;
572
559
  return {
573
- ...withResolvedSpecialty(rest),
560
+ ...rest,
574
561
  memberEmails,
575
562
  accountStatus: account?.status ?? null,
576
563
  };
@@ -583,7 +570,7 @@ export class AgentService {
583
570
  * order Prisma returns.
584
571
  */
585
572
  async listAll(): Promise<AgentRecord[]> {
586
- return (await this.prisma.agent.findMany()).map(withResolvedSpecialty);
573
+ return await this.prisma.agent.findMany();
587
574
  }
588
575
 
589
576
  /**
@@ -595,7 +582,7 @@ export class AgentService {
595
582
  const rows = await this.prisma.agent.findMany({
596
583
  where: { id: { in: ids } },
597
584
  });
598
- return rows.map(withResolvedSpecialty);
585
+ return rows;
599
586
  }
600
587
 
601
588
  /**
@@ -606,7 +593,7 @@ export class AgentService {
606
593
  const rows = await this.prisma.agent.findMany({
607
594
  where: { name: { contains: query, mode: "insensitive" } },
608
595
  });
609
- return rows.map(withResolvedSpecialty);
596
+ return rows;
610
597
  }
611
598
 
612
599
  /**
@@ -677,10 +664,10 @@ export class AgentService {
677
664
  });
678
665
  const missingRequiredEnv = await this.computeMissingRequiredEnv(
679
666
  id,
680
- resolveSpecialty(row),
667
+ row.specialty,
681
668
  client,
682
669
  );
683
- return { ...withResolvedSpecialty(row), missingRequiredEnv };
670
+ return { ...row, missingRequiredEnv };
684
671
  }
685
672
  }
686
673
 
@@ -696,7 +683,7 @@ export class AgentService {
696
683
  */
697
684
  export interface CreateAgentFormInput {
698
685
  name: string | undefined;
699
- typeName: string | undefined;
686
+ specialty: string | undefined;
700
687
  /**
701
688
  * "in-cluster" means K8s-provisioned; anything else (including absent)
702
689
  * means self-hosted — matches the pre-APA-1.1 handler's fallback rule.
@@ -845,7 +832,7 @@ type CreateAgentTxOutcome =
845
832
  | { ok: false; errorCode: CreateAgentErrorCode; errorMessage?: string };
846
833
 
847
834
  /**
848
- * Atomically create an agent: validate name/typeName, create the Agent row,
835
+ * Atomically create an agent: validate name/specialty, create the Agent row,
849
836
  * seed AgentTool/AgentPlugin from the resolved type manifest, attach
850
837
  * repos/allowlists/members, patch Claude credentials, provision Kubernetes
851
838
  * resources when requested, and best-effort reconcile system crons.
@@ -887,11 +874,11 @@ export async function createAgent(
887
874
  // Resolve the requested type BEFORE creating any row — an unknown/missing
888
875
  // type must fail with zero rows created. The resolved manifest is
889
876
  // captured (not discarded) so its tools/plugins can be seeded below.
890
- const typeName = input.typeName;
891
- const manifest = typeName
892
- ? deps.specialtyRegistry.tryGetManifest(typeName)
877
+ const specialty = input.specialty;
878
+ const manifest = specialty
879
+ ? deps.specialtyRegistry.tryGetManifest(specialty)
893
880
  : undefined;
894
- if (!typeName || !manifest) {
881
+ if (!specialty || !manifest) {
895
882
  return { ok: false, errorCode: "invalid_type" };
896
883
  }
897
884
 
@@ -936,7 +923,7 @@ export async function createAgent(
936
923
  {
937
924
  name,
938
925
  selfHosted: !inCluster,
939
- typeName,
926
+ specialty,
940
927
  restrictSlackToMembers,
941
928
  ...(input.accountId !== undefined
942
929
  ? { accountId: input.accountId }