@app-vitals/agent-admin 1.2.7 → 1.4.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.
@@ -14,6 +14,7 @@ export type * from './models/SessionFollow.ts'
14
14
  export type * from './models/SessionAlertState.ts'
15
15
  export type * from './models/Agent.ts'
16
16
  export type * from './models/AgentEnv.ts'
17
+ export type * from './models/GitHubEnterpriseApp.ts'
17
18
  export type * from './models/AgentCronJob.ts'
18
19
  export type * from './models/AgentTool.ts'
19
20
  export type * from './models/AgentToken.ts'
@@ -34,4 +35,5 @@ export type * from './models/AgentGitHubInstallationsSnapshot.ts'
34
35
  export type * from './models/PushSubscription.ts'
35
36
  export type * from './models/ChatThreadWatch.ts'
36
37
  export type * from './models/UserNotificationPrefs.ts'
38
+ export type * from './models/AgentMarketplace.ts'
37
39
  export type * from './commonInputTypes.ts'
@@ -0,0 +1,19 @@
1
+ -- PMK-1.1: AgentMarketplace table for per-agent plugin marketplaces (additive only).
2
+ -- CreateTable
3
+ CREATE TABLE "AgentMarketplace" (
4
+ "id" TEXT NOT NULL,
5
+ "agentId" TEXT NOT NULL,
6
+ "name" TEXT NOT NULL,
7
+ "source" TEXT NOT NULL,
8
+ "ref" TEXT,
9
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "updatedAt" TIMESTAMP(3) NOT NULL,
11
+
12
+ CONSTRAINT "AgentMarketplace_pkey" PRIMARY KEY ("id")
13
+ );
14
+
15
+ -- CreateIndex
16
+ CREATE UNIQUE INDEX "AgentMarketplace_agentId_name_key" ON "AgentMarketplace"("agentId", "name");
17
+
18
+ -- AddForeignKey
19
+ ALTER TABLE "AgentMarketplace" ADD CONSTRAINT "AgentMarketplace_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
1
+ -- GHE-1.1: optional admin-level GitHub Apps (installer / lookup). Additive only: new table.
2
+ -- CreateTable
3
+ CREATE TABLE "GitHubEnterpriseApp" (
4
+ "role" TEXT NOT NULL,
5
+ "appId" TEXT NOT NULL,
6
+ "slug" TEXT NOT NULL,
7
+ "enterpriseSlug" TEXT,
8
+ "privateKeyEncrypted" TEXT NOT NULL,
9
+ "lastValidationStatus" TEXT,
10
+ "lastValidatedAt" TIMESTAMP(3),
11
+ "lastValidationError" TEXT,
12
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ "updatedAt" TIMESTAMP(3) NOT NULL,
14
+
15
+ CONSTRAINT "GitHubEnterpriseApp_pkey" PRIMARY KEY ("role")
16
+ );
@@ -1,5 +1,6 @@
1
1
  // Shipwright — Admin Service Schema
2
2
  // Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,
3
+ // AgentMarketplace,
3
4
  // plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,
4
5
  // AgentPhaseMethodology, SessionFollow, SessionAlertState).
5
6
  // Agent is a first-class model (standalone, not embedded in a User model).
