@app-vitals/agent-admin 0.13.1 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.13.1",
46
+ "@app-vitals/agent-lib": "0.14.0",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -12,6 +12,7 @@
12
12
  import { join } from "node:path";
13
13
  import type { PrismaClient } from "../prisma/client/client.ts";
14
14
  import { AgentAnthropicIdentityService } from "./agent-anthropic-identity.ts";
15
+ import { parseAgentImageMap } from "./agent-image-map.ts";
15
16
  import type { AgentAnthropicFederation } from "./agent-manifest.ts";
16
17
  import type {
17
18
  AgentProvisioner,
@@ -120,6 +121,8 @@ export function buildProvisioner(
120
121
  const namespace = env.SHIPWRIGHT_K8S_NAMESPACE ?? "default";
121
122
  const image = env.SHIPWRIGHT_AGENT_IMAGE ?? "";
122
123
  const imageTag = env.SHIPWRIGHT_AGENT_IMAGE_TAG ?? "latest";
124
+ // Malformed → throws here, so admin boot fails rather than falling back.
125
+ const images = parseAgentImageMap(env.SHIPWRIGHT_AGENT_IMAGES);
123
126
  const apiUrl = env.SHIPWRIGHT_API_URL ?? "";
124
127
  const replicasRaw = env.SHIPWRIGHT_AGENT_REPLICAS;
125
128
  const replicas = replicasRaw ? Number(replicasRaw) : undefined;
@@ -201,6 +204,7 @@ export function buildProvisioner(
201
204
  namespace,
202
205
  image,
203
206
  imageTag,
207
+ ...(images ? { images } : {}),
204
208
  apiUrl,
205
209
  ...(replicas !== undefined && Number.isFinite(replicas)
206
210
  ? { replicas }
@@ -21,6 +21,7 @@ type ReconcilableAgent = Partial<AgentAnthropicIdentity> & {
21
21
  selfHosted: boolean;
22
22
  accountId?: string | null;
23
23
  podServiceAccountName?: string | null;
24
+ specialty?: string;
24
25
  };
25
26
 
26
27
  export function reconcileAgentAfterAccountChange(
@@ -60,6 +61,9 @@ async function reconcileAgentWorkload(
60
61
  {
61
62
  id: agent.id,
62
63
  slug: agent.name,
64
+ ...(agent.specialty !== undefined
65
+ ? { specialty: agent.specialty }
66
+ : {}),
63
67
  accountId: agent.accountId ?? null,
64
68
  ownerEmail: agent.ownerEmail,
65
69
  anthropicIdentityName: agent.anthropicIdentityName,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * admin/src/agent-image-map.ts
3
+ *
4
+ * Per-specialty agent image resolution (SPI-1.1). SHIPWRIGHT_AGENT_IMAGES is an
5
+ * optional JSON object keyed by specialty whose values are `{repository, tag}`.
6
+ * An agent whose specialty is unmapped (or has no specialty) runs the
7
+ * single-image default (SHIPWRIGHT_AGENT_IMAGE / _TAG); a `default` key in the
8
+ * map overrides that default. A malformed map throws, so admin boot fails
9
+ * instead of silently falling back to the wrong image.
10
+ */
11
+
12
+ export interface AgentImageRef {
13
+ repository: string;
14
+ tag: string;
15
+ }
16
+
17
+ export type AgentImageMap = Readonly<Record<string, AgentImageRef>>;
18
+
19
+ export const DEFAULT_IMAGE_KEY = "default";
20
+
21
+ function fail(reason: string): never {
22
+ throw new Error(`SHIPWRIGHT_AGENT_IMAGES is malformed: ${reason}`);
23
+ }
24
+
25
+ /** Parse SHIPWRIGHT_AGENT_IMAGES. Unset/blank → undefined (single-image mode). */
26
+ export function parseAgentImageMap(
27
+ raw: string | undefined,
28
+ ): AgentImageMap | undefined {
29
+ if (raw === undefined || raw.trim() === "") return undefined;
30
+ let parsed: unknown;
31
+ try {
32
+ parsed = JSON.parse(raw);
33
+ } catch (err) {
34
+ return fail(err instanceof Error ? err.message : String(err));
35
+ }
36
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
37
+ return fail("expected a JSON object keyed by specialty");
38
+ }
39
+ const map: Record<string, AgentImageRef> = {};
40
+ for (const [specialty, value] of Object.entries(parsed)) {
41
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
42
+ return fail(`"${specialty}" must be an object {repository, tag}`);
43
+ }
44
+ const { repository, tag } = value as Record<string, unknown>;
45
+ if (typeof repository !== "string" || repository.trim() === "") {
46
+ return fail(`"${specialty}".repository must be a non-empty string`);
47
+ }
48
+ if (typeof tag !== "string" || tag.trim() === "") {
49
+ return fail(`"${specialty}".tag must be a non-empty string`);
50
+ }
51
+ map[specialty] = { repository, tag };
52
+ }
53
+ return Object.keys(map).length > 0 ? map : undefined;
54
+ }
55
+
56
+ /**
57
+ * The image an agent of `specialty` runs: its own map entry, else the map's
58
+ * `default` entry, else the single-image fallback.
59
+ */
60
+ export function resolveImage(
61
+ fallback: AgentImageRef,
62
+ images: AgentImageMap | undefined,
63
+ specialty?: string | null,
64
+ ): AgentImageRef {
65
+ if (!images) return fallback;
66
+ const own =
67
+ specialty && Object.hasOwn(images, specialty)
68
+ ? images[specialty]
69
+ : undefined;
70
+ if (own) return own;
71
+ return Object.hasOwn(images, DEFAULT_IMAGE_KEY)
72
+ ? (images[DEFAULT_IMAGE_KEY] as AgentImageRef)
73
+ : fallback;
74
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseAgentImageMap, resolveImage } from "./agent-image-map.ts";
3
+
4
+ const fallback = { repository: "ghcr.io/x/agent", tag: "1.0.0" };
5
+ const json = JSON.stringify({
6
+ reviewer: { repository: "ghcr.io/x/reviewer", tag: "2.0.0" },
7
+ });
8
+
9
+ describe("parseAgentImageMap", () => {
10
+ it("returns undefined when unset, empty or blank", () => {
11
+ expect(parseAgentImageMap(undefined)).toBeUndefined();
12
+ expect(parseAgentImageMap("")).toBeUndefined();
13
+ expect(parseAgentImageMap(" ")).toBeUndefined();
14
+ expect(parseAgentImageMap("{}")).toBeUndefined();
15
+ });
16
+
17
+ it("parses a valid map", () => {
18
+ expect(parseAgentImageMap(json)).toEqual({
19
+ reviewer: { repository: "ghcr.io/x/reviewer", tag: "2.0.0" },
20
+ });
21
+ });
22
+
23
+ it.each([
24
+ ["invalid JSON", "{nope"],
25
+ ["an array", "[]"],
26
+ ["null", "null"],
27
+ ["a string", '"x"'],
28
+ ["a non-object entry", '{"a":"img:1"}'],
29
+ ["a null entry", '{"a":null}'],
30
+ ["a missing tag", '{"a":{"repository":"r"}}'],
31
+ ["an empty repository", '{"a":{"repository":"","tag":"t"}}'],
32
+ ["a non-string tag", '{"a":{"repository":"r","tag":1}}'],
33
+ ])("throws on %s", (_name, raw) => {
34
+ expect(() => parseAgentImageMap(raw)).toThrow(/SHIPWRIGHT_AGENT_IMAGES/);
35
+ });
36
+ });
37
+
38
+ describe("resolveImage", () => {
39
+ it("uses the fallback when no map is set", () => {
40
+ expect(resolveImage(fallback, undefined, "reviewer")).toEqual(fallback);
41
+ expect(resolveImage(fallback, undefined)).toEqual(fallback);
42
+ });
43
+
44
+ it("uses the mapped entry for a mapped specialty", () => {
45
+ const map = parseAgentImageMap(json);
46
+ expect(resolveImage(fallback, map, "reviewer")).toEqual({
47
+ repository: "ghcr.io/x/reviewer",
48
+ tag: "2.0.0",
49
+ });
50
+ });
51
+
52
+ it("falls back for an unmapped or absent specialty", () => {
53
+ const map = parseAgentImageMap(json);
54
+ expect(resolveImage(fallback, map, "other")).toEqual(fallback);
55
+ expect(resolveImage(fallback, map, undefined)).toEqual(fallback);
56
+ expect(resolveImage(fallback, map, "toString")).toEqual(fallback);
57
+ });
58
+
59
+ it("uses the default key for unmapped specialties", () => {
60
+ const map = parseAgentImageMap(
61
+ JSON.stringify({ default: { repository: "d", tag: "9" } }),
62
+ );
63
+ expect(resolveImage(fallback, map, "other")).toEqual({
64
+ repository: "d",
65
+ tag: "9",
66
+ });
67
+ });
68
+ });
@@ -1243,3 +1243,114 @@ describe("KubernetesAgentProvisioner.reconcile() — anthropic federation (casse
1243
1243
  expect(patches).toEqual([]);
1244
1244
  });
1245
1245
  });
1246
+
1247
+ // ─── Per-specialty images (SPI-1.1) ─────────────────────────────────────────
1248
+ // Not DB-backed: a stub token service stands in for the minting path, and the
1249
+ // in-memory RecordedKubernetesClient for the cluster. The single-image tests
1250
+ // above are the unset-map regression guard.
1251
+
1252
+ describe("KubernetesAgentProvisioner — per-specialty images", () => {
1253
+ const stubTokens = {
1254
+ create: async () => ({ rawToken: "raw-token" }),
1255
+ } as unknown as AgentTokenService;
1256
+ const REVIEWER = "ghcr.io/app-vitals/shipwright-agent-reviewer:v2.0.0";
1257
+ const SINGLE = `${CONFIG.image}:${CONFIG.imageTag}`;
1258
+ const MAPPED: KubernetesAgentProvisionerConfig = {
1259
+ ...CONFIG,
1260
+ images: {
1261
+ reviewer: {
1262
+ repository: "ghcr.io/app-vitals/shipwright-agent-reviewer",
1263
+ tag: "v2.0.0",
1264
+ },
1265
+ },
1266
+ };
1267
+
1268
+ async function imageOf(
1269
+ k8s: RecordedKubernetesClient,
1270
+ agentId: string,
1271
+ ): Promise<string | undefined> {
1272
+ const dep = await k8s.getDeployment(NAMESPACE, sanitizeAgentName(agentId));
1273
+ return dep.spec.template.spec.containers[0]?.image;
1274
+ }
1275
+
1276
+ it("provision() applies the mapped image for a mapped specialty and the single image otherwise", async () => {
1277
+ const k8s = emptyClient();
1278
+ const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, MAPPED);
1279
+
1280
+ await provisioner.provision("agent-rev", { specialty: "reviewer" });
1281
+ await provisioner.provision("agent-dev", { specialty: "developer" });
1282
+ await provisioner.provision("agent-none");
1283
+
1284
+ expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
1285
+ expect(await imageOf(k8s, "agent-dev")).toBe(SINGLE);
1286
+ expect(await imageOf(k8s, "agent-none")).toBe(SINGLE);
1287
+ });
1288
+
1289
+ it("provision() ignores specialty when no map is configured", async () => {
1290
+ const k8s = emptyClient();
1291
+ const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, CONFIG);
1292
+
1293
+ await provisioner.provision("agent-rev", { specialty: "reviewer" });
1294
+
1295
+ expect(await imageOf(k8s, "agent-rev")).toBe(SINGLE);
1296
+ });
1297
+
1298
+ it("reconcile() patches only the Deployments whose resolved image differs", async () => {
1299
+ const k8s = emptyClient();
1300
+ const patched: string[] = [];
1301
+ const spy: KubernetesClient = {
1302
+ createSecret: (ns, s) => k8s.createSecret(ns, s),
1303
+ createDeploymentManifest: (ns, d) => k8s.createDeploymentManifest(ns, d),
1304
+ createPvc: (ns, p) => k8s.createPvc(ns, p),
1305
+ getSecret: (ns, n) => k8s.getSecret(ns, n),
1306
+ getDeployment: (ns, n) => k8s.getDeployment(ns, n),
1307
+ listDeployments: (ns, l) => k8s.listDeployments(ns, l),
1308
+ deleteDeployment: (ns, n) => k8s.deleteDeployment(ns, n),
1309
+ deleteSecret: (ns, n) => k8s.deleteSecret(ns, n),
1310
+ deletePvc: (ns, n) => k8s.deletePvc(ns, n),
1311
+ patchDeployment: (ns, n, p) => {
1312
+ patched.push(n);
1313
+ return k8s.patchDeployment(ns, n, p);
1314
+ },
1315
+ } as KubernetesClient;
1316
+
1317
+ // Both agents were provisioned under the single-image config.
1318
+ const before = new KubernetesAgentProvisioner(spy, stubTokens, CONFIG);
1319
+ await before.provision("agent-rev", { specialty: "reviewer" });
1320
+ await before.provision("agent-dev", { specialty: "developer" });
1321
+
1322
+ // The map is introduced: only the reviewer's resolved image changes.
1323
+ const after = new KubernetesAgentProvisioner(spy, stubTokens, MAPPED);
1324
+ const result = await after.reconcile([
1325
+ { id: "agent-rev", specialty: "reviewer" },
1326
+ { id: "agent-dev", specialty: "developer" },
1327
+ ]);
1328
+
1329
+ expect(result.updated).toEqual(["agent-rev"]);
1330
+ expect(result.failed).toEqual([]);
1331
+ expect(patched).toEqual([sanitizeAgentName("agent-rev")]);
1332
+ expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
1333
+ expect(await imageOf(k8s, "agent-dev")).toBe(SINGLE);
1334
+
1335
+ // A second pass is a no-op.
1336
+ patched.length = 0;
1337
+ const again = await after.reconcile([
1338
+ { id: "agent-rev", specialty: "reviewer" },
1339
+ { id: "agent-dev", specialty: "developer" },
1340
+ ]);
1341
+ expect(again.updated).toEqual([]);
1342
+ expect(patched).toEqual([]);
1343
+ });
1344
+
1345
+ it("reconcile() recreates a missing Deployment with its specialty's image", async () => {
1346
+ const k8s = emptyClient();
1347
+ const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, MAPPED);
1348
+
1349
+ const result = await provisioner.reconcile([
1350
+ { id: "agent-rev", specialty: "reviewer" },
1351
+ ]);
1352
+
1353
+ expect(result.recreated).toEqual(["agent-rev"]);
1354
+ expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
1355
+ });
1356
+ });
@@ -44,6 +44,11 @@ import type {
44
44
  AgentAnthropicIdentity,
45
45
  AgentAnthropicIdentityService,
46
46
  } from "./agent-anthropic-identity.ts";
