@app-vitals/agent-admin 0.16.2 → 1.1.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 +9 -9
  2. package/e2e/test-server.ts +6 -6
  3. package/openapi.json +3 -3
  4. package/openapi.platform.json +3 -3
  5. package/package.json +3 -5
  6. package/src/account-agent-create.ts +1 -1
  7. package/src/admin-boot.ts +6 -6
  8. package/src/admin-extension.ts +16 -15
  9. package/src/admin-extension.unit.test.ts +2 -2
  10. package/src/admin-local-agent.smoke.test.ts +8 -5
  11. package/src/admin-ui-pages.ts +4 -4
  12. package/src/admin-ui.smoke.test.ts +27 -39
  13. package/src/admin-ui.ts +12 -12
  14. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  15. package/src/agent-cron-jobs.integration.test.ts +23 -21
  16. package/src/agent-cron-jobs.ts +30 -40
  17. package/src/agent-cron-jobs.unit.test.ts +51 -39
  18. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  19. package/src/agent-manifest.ts +2 -6
  20. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  21. package/src/agents-api.integration.test.ts +20 -31
  22. package/src/agents-api.sentry.smoke.test.ts +2 -2
  23. package/src/agents-api.smoke.test.ts +17 -30
  24. package/src/agents-api.ts +13 -13
  25. package/src/agents.integration.test.ts +21 -31
  26. package/src/agents.ts +27 -34
  27. package/src/agents.unit.test.ts +35 -55
  28. package/src/api.smoke.test.ts +2 -2
  29. package/src/api.ts +1 -1
  30. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  31. package/src/server.smoke.test.ts +2 -2
  32. package/src/specialty-defaults.ts +4 -4
  33. package/src/specialty-types.ts +92 -0
  34. package/src/specialty-types.unit.test.ts +67 -0
  35. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  36. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  37. package/src/coding-task-store-provisioning-client.ts +0 -156
  38. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  39. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  40. package/src/specialty-manifest-loader.ts +0 -285
  41. package/src/specialty-manifest-loader.unit.test.ts +0 -364
  42. package/src/specialty-registry.schema.unit.test.ts +0 -35
  43. package/src/specialty-registry.ts +0 -236
  44. package/src/specialty-registry.unit.test.ts +0 -272
  45. /package/e2e/fixtures/specialties/{coding → specialist}/templates/CLAUDE.md.template +0 -0
  46. /package/e2e/fixtures/specialties/{coding → specialist}/templates/IDENTITY.md.template +0 -0
  47. /package/e2e/fixtures/specialties/{coding → specialist}/templates/SOUL.md.template +0 -0
