@app-vitals/agent-admin 0.16.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/e2e/agents-page.e2e.ts +2 -2
  2. package/openapi.json +3 -3
  3. package/openapi.platform.json +7 -7
  4. package/package.json +3 -5
  5. package/prisma/client/internal/class.ts +1 -1
  6. package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
  7. package/prisma/schema/schema.prisma +1 -1
  8. package/src/account-agent-create.ts +1 -1
  9. package/src/admin-boot.ts +12 -6
  10. package/src/admin-extension.ts +51 -5
  11. package/src/admin-extension.unit.test.ts +61 -0
  12. package/src/admin-local-agent.smoke.test.ts +8 -5
  13. package/src/admin-ui-pages.ts +4 -4
  14. package/src/admin-ui.smoke.test.ts +54 -34
  15. package/src/admin-ui.ts +12 -12
  16. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  17. package/src/agent-cron-jobs.integration.test.ts +23 -21
  18. package/src/agent-cron-jobs.ts +30 -40
  19. package/src/agent-cron-jobs.unit.test.ts +51 -39
  20. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  21. package/src/agent-manifest.ts +2 -6
  22. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  23. package/src/agent-specialty-backfill.integration.test.ts +30 -0
  24. package/src/agents-api.integration.test.ts +29 -40
  25. package/src/agents-api.sentry.smoke.test.ts +2 -2
  26. package/src/agents-api.smoke.test.ts +17 -30
  27. package/src/agents-api.ts +13 -13
  28. package/src/agents.integration.test.ts +23 -33
  29. package/src/agents.ts +35 -34
  30. package/src/agents.unit.test.ts +35 -55
  31. package/src/api.smoke.test.ts +2 -2
  32. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  33. package/src/openapi-schemas.ts +4 -4
  34. package/src/server.smoke.test.ts +2 -2
  35. package/src/specialty-defaults.ts +4 -4
  36. package/src/specialty-types.ts +92 -0
  37. package/src/specialty-types.unit.test.ts +67 -0
  38. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  39. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  40. package/src/coding-task-store-provisioning-client.ts +0 -156
  41. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  42. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  43. package/src/specialty-manifest-loader.ts +0 -245
  44. package/src/specialty-manifest-loader.unit.test.ts +0 -299
  45. package/src/specialty-registry.schema.unit.test.ts +0 -35
  46. package/src/specialty-registry.ts +0 -236
  47. package/src/specialty-registry.unit.test.ts +0 -272
@@ -2,7 +2,7 @@
2
2
  * Unit tests for agent-cron-jobs.ts:
3
3
  * - the pure cron-expression validator (isValidCron)
4
4
  * - reconcileSystemCrons()'s create/update/orphan/parent-link logic, driven
5
- * by an injected SpecialtyManifestResolver double and an in-memory prisma
5
+ * by an injected SpecialtyTypeResolver double and an in-memory prisma
6
6
  * test double (no real DB, no filesystem).
7
7
  *
8
8
  * Pure logic against injected doubles — see docs/testing.md for the unit-layer
@@ -11,11 +11,16 @@
11
11
  import { describe, expect, test } from "bun:test";
12
12
  import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
13
13
  import { AgentCronJobService, isValidCron } from "./agent-cron-jobs.ts";
14
+ import type {
15
+ SpecialtyDefaultCron,
16
+ SpecialtyDefaultsResolver,
17
+ } from "./specialty-defaults.ts";
14
18
  import {
15
19
  resolveDefaultSpecialtyName,
16
- type SpecialtyManifestResolver,
17
- } from "./specialty-manifest-loader.ts";
18
- import type { SpecialtyManifest } from "./specialty-registry.ts";
20
+ SPECIALIST_TYPE,
21
+ type SpecialtyType,
22
+ type SpecialtyTypeResolver,
23
+ } from "./specialty-types.ts";
19
24
 
