@app-vitals/agent-admin 0.16.1 → 1.0.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/e2e/agents-page.e2e.ts +2 -2
- package/openapi.json +3 -3
- package/openapi.platform.json +7 -7
- package/package.json +3 -5
- package/prisma/client/internal/class.ts +1 -1
- package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
- package/prisma/schema/schema.prisma +1 -1
- package/src/account-agent-create.ts +1 -1
- package/src/admin-boot.ts +12 -6
- package/src/admin-extension.ts +51 -5
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-local-agent.smoke.test.ts +8 -5
- package/src/admin-ui-pages.ts +4 -4
- package/src/admin-ui.smoke.test.ts +54 -34
- package/src/admin-ui.ts +12 -12
- package/src/agent-chat-tokens.smoke.test.ts +2 -2
- package/src/agent-cron-jobs.integration.test.ts +23 -21
- package/src/agent-cron-jobs.ts +30 -40
- package/src/agent-cron-jobs.unit.test.ts +51 -39
- package/src/agent-cron-run-stats.smoke.test.ts +2 -2
- package/src/agent-manifest.ts +2 -6
- package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +29 -40
- package/src/agents-api.sentry.smoke.test.ts +2 -2
- package/src/agents-api.smoke.test.ts +17 -30
- package/src/agents-api.ts +13 -13
- package/src/agents.integration.test.ts +23 -33
- package/src/agents.ts +35 -34
- package/src/agents.unit.test.ts +35 -55
- package/src/api.smoke.test.ts +2 -2
- package/src/chat-service-provisioning-client.integration.test.ts +1 -1
- package/src/openapi-schemas.ts +4 -4
- package/src/server.smoke.test.ts +2 -2
- package/src/specialty-defaults.ts +4 -4
- package/src/specialty-types.ts +92 -0
- package/src/specialty-types.unit.test.ts +67 -0
- package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
- package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
- package/src/coding-task-store-provisioning-client.ts +0 -156
- package/src/fixtures/task-store-provisioning-cassette.json +0 -85
- package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
- package/src/specialty-manifest-loader.ts +0 -245
- package/src/specialty-manifest-loader.unit.test.ts +0 -299
- package/src/specialty-registry.schema.unit.test.ts +0 -35
- package/src/specialty-registry.ts +0 -236
- package/src/specialty-registry.unit.test.ts +0 -272
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Unit tests for agent-cron-jobs.ts:
|
|
3
3
|
* - the pure cron-expression validator (isValidCron)
|
|
4
4
|
* - reconcileSystemCrons()'s create/update/orphan/parent-link logic, driven
|
|
5
|
-
* by an injected
|
|
5
|
+
* by an injected SpecialtyTypeResolver double and an in-memory prisma
|
|
6
6
|
* test double (no real DB, no filesystem).
|
|
7
7
|
*
|
|
8
8
|
* Pure logic against injected doubles — see docs/testing.md for the unit-layer
|
|
@@ -11,11 +11,16 @@
|
|
|
11
11
|
import { describe, expect, test } from "bun:test";
|
|
12
12
|
import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
|
|
13
13
|
import { AgentCronJobService, isValidCron } from "./agent-cron-jobs.ts";
|
|
14
|
+
import type {
|
|
15
|
+
SpecialtyDefaultCron,
|
|
16
|
+
SpecialtyDefaultsResolver,
|
|
17
|
+
} from "./specialty-defaults.ts";
|
|
14
18
|
import {
|
|
15
19
|
resolveDefaultSpecialtyName,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
SPECIALIST_TYPE,
|
|
21
|
+
type SpecialtyType,
|
|
22
|
+
type SpecialtyTypeResolver,
|
|
23
|
+
} from "./specialty-types.ts";
|
|
19
24
|
|
|
20
25
|
describe("isValidCron", () => {
|
|
21
26
|
test("accepts standard 5-field schedules", () => {
|
|
@@ -54,31 +59,31 @@ describe("isValidCron", () => {
|
|
|
54
59
|
|
|
55
60
|
// ─── reconcileSystemCrons() with injected registry + fake prisma ────────────
|
|
56
61
|
|
|
57
|
-
type
|
|
62
|
+
type DefaultCron = SpecialtyDefaultCron;
|
|
58
63
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/** A registry double that always returns a fixed manifest and records lookups. */
|
|
64
|
+
/**
|
|
65
|
+
* A resolver double knowing the types in `byType`, plus the matching
|
|
66
|
+
* specialtyDefaults() hook supplying their crons. Records registry lookups.
|
|
67
|
+
*/
|
|
65
68
|
function fakeRegistry(
|
|
66
|
-
byType: Record<string,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
byType: Record<string, DefaultCron[]>,
|
|
70
|
+
): SpecialtyTypeResolver & {
|
|
71
|
+
lookups: string[];
|
|
72
|
+
defaults: SpecialtyDefaultsResolver;
|
|
73
|
+
} {
|
|
69
74
|
const lookups: string[] = [];
|
|
70
75
|
return {
|
|
71
76
|
lookups,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!crons) throw new Error(`no manifest for ${specialty}`);
|
|
76
|
-
return manifestWithCrons(crons);
|
|
77
|
+
defaults: (specialty) => {
|
|
78
|
+
const crons = byType[specialty];
|
|
79
|
+
return crons ? { crons } : undefined;
|
|
77
80
|
},
|
|
78
|
-
|
|
81
|
+
getType(): SpecialtyType {
|
|
82
|
+
return SPECIALIST_TYPE;
|
|
83
|
+
},
|
|
84
|
+
tryGetType(specialty: string): SpecialtyType | undefined {
|
|
79
85
|
lookups.push(specialty);
|
|
80
|
-
|
|
81
|
-
return crons ? manifestWithCrons(crons) : undefined;
|
|
86
|
+
return specialty in byType ? SPECIALIST_TYPE : undefined;
|
|
82
87
|
},
|
|
83
88
|
listTypes() {
|
|
84
89
|
return Object.keys(byType).map((name) => ({ name, displayName: name }));
|
|
@@ -211,7 +216,7 @@ function makeFakePrisma(opts: {
|
|
|
211
216
|
return { prisma, rows };
|
|
212
217
|
}
|
|
213
218
|
|
|
214
|
-
const loopCron:
|
|
219
|
+
const loopCron: DefaultCron = {
|
|
215
220
|
name: "shipwright-loop",
|
|
216
221
|
schedule: "* * * * *",
|
|
217
222
|
prompt: "internal",
|
|
@@ -219,7 +224,7 @@ const loopCron: ManifestCron = {
|
|
|
219
224
|
enabled: false,
|
|
220
225
|
};
|
|
221
226
|
|
|
222
|
-
const childCron:
|
|
227
|
+
const childCron: DefaultCron = {
|
|
223
228
|
name: "shipwright-dev-task",
|
|
224
229
|
schedule: "* * * * *",
|
|
225
230
|
prompt: "/shipwright:dev-task",
|
|
@@ -229,18 +234,22 @@ const childCron: ManifestCron = {
|
|
|
229
234
|
};
|
|
230
235
|
|
|
231
236
|
describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
232
|
-
test("
|
|
237
|
+
test("uses the extension defaults for the agent's specialty without consulting the registry", async () => {
|
|
233
238
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
234
|
-
const { prisma } = makeFakePrisma({ specialty: "coding" });
|
|
239
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
235
240
|
const service = new AgentCronJobService(
|
|
236
241
|
prisma as unknown as PrismaClient,
|
|
237
242
|
undefined,
|
|
238
243
|
registry,
|
|
244
|
+
registry.defaults,
|
|
239
245
|
);
|
|
240
246
|
|
|
241
247
|
await service.reconcileSystemCrons("agent-1");
|
|
242
248
|
|
|
243
|
-
expect(registry.lookups).toEqual([
|
|
249
|
+
expect(registry.lookups).toEqual([]);
|
|
250
|
+
expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
|
|
251
|
+
"shipwright-loop",
|
|
252
|
+
]);
|
|
244
253
|
});
|
|
245
254
|
|
|
246
255
|
test("creates one row per manifest cron on a fresh agent", async () => {
|
|
@@ -250,6 +259,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
250
259
|
prisma as unknown as PrismaClient,
|
|
251
260
|
undefined,
|
|
252
261
|
registry,
|
|
262
|
+
registry.defaults,
|
|
253
263
|
);
|
|
254
264
|
|
|
255
265
|
const result = await service.reconcileSystemCrons("agent-1");
|
|
@@ -270,6 +280,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
270
280
|
prisma as unknown as PrismaClient,
|
|
271
281
|
undefined,
|
|
272
282
|
registry,
|
|
283
|
+
registry.defaults,
|
|
273
284
|
);
|
|
274
285
|
|
|
275
286
|
await service.reconcileSystemCrons("agent-1");
|
|
@@ -291,6 +302,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
291
302
|
prisma as unknown as PrismaClient,
|
|
292
303
|
undefined,
|
|
293
304
|
registry,
|
|
305
|
+
registry.defaults,
|
|
294
306
|
);
|
|
295
307
|
|
|
296
308
|
const result = await service.reconcileSystemCrons("agent-1");
|
|
@@ -310,6 +322,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
310
322
|
prisma as unknown as PrismaClient,
|
|
311
323
|
undefined,
|
|
312
324
|
registry,
|
|
325
|
+
registry.defaults,
|
|
313
326
|
);
|
|
314
327
|
|
|
315
328
|
const result = await service.reconcileSystemCrons("agent-1");
|
|
@@ -334,6 +347,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
334
347
|
prisma as unknown as PrismaClient,
|
|
335
348
|
undefined,
|
|
336
349
|
registry,
|
|
350
|
+
registry.defaults,
|
|
337
351
|
);
|
|
338
352
|
|
|
339
353
|
await service.reconcileSystemCrons("agent-1");
|
|
@@ -354,6 +368,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
354
368
|
prisma as unknown as PrismaClient,
|
|
355
369
|
undefined,
|
|
356
370
|
registry,
|
|
371
|
+
registry.defaults,
|
|
357
372
|
);
|
|
358
373
|
const errors: unknown[][] = [];
|
|
359
374
|
const original = console.error;
|
|
@@ -384,6 +399,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
384
399
|
prisma as unknown as PrismaClient,
|
|
385
400
|
undefined,
|
|
386
401
|
registry,
|
|
402
|
+
registry.defaults,
|
|
387
403
|
);
|
|
388
404
|
const original = console.error;
|
|
389
405
|
console.error = () => {};
|
|
@@ -399,19 +415,19 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
399
415
|
expect(Array.from(rows.keys())).toEqual(["sys-1"]);
|
|
400
416
|
});
|
|
401
417
|
|
|
402
|
-
test("a
|
|
418
|
+
test("extension crons for a non-coding type reconcile normally", async () => {
|
|
403
419
|
const registry = fakeRegistry({ specialist: [loopCron] });
|
|
404
420
|
const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
|
|
405
421
|
const service = new AgentCronJobService(
|
|
406
422
|
prisma as unknown as PrismaClient,
|
|
407
423
|
undefined,
|
|
408
424
|
registry,
|
|
425
|
+
registry.defaults,
|
|
409
426
|
);
|
|
410
427
|
|
|
411
428
|
const result = await service.reconcileSystemCrons("agent-1");
|
|
412
429
|
|
|
413
430
|
expect(result.created).toBe(1);
|
|
414
|
-
expect(registry.lookups).toEqual(["specialist"]);
|
|
415
431
|
expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
|
|
416
432
|
});
|
|
417
433
|
});
|
|
@@ -444,8 +460,8 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
|
|
|
444
460
|
expect(row?.enabled).toBe(false);
|
|
445
461
|
});
|
|
446
462
|
|
|
447
|
-
test("hook returning nothing falls back to the
|
|
448
|
-
const registry = fakeRegistry({ coding: [
|
|
463
|
+
test("hook returning nothing falls back to the built-in type, which declares no crons", async () => {
|
|
464
|
+
const registry = fakeRegistry({ coding: [] });
|
|
449
465
|
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
450
466
|
const service = new AgentCronJobService(
|
|
451
467
|
prisma as unknown as PrismaClient,
|
|
@@ -456,13 +472,11 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
|
|
|
456
472
|
|
|
457
473
|
await service.reconcileSystemCrons("agent-1");
|
|
458
474
|
|
|
459
|
-
expect(Array.from(rows.values())
|
|
460
|
-
"shipwright-loop",
|
|
461
|
-
]);
|
|
475
|
+
expect(Array.from(rows.values())).toEqual([]);
|
|
462
476
|
});
|
|
463
477
|
|
|
464
|
-
test("defaults without crons fall back to the
|
|
465
|
-
const registry = fakeRegistry({ coding: [
|
|
478
|
+
test("defaults without crons fall back to the built-in type, which declares no crons", async () => {
|
|
479
|
+
const registry = fakeRegistry({ coding: [] });
|
|
466
480
|
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
467
481
|
const service = new AgentCronJobService(
|
|
468
482
|
prisma as unknown as PrismaClient,
|
|
@@ -473,9 +487,7 @@ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
|
|
|
473
487
|
|
|
474
488
|
await service.reconcileSystemCrons("agent-1");
|
|
475
489
|
|
|
476
|
-
expect(Array.from(rows.values())
|
|
477
|
-
"shipwright-loop",
|
|
478
|
-
]);
|
|
490
|
+
expect(Array.from(rows.values())).toEqual([]);
|
|
479
491
|
});
|
|
480
492
|
|
|
481
493
|
test("reconciles hook crons without any manifest for the specialty", async () => {
|
|
@@ -432,10 +432,10 @@ function makeMockDeps(): AdminDeps {
|
|
|
432
432
|
listByAgentId: async () => [],
|
|
433
433
|
},
|
|
434
434
|
specialtyRegistry: {
|
|
435
|
-
|
|
435
|
+
getType: () => {
|
|
436
436
|
throw new Error("not implemented");
|
|
437
437
|
},
|
|
438
|
-
|
|
438
|
+
tryGetType: () => undefined,
|
|
439
439
|
listTypes: () => [],
|
|
440
440
|
},
|
|
441
441
|
agentChatTokenService: {
|
package/src/agent-manifest.ts
CHANGED
|
@@ -70,13 +70,9 @@ const DEFAULT_TENANT_CPU_LIMIT = "2";
|
|
|
70
70
|
* from `buildAgentDeploymentManifest` with `opts.resources` — omitted or
|
|
71
71
|
* partial overrides fall back field-by-field to the default below.
|
|
72
72
|
*
|
|
73
|
-
* Precedence (highest wins):
|
|
73
|
+
* Precedence (highest wins): env override > default.
|
|
74
74
|
* `overrides` here is the ENV layer (populated in admin/src/main.ts's
|
|
75
|
-
* buildProvisioner from SHIPWRIGHT_K8S_AGENT_* vars).
|
|
76
|
-
* SpecialtyResourcesSchema in specialty-registry.ts is already parsed as
|
|
77
|
-
* part of SpecialtyManifestSchema.resources but is NOT consumed here or by
|
|
78
|
-
* any provisioner — it stays inert until a future task wires specialty-level
|
|
79
|
-
* resource resolution in ahead of this function's env layer.
|
|
75
|
+
* buildProvisioner from SHIPWRIGHT_K8S_AGENT_* vars).
|
|
80
76
|
*
|
|
81
77
|
* Defaults: requests mirror the GKE Autopilot defaults the agent ran with
|
|
82
78
|
* before these were explicit. The memory limit exists to contain a runaway
|
|
@@ -27,13 +27,15 @@ import { AgentPluginService } from "./agent-plugins.ts";
|
|
|
27
27
|
import { NoopAgentProvisioner } from "./agent-provisioner.ts";
|
|
28
28
|
import { AgentTokenService } from "./agent-tokens.ts";
|
|
29
29
|
import { AgentToolService } from "./agent-tools.ts";
|
|
30
|
-
import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
|
|
31
|
-
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
32
30
|
import { AgentService } from "./agents.ts";
|
|
33
31
|
import type { AdminDeps } from "./agents-api.ts";
|
|
34
32
|
import { createAdminApp } from "./agents-api.ts";
|
|
35
33
|
import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
36
34
|
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
35
|
+
import type {
|
|
36
|
+
SpecialtyType,
|
|
37
|
+
SpecialtyTypeResolver,
|
|
38
|
+
} from "./specialty-types.ts";
|
|
37
39
|
import { makeTokenCrypto } from "./token-crypto.ts";
|
|
38
40
|
|
|
39
41
|
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
@@ -63,40 +65,27 @@ function makePrisma(): PrismaClient {
|
|
|
63
65
|
return createAdminPrismaClient(TEST_DB as string);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
name: "coding",
|
|
71
|
-
displayName: "Coding Agent",
|
|
72
|
-
description: "test manifest",
|
|
73
|
-
version: "1.0.0",
|
|
74
|
-
skills: [],
|
|
75
|
-
},
|
|
76
|
-
identity: { templatesDir: "workspace/" },
|
|
77
|
-
crons: [],
|
|
78
|
-
plugins: ["shipwright"],
|
|
68
|
+
const CODING_TYPE: SpecialtyType = {
|
|
69
|
+
name: "coding",
|
|
70
|
+
displayName: "Coding Agent",
|
|
71
|
+
templatesDir: "workspace/",
|
|
79
72
|
tools: ["Read", "Write", "Edit"],
|
|
80
|
-
|
|
81
|
-
members: [],
|
|
82
|
-
repos: [],
|
|
83
|
-
chat: true,
|
|
84
|
-
voice: true,
|
|
73
|
+
requiredEnv: [],
|
|
85
74
|
};
|
|
86
75
|
|
|
87
|
-
function
|
|
88
|
-
const byType: Record<string,
|
|
76
|
+
function fakeSpecialtyTypes(): SpecialtyTypeResolver {
|
|
77
|
+
const byType: Record<string, SpecialtyType> = { coding: CODING_TYPE };
|
|
89
78
|
return {
|
|
90
|
-
|
|
91
|
-
return byType[specialty] ?? (byType.coding as
|
|
79
|
+
getType(specialty: string): SpecialtyType {
|
|
80
|
+
return byType[specialty] ?? (byType.coding as SpecialtyType);
|
|
92
81
|
},
|
|
93
|
-
|
|
82
|
+
tryGetType(specialty: string): SpecialtyType | undefined {
|
|
94
83
|
return byType[specialty];
|
|
95
84
|
},
|
|
96
85
|
listTypes() {
|
|
97
86
|
return Object.entries(byType).map(([name, manifest]) => ({
|
|
98
87
|
name,
|
|
99
|
-
displayName: manifest.
|
|
88
|
+
displayName: manifest.displayName,
|
|
100
89
|
}));
|
|
101
90
|
},
|
|
102
91
|
};
|
|
@@ -122,7 +111,7 @@ function makeDeps(prisma: PrismaClient): AdminDeps {
|
|
|
122
111
|
agentTokenService: new AgentTokenService(prisma),
|
|
123
112
|
agentPluginService: new AgentPluginService(prisma),
|
|
124
113
|
agentMemberService: new AgentMemberService(prisma),
|
|
125
|
-
specialtyRegistry:
|
|
114
|
+
specialtyRegistry: fakeSpecialtyTypes(),
|
|
126
115
|
agentChatTokenService: new AgentChatTokenService(prisma),
|
|
127
116
|
agentGitHubInstallationsService: new AgentGitHubInstallationsService(
|
|
128
117
|
prisma,
|
|
@@ -40,6 +40,9 @@ function migrationSql(dir: string): string {
|
|
|
40
40
|
const ADD_TYPE_NAME = migrationSql("20260724000000_add_agent_type_name");
|
|
41
41
|
const ADD_SPECIALTY = migrationSql("20261029000000_add_agent_specialty");
|
|
42
42
|
const DROP_TYPE_NAME = migrationSql("20261030000000_drop_agent_type_name");
|
|
43
|
+
const SET_DEFAULT_SPECIALIST = migrationSql(
|
|
44
|
+
"20261030100000_set_agent_specialty_default_specialist",
|
|
45
|
+
);
|
|
43
46
|
|
|
44
47
|
describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
|
|
45
48
|
const schema = `agr63_${process.pid}_${Date.now()}`;
|
|
@@ -123,4 +126,31 @@ describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
|
|
|
123
126
|
);
|
|
124
127
|
expect(def.rows[0]?.specialty).toBe("coding");
|
|
125
128
|
});
|
|
129
|
+
|
|
130
|
+
it("set_agent_specialty_default_specialist changes only the default and leaves rows unchanged (PCD-3.4)", async () => {
|
|
131
|
+
await client.query(ADD_TYPE_NAME);
|
|
132
|
+
await client.query(ADD_SPECIALTY);
|
|
133
|
+
await client.query(DROP_TYPE_NAME);
|
|
134
|
+
for (const [i, s] of LIVE_SPECIALTIES.entries()) {
|
|
135
|
+
await client.query(
|
|
136
|
+
`INSERT INTO "Agent" ("id","name","specialty") VALUES ($1,$2,$3)`,
|
|
137
|
+
[`a${i}`, `agent-${i}`, s],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await client.query(SET_DEFAULT_SPECIALIST);
|
|
142
|
+
|
|
143
|
+
const { rows } = await client.query<{ specialty: string }>(
|
|
144
|
+
`SELECT "specialty" FROM "Agent" ORDER BY "id"`,
|
|
145
|
+
);
|
|
146
|
+
expect(rows.map((r) => r.specialty)).toEqual([...LIVE_SPECIALTIES]);
|
|
147
|
+
|
|
148
|
+
await client.query(
|
|
149
|
+
`INSERT INTO "Agent" ("id","name") VALUES ('a9','agent-9')`,
|
|
150
|
+
);
|
|
151
|
+
const def = await client.query<{ specialty: string }>(
|
|
152
|
+
`SELECT "specialty" FROM "Agent" WHERE "id" = 'a9'`,
|
|
153
|
+
);
|
|
154
|
+
expect(def.rows[0]?.specialty).toBe("specialist");
|
|
155
|
+
});
|
|
126
156
|
});
|
|
@@ -25,13 +25,15 @@ import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
|
25
25
|
import { NoopAgentProvisioner } from "./agent-provisioner.ts";
|
|
26
26
|
import { AgentTokenService } from "./agent-tokens.ts";
|
|
27
27
|
import { AgentToolService } from "./agent-tools.ts";
|
|
28
|
-
import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
|
|
29
|
-
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
30
28
|
import { AgentService } from "./agents.ts";
|
|
31
29
|
import type { AdminDeps } from "./agents-api.ts";
|
|
32
30
|
import { createAdminApp } from "./agents-api.ts";
|
|
33
31
|
import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
34
32
|
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
33
|
+
import type {
|
|
34
|
+
SpecialtyType,
|
|
35
|
+
SpecialtyTypeResolver,
|
|
36
|
+
} from "./specialty-types.ts";
|
|
35
37
|
import { makeTokenCrypto } from "./token-crypto.ts";
|
|
36
38
|
|
|
37
39
|
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
@@ -70,19 +72,10 @@ async function createAgent(
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** A minimal valid "coding" manifest used to drive seeding assertions below. */
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
name: "coding",
|
|
78
|
-
displayName: "Coding Agent",
|
|
79
|
-
description: "test manifest",
|
|
80
|
-
version: "1.0.0",
|
|
81
|
-
skills: [],
|
|
82
|
-
},
|
|
83
|
-
identity: { templatesDir: "workspace/" },
|
|
84
|
-
crons: [],
|
|
85
|
-
plugins: ["shipwright"],
|
|
75
|
+
const CODING_TYPE: SpecialtyType = {
|
|
76
|
+
name: "coding",
|
|
77
|
+
displayName: "Coding Agent",
|
|
78
|
+
templatesDir: "workspace/",
|
|
86
79
|
tools: [
|
|
87
80
|
"Read",
|
|
88
81
|
"Write",
|
|
@@ -95,32 +88,28 @@ const CODING_MANIFEST: SpecialtyManifest = {
|
|
|
95
88
|
"Skill",
|
|
96
89
|
"Agent",
|
|
97
90
|
],
|
|
98
|
-
|
|
99
|
-
members: [],
|
|
100
|
-
repos: [],
|
|
101
|
-
chat: true,
|
|
102
|
-
voice: true,
|
|
91
|
+
requiredEnv: [],
|
|
103
92
|
};
|
|
104
93
|
|
|
105
94
|
/**
|
|
106
|
-
* Fake
|
|
107
|
-
* specialty.
|
|
108
|
-
* not in the map, mirroring
|
|
95
|
+
* Fake SpecialtyTypeResolver — resolves a fixed map of manifests by
|
|
96
|
+
* specialty. tryGetType() returns undefined (no fallback) for anything
|
|
97
|
+
* not in the map, mirroring SpecialtyTypes's real contract.
|
|
109
98
|
*/
|
|
110
|
-
function
|
|
111
|
-
byType: Record<string,
|
|
112
|
-
):
|
|
99
|
+
function fakeSpecialtyTypes(
|
|
100
|
+
byType: Record<string, SpecialtyType> = { coding: CODING_TYPE },
|
|
101
|
+
): SpecialtyTypeResolver {
|
|
113
102
|
return {
|
|
114
|
-
|
|
115
|
-
return byType[specialty] ?? (byType.coding as
|
|
103
|
+
getType(specialty: string): SpecialtyType {
|
|
104
|
+
return byType[specialty] ?? (byType.coding as SpecialtyType);
|
|
116
105
|
},
|
|
117
|
-
|
|
106
|
+
tryGetType(specialty: string): SpecialtyType | undefined {
|
|
118
107
|
return byType[specialty];
|
|
119
108
|
},
|
|
120
109
|
listTypes() {
|
|
121
110
|
return Object.entries(byType).map(([name, manifest]) => ({
|
|
122
111
|
name,
|
|
123
|
-
displayName: manifest.
|
|
112
|
+
displayName: manifest.displayName,
|
|
124
113
|
}));
|
|
125
114
|
},
|
|
126
115
|
};
|
|
@@ -129,7 +118,7 @@ function fakeSpecialtyRegistry(
|
|
|
129
118
|
function makeDeps(
|
|
130
119
|
prisma: PrismaClient,
|
|
131
120
|
provisioner: AgentProvisioner = new NoopAgentProvisioner(),
|
|
132
|
-
specialtyRegistry:
|
|
121
|
+
specialtyRegistry: SpecialtyTypeResolver = fakeSpecialtyTypes(),
|
|
133
122
|
): AdminDeps {
|
|
134
123
|
const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
135
124
|
process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
|
|
@@ -311,7 +300,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
311
300
|
|
|
312
301
|
// ─── specialty field ─────────────────────────────────────────────────────────
|
|
313
302
|
|
|
314
|
-
it("GET /agents includes specialty:'
|
|
303
|
+
it("GET /agents includes specialty:'specialist' for an agent created before specialty existed", async () => {
|
|
315
304
|
const res = await app.request("/agents", {
|
|
316
305
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
317
306
|
});
|
|
@@ -319,28 +308,28 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
319
308
|
const body = await res.json();
|
|
320
309
|
const entry = body.find((a: { id: string }) => a.id === agentId);
|
|
321
310
|
expect(entry).toBeDefined();
|
|
322
|
-
expect(entry.specialty).toBe("
|
|
311
|
+
expect(entry.specialty).toBe("specialist");
|
|
323
312
|
});
|
|
324
313
|
|
|
325
|
-
it("GET /agents/:id includes specialty:'
|
|
314
|
+
it("GET /agents/:id includes specialty:'specialist' by default", async () => {
|
|
326
315
|
const res = await app.request(`/agents/${agentId}`, {
|
|
327
316
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
328
317
|
});
|
|
329
318
|
expect(res.status).toBe(200);
|
|
330
319
|
const body = await res.json();
|
|
331
|
-
expect(body.specialty).toBe("
|
|
320
|
+
expect(body.specialty).toBe("specialist");
|
|
332
321
|
});
|
|
333
322
|
|
|
334
323
|
// ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
|
|
335
324
|
//
|
|
336
325
|
// Simulates the exact scenario the 20260724000000_add_agent_type_name
|
|
337
326
|
// migration must handle: rows that existed *before* the specialty column was
|
|
338
|
-
// added (never received an explicit value) must read back specialty="
|
|
327
|
+
// added (never received an explicit value) must read back specialty="specialist"
|
|
339
328
|
// via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
|
|
340
329
|
// must be completely untouched — same count, same updatedAt — since the
|
|
341
330
|
// migration only ALTERs the Agent table.
|
|
342
331
|
|
|
343
|
-
it("migration backfill: pre-existing agent rows read back specialty='
|
|
332
|
+
it("migration backfill: pre-existing agent rows read back specialty='specialist' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
|
|
344
333
|
// Seed a second "pre-existing" agent with one row in every child table,
|
|
345
334
|
// simulating data that existed prior to the specialty migration landing.
|
|
346
335
|
const preExisting = await prisma.agent.create({
|
|
@@ -383,7 +372,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
383
372
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
384
373
|
where: { id: preExisting.id },
|
|
385
374
|
});
|
|
386
|
-
expect(rawAgent.specialty).toBe("
|
|
375
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
387
376
|
|
|
388
377
|
// Read back via the HTTP API too — end-to-end confirmation.
|
|
389
378
|
const res = await app.request(`/agents/${preExisting.id}`, {
|
|
@@ -391,7 +380,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
391
380
|
});
|
|
392
381
|
expect(res.status).toBe(200);
|
|
393
382
|
const body = await res.json();
|
|
394
|
-
expect(body.specialty).toBe("
|
|
383
|
+
expect(body.specialty).toBe("specialist");
|
|
395
384
|
|
|
396
385
|
// Child row counts are unchanged — exactly one of each, still linked to
|
|
397
386
|
// the same agent.
|
|
@@ -455,7 +444,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
455
444
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
456
445
|
where: { id: agentId },
|
|
457
446
|
});
|
|
458
|
-
expect(rawAgent.specialty).toBe("
|
|
447
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
459
448
|
});
|
|
460
449
|
|
|
461
450
|
// ─── repos field round-trip ───────────────────────────────────────────────────
|
|
@@ -247,10 +247,10 @@ function makeBaseDeps(
|
|
|
247
247
|
listByAgentId: async () => [],
|
|
248
248
|
},
|
|
249
249
|
specialtyRegistry: {
|
|
250
|
-
|
|
250
|
+
getType: () => {
|
|
251
251
|
throw new Error("not implemented");
|
|
252
252
|
},
|
|
253
|
-
|
|
253
|
+
tryGetType: () => undefined,
|
|
254
254
|
listTypes: () => [],
|
|
255
255
|
},
|
|
256
256
|
agentChatTokenService: {
|
|
@@ -22,28 +22,19 @@ import type { AgentTokenService } from "./agent-tokens.ts";
|
|
|
22
22
|
import type { AdminDeps } from "./agents-api.ts";
|
|
23
23
|
import { createAdminApp, parseAdminApiKeys } from "./agents-api.ts";
|
|
24
24
|
import { PLATFORM_OPS_ROUTES } from "./api-auth.ts";
|
|
25
|
-
import type {
|
|
25
|
+
import type { SpecialtyType } from "./specialty-types.ts";
|
|
26
26
|
import { identityCrypto } from "./token-crypto.ts";
|
|
27
27
|
|
|
28
|
-
// ─── Fake
|
|
28
|
+
// ─── Fake SpecialtyTypeResolver ────────────────────────────────────────
|
|
29
29
|
//
|
|
30
30
|
// Mirrors the shape used by agent-cron-jobs.unit.test.ts's fakeRegistry —
|
|
31
|
-
// resolves a fixed set of manifests by specialty, with
|
|
31
|
+
// resolves a fixed set of manifests by specialty, with tryGetType()
|
|
32
32
|
// returning undefined (no fallback) for anything not in the map.
|
|
33
33
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
name: "coding",
|
|
39
|
-
displayName: "Coding Agent",
|
|
40
|
-
description: "test manifest",
|
|
41
|
-
version: "1.0.0",
|
|
42
|
-
skills: [],
|
|
43
|
-
},
|
|
44
|
-
identity: { templatesDir: "workspace/" },
|
|
45
|
-
crons: [],
|
|
46
|
-
plugins: ["shipwright"],
|
|
34
|
+
const CODING_TYPE: SpecialtyType = {
|
|
35
|
+
name: "coding",
|
|
36
|
+
displayName: "Coding Agent",
|
|
37
|
+
templatesDir: "workspace/",
|
|
47
38
|
tools: [
|
|
48
39
|
"Read",
|
|
49
40
|
"Write",
|
|
@@ -56,32 +47,28 @@ const CODING_MANIFEST: SpecialtyManifest = {
|
|
|
56
47
|
"Skill",
|
|
57
48
|
"Agent",
|
|
58
49
|
],
|
|
59
|
-
|
|
60
|
-
members: [],
|
|
61
|
-
repos: [],
|
|
62
|
-
chat: true,
|
|
63
|
-
voice: true,
|
|
50
|
+
requiredEnv: [],
|
|
64
51
|
};
|
|
65
52
|
|
|
66
|
-
function
|
|
53
|
+
function fakeSpecialtyTypes(
|
|
67
54
|
// The self-serve specialty is registered too: account users are forced to it
|
|
68
55
|
// (the platform test run points it at the fixture specialty, not "coding").
|
|
69
|
-
byType: Record<string,
|
|
70
|
-
coding:
|
|
71
|
-
[getSelfServeSpecialty()]:
|
|
56
|
+
byType: Record<string, SpecialtyType> = {
|
|
57
|
+
coding: CODING_TYPE,
|
|
58
|
+
[getSelfServeSpecialty()]: CODING_TYPE,
|
|
72
59
|
},
|
|
73
60
|
): AdminDeps["specialtyRegistry"] {
|
|
74
61
|
return {
|
|
75
|
-
|
|
76
|
-
return byType[specialty] ?? (byType.coding as
|
|
62
|
+
getType(specialty: string) {
|
|
63
|
+
return byType[specialty] ?? (byType.coding as SpecialtyType);
|
|
77
64
|
},
|
|
78
|
-
|
|
65
|
+
tryGetType(specialty: string) {
|
|
79
66
|
return byType[specialty];
|
|
80
67
|
},
|
|
81
68
|
listTypes() {
|
|
82
69
|
return Object.entries(byType).map(([name, manifest]) => ({
|
|
83
70
|
name,
|
|
84
|
-
displayName: manifest.
|
|
71
|
+
displayName: manifest.displayName,
|
|
85
72
|
}));
|
|
86
73
|
},
|
|
87
74
|
};
|
|
@@ -578,7 +565,7 @@ function makeMockDeps(): AdminDeps {
|
|
|
578
565
|
},
|
|
579
566
|
],
|
|
580
567
|
},
|
|
581
|
-
specialtyRegistry:
|
|
568
|
+
specialtyRegistry: fakeSpecialtyTypes(),
|
|
582
569
|
agentChatTokenService: {
|
|
583
570
|
upsertDailyByModel: async (
|
|
584
571
|
_agentId: string,
|