@@ -18,8 +18,14 @@ import {
18
18
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
19
19
  import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
20
20
  import { createAdminPrismaClient } from "./prisma-client.ts";
21
- import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
22
- import type { SpecialtyManifest } from "./specialty-registry.ts";
21
+ import type {
22
+ SpecialtyDefaultCron,
23
+ SpecialtyDefaultsResolver,
24
+ } from "./specialty-defaults.ts";
25
+ import {
26
+ SPECIALIST_TYPE,
27
+ type SpecialtyTypeResolver,
28
+ } from "./specialty-types.ts";
23
29
 
24
30
  const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
25
31
 
@@ -34,9 +40,7 @@ function makePrisma(): PrismaClient {
34
40
  // cron, covering every field reconcile maps into a row.
35
41
  const FIXTURE_TYPE = "fixture";
36
42
 
37
- type ManifestCron = SpecialtyManifest["crons"][number];
38
-
39
- const FIXTURE_CRONS: ManifestCron[] = [
43
+ const FIXTURE_CRONS: SpecialtyDefaultCron[] = [
40
44
  {
41
45
  name: "fixture-loop",
42
46
  schedule: "*/30 * * * *",
@@ -70,23 +74,16 @@ const FIXTURE_CRONS: ManifestCron[] = [
70
74
  },
71
75
  ];
72
76
 
73
- const fixtureManifest = {
74
- crons: FIXTURE_CRONS,
75
- } as unknown as SpecialtyManifest;
77
+ /** The extension hook supplying FIXTURE_TYPE's crons. */
78
+ const fixtureDefaults: SpecialtyDefaultsResolver = (specialty) =>
79
+ specialty === FIXTURE_TYPE ? { crons: FIXTURE_CRONS } : undefined;
76
80
 
77
81
  /** Resolves only FIXTURE_TYPE; every other type is unknown. */
78
- const fixtureResolver: SpecialtyManifestResolver = {
79
- getManifest(specialty: string): SpecialtyManifest {
80
- if (specialty !== FIXTURE_TYPE)
81
- throw new Error(`no manifest: ${specialty}`);
82
- return fixtureManifest;
83
- },
84
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
85
- return specialty === FIXTURE_TYPE ? fixtureManifest : undefined;
86
- },
87
- listTypes() {
88
- return [{ name: FIXTURE_TYPE, displayName: FIXTURE_TYPE }];
89
- },
82
+ const fixtureResolver: SpecialtyTypeResolver = {
83
+ getType: () => SPECIALIST_TYPE,
84
+ tryGetType: (specialty) =>
85
+ specialty === FIXTURE_TYPE ? SPECIALIST_TYPE : undefined,
86
+ listTypes: () => [{ name: FIXTURE_TYPE, displayName: FIXTURE_TYPE }],
90
87
  };
91
88
 
92
89
  async function createAgent(
@@ -109,7 +106,12 @@ describeOrSkip("AgentCronJobService (integration)", () => {
109
106
  await prisma.agentTool.deleteMany();
110
107
  await prisma.agentEnv.deleteMany();
111
108
  await prisma.agent.deleteMany();
112
- service = new AgentCronJobService(prisma, undefined, fixtureResolver);
109
+ service = new AgentCronJobService(
110
+ prisma,
111
+ undefined,
112
+ fixtureResolver,
113
+ fixtureDefaults,
114
+ );
113
115
  });
114
116
 
115
117
  afterEach(async () => {
@@ -15,17 +15,9 @@ import type {
15
15
  SpecialtyDefaultsResolver,
16
16
  } from "./specialty-defaults.ts";
17
17
  import {
18
- type SpecialtyManifestResolver,
19
- SpecialtyRegistry,
20
- } from "./specialty-manifest-loader.ts";
21
- import type { SpecialtyManifest } from "./specialty-registry.ts";
22
-
23
- /**
24
- * The shape of a single cron template resolved from a specialty's manifest.
25
- * Structurally identical to the former SYSTEM_CRONS SystemCron entries — the
26
- * manifest is now the source of truth (see specialty-manifest-loader.ts).
27
- */
28
- type ManifestCron = SpecialtyManifest["crons"][number];
18
+ type SpecialtyTypeResolver,
19
+ SpecialtyTypes,
20
+ } from "./specialty-types.ts";
29
21
 
30
22
  export type { AgentCronJob };
31
23
 
@@ -87,7 +79,7 @@ export class AgentCronJobService {
87
79
  constructor(
88
80
  private prisma: PrismaClient,
89
81
  private clock: Clock = SystemClock(),
90
- private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
82
+ private specialtyRegistry: SpecialtyTypeResolver = new SpecialtyTypes(),
91
83
  private specialtyDefaults: SpecialtyDefaultsResolver = () => undefined,
92
84
  ) {}
93
85
 
@@ -442,26 +434,25 @@ export class AgentCronJobService {
442
434
  /**
443
435
  * Reconcile system crons for the given agent.
444
436
  *
445
- * The cron list is resolved from the agent's type manifest — the agent's
446
- * stored `specialty` is looked up via the injected SpecialtyRegistry's
447
- * `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
448
- * deliberately WITHOUT the default-type fallback: any type (the default
449
- * included) with no loadable manifest logs an error and reconciles nothing
450
- * (no seeding of another type's crons, no deletion of existing system
451
- * crons). This replaced the former hardcoded
452
- * SYSTEM_CRONS array as the source of truth (ATS-3.2).
437
+ * The cron list comes from the extension's specialtyDefaults() for the
438
+ * agent's stored `specialty`. Without extension crons the built-in types
439
+ * (looked up via the injected resolver's `tryGetType()`, deliberately
440
+ * WITHOUT a fallback) declare none, so their system crons are cleared; a
441
+ * specialty that is neither registered nor built in logs an error and
442
+ * reconciles nothing (no seeding of another type's crons, no deletion of
443
+ * existing system crons).
453
444
  *
454
- * Two passes so parent/child links resolve regardless of the manifest's
445
+ * Two passes so parent/child links resolve regardless of the default
455
446
  * cron order or whether this is the agent's very first reconcile (i.e.
456
447
  * the parent row may not exist yet when a child entry is processed):
457
448
  *
458
- * Pass 1 — for each manifest cron entry:
449
+ * Pass 1 — for each default cron entry:
459
450
  * - If an existing system cron with matching name exists: update it in
460
- * place with the current manifest definition, preserving its id and
451
+ * place with the current default definition, preserving its id and
461
452
  * existing enabled state. Updating in place (rather than delete+create)
462
453
  * keeps the row's id stable across restarts so AgentCronRun history
463
454
  * (FK onDelete: Cascade) is never wiped out from under it.
464
- * - If no matching cron exists: create it with the manifest's default
455
+ * - If no matching cron exists: create it with the default
465
456
  * enabled state.
466
457
  * Records each entry's resulting row id in a name → id map as it goes.
467
458
  *
@@ -472,10 +463,10 @@ export class AgentCronJobService {
472
463
  * non-null parentCronId on that row is cleared back to null. Self-heals
473
464
  * the link on every reconcile call in both directions — null→set (e.g.
474
465
  * a pre-existing row from before parentCron was introduced) and set→null
475
- * (e.g. a parentCron declaration removed from the manifest).
466
+ * (e.g. a parentCron declaration removed from the defaults).
476
467
  *
477
468
  * Orphan pass: delete any cron with system=true whose name is no longer in
478
- * the resolved manifest cron list.
469
+ * the resolved default cron list.
479
470
  *
480
471
  * All three passes run inside a single transaction for atomicity.
481
472
  *
@@ -484,7 +475,7 @@ export class AgentCronJobService {
484
475
  async reconcileSystemCrons(
485
476
  agentId: string,
486
477
  ): Promise<{ created: number; updated: number; deleted: number }> {
487
- // Resolve the agent's type manifest → its declared cron list. No default-type
478
+ // Resolve the agent's default cron list. No default-type
488
479
  // fallback: seeding another type's crons on an agent is worse than
489
480
  // leaving its crons untouched.
490
481
  const agent = await this.prisma.agent.findUnique({
@@ -495,19 +486,18 @@ export class AgentCronJobService {
495
486
  throw new NotFoundError(`agent ${agentId} not found`);
496
487
  }
497
488
  const { specialty } = agent;
498
- // Extension-supplied crons win; the manifest is the fallback.
489
+ // Extension-supplied crons win; built-in types declare none.
499
490
  const extensionCrons = this.specialtyDefaults(specialty)?.crons;
500
- const manifest = extensionCrons
491
+ const knownType = extensionCrons
501
492
  ? undefined
502
- : this.specialtyRegistry.tryGetManifest(specialty);
503
- if (!extensionCrons && !manifest) {
493
+ : this.specialtyRegistry.tryGetType(specialty);
494
+ if (!extensionCrons && !knownType) {
504
495
  console.error(
505
- `reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
496
+ `reconcileSystemCrons: no known type for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
506
497
  );
507
498
  return { created: 0, updated: 0, deleted: 0 };
508
499
  }
509
- const manifestCrons: readonly (ManifestCron | SpecialtyDefaultCron)[] =
510
- extensionCrons ?? manifest?.crons ?? [];
500
+ const defaultCrons: readonly SpecialtyDefaultCron[] = extensionCrons ?? [];
511
501
 
512
502
  // Fetch all existing system crons for this agent
513
503
  const existingSystemCrons = await this.prisma.agentCronJob.findMany({
@@ -522,8 +512,8 @@ export class AgentCronJobService {
522
512
  }
523
513
  }
524
514
 
525
- // Build set of valid manifest cron names for orphan detection
526
- const systemCronNames = new Set(manifestCrons.map((c) => c.name));
515
+ // Build set of valid default cron names for orphan detection
516
+ const systemCronNames = new Set(defaultCrons.map((c) => c.name));
527
517
 
528
518
  let created = 0;
529
519
  let updated = 0;
@@ -532,9 +522,9 @@ export class AgentCronJobService {
532
522
  // Wrap all mutations in a transaction so a crash mid-loop cannot leave
533
523
  // the agent missing required system crons (e.g. shipwright-dev-task).
534
524
  await this.prisma.$transaction(async (tx) => {
535
- // Pass 1: reconcile each manifest cron entry, recording name → row id.
525
+ // Pass 1: reconcile each default cron entry, recording name → row id.
536
526
  const idByName = new Map<string, string>();
537
- for (const systemCron of manifestCrons) {
527
+ for (const systemCron of defaultCrons) {
538
528
  const existing = existingByName.get(systemCron.name);
539
529
 
540
530
  if (existing) {
@@ -577,7 +567,7 @@ export class AgentCronJobService {
577
567
  // Also clears parentCronId back to null when an entry no longer
578
568
  // declares a resolvable parentCron, so the link self-heals in both
579
569
  // directions rather than only null→set.
580
- for (const systemCron of manifestCrons) {
570
+ for (const systemCron of defaultCrons) {
581
571
  const childId = idByName.get(systemCron.name);
582
572
  if (!childId) continue;
583
573
  const parentId = systemCron.parentCron
@@ -600,7 +590,7 @@ export class AgentCronJobService {
600
590
  }
601
591
  }
602
592
 
603
- // Orphan pass: delete any system cron whose name is not in the manifest.
593
+ // Orphan pass: delete any system cron whose name is not in the defaults.
604
594
  for (const cron of existingSystemCrons) {
605
595
  if (!cron.name || !systemCronNames.has(cron.name)) {
606
596
  await tx.agentCronJob.delete({ where: { id: cron.id } });
@@ -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,
@@ -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;