@@ -88,6 +89,7 @@ model Agent {
88
89
  tools AgentTool[]
89
90
  tokens AgentToken[]
90
91
  plugins AgentPlugin[]
92
+ marketplaces AgentMarketplace[]
91
93
  members AgentMember[]
92
94
  chatTokenUsage AgentChatTokenUsageDailyByModel[]
93
95
  workQueueSnapshot AgentWorkQueueSnapshot?
@@ -114,6 +116,24 @@ model AgentEnv {
114
116
  @@unique([agentId, key])
115
117
  }
116
118
 
119
+ // ─── GitHub Enterprise Apps ───────────────────────────────────────────────────
120
+ // Optional admin-level GitHub Apps, one per role ('installer' | 'lookup'), at most
121
+ // two rows per deployment; no rows means the feature is off. Deliberately NOT in
122
+ // AgentEnv so the private key can never reach the agent config bundle.
123
+
124
+ model GitHubEnterpriseApp {
125
+ role String @id // 'installer' | 'lookup'
126
+ appId String
127
+ slug String
128
+ enterpriseSlug String? // installer only
129
+ privateKeyEncrypted String // AES-256-GCM (iv:ciphertext:authTag); never returned by any read path
130
+ lastValidationStatus String?
131
+ lastValidatedAt DateTime?
132
+ lastValidationError String?
133
+ createdAt DateTime @default(now())
134
+ updatedAt DateTime @updatedAt
135
+ }
136
+
117
137
  // ─── Agent Cron Jobs ──────────────────────────────────────────────────────────
118
138
  // Scheduled prompts per agent.
119
139
  // Delivery target is either a Slack channel ID or a Slack user ID (DM).
@@ -569,3 +589,20 @@ model UserNotificationPrefs {
569
589
  updatedAt DateTime @updatedAt
570
590
  }
571
591
 
592
+
593
+ // ─── AgentMarketplace ─────────────────────────────────────────────────────────
594
+ // Plugin marketplaces registered per agent. `name` is the marketplace name that
595
+ // AgentPlugin specs reference after the `@` (e.g. "plugin@<name>").
596
+
597
+ model AgentMarketplace {
598
+ id String @id @default(cuid())
599
+ agentId String
600
+ agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
601
+ name String
602
+ source String
603
+ ref String?
604
+ createdAt DateTime @default(now())
605
+ updatedAt DateTime @updatedAt
606
+
607
+ @@unique([agentId, name])
608
+ }
@@ -31,6 +31,18 @@ export function reconcileAgentAfterAccountChange(
31
31
  return reconcileAgentWorkload(provisioner, agent, "account updated");
32
32
  }
33
33
 
34
+ /**
35
+ * SAL-1.2: reconciles the workload after the agent's specialty changed, so the
36
+ * pod is recreated on the image mapped to the new specialty (the target
37
+ * carries `agent.specialty`, which must be the post-update value).
38
+ */
39
+ export function reconcileAgentAfterSpecialtyChange(
40
+ provisioner: Pick<AgentProvisioner, "reconcile">,
41
+ agent: ReconcilableAgent,
42
+ ): Promise<string | undefined> {
43
+ return reconcileAgentWorkload(provisioner, agent, "specialty updated");
44
+ }
45
+
34
46
  /**
35
47
  * COS-49.1: reconciles the workload after podServiceAccountName changed.
36
48
  * `previous` is the column value before the update; the provisioner uses it to
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { reconcileAgentAfterAccountChange } from "./agent-account-assignment.ts";
2
+ import {
3
+ reconcileAgentAfterAccountChange,
4
+ reconcileAgentAfterSpecialtyChange,
5
+ } from "./agent-account-assignment.ts";
3
6
 
4
7
  interface Result {
5
8
  recreated: string[];
@@ -97,3 +100,34 @@ describe("reconcileAgentAfterAccountChange", () => {
97
100
  expect(await reconcileAgentAfterAccountChange(p, agent)).toContain("boom");
98
101
  });
99
102
  });
103
+
104
+ describe("reconcileAgentAfterSpecialtyChange", () => {
105
+ it("reconciles the workload with the new specialty", async () => {
106
+ const p = makeProvisioner();
107
+ expect(
108
+ await reconcileAgentAfterSpecialtyChange(p, {
109
+ ...agent,
110
+ specialty: "specialist",
111
+ }),
112
+ ).toBeUndefined();
113
+ expect(p.calls[0]?.[0]).toMatchObject({
114
+ id: "a1",
115
+ specialty: "specialist",
116
+ });
117
+ });
118
+
119
+ it("skips self-hosted agents", async () => {
120
+ const p = makeProvisioner();
121
+ await reconcileAgentAfterSpecialtyChange(p, { ...agent, selfHosted: true });
122
+ expect(p.calls).toEqual([]);
123
+ });
124
+
125
+ it("returns a warning when the reconcile throws", async () => {
126
+ const p = makeProvisioner(async () => {
127
+ throw new Error("boom");
128
+ });
129
+ expect(await reconcileAgentAfterSpecialtyChange(p, agent)).toContain(
130
+ "specialty updated",
131
+ );
132
+ });
133
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * admin/src/agent-marketplaces.integration.test.ts
3
+ * Integration tests for the AgentMarketplace model against a real PostgreSQL DB.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { createAdminPrismaClient } from "./prisma-client.ts";
11
+
12
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
13
+
14
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
15
+
16
+ describeOrSkip("AgentMarketplace (integration)", () => {
17
+ let prisma: PrismaClient;
18
+
19
+ beforeEach(async () => {
20
+ prisma = createAdminPrismaClient(TEST_DB as string);
21
+ await prisma.agentMarketplace.deleteMany();
22
+ await prisma.agentPlugin.deleteMany();
23
+ await prisma.agentToken.deleteMany();
24
+ await prisma.agentCronJob.deleteMany();
25
+ await prisma.agentTool.deleteMany();
26
+ await prisma.agentEnv.deleteMany();
27
+ await prisma.agent.deleteMany();
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await prisma.$disconnect();
32
+ });
33
+
34
+ async function createAgent(): Promise<string> {
35
+ return (await prisma.agent.create({ data: { name: "Test Agent" } })).id;
36
+ }
37
+
38
+ it("inserts a row and reads it back", async () => {
39
+ const agentId = await createAgent();
40
+ const created = await prisma.agentMarketplace.create({
41
+ data: {
42
+ agentId,
43
+ name: "shipwright",
44
+ source: "github:app-vitals/shipwright",
45
+ },
46
+ });
47
+ const found = await prisma.agentMarketplace.findUnique({
48
+ where: { id: created.id },
49
+ });
50
+ expect(found?.agentId).toBe(agentId);
51
+ expect(found?.name).toBe("shipwright");
52
+ expect(found?.source).toBe("github:app-vitals/shipwright");
53
+ expect(found?.ref).toBeNull();
54
+ expect(found?.createdAt).toBeInstanceOf(Date);
55
+ expect(found?.updatedAt).toBeInstanceOf(Date);
56
+ });
57
+
58
+ it("rejects a duplicate (agentId, name)", async () => {
59
+ const agentId = await createAgent();
60
+ await prisma.agentMarketplace.create({
61
+ data: { agentId, name: "dup", source: "a" },
62
+ });
63
+ await expect(
64
+ (async () =>
65
+ prisma.agentMarketplace.create({
66
+ data: { agentId, name: "dup", source: "b", ref: "main" },
67
+ }))(),
68
+ ).rejects.toThrow();
69
+ });
70
+
71
+ it("cascades when the agent is deleted", async () => {
72
+ const agentId = await createAgent();
73
+ await prisma.agentMarketplace.create({
74
+ data: { agentId, name: "m1", source: "a" },
75
+ });
76
+ await prisma.agentMarketplace.create({
77
+ data: { agentId, name: "m2", source: "b", ref: "v1" },
78
+ });
79
+ await prisma.agent.delete({ where: { id: agentId } });
80
+ expect(await prisma.agentMarketplace.count({ where: { agentId } })).toBe(0);
81
+ });
82
+ });
@@ -6287,3 +6287,201 @@ describe("chat-credentials routes", () => {
6287
6287
  expect(minted).toEqual([]);
6288
6288
  });
6289
6289
  });
6290
+
6291
+ // ─── SAL-1.2: PATCH /agents/:id specialty ────────────────────────────────────
6292
+
6293
+ describe("admin API — PATCH /agents/:id specialty (SAL-1.2)", () => {
6294
+ const patch = (
6295
+ app: ReturnType<typeof createAdminApp>,
6296
+ body: unknown,
6297
+ headers: Record<string, string>,
6298
+ ) =>
6299
+ app.request(`/agents/${AGENT_ID}`, {
6300
+ method: "PATCH",
6301
+ body: JSON.stringify(body),
6302
+ headers: { "Content-Type": "application/json", ...headers },
6303
+ });
6304
+
6305
+ function depsFor(opts: {
6306
+ provisioner: RecordingProvisioner;
6307
+ events: string[];
6308
+ failCrons?: boolean;
6309
+ }): AdminDeps {
6310
+ const base = makeMockDeps();
6311
+ return {
6312
+ ...base,
6313
+ provisioner: opts.provisioner,
6314
+ specialtyRegistry: fakeSpecialtyTypes({
6315
+ coding: CODING_TYPE,
6316
+ specialist: CODING_TYPE,
6317
+ }),
6318
+ agentService: {
6319
+ ...base.agentService,
6320
+ updateSelfHosted: async (id, input) => {
6321
+ opts.events.push("write");
6322
+ return {
6323
+ ...(await base.agentService.updateSelfHosted(id, input)),
6324
+ specialty: (input as { specialty?: string }).specialty ?? "coding",
6325
+ };
6326
+ },
6327
+ },
6328
+ agentCronJobService: {
6329
+ ...base.agentCronJobService,
6330
+ reconcileSystemCrons: async () => {
6331
+ opts.events.push("crons");
6332
+ if (opts.failCrons) throw new Error("cron boom");
6333
+ return { created: 0, updated: 0, deleted: 0 };
6334
+ },
6335
+ },
6336
+ };
6337
+ }
6338
+
6339
+ it("platform admin changes specialty: write, then crons, then workload reconcile", async () => {
6340
+ const events: string[] = [];
6341
+ const provisioner = new RecordingProvisioner();
6342
+ const origReconcile = provisioner.reconcile.bind(provisioner);
6343
+ provisioner.reconcile = async (agents, o) => {
6344
+ events.push("workload");
6345
+ return origReconcile(agents, o);
6346
+ };
6347
+ const app = createAdminApp(depsFor({ provisioner, events }));
6348
+ const cookie = await makeSessionCookie();
6349
+ const infos: string[] = [];
6350
+ const origInfo = console.info;
6351
+ console.info = (...a: unknown[]) => {
6352
+ infos.push(a.join(" "));
6353
+ };
6354
+ let res: Response;
6355
+ try {
6356
+ res = await patch(
6357
+ app,
6358
+ { specialty: "specialist" },
6359
+ { Cookie: `admin_session=${cookie}` },
6360
+ );
6361
+ } finally {
6362
+ console.info = origInfo;
6363
+ }
6364
+ expect(res.status).toBe(200);
6365
+ expect((await res.json()).specialty).toBe("specialist");
6366
+ expect(events).toEqual(["write", "crons", "workload"]);
6367
+ expect(provisioner.reconciled.map((r) => r.specialty)).toEqual([
6368
+ "specialist",
6369
+ ]);
6370
+ const audit = infos.filter((l) => l.includes("specialty changed"));
6371
+ expect(audit).toHaveLength(1);
6372
+ expect(audit[0]).toContain(AGENT_ID);
6373
+ expect(audit[0]).toContain("old=coding");
6374
+ expect(audit[0]).toContain("new=specialist");
6375
+ expect(audit[0]).toContain("caller=");
6376
+ });
6377
+
6378
+ it("returns 403 for a non-admin session member and changes nothing", async () => {
6379
+ const events: string[] = [];
6380
+ const provisioner = new RecordingProvisioner();
6381
+ const app = createAdminApp({
6382
+ ...depsFor({ provisioner, events }),
6383
+ callerScopeResolver: async () => ({
6384
+ kind: "scoped",
6385
+ accountId: "acct-a",
6386
+ agentIds: [AGENT_ID],
6387
+ }),
6388
+ });
6389
+ const cookie = await sign(
6390
+ {
6391
+ isAdmin: false,
6392
+ userId: "user-acct",
6393
+ email: "owner@acct-a.example.com",
6394
+ iat: Math.floor(Date.now() / 1000),
6395
+ exp: Math.floor(Date.now() / 1000) + 3600,
6396
+ },
6397
+ SESSION_SECRET,
6398
+ "HS256",
6399
+ );
6400
+ const res = await patch(
6401
+ app,
6402
+ { specialty: "specialist" },
6403
+ { Cookie: `admin_session=${cookie}` },
6404
+ );
6405
+ expect(res.status).toBe(403);
6406
+ expect(events).toEqual([]);
6407
+ expect(provisioner.reconciled).toEqual([]);
6408
+ });
6409
+
6410
+ it("returns 422 for an unknown specialty and changes nothing", async () => {
6411
+ const events: string[] = [];
6412
+ const provisioner = new RecordingProvisioner();
6413
+ const app = createAdminApp(depsFor({ provisioner, events }));
6414
+ const cookie = await makeSessionCookie();
6415
+ const res = await patch(
6416
+ app,
6417
+ { specialty: "no-such-type" },
6418
+ { Cookie: `admin_session=${cookie}` },
6419
+ );
6420
+ expect(res.status).toBe(422);
6421
+ expect(events).toEqual([]);
6422
+ expect(provisioner.reconciled).toEqual([]);
6423
+ });
6424
+
6425
+ it("is a no-op for the current specialty: 200, no reconcile, no log", async () => {
6426
+ const events: string[] = [];
6427
+ const provisioner = new RecordingProvisioner();
6428
+ const app = createAdminApp(depsFor({ provisioner, events }));
6429
+ const cookie = await makeSessionCookie();
6430
+ const infos: string[] = [];
6431
+ const origInfo = console.info;
6432
+ console.info = (...a: unknown[]) => {
6433
+ infos.push(a.join(" "));
6434
+ };
6435
+ let res: Response;
6436
+ try {
6437
+ res = await patch(
6438
+ app,
6439
+ { specialty: "coding" },
6440
+ { Cookie: `admin_session=${cookie}` },
6441
+ );
6442
+ } finally {
6443
+ console.info = origInfo;
6444
+ }
6445
+ expect(res.status).toBe(200);
6446
+ expect(events).not.toContain("crons");
6447
+ expect(provisioner.reconciled).toEqual([]);
6448
+ expect(infos.filter((l) => l.includes("specialty changed"))).toEqual([]);
6449
+ });
6450
+
6451
+ it("returns a reconcile failure in `warning` instead of a 5xx", async () => {
6452
+ const events: string[] = [];
6453
+ const provisioner = new RecordingProvisioner();
6454
+ const app = createAdminApp(depsFor({ provisioner, events }));
6455
+ const cookie = await makeSessionCookie();
6456
+ provisioner.reconcileResult = {
6457
+ ...provisioner.reconcileResult,
6458
+ failed: [{ agentId: AGENT_ID, error: "k8s down" }],
6459
+ };
6460
+ const res = await patch(
6461
+ app,
6462
+ { specialty: "specialist" },
6463
+ { Cookie: `admin_session=${cookie}` },
6464
+ );
6465
+ expect(res.status).toBe(200);
6466
+ expect((await res.json()).warning).toContain("k8s down");
6467
+ });
6468
+
6469
+ it("returns a cron reconcile failure in `warning` and still reconciles the workload", async () => {
6470
+ const events: string[] = [];
6471
+ const provisioner = new RecordingProvisioner();
6472
+ const app = createAdminApp(
6473
+ depsFor({ provisioner, events, failCrons: true }),
6474
+ );
6475
+ const cookie = await makeSessionCookie();
6476
+ const res = await patch(
6477
+ app,
6478
+ { specialty: "specialist" },
6479
+ { Cookie: `admin_session=${cookie}` },
6480
+ );
6481
+ expect(res.status).toBe(200);
6482
+ expect((await res.json()).warning).toContain("cron boom");
6483
+ expect(provisioner.reconciled.map((r) => r.specialty)).toEqual([
6484
+ "specialist",
6485
+ ]);
6486
+ });
6487
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * admin/src/agents-api.specialty.integration.test.ts
3
+ * SAL-1.2: PATCH /agents/:id { specialty } against a real PostgreSQL DB.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import { sign } from "hono/jwt";
10
+ import type { PrismaClient } from "../prisma/client/client.ts";
11
+ import { AgentChatTokenService } from "./agent-chat-tokens.ts";
12
+ import { AgentCronJobService } from "./agent-cron-jobs.ts";
13
+ import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
14
+ import { AgentCronRunService } from "./agent-cron-runs.ts";
15
+ import { AgentEnvService } from "./agent-envs.ts";
16
+ import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
17
+ import { AgentMemberService } from "./agent-members.ts";
18
+ import { AgentPluginService } from "./agent-plugins.ts";
19
+ import type {
20
+ AgentProvisioner,
21
+ ReconcileResult,
22
+ ReconcileTarget,
23
+ } from "./agent-provisioner.ts";
24
+ import { NoopAgentProvisioner } from "./agent-provisioner.ts";
25
+ import { AgentTokenService } from "./agent-tokens.ts";
26
+ import { AgentToolService } from "./agent-tools.ts";
27
+ import { AgentService } from "./agents.ts";
28
+ import type { AdminDeps } from "./agents-api.ts";
29
+ import { createAdminApp } from "./agents-api.ts";
30
+ import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
31
+ import { createAdminPrismaClient } from "./prisma-client.ts";
32
+ import { SpecialtyTypes } from "./specialty-types.ts";
33
+ import { makeTokenCrypto } from "./token-crypto.ts";
34
+
35
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
36
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
37
+
38
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
39
+ const REAL_KEY =
40
+ "0000000000000000000000000000000000000000000000000000000000000001";
41
+
42
+ async function makeSessionCookie(): Promise<string> {
43
+ return sign(
44
+ {
45
+ isAdmin: true,
46
+ userId: "user-123",
47
+ email: "admin@example.com",
48
+ name: "Admin User",
49
+ iat: Math.floor(Date.now() / 1000),
50
+ exp: Math.floor(Date.now() / 1000) + 3600,
51
+ },
52
+ SESSION_SECRET,
53
+ "HS256",
54
+ );
55
+ }
56
+
57
+ /** Injected provisioner double that records every reconcile target. */
58
+ class RecordingProvisioner extends NoopAgentProvisioner {
59
+ readonly reconciled: ReconcileTarget[] = [];
60
+ override async reconcile(
61
+ agents: ReconcileTarget[],
62
+ ): Promise<ReconcileResult> {
63
+ this.reconciled.push(...agents);
64
+ return {
65
+ recreated: [],
66
+ migrated: [],
67
+ noOwner: [],
68
+ dryRun: false,
69
+ updated: [],
70
+ orphans: [],
71
+ failed: [],
72
+ };
73
+ }
74
+ }
75
+
76
+ function makeDeps(
77
+ prisma: PrismaClient,
78
+ provisioner: AgentProvisioner,
79
+ ): AdminDeps {
80
+ const saved = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
81
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
82
+ const crypto = makeTokenCrypto();
83
+ if (saved === undefined) delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
84
+ else process.env.SHIPWRIGHT_ENCRYPTION_KEY = saved;
85
+ const specialtyRegistry = new SpecialtyTypes();
86
+ return {
87
+ agentService: new AgentService(prisma),
88
+ agentEnvService: new AgentEnvService(prisma, crypto),
89
+ agentCronJobService: new AgentCronJobService(
90
+ prisma,
91
+ undefined,
92
+ specialtyRegistry,
93
+ ),
94
+ agentCronRunService: new AgentCronRunService(prisma),
95
+ agentCronRunStatsService: new AgentCronRunStatsService(prisma),
96
+ agentToolService: new AgentToolService(prisma),
97
+ agentTokenService: new AgentTokenService(prisma),
98
+ agentPluginService: new AgentPluginService(prisma),
99
+ agentMemberService: new AgentMemberService(prisma),
100
+ specialtyRegistry,
101
+ agentChatTokenService: new AgentChatTokenService(prisma),
102
+ agentGitHubInstallationsService: new AgentGitHubInstallationsService(
103
+ prisma,
104
+ ),
105
+ prisma,
106
+ provisioner,
107
+ serviceIntegrations: [
108
+ {
109
+ name: "task-store",
110
+ listTokensForAgent: async () => [],
111
+ revokeToken: async () => {},
112
+ },
113
+ ],
114
+ chatService: new NoopChatServiceProvisioningClient(),
115
+ slack: { deleteApp: async () => {} },
116
+ decrypt: (value: string) => crypto.decrypt(value),
117
+ sessionSecret: SESSION_SECRET,
118
+ };
119
+ }
120
+
121
+ describeOrSkip("PATCH /agents/:id specialty (integration)", () => {
122
+ let prisma: PrismaClient;
123
+
124
+ beforeEach(async () => {
125
+ prisma = createAdminPrismaClient(TEST_DB as string);
126
+ await prisma.agentCronJob.deleteMany();
127
+ await prisma.agent.deleteMany();
128
+ });
129
+
130
+ afterEach(async () => {
131
+ await prisma.$disconnect();
132
+ });
133
+
134
+ it("flips Agent.specialty and reconciles a Saul-shaped cron set against the specialist type", async () => {
135
+ const agent = await prisma.agent.create({
136
+ data: { name: "Saul", specialty: "saul" },
137
+ });
138
+ const systemNames = [
139
+ "shipwright-dev-loop",
140
+ "shipwright-dev-task",
141
+ "shipwright-review",
142
+ "shipwright-patch",
143
+ "shipwright-deploy",
144
+ "shipwright-entropy-scan",
145
+ "shipwright-security-scan",
146
+ "shipwright-error-scan",
147
+ "shipwright-learn-dream-sys",
148
+ ];
149
+ for (const name of systemNames) {
150
+ await prisma.agentCronJob.create({
151
+ data: {
152
+ agentId: agent.id,
153
+ name,
154
+ system: true,
155
+ schedule: "0 * * * *",
156
+ prompt: `/${name}`,
157
+ enabled: false,
158
+ },
159
+ });
160
+ }
161
+ const userCron = await prisma.agentCronJob.create({
162
+ data: {
163
+ agentId: agent.id,
164
+ name: "learn-dream",
165
+ system: false,
166
+ schedule: "0 3 * * *",
167
+ prompt: "/learn-dream",
168
+ enabled: true,
169
+ },
170
+ });
171
+ for (let i = 0; i < 2; i++) {
172
+ await prisma.agentCronRun.create({
173
+ data: {
174
+ cronId: userCron.id,
175
+ agentId: agent.id,
176
+ startedAt: new Date(Date.now() - i * 60_000),
177
+ },
178
+ });
179
+ }
180
+
181
+ const provisioner = new RecordingProvisioner();
182
+ const app = createAdminApp(makeDeps(prisma, provisioner));
183
+ const cookie = await makeSessionCookie();
184
+ const res = await app.request(`/agents/${agent.id}`, {
185
+ method: "PATCH",
186
+ body: JSON.stringify({ specialty: "specialist" }),
187
+ headers: {
188
+ "Content-Type": "application/json",
189
+ Cookie: `admin_session=${cookie}`,
190
+ },
191
+ });
192
+
193
+ expect(res.status).toBe(200);
194
+ expect((await res.json()).specialty).toBe("specialist");
195
+ const row = await prisma.agent.findUniqueOrThrow({
196
+ where: { id: agent.id },
197
+ });
198
+ expect(row.specialty).toBe("specialist");
199
+ expect(
200
+ await prisma.agentCronJob.count({
201
+ where: { agentId: agent.id, system: true },
202
+ }),
203
+ ).toBe(0);
204
+ const survivors = await prisma.agentCronJob.findMany({
205
+ where: { agentId: agent.id },
206
+ });
207
+ expect(survivors.map((c) => c.id)).toEqual([userCron.id]);
208
+ expect(survivors[0]?.system).toBe(false);
209
+ expect(
210
+ await prisma.agentCronRun.count({ where: { cronId: userCron.id } }),
211
+ ).toBe(2);
212
+ // selfHosted defaults false, so the workload reconcile ran once.
213
+ expect(provisioner.reconciled).toHaveLength(1);
214
+ expect(provisioner.reconciled[0]?.id).toBe(agent.id);
215
+ expect(provisioner.reconciled[0]?.specialty).toBe("specialist");
216
+ });
217
+ });