@app-vitals/agent-admin 1.2.7 → 1.3.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.
package/openapi.json CHANGED
@@ -340,6 +340,11 @@
340
340
  "type": "boolean",
341
341
  "example": false
342
342
  },
343
+ "specialty": {
344
+ "type": "string",
345
+ "minLength": 1,
346
+ "example": "specialist"
347
+ },
343
348
  "repos": {
344
349
  "type": "array",
345
350
  "items": {
@@ -3568,7 +3573,7 @@
3568
3573
  },
3569
3574
  "patch": {
3570
3575
  "summary": "Update an agent",
3571
- "description": "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.",
3576
+ "description": "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` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is 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.",
3572
3577
  "parameters": [
3573
3578
  {
3574
3579
  "schema": {
@@ -3641,7 +3646,7 @@
3641
3646
  }
3642
3647
  },
3643
3648
  "422": {
3644
- "description": "podServiceAccountName supplied for an agent with a per-agent federated identity",
3649
+ "description": "podServiceAccountName supplied for an agent with a per-agent federated identity, or `specialty` naming an unknown type",
3645
3650
  "content": {
3646
3651
  "application/json": {
3647
3652
  "schema": {
@@ -340,6 +340,11 @@
340
340
  "type": "boolean",
341
341
  "example": false
342
342
  },
343
+ "specialty": {
344
+ "type": "string",
345
+ "minLength": 1,
346
+ "example": "specialist"
347
+ },
343
348
  "repos": {
344
349
  "type": "array",
345
350
  "items": {
@@ -3396,7 +3401,7 @@
3396
3401
  },
3397
3402
  "patch": {
3398
3403
  "summary": "Update an agent",
3399
- "description": "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.",
3404
+ "description": "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` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is 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.",
3400
3405
  "parameters": [
3401
3406
  {
3402
3407
  "schema": {
@@ -3469,7 +3474,7 @@
3469
3474
  }
3470
3475
  },
3471
3476
  "422": {
3472
- "description": "podServiceAccountName supplied for an agent with a per-agent federated identity",
3477
+ "description": "podServiceAccountName supplied for an agent with a per-agent federated identity, or `specialty` naming an unknown type",
3473
3478
  "content": {
3474
3479
  "application/json": {
3475
3480
  "schema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.2.7",
3
+ "version": "1.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "0.135.0",
45
- "@app-vitals/agent-lib": "1.2.7",
45
+ "@app-vitals/agent-lib": "1.3.0",
46
46
  "@hono/zod-openapi": "^1.0.0",
47
47
  "@prisma/adapter-pg": "^7.10.0",
48
48
  "@prisma/client": "^7.10.0",
@@ -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
+ });
@@ -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
+ });
package/src/agents-api.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "./account-agent-create.ts";
42
42
  import {
43
43
  reconcileAgentAfterAccountChange,
44
+ reconcileAgentAfterSpecialtyChange,
44
45
  reconcileAgentAfterPodServiceAccountChange,
45
46
  } from "./agent-account-assignment.ts";
46
47
  import {
@@ -623,7 +624,7 @@ const patchAgentRoute = createRoute({
623
624
  path: "/agents/{id}",
624
625
  summary: "Update an agent",
625
626
  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). `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
+ "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` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is 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
628
  request: {
628
629
  params: AgentIdParamSchema,
629
630
  body: {
@@ -645,7 +646,7 @@ const patchAgentRoute = createRoute({
645
646
  },
646
647
  422: {
647
648
  description:
648
- "podServiceAccountName supplied for an agent with a per-agent federated identity",
649
+ "podServiceAccountName supplied for an agent with a per-agent federated identity, or `specialty` naming an unknown type",
649
650
  ...jsonError,
650
651
  },
651
652
  },
@@ -1688,6 +1689,25 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1688
1689
  throw new ForbiddenError("Admin access required to change accountId");
1689
1690
  }
1690
1691
  }
1692
+ // SAL-1.2: specialty change — platform-admin only, known type only.
1693
+ let previousSpecialty: string | undefined;
1694
+ if (body.specialty !== undefined) {
1695
+ if (c.get("isAdmin") !== true) {
1696
+ throw new ForbiddenError("Admin access required to change specialty");
1697
+ }
1698
+ if (!specialtyRegistry.tryGetType(body.specialty)) {
1699
+ throw new UnprocessableEntityError(
1700
+ `unknown specialty "${body.specialty}"`,
1701
+ );
1702
+ }
1703
+ const current = await agentService.getDetail(agentId);
1704
+ if (!current) {
1705
+ throw new NotFoundError(`agent ${agentId} not found`);
1706
+ }
1707
+ if (current.specialty !== body.specialty) {
1708
+ previousSpecialty = current.specialty;
1709
+ }
1710
+ }
1691
1711
  // COS-49.1: pod ServiceAccount — platform-admin only, flag-gated, and not
1692
1712
  // for agents whose federated identity owns the ServiceAccount.
1693
1713
  let previousPodServiceAccountName: string | null = null;
@@ -1733,6 +1753,9 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1733
1753
  ? { podServiceAccountName: body.podServiceAccountName }
1734
1754
  : {}),
1735
1755
  ...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
1756
+ ...(previousSpecialty !== undefined
1757
+ ? { specialty: body.specialty }
1758
+ : {}),
1736
1759
  ...(body.repos !== undefined ? { repos: body.repos } : {}),
1737
1760
  ...(body.reviewAuthorAllowlist !== undefined
1738
1761
  ? { reviewAuthorAllowlist: body.reviewAuthorAllowlist }
@@ -1786,6 +1809,28 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1786
1809
  body.accountId !== undefined
1787
1810
  ? await reconcileAgentAfterAccountChange(provisioner, agent)
1788
1811
  : undefined;
1812
+ let specialtyWarning: string | undefined;
1813
+ if (previousSpecialty !== undefined) {
1814
+ // Audit: ids and names only — never the caller's key.
1815
+ console.info(
1816
+ `[agents-api] specialty changed: agent=${agentId} old=${previousSpecialty} new=${agent.specialty} caller=${c.get("caller")?.name ?? "anonymous"}`,
1817
+ );
1818
+ const warnings: string[] = [];
1819
+ try {
1820
+ await agentCronJobService.reconcileSystemCrons(agentId);
1821
+ } catch (err) {
1822
+ const message = err instanceof Error ? err.message : String(err);
1823
+ warnings.push(
1824
+ `specialty updated, but reconciling system crons failed: ${message}`,
1825
+ );
1826
+ }
1827
+ const workloadWarning = await reconcileAgentAfterSpecialtyChange(
1828
+ provisioner,
1829
+ agent,
1830
+ );
1831
+ if (workloadWarning) warnings.push(workloadWarning);
1832
+ specialtyWarning = warnings.join("; ") || undefined;
1833
+ }
1789
1834
  let podSaWarning: string | undefined;
1790
1835
  if (body.podServiceAccountName !== undefined) {
1791
1836
  // Audit: ids and names only — never the caller's key.
@@ -1801,7 +1846,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1801
1846
  }
1802
1847
  }
1803
1848
  const warning =
1804
- [slackWarning, reconcileWarning, podSaWarning]
1849
+ [slackWarning, reconcileWarning, specialtyWarning, podSaWarning]
1805
1850
  .filter(Boolean)
1806
1851
  .join("; ") || undefined;
1807
1852
  return c.json(serializeAgent(agent, warning), 200);
package/src/agents.ts CHANGED
@@ -140,6 +140,8 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
140
140
  }
141
141
 
142
142
  interface UpdateSelfHostedInput {
143
+ /** SAL-1.2: the agent's specialty; callers validate it against the registry. */
144
+ specialty?: string;
143
145
  /** COS-49.1: pod ServiceAccount; null clears it. */
144
146
  podServiceAccountName?: string | null;
145
147
  /** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
@@ -475,6 +477,9 @@ export class AgentService {
475
477
  where: { id },
476
478
  data: {
477
479
  selfHosted: input.selfHosted,
480
+ ...(input.specialty !== undefined
481
+ ? { specialty: input.specialty }
482
+ : {}),
478
483
  ...(input.accountId !== undefined
479
484
  ? { accountId: input.accountId }
480
485
  : {}),
@@ -52,6 +52,10 @@ export const PatchAgentBodySchema = z
52
52
  .optional()
53
53
  .openapi({ example: "U0AALR8M69X" }),
54
54
  selfHosted: z.boolean().optional().openapi({ example: false }),
55
+ /**
56
+ * SAL-1.2: platform-admin only. Must name a known specialty type.
57
+ */
58
+ specialty: z.string().min(1).optional().openapi({ example: "specialist" }),
55
59
  repos: z
56
60
  .array(
57
61
  z.string().refine(isOrgRepo, {