@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 +7 -2
- package/openapi.platform.json +7 -2
- package/package.json +2 -2
- package/src/agent-account-assignment.ts +12 -0
- package/src/agent-account-assignment.unit.test.ts +35 -1
- package/src/agents-api.smoke.test.ts +198 -0
- package/src/agents-api.specialty.integration.test.ts +217 -0
- package/src/agents-api.ts +48 -3
- package/src/agents.ts +5 -0
- package/src/openapi-schemas.ts +4 -0
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`
|
|
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": {
|
package/openapi.platform.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": {
|
|
@@ -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`
|
|
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.
|
|
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.
|
|
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 {
|
|
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`
|
|
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
|
: {}),
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -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, {
|