20
25
  describe("isValidCron", () => {
21
26
  test("accepts standard 5-field schedules", () => {
@@ -54,31 +59,31 @@ describe("isValidCron", () => {
54
59
 
55
60
  // ─── reconcileSystemCrons() with injected registry + fake prisma ────────────
56
61
 
57
- type ManifestCron = SpecialtyManifest["crons"][number];
62
+ type DefaultCron = SpecialtyDefaultCron;
58
63
 
59
- /** Build a minimal valid-enough manifest carrying just the crons we care about. */
60
- function manifestWithCrons(crons: ManifestCron[]): SpecialtyManifest {
61
- return { crons } as unknown as SpecialtyManifest;
62
- }
63
-
64
- /** A registry double that always returns a fixed manifest and records lookups. */
64
+ /**
65
+ * A resolver double knowing the types in `byType`, plus the matching
66
+ * specialtyDefaults() hook supplying their crons. Records registry lookups.
67
+ */
65
68
  function fakeRegistry(
66
- byType: Record<string, ManifestCron[]>,
67
- fallbackType = "coding",
68
- ): SpecialtyManifestResolver & { lookups: string[] } {
69
+ byType: Record<string, DefaultCron[]>,
70
+ ): SpecialtyTypeResolver & {
71
+ lookups: string[];
72
+ defaults: SpecialtyDefaultsResolver;
73
+ } {
69
74
  const lookups: string[] = [];
70
75
  return {
71
76
  lookups,
72
- getManifest(specialty: string): SpecialtyManifest {
73
- lookups.push(specialty);
74
- const crons = byType[specialty] ?? byType[fallbackType];
75
- if (!crons) throw new Error(`no manifest for ${specialty}`);
76
- return manifestWithCrons(crons);
77
+ defaults: (specialty) => {
78
+ const crons = byType[specialty];
79
+ return crons ? { crons } : undefined;
77
80
  },
78
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
81
+ getType(): SpecialtyType {
82
+ return SPECIALIST_TYPE;
83
+ },
84
+ tryGetType(specialty: string): SpecialtyType | undefined {
79
85
  lookups.push(specialty);
80
- const crons = byType[specialty];
81
- return crons ? manifestWithCrons(crons) : undefined;
86
+ return specialty in byType ? SPECIALIST_TYPE : undefined;
82
87
  },
83
88
  listTypes() {
84
89
  return Object.keys(byType).map((name) => ({ name, displayName: name }));
@@ -211,7 +216,7 @@ function makeFakePrisma(opts: {
211
216
  return { prisma, rows };
212
217
  }
213
218
 
214
- const loopCron: ManifestCron = {
219
+ const loopCron: DefaultCron = {
215
220
  name: "shipwright-loop",
216
221
  schedule: "* * * * *",
217
222
  prompt: "internal",
@@ -219,7 +224,7 @@ const loopCron: ManifestCron = {
219
224
  enabled: false,
220
225
  };
221
226
 
222
- const childCron: ManifestCron = {
227
+ const childCron: DefaultCron = {
223
228
  name: "shipwright-dev-task",
224
229
  schedule: "* * * * *",
225
230
  prompt: "/shipwright:dev-task",
@@ -229,18 +234,22 @@ const childCron: ManifestCron = {
229
234
  };
230
235
 
231
236
  describe("reconcileSystemCrons (unit, injected registry)", () => {
232
- test("resolves the agent's specialty via the injected registry", async () => {
237
+ test("uses the extension defaults for the agent's specialty without consulting the registry", async () => {
233
238
  const registry = fakeRegistry({ coding: [loopCron] });
234
- const { prisma } = makeFakePrisma({ specialty: "coding" });
239
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
235
240
  const service = new AgentCronJobService(
236
241
  prisma as unknown as PrismaClient,
237
242
  undefined,
238
243
  registry,
244
+ registry.defaults,
239
245
  );
240
246
 
241
247
  await service.reconcileSystemCrons("agent-1");
242
248
 
243
- expect(registry.lookups).toEqual(["coding"]);
249
+ expect(registry.lookups).toEqual([]);
250
+ expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
251
+ "shipwright-loop",
252
+ ]);
244
253
  });
245
254
 
246
255
  test("creates one row per manifest cron on a fresh agent", async () => {
@@ -250,6 +259,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
250
259
  prisma as unknown as PrismaClient,
251
260
  undefined,
252
261
  registry,
262
+ registry.defaults,
253
263
  );
254
264
 
255
265
  const result = await service.reconcileSystemCrons("agent-1");
@@ -270,6 +280,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
270
280
  prisma as unknown as PrismaClient,
271
281
  undefined,
272
282
  registry,
283
+ registry.defaults,
273
284
  );
274
285
 
275
286
  await service.reconcileSystemCrons("agent-1");
@@ -291,6 +302,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
291
302
  prisma as unknown as PrismaClient,
292
303
  undefined,
293
304
  registry,
305
+ registry.defaults,
294
306
  );
295
307
 
296
308
  const result = await service.reconcileSystemCrons("agent-1");
@@ -310,6 +322,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
310
322
  prisma as unknown as PrismaClient,
311
323
  undefined,
312
324
  registry,
325
+ registry.defaults,
313
326
  );
314
327
 
315
328
  const result = await service.reconcileSystemCrons("agent-1");
@@ -334,6 +347,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
334
347
  prisma as unknown as PrismaClient,
335
348
  undefined,
336
349
  registry,
350
+ registry.defaults,
337
351
  );
338
352
 
339
353
  await service.reconcileSystemCrons("agent-1");
@@ -354,6 +368,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
354
368
  prisma as unknown as PrismaClient,
355
369
  undefined,
356
370
  registry,
371
+ registry.defaults,
357
372
  );
358
373
  const errors: unknown[][] = [];
359
374
  const original = console.error;
@@ -384,6 +399,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
384
399
  prisma as unknown as PrismaClient,
385
400
  undefined,
386
401
  registry,
402
+ registry.defaults,
387
403
  );
388
404
  const original = console.error;
389
405
  console.error = () => {};
@@ -399,19 +415,19 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
399
415
  expect(Array.from(rows.keys())).toEqual(["sys-1"]);
400
416
  });
401
417
 
402
- test("a valid non-coding manifest reconciles normally", async () => {
418
+ test("extension crons for a non-coding type reconcile normally", async () => {
403
419
  const registry = fakeRegistry({ specialist: [loopCron] });
404
420
  const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
405
421
  const service = new AgentCronJobService(
406
422
  prisma as unknown as PrismaClient,
407
423
  undefined,
408
424
  registry,
425
+ registry.defaults,
409
426
  );
410
427
 
411
428
  const result = await service.reconcileSystemCrons("agent-1");
412
429
 
413
430
  expect(result.created).toBe(1);
414
- expect(registry.lookups).toEqual(["specialist"]);
415
431
  expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
416
432
  });
417
433
  });
@@ -444,8 +460,8 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
444
460
  expect(row?.enabled).toBe(false);
445
461
  });
446
462
 
447
- test("hook returning nothing falls back to the manifest", async () => {
448
- const registry = fakeRegistry({ coding: [loopCron] });
463
+ test("hook returning nothing falls back to the built-in type, which declares no crons", async () => {
464
+ const registry = fakeRegistry({ coding: [] });
449
465
  const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
450
466
  const service = new AgentCronJobService(
451
467
  prisma as unknown as PrismaClient,
@@ -456,13 +472,11 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
456
472
 
457
473
  await service.reconcileSystemCrons("agent-1");
458
474
 
459
- expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
460
- "shipwright-loop",
461
- ]);
475
+ expect(Array.from(rows.values())).toEqual([]);
462
476
  });
463
477
 
464
- test("defaults without crons fall back to the manifest crons", async () => {
465
- const registry = fakeRegistry({ coding: [loopCron] });
478
+ test("defaults without crons fall back to the built-in type, which declares no crons", async () => {
479
+ const registry = fakeRegistry({ coding: [] });
466
480
  const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
467
481
  const service = new AgentCronJobService(
468
482
  prisma as unknown as PrismaClient,
@@ -473,9 +487,7 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
473
487
 
474
488
  await service.reconcileSystemCrons("agent-1");
475
489
 
476
- expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
477
- "shipwright-loop",
478
- ]);
490
+ expect(Array.from(rows.values())).toEqual([]);
479
491
  });
480
492
 
481
493
  test("reconciles hook crons without any manifest for the specialty", async () => {
@@ -432,10 +432,10 @@ function makeMockDeps(): AdminDeps {
432
432
  listByAgentId: async () => [],
433
433
  },
434
434
  specialtyRegistry: {
435
- getManifest: () => {
435
+ getType: () => {
436
436
  throw new Error("not implemented");
437
437
  },
438
- tryGetManifest: () => undefined,
438
+ tryGetType: () => undefined,
439
439
  listTypes: () => [],
440
440
  },
441
441
  agentChatTokenService: {
@@ -70,13 +70,9 @@ const DEFAULT_TENANT_CPU_LIMIT = "2";
70
70
  * from `buildAgentDeploymentManifest` with `opts.resources` — omitted or
71
71
  * partial overrides fall back field-by-field to the default below.
72
72
  *
73
- * Precedence (highest wins): specialty override > env override > default.
73
+ * Precedence (highest wins): env override > default.
74
74
  * `overrides` here is the ENV layer (populated in admin/src/main.ts's
75
- * buildProvisioner from SHIPWRIGHT_K8S_AGENT_* vars). The per-type
76
- * SpecialtyResourcesSchema in specialty-registry.ts is already parsed as
77
- * part of SpecialtyManifestSchema.resources but is NOT consumed here or by
78
- * any provisioner — it stays inert until a future task wires specialty-level
79
- * resource resolution in ahead of this function's env layer.
75
+ * buildProvisioner from SHIPWRIGHT_K8S_AGENT_* vars).
80
76
  *
81
77
  * Defaults: requests mirror the GKE Autopilot defaults the agent ran with
82
78
  * before these were explicit. The memory limit exists to contain a runaway
@@ -27,13 +27,15 @@ import { AgentPluginService } from "./agent-plugins.ts";
27
27
  import { NoopAgentProvisioner } from "./agent-provisioner.ts";
28
28
  import { AgentTokenService } from "./agent-tokens.ts";
29
29
  import { AgentToolService } from "./agent-tools.ts";
30
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
31
- import type { SpecialtyManifest } from "./specialty-registry.ts";
32
30
  import { AgentService } from "./agents.ts";
33
31
  import type { AdminDeps } from "./agents-api.ts";
34
32
  import { createAdminApp } from "./agents-api.ts";
35
33
  import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
36
34
  import { createAdminPrismaClient } from "./prisma-client.ts";
35
+ import type {
36
+ SpecialtyType,
37
+ SpecialtyTypeResolver,
38
+ } from "./specialty-types.ts";
37
39
  import { makeTokenCrypto } from "./token-crypto.ts";
38
40
 
39
41
  const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
@@ -63,40 +65,27 @@ function makePrisma(): PrismaClient {
63
65
  return createAdminPrismaClient(TEST_DB as string);
64
66
  }
65
67
 
66
- const CODING_MANIFEST: SpecialtyManifest = {
67
- apiVersion: "shipwright.dev/v1alpha1",
68
- kind: "Specialty",
69
- metadata: {
70
- name: "coding",
71
- displayName: "Coding Agent",
72
- description: "test manifest",
73
- version: "1.0.0",
74
- skills: [],
75
- },
76
- identity: { templatesDir: "workspace/" },
77
- crons: [],
78
- plugins: ["shipwright"],
68
+ const CODING_TYPE: SpecialtyType = {
69
+ name: "coding",
70
+ displayName: "Coding Agent",
71
+ templatesDir: "workspace/",
79
72
  tools: ["Read", "Write", "Edit"],
80
- env: { required: [], optional: [] },
81
- members: [],
82
- repos: [],
83
- chat: true,
84
- voice: true,
73
+ requiredEnv: [],
85
74
  };
86
75
 
87
- function fakeSpecialtyRegistry(): SpecialtyManifestResolver {
88
- const byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST };
76
+ function fakeSpecialtyTypes(): SpecialtyTypeResolver {
77
+ const byType: Record<string, SpecialtyType> = { coding: CODING_TYPE };
89
78
  return {
90
- getManifest(specialty: string): SpecialtyManifest {
91
- return byType[specialty] ?? (byType.coding as SpecialtyManifest);
79
+ getType(specialty: string): SpecialtyType {
80
+ return byType[specialty] ?? (byType.coding as SpecialtyType);
92
81
  },
93
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
82
+ tryGetType(specialty: string): SpecialtyType | undefined {
94
83
  return byType[specialty];
95
84
  },
96
85
  listTypes() {
97
86
  return Object.entries(byType).map(([name, manifest]) => ({
98
87
  name,
99
- displayName: manifest.metadata.displayName,
88
+ displayName: manifest.displayName,
100
89
  }));
101
90
  },
102
91
  };
@@ -122,7 +111,7 @@ function makeDeps(prisma: PrismaClient): AdminDeps {
122
111
  agentTokenService: new AgentTokenService(prisma),
123
112
  agentPluginService: new AgentPluginService(prisma),
124
113
  agentMemberService: new AgentMemberService(prisma),
125
- specialtyRegistry: fakeSpecialtyRegistry(),
114
+ specialtyRegistry: fakeSpecialtyTypes(),
126
115
  agentChatTokenService: new AgentChatTokenService(prisma),
127
116
  agentGitHubInstallationsService: new AgentGitHubInstallationsService(
128
117
  prisma,
@@ -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
  });
@@ -25,13 +25,15 @@ import type { AgentProvisioner } from "./agent-provisioner.ts";
25
25
  import { NoopAgentProvisioner } from "./agent-provisioner.ts";
26
26
  import { AgentTokenService } from "./agent-tokens.ts";
27
27
  import { AgentToolService } from "./agent-tools.ts";
28
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
29
- import type { SpecialtyManifest } from "./specialty-registry.ts";
30
28
  import { AgentService } from "./agents.ts";
31
29
  import type { AdminDeps } from "./agents-api.ts";
32
30
  import { createAdminApp } from "./agents-api.ts";
33
31
  import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
34
32
  import { createAdminPrismaClient } from "./prisma-client.ts";
33
+ import type {
34
+ SpecialtyType,
35
+ SpecialtyTypeResolver,
36
+ } from "./specialty-types.ts";
35
37
  import { makeTokenCrypto } from "./token-crypto.ts";
36
38
 
37
39
  const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
@@ -70,19 +72,10 @@ async function createAgent(
70
72
  }
71
73
 
72
74
  /** A minimal valid "coding" manifest used to drive seeding assertions below. */
73
- const CODING_MANIFEST: SpecialtyManifest = {
74
- apiVersion: "shipwright.dev/v1alpha1",
75
- kind: "Specialty",
76
- metadata: {
77
- name: "coding",
78
- displayName: "Coding Agent",
79
- description: "test manifest",
80
- version: "1.0.0",
81
- skills: [],
82
- },
83
- identity: { templatesDir: "workspace/" },
84
- crons: [],
85
- plugins: ["shipwright"],
75
+ const CODING_TYPE: SpecialtyType = {
76
+ name: "coding",
77
+ displayName: "Coding Agent",
78
+ templatesDir: "workspace/",
86
79
  tools: [
87
80
  "Read",
88
81
  "Write",
@@ -95,32 +88,28 @@ const CODING_MANIFEST: SpecialtyManifest = {
95
88
  "Skill",
96
89
  "Agent",
97
90
  ],
98
- env: { required: [], optional: [] },
99
- members: [],
100
- repos: [],
101
- chat: true,
102
- voice: true,
91
+ requiredEnv: [],
103
92
  };
104
93
 
105
94
  /**
106
- * Fake SpecialtyManifestResolver — resolves a fixed map of manifests by
107
- * specialty. tryGetManifest() returns undefined (no fallback) for anything
108
- * not in the map, mirroring SpecialtyRegistry's real contract.
95
+ * Fake SpecialtyTypeResolver — resolves a fixed map of manifests by
96
+ * specialty. tryGetType() returns undefined (no fallback) for anything
97
+ * not in the map, mirroring SpecialtyTypes's real contract.
109
98
  */
110
- function fakeSpecialtyRegistry(
111
- byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
112
- ): SpecialtyManifestResolver {
99
+ function fakeSpecialtyTypes(
100
+ byType: Record<string, SpecialtyType> = { coding: CODING_TYPE },
101
+ ): SpecialtyTypeResolver {
113
102
  return {
114
- getManifest(specialty: string): SpecialtyManifest {
115
- return byType[specialty] ?? (byType.coding as SpecialtyManifest);
103
+ getType(specialty: string): SpecialtyType {
104
+ return byType[specialty] ?? (byType.coding as SpecialtyType);
116
105
  },
117
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
106
+ tryGetType(specialty: string): SpecialtyType | undefined {
118
107
  return byType[specialty];
119
108
  },
120
109
  listTypes() {
121
110
  return Object.entries(byType).map(([name, manifest]) => ({
122
111
  name,
123
- displayName: manifest.metadata.displayName,
112
+ displayName: manifest.displayName,
124
113
  }));
125
114
  },
126
115
  };
@@ -129,7 +118,7 @@ function fakeSpecialtyRegistry(
129
118
  function makeDeps(
130
119
  prisma: PrismaClient,
131
120
  provisioner: AgentProvisioner = new NoopAgentProvisioner(),
132
- specialtyRegistry: SpecialtyManifestResolver = fakeSpecialtyRegistry(),
121
+ specialtyRegistry: SpecialtyTypeResolver = fakeSpecialtyTypes(),
133
122
  ): AdminDeps {
134
123
  const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
135
124
  process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
@@ -311,7 +300,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
311
300
 
312
301
  // ─── specialty field ─────────────────────────────────────────────────────────
313
302
 
314
- it("GET /agents includes specialty:'coding' for an agent created before specialty existed", async () => {
303
+ it("GET /agents includes specialty:'specialist' for an agent created before specialty existed", async () => {
315
304
  const res = await app.request("/agents", {
316
305
  headers: { Cookie: `admin_session=${cookie}` },
317
306
  });
@@ -319,28 +308,28 @@ describeOrSkip("admin CRUD API (integration)", () => {
319
308
  const body = await res.json();
320
309
  const entry = body.find((a: { id: string }) => a.id === agentId);
321
310
  expect(entry).toBeDefined();
322
- expect(entry.specialty).toBe("coding");
311
+ expect(entry.specialty).toBe("specialist");
323
312
  });
324
313
 
325
- it("GET /agents/:id includes specialty:'coding' by default", async () => {
314
+ it("GET /agents/:id includes specialty:'specialist' by default", async () => {
326
315
  const res = await app.request(`/agents/${agentId}`, {
327
316
  headers: { Cookie: `admin_session=${cookie}` },
328
317
  });
329
318
  expect(res.status).toBe(200);
330
319
  const body = await res.json();
331
- expect(body.specialty).toBe("coding");
320
+ expect(body.specialty).toBe("specialist");
332
321
  });
333
322
 
334
323
  // ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
335
324
  //
336
325
  // Simulates the exact scenario the 20260724000000_add_agent_type_name
337
326
  // migration must handle: rows that existed *before* the specialty column was
338
- // added (never received an explicit value) must read back specialty="coding"
327
+ // added (never received an explicit value) must read back specialty="specialist"
339
328
  // via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
340
329
  // must be completely untouched — same count, same updatedAt — since the
341
330
  // migration only ALTERs the Agent table.
342
331
 
343
- it("migration backfill: pre-existing agent rows read back specialty='coding' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
332
+ it("migration backfill: pre-existing agent rows read back specialty='specialist' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
344
333
  // Seed a second "pre-existing" agent with one row in every child table,
345
334
  // simulating data that existed prior to the specialty migration landing.
346
335
  const preExisting = await prisma.agent.create({
@@ -383,7 +372,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
383
372
  const rawAgent = await prisma.agent.findUniqueOrThrow({
384
373
  where: { id: preExisting.id },
385
374
  });
386
- expect(rawAgent.specialty).toBe("coding");
375
+ expect(rawAgent.specialty).toBe("specialist");
387
376
 
388
377
  // Read back via the HTTP API too — end-to-end confirmation.
389
378
  const res = await app.request(`/agents/${preExisting.id}`, {
@@ -391,7 +380,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
391
380
  });
392
381
  expect(res.status).toBe(200);
393
382
  const body = await res.json();
394
- expect(body.specialty).toBe("coding");
383
+ expect(body.specialty).toBe("specialist");
395
384
 
396
385
  // Child row counts are unchanged — exactly one of each, still linked to
397
386
  // the same agent.
@@ -455,7 +444,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
455
444
  const rawAgent = await prisma.agent.findUniqueOrThrow({
456
445
  where: { id: agentId },
457
446
  });
458
- expect(rawAgent.specialty).toBe("coding");
447
+ expect(rawAgent.specialty).toBe("specialist");
459
448
  });
460
449
 
461
450
  // ─── repos field round-trip ───────────────────────────────────────────────────
@@ -247,10 +247,10 @@ function makeBaseDeps(
247
247
  listByAgentId: async () => [],
248
248
  },
249
249
  specialtyRegistry: {
250
- getManifest: () => {
250
+ getType: () => {
251
251
  throw new Error("not implemented");
252
252
  },
253
- tryGetManifest: () => undefined,
253
+ tryGetType: () => undefined,
254
254
  listTypes: () => [],
255
255
  },
256
256
  agentChatTokenService: {
@@ -22,28 +22,19 @@ import type { AgentTokenService } from "./agent-tokens.ts";
22
22
  import type { AdminDeps } from "./agents-api.ts";
23
23
  import { createAdminApp, parseAdminApiKeys } from "./agents-api.ts";
24
24
  import { PLATFORM_OPS_ROUTES } from "./api-auth.ts";
25
- import type { SpecialtyManifest } from "./specialty-registry.ts";
25
+ import type { SpecialtyType } from "./specialty-types.ts";
26
26
  import { identityCrypto } from "./token-crypto.ts";
27
27
 
28
- // ─── Fake SpecialtyManifestResolver ────────────────────────────────────────
28
+ // ─── Fake SpecialtyTypeResolver ────────────────────────────────────────
29
29
  //
30
30
  // Mirrors the shape used by agent-cron-jobs.unit.test.ts's fakeRegistry —
31
- // resolves a fixed set of manifests by specialty, with tryGetManifest()
31
+ // resolves a fixed set of manifests by specialty, with tryGetType()
32
32
  // returning undefined (no fallback) for anything not in the map.
33
33
 
34
- const CODING_MANIFEST: SpecialtyManifest = {
35
- apiVersion: "shipwright.dev/v1alpha1",
36
- kind: "Specialty",
37
- metadata: {
38
- name: "coding",
39
- displayName: "Coding Agent",
40
- description: "test manifest",
41
- version: "1.0.0",
42
- skills: [],
43
- },
44
- identity: { templatesDir: "workspace/" },
45
- crons: [],
46
- plugins: ["shipwright"],
34
+ const CODING_TYPE: SpecialtyType = {
35
+ name: "coding",
36
+ displayName: "Coding Agent",
37
+ templatesDir: "workspace/",
47
38
  tools: [
48
39
  "Read",
49
40
  "Write",
@@ -56,32 +47,28 @@ const CODING_MANIFEST: SpecialtyManifest = {
56
47
  "Skill",
57
48
  "Agent",
58
49
  ],
59
- env: { required: [], optional: [] },
60
- members: [],
61
- repos: [],
62
- chat: true,
63
- voice: true,
50
+ requiredEnv: [],
64
51
  };
65
52
 
66
- function fakeSpecialtyRegistry(
53
+ function fakeSpecialtyTypes(
67
54
  // The self-serve specialty is registered too: account users are forced to it
68
55
  // (the platform test run points it at the fixture specialty, not "coding").
69
- byType: Record<string, SpecialtyManifest> = {
70
- coding: CODING_MANIFEST,
71
- [getSelfServeSpecialty()]: CODING_MANIFEST,
56
+ byType: Record<string, SpecialtyType> = {
57
+ coding: CODING_TYPE,
58
+ [getSelfServeSpecialty()]: CODING_TYPE,
72
59
  },
73
60
  ): AdminDeps["specialtyRegistry"] {
74
61
  return {
75
- getManifest(specialty: string) {
76
- return byType[specialty] ?? (byType.coding as SpecialtyManifest);
62
+ getType(specialty: string) {
63
+ return byType[specialty] ?? (byType.coding as SpecialtyType);
77
64
  },
78
- tryGetManifest(specialty: string) {
65
+ tryGetType(specialty: string) {
79
66
  return byType[specialty];
80
67
  },
81
68
  listTypes() {
82
69
  return Object.entries(byType).map(([name, manifest]) => ({
83
70
  name,
84
- displayName: manifest.metadata.displayName,
71
+ displayName: manifest.displayName,
85
72
  }));
86
73
  },
87
74
  };
@@ -578,7 +565,7 @@ function makeMockDeps(): AdminDeps {
578
565
  },
579
566
  ],
580
567
  },
581
- specialtyRegistry: fakeSpecialtyRegistry(),
568
+ specialtyRegistry: fakeSpecialtyTypes(),
582
569
  agentChatTokenService: {
583
570
  upsertDailyByModel: async (
584
571
  _agentId: string,