47
+ import {
48
+ type AgentImageMap,
49
+ type AgentImageRef,
50
+ resolveImage,
51
+ } from "./agent-image-map.ts";
47
52
  import {
48
53
  type AgentAnthropicFederation,
49
54
  type AgentContainerResourceOverrides,
@@ -151,6 +156,8 @@ export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
151
156
  accountId?: string | null;
152
157
  /** The Agent row's pod ServiceAccount (COS-48.1); rendered only when the flag is on. */
153
158
  podServiceAccountName?: string | null;
159
+ /** The agent's specialty; selects its image (SHIPWRIGHT_AGENT_IMAGES). */
160
+ specialty?: string;
154
161
  /**
155
162
  * The pod ServiceAccount this agent had before the change being reconciled
156
163
  * (COS-49.1). Only used to clear: when `podServiceAccountName` is now unset,
@@ -179,6 +186,8 @@ export interface AgentProvisioner {
179
186
  slug?: string;
180
187
  accountId?: string | null;
181
188
  podServiceAccountName?: string | null;
189
+ /** Selects the agent's image (SHIPWRIGHT_AGENT_IMAGES). */
190
+ specialty?: string;
182
191
  },
183
192
  ): Promise<ProvisionResult>;
184
193
  /**
@@ -236,6 +245,11 @@ export interface KubernetesAgentProvisionerConfig {
236
245
  image: string;
237
246
  /** Image tag, joined as `image:tag`. */
238
247
  imageTag: string;
248
+ /**
249
+ * Optional per-specialty image map (SHIPWRIGHT_AGENT_IMAGES). A specialty
250
+ * without an entry (and no `default` key) uses `image`/`imageTag`.
251
+ */
252
+ images?: AgentImageMap;
239
253
  /** In-cluster admin/API base URL the agent calls home to. */
240
254
  apiUrl: string;
241
255
  /**
@@ -833,6 +847,15 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
833
847
  : `${resourceName}-token`;
834
848
  }
835
849
 
850
+ /** The image an agent of `specialty` runs (see resolveImage). */
851
+ private resolveImage(specialty?: string | null): AgentImageRef {
852
+ return resolveImage(
853
+ { repository: this.config.image, tag: this.config.imageTag },
854
+ this.config.images,
855
+ specialty,
856
+ );
857
+ }
858
+
836
859
  private pvcNameFor(resourceName: string, slug?: string): string {
837
860
  return this.config.pvcName
838
861
  ? this.config.pvcName(slug ? sanitizeAgentName(slug) : resourceName)
@@ -902,13 +925,15 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
902
925
  accountId: string | null | undefined,
903
926
  federation: AgentAnthropicFederation | undefined,
904
927
  podServiceAccountName?: string | null,
928
+ specialty?: string,
905
929
  ) {
906
930
  const resourceName = this.resourceName(agentId);
931
+ const { repository, tag } = this.resolveImage(specialty);
907
932
  return buildAgentDeploymentManifest({
908
933
  agentId,
909
934
  namespace: this.config.namespace,
910
- image: this.config.image,
911
- imageTag: this.config.imageTag,
935
+ image: repository,
936
+ imageTag: tag,
912
937
  apiUrl: this.config.apiUrl,
913
938
  pvcName: this.pvcNameFor(resourceName, slug),
914
939
  secretName: this.secretNameFor(resourceName),
@@ -935,6 +960,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
935
960
  slug?: string;
936
961
  accountId?: string | null;
937
962
  podServiceAccountName?: string | null;
963
+ specialty?: string;
938
964
  },
939
965
  ): Promise<ProvisionResult> {
940
966
  const resourceName = this.resourceName(agentId);
@@ -1147,6 +1173,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1147
1173
  slug?: string;
1148
1174
  accountId?: string | null;
1149
1175
  podServiceAccountName?: string | null;
1176
+ specialty?: string;
1150
1177
  }
1151
1178
  | undefined,
1152
1179
  result: ProvisionResult,
@@ -1241,6 +1268,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1241
1268
  opts?.accountId,
1242
1269
  federation,
1243
1270
  opts?.podServiceAccountName,
1271
+ opts?.specialty,
1244
1272
  ),
1245
1273
  );
1246
1274
  } catch (err) {
@@ -1388,6 +1416,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1388
1416
  slug: agent.slug,
1389
1417
  accountId: agent.accountId,
1390
1418
  podServiceAccountName: agent.podServiceAccountName,
1419
+ specialty: agent.specialty,
1391
1420
  });
1392
1421
  }
1393
1422
  recreated.push(agent.id);
@@ -1453,6 +1482,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1453
1482
  agent.accountId,
1454
1483
  desiredFederation,
1455
1484
  agent.podServiceAccountName,
1485
+ agent.specialty,
1456
1486
  );
1457
1487
  const desired = desiredDeployment.spec.template.spec.containers[0];
1458
1488
  const desiredLabels = desiredDeployment.spec.template.metadata.labels;
@@ -2293,6 +2293,7 @@ describe("admin API — POST /agents/reconcile", () => {
2293
2293
  anthropicServiceAccountId: null,
2294
2294
  anthropicFederationRuleId: null,
2295
2295
  k8sServiceAccountName: null,
2296
+ specialty: "coding",
2296
2297
  },
2297
2298
  ]);
2298
2299
  });
@@ -5407,7 +5408,12 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
5407
5408
  "acct-b",
5408
5409
  );
5409
5410
  expect(provisioner.reconciled).toEqual([
5410
- { id: AGENT_ID, slug: "Existing Agent", accountId: "acct-b" },
5411
+ {
5412
+ id: AGENT_ID,
5413
+ slug: "Existing Agent",
5414
+ accountId: "acct-b",
5415
+ specialty: "coding",
5416
+ },
5411
5417
  ]);
5412
5418
  });
5413
5419
 
@@ -5423,7 +5429,12 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
5423
5429
  expect(res.status).toBe(200);
5424
5430
  expect(((await res.json()) as { accountId: unknown }).accountId).toBeNull();
5425
5431
  expect(provisioner.reconciled).toEqual([
5426
- { id: AGENT_ID, slug: "Existing Agent", accountId: null },
5432
+ {
5433
+ id: AGENT_ID,
5434
+ slug: "Existing Agent",
5435
+ accountId: null,
5436
+ specialty: "coding",
5437
+ },
5427
5438
  ]);
5428
5439
  });
5429
5440
 
package/src/agents-api.ts CHANGED
@@ -1645,6 +1645,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1645
1645
  await provisioner.provision(agent.id, {
1646
1646
  slug: agent.name,
1647
1647
  accountId: agent.accountId,
1648
+ specialty: agent.specialty,
1648
1649
  });
1649
1650
  return c.json({ resourceName, secretName, deploymentName }, 200);
1650
1651
  });
package/src/agents.ts CHANGED
@@ -1087,6 +1087,7 @@ export async function createAgent(
1087
1087
  await deps.provisioner.provision(agent.id, {
1088
1088
  slug: agent.name,
1089
1089
  accountId: agent.accountId,
1090
+ specialty: agent.specialty,
1090
1091
  });
1091
1092
  } catch (err) {
1092
1093
  console.error(
@@ -98,6 +98,35 @@ describe("buildProvisioner", () => {
98
98
  expect(configOf(off).podServiceAccount).toEqual({ enabled: false });
99
99
  });
100
100
 
101
+ it("parses SHIPWRIGHT_AGENT_IMAGES into the config, and omits it when unset", () => {
102
+ const env = { SHIPWRIGHT_K8S_PROVISIONING: "enabled" };
103
+ expect(
104
+ configOf(buildProvisioner(env, stubAgentTokenService())).images,
105
+ ).toBe(undefined);
106
+ const mapped = buildProvisioner(
107
+ {
108
+ ...env,
109
+ SHIPWRIGHT_AGENT_IMAGES: '{"reviewer":{"repository":"r","tag":"1"}}',
110
+ },
111
+ stubAgentTokenService(),
112
+ );
113
+ expect(configOf(mapped).images).toEqual({
114
+ reviewer: { repository: "r", tag: "1" },
115
+ });
116
+ });
117
+
118
+ it("throws at boot on a malformed SHIPWRIGHT_AGENT_IMAGES", () => {
119
+ expect(() =>
120
+ buildProvisioner(
121
+ {
122
+ SHIPWRIGHT_K8S_PROVISIONING: "enabled",
123
+ SHIPWRIGHT_AGENT_IMAGES: "{oops",
124
+ },
125
+ stubAgentTokenService(),
126
+ ),
127
+ ).toThrow(/SHIPWRIGHT_AGENT_IMAGES/);
128
+ });
129
+
101
130
  it("returns a NoopAgentProvisioner when SHIPWRIGHT_K8S_PROVISIONING is unset", () => {
102
131
  const provisioner = buildProvisioner({}, stubAgentTokenService());
103
132
  expect(provisioner).toBeInstanceOf(NoopAgentProvisioner);
@@ -44,6 +44,7 @@ export function managedReconcileTargets(
44
44
  .map((a) => ({
45
45
  id: a.id,
46
46
  slug: a.name,
47
+ specialty: a.specialty,
47
48
  accountId: a.accountId,
48
49
  ownerEmail: a.ownerEmail,
49
50
  anthropicIdentityName: a.anthropicIdentityName,
@@ -97,8 +97,8 @@ describe("managedReconcileTargets", () => {
97
97
  agent({ id: "a3", name: "gamma" }),
98
98
  ]),
99
99
  ).toEqual([
100
- { id: "a1", slug: "alpha", accountId: "acct-1" },
101
- { id: "a3", slug: "gamma", accountId: null },
100
+ { id: "a1", slug: "alpha", accountId: "acct-1", specialty: "dev" },
101
+ { id: "a3", slug: "gamma", accountId: null, specialty: "dev" },
102
102
  ]);
103
103
  });
104
104
  });
@@ -128,8 +128,15 @@ describe("managedReconcileTargets identity fields", () => {
128
128
  anthropicServiceAccountId: "svac_1",
129
129
  anthropicFederationRuleId: "fdrl_1",
130
130
  k8sServiceAccountName: "a1",
131
+ specialty: "dev",
132
+ },
133
+ {
134
+ id: "a2",
135
+ slug: "beta",
136
+ accountId: null,
137
+ ownerEmail: null,
138
+ specialty: "dev",
131
139
  },
132
- { id: "a2", slug: "beta", accountId: null, ownerEmail: null },
133
140
  ]);
134
141
  });
135
142
  });
@@ -151,8 +158,13 @@ describe("runStartupPolicyReconcile", () => {
151
158
  expect(calls).toEqual([
152
159
  {
153
160
  agents: [
154
- { id: "a1", slug: "alpha", accountId: null },
155
- { id: "a3", slug: "gamma", accountId: "acct-3" },
161
+ { id: "a1", slug: "alpha", accountId: null, specialty: "dev" },
162
+ {
163
+ id: "a3",
164
+ slug: "gamma",
165
+ accountId: "acct-3",
166
+ specialty: "dev",
167
+ },
156
168
  ],
157
169
  opts: { policyOnly: true },
158
170
  },