@app-vitals/agent-admin 0.16.0 → 0.16.2
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.platform.json +4 -4
- package/package.json +2 -2
- 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/account-agent-create.unit.test.ts +2 -2
- package/src/admin-boot.ts +7 -1
- package/src/admin-extension.ts +45 -0
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-ui-pages.ts +2 -2
- package/src/admin-ui-pages.unit.test.ts +3 -1
- package/src/admin-ui.smoke.test.ts +33 -1
- package/src/admin-ui.ts +1 -1
- package/src/agent-cron-jobs.ts +13 -25
- package/src/agent-cron-jobs.unit.test.ts +17 -24
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +9 -9
- package/src/agents.integration.test.ts +2 -2
- package/src/agents.ts +11 -3
- package/src/openapi-schemas.ts +4 -4
- package/src/specialty-manifest-loader.fixture.integration.test.ts +7 -3
- package/src/specialty-manifest-loader.ts +123 -93
- package/src/specialty-manifest-loader.unit.test.ts +113 -50
|
@@ -12,8 +12,8 @@ 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
14
|
import {
|
|
15
|
-
type SpecialtyManifestResolver,
|
|
16
15
|
resolveDefaultSpecialtyName,
|
|
16
|
+
type SpecialtyManifestResolver,
|
|
17
17
|
} from "./specialty-manifest-loader.ts";
|
|
18
18
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
19
19
|
|
|
@@ -374,36 +374,29 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
374
374
|
expect(String(errors[0]?.[0])).toContain("agent-1");
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
-
test("the default manifest
|
|
377
|
+
test("the default type with no loadable manifest logs an error and changes nothing", async () => {
|
|
378
378
|
const registry = fakeRegistry({});
|
|
379
|
-
const { prisma } = makeFakePrisma({
|
|
380
|
-
specialty:
|
|
381
|
-
|
|
382
|
-
const service = new AgentCronJobService(
|
|
383
|
-
prisma as unknown as PrismaClient,
|
|
384
|
-
undefined,
|
|
385
|
-
registry,
|
|
386
|
-
);
|
|
387
|
-
|
|
388
|
-
await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
|
|
389
|
-
resolveDefaultSpecialtyName(),
|
|
390
|
-
);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
test("the default-manifest failure names the env vars that configure it", async () => {
|
|
394
|
-
const registry = fakeRegistry({});
|
|
395
|
-
const { prisma } = makeFakePrisma({
|
|
396
|
-
specialty: resolveDefaultSpecialtyName(),
|
|
379
|
+
const { prisma, rows } = makeFakePrisma({
|
|
380
|
+
specialty: "specialist",
|
|
381
|
+
existing: [{ id: "sys-1", name: "old-system", system: true }],
|
|
397
382
|
});
|
|
398
383
|
const service = new AgentCronJobService(
|
|
399
384
|
prisma as unknown as PrismaClient,
|
|
400
385
|
undefined,
|
|
401
386
|
registry,
|
|
402
387
|
);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
388
|
+
const original = console.error;
|
|
389
|
+
console.error = () => {};
|
|
390
|
+
try {
|
|
391
|
+
expect(await service.reconcileSystemCrons("agent-1")).toEqual({
|
|
392
|
+
created: 0,
|
|
393
|
+
updated: 0,
|
|
394
|
+
deleted: 0,
|
|
395
|
+
});
|
|
396
|
+
} finally {
|
|
397
|
+
console.error = original;
|
|
398
|
+
}
|
|
399
|
+
expect(Array.from(rows.keys())).toEqual(["sys-1"]);
|
|
407
400
|
});
|
|
408
401
|
|
|
409
402
|
test("a valid non-coding manifest reconciles normally", async () => {
|
|
@@ -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
|
});
|
|
@@ -311,7 +311,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
311
311
|
|
|
312
312
|
// ─── specialty field ─────────────────────────────────────────────────────────
|
|
313
313
|
|
|
314
|
-
it("GET /agents includes specialty:'
|
|
314
|
+
it("GET /agents includes specialty:'specialist' for an agent created before specialty existed", async () => {
|
|
315
315
|
const res = await app.request("/agents", {
|
|
316
316
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
317
317
|
});
|
|
@@ -319,28 +319,28 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
319
319
|
const body = await res.json();
|
|
320
320
|
const entry = body.find((a: { id: string }) => a.id === agentId);
|
|
321
321
|
expect(entry).toBeDefined();
|
|
322
|
-
expect(entry.specialty).toBe("
|
|
322
|
+
expect(entry.specialty).toBe("specialist");
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
it("GET /agents/:id includes specialty:'
|
|
325
|
+
it("GET /agents/:id includes specialty:'specialist' by default", async () => {
|
|
326
326
|
const res = await app.request(`/agents/${agentId}`, {
|
|
327
327
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
328
328
|
});
|
|
329
329
|
expect(res.status).toBe(200);
|
|
330
330
|
const body = await res.json();
|
|
331
|
-
expect(body.specialty).toBe("
|
|
331
|
+
expect(body.specialty).toBe("specialist");
|
|
332
332
|
});
|
|
333
333
|
|
|
334
334
|
// ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
|
|
335
335
|
//
|
|
336
336
|
// Simulates the exact scenario the 20260724000000_add_agent_type_name
|
|
337
337
|
// migration must handle: rows that existed *before* the specialty column was
|
|
338
|
-
// added (never received an explicit value) must read back specialty="
|
|
338
|
+
// added (never received an explicit value) must read back specialty="specialist"
|
|
339
339
|
// via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
|
|
340
340
|
// must be completely untouched — same count, same updatedAt — since the
|
|
341
341
|
// migration only ALTERs the Agent table.
|
|
342
342
|
|
|
343
|
-
it("migration backfill: pre-existing agent rows read back specialty='
|
|
343
|
+
it("migration backfill: pre-existing agent rows read back specialty='specialist' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
|
|
344
344
|
// Seed a second "pre-existing" agent with one row in every child table,
|
|
345
345
|
// simulating data that existed prior to the specialty migration landing.
|
|
346
346
|
const preExisting = await prisma.agent.create({
|
|
@@ -383,7 +383,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
383
383
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
384
384
|
where: { id: preExisting.id },
|
|
385
385
|
});
|
|
386
|
-
expect(rawAgent.specialty).toBe("
|
|
386
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
387
387
|
|
|
388
388
|
// Read back via the HTTP API too — end-to-end confirmation.
|
|
389
389
|
const res = await app.request(`/agents/${preExisting.id}`, {
|
|
@@ -391,7 +391,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
391
391
|
});
|
|
392
392
|
expect(res.status).toBe(200);
|
|
393
393
|
const body = await res.json();
|
|
394
|
-
expect(body.specialty).toBe("
|
|
394
|
+
expect(body.specialty).toBe("specialist");
|
|
395
395
|
|
|
396
396
|
// Child row counts are unchanged — exactly one of each, still linked to
|
|
397
397
|
// the same agent.
|
|
@@ -455,7 +455,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
455
455
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
456
456
|
where: { id: agentId },
|
|
457
457
|
});
|
|
458
|
-
expect(rawAgent.specialty).toBe("
|
|
458
|
+
expect(rawAgent.specialty).toBe("specialist");
|
|
459
459
|
});
|
|
460
460
|
|
|
461
461
|
// ─── repos field round-trip ───────────────────────────────────────────────────
|
|
@@ -574,7 +574,7 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
|
|
|
574
574
|
).toBe("research");
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
-
it("specialty is required: NULL is rejected and omitting it applies the '
|
|
577
|
+
it("specialty is required: NULL is rejected and omitting it applies the 'specialist' default", async () => {
|
|
578
578
|
await expect(
|
|
579
579
|
(async () =>
|
|
580
580
|
prisma.agent.create({
|
|
@@ -584,6 +584,6 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
|
|
|
584
584
|
|
|
585
585
|
const created = await agentService.create({ name: "No Specialty" });
|
|
586
586
|
createdIds.push(created.id);
|
|
587
|
-
expect(created.specialty).toBe("
|
|
587
|
+
expect(created.specialty).toBe("specialist");
|
|
588
588
|
});
|
|
589
589
|
});
|
package/src/agents.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface CreateAgentInput {
|
|
|
33
33
|
name: string;
|
|
34
34
|
slackId?: string | null;
|
|
35
35
|
selfHosted?: boolean;
|
|
36
|
-
/** Specialty name. Omitted means Prisma's column default ("
|
|
36
|
+
/** Specialty name. Omitted means Prisma's column default ("specialist") applies. */
|
|
37
37
|
specialty?: string;
|
|
38
38
|
/** Initial repos[] — the manifest repos merged with any request-supplied repos. */
|
|
39
39
|
repos?: string[];
|
|
@@ -784,7 +784,10 @@ export interface CreateAgentDeps {
|
|
|
784
784
|
agentEnvService: Pick<AgentEnvService, "patch">;
|
|
785
785
|
agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
|
|
786
786
|
provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
|
|
787
|
-
specialtyRegistry: Pick<
|
|
787
|
+
specialtyRegistry: Pick<
|
|
788
|
+
SpecialtyManifestResolver,
|
|
789
|
+
"tryGetManifest" | "hasExtensionType"
|
|
790
|
+
>;
|
|
788
791
|
/** Extension-supplied tools/plugins, consulted before the manifest's. */
|
|
789
792
|
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
790
793
|
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
@@ -884,7 +887,12 @@ export async function createAgent(
|
|
|
884
887
|
const extensionDefaults = specialty
|
|
885
888
|
? deps.specialtyDefaults?.(specialty)
|
|
886
889
|
: undefined;
|
|
887
|
-
if (
|
|
890
|
+
if (
|
|
891
|
+
!specialty ||
|
|
892
|
+
(!manifest &&
|
|
893
|
+
!extensionDefaults &&
|
|
894
|
+
!deps.specialtyRegistry.hasExtensionType?.(specialty))
|
|
895
|
+
) {
|
|
888
896
|
return { ok: false, errorCode: "invalid_type" };
|
|
889
897
|
}
|
|
890
898
|
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const AgentSummarySchema = z
|
|
|
34
34
|
id: z.string().openapi({ example: "clx1234567890" }),
|
|
35
35
|
name: z.string().openapi({ example: "Bodhi" }),
|
|
36
36
|
selfHosted: z.boolean().openapi({ example: false }),
|
|
37
|
-
specialty: z.string().openapi({ example: "
|
|
37
|
+
specialty: z.string().openapi({ example: "specialist" }),
|
|
38
38
|
})
|
|
39
39
|
.openapi("AgentSummary");
|
|
40
40
|
|
|
@@ -147,7 +147,7 @@ export const PatchAgentBodySchema = z
|
|
|
147
147
|
export const CreateAgentBodySchema = z
|
|
148
148
|
.object({
|
|
149
149
|
name: z.string().min(1).openapi({ example: "Bodhi" }),
|
|
150
|
-
specialty: z.string().min(1).openapi({ example: "
|
|
150
|
+
specialty: z.string().min(1).openapi({ example: "specialist" }),
|
|
151
151
|
/**
|
|
152
152
|
* "in-cluster" means K8s-provisioned; anything else (including absent)
|
|
153
153
|
* means self-hosted.
|
|
@@ -1343,7 +1343,7 @@ export const AgentConfigResponseSchema = z
|
|
|
1343
1343
|
* ARD-6.1: the agent's Specialty name, used by the runtime to pick its
|
|
1344
1344
|
* workspace template directory.
|
|
1345
1345
|
*/
|
|
1346
|
-
specialty: z.string().openapi({ example: "
|
|
1346
|
+
specialty: z.string().openapi({ example: "specialist" }),
|
|
1347
1347
|
/**
|
|
1348
1348
|
* DEPRECATED back-compat alias of `specialty` (always equal to it). Kept
|
|
1349
1349
|
* only for pre-AGR-6.3 runtime images, whose entrypoint reads
|
|
@@ -1353,7 +1353,7 @@ export const AgentConfigResponseSchema = z
|
|
|
1353
1353
|
typeName: z
|
|
1354
1354
|
.string()
|
|
1355
1355
|
.optional()
|
|
1356
|
-
.openapi({ example: "
|
|
1356
|
+
.openapi({ example: "specialist", deprecated: true }),
|
|
1357
1357
|
/**
|
|
1358
1358
|
* PMC-1.1: phase -> subagentType (or null) map, covering all six pipeline
|
|
1359
1359
|
* phases (see AGENT_PHASES) regardless of whether the agent has an
|
|
@@ -50,13 +50,17 @@ describe("SpecialtyRegistry with the fixture specialty", () => {
|
|
|
50
50
|
expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
it("
|
|
53
|
+
it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
|
|
54
|
+
const warnings: string[] = [];
|
|
54
55
|
const registry = new SpecialtyRegistry({
|
|
55
56
|
typesDir: FIXTURE_SPECIALTIES_DIR,
|
|
56
57
|
defaultTypeName: "not-there",
|
|
57
|
-
warn: () =>
|
|
58
|
+
warn: (m) => warnings.push(m),
|
|
58
59
|
});
|
|
59
|
-
|
|
60
|
+
const manifest = registry.getManifest("fixture-or-anything");
|
|
61
|
+
expect(manifest.crons).toEqual([]);
|
|
62
|
+
expect(manifest.plugins).toEqual([]);
|
|
63
|
+
expect(warnings.join("\n")).toMatch(
|
|
60
64
|
/SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
|
|
61
65
|
);
|
|
62
66
|
});
|
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
* admin/src/specialty-manifest-loader.ts
|
|
3
3
|
*
|
|
4
4
|
* SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
|
|
5
|
-
* Specialty manifest by loading `
|
|
6
|
-
* disk (
|
|
5
|
+
* Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
|
|
6
|
+
* disk (typesDir defaults to the shipped agent-types/) and validating it via
|
|
7
7
|
* parseSpecialtyManifest().
|
|
8
8
|
*
|
|
9
9
|
* This is the runtime source of truth for an agent's default system crons
|
|
10
10
|
* (and, in time, its default plugins/tools/env contract). It replaces the
|
|
11
11
|
* former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* whose type was renamed or removed.
|
|
13
|
+
* Defaults: the default type is `specialist` and the type directory is the
|
|
14
|
+
* shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
|
|
15
|
+
* the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
|
|
16
|
+
* SHIPWRIGHT_SPECIALTIES_DIR override them).
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Fallback contract: getManifest() never throws. An unknown `specialty` (no
|
|
19
|
+
* matching directory under the type dir) resolves to the default type's
|
|
20
|
+
* manifest with a logged warning. If even the default cannot be loaded (bad
|
|
21
|
+
* env override, missing/invalid manifest) it warns and returns a built-in
|
|
22
|
+
* minimal manifest with no crons/plugins, so the boot-path
|
|
23
|
+
* POST /agents/:id/crons/reconcile route never 5xxs over configuration.
|
|
23
24
|
*
|
|
24
25
|
* The class takes an injectable reader/warner so unit tests can drive it
|
|
25
26
|
* without touching the filesystem (see agent-cron-jobs.unit.test.ts).
|
|
@@ -28,12 +29,47 @@
|
|
|
28
29
|
import { readdirSync, readFileSync } from "node:fs";
|
|
29
30
|
import { join } from "node:path";
|
|
30
31
|
import {
|
|
31
|
-
|
|
32
|
+
DEFAULT_SPECIALTY_NAME,
|
|
33
|
+
resolveDefaultSpecialtyName,
|
|
34
|
+
resolveSpecialtiesDir,
|
|
35
|
+
} from "@app-vitals/agent-lib/default-specialty";
|
|
36
|
+
import {
|
|
32
37
|
parseSpecialtyManifest,
|
|
38
|
+
type SpecialtyManifest,
|
|
33
39
|
} from "./specialty-registry.ts";
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
export {
|
|
42
|
+
DEFAULT_SPECIALTY_NAME,
|
|
43
|
+
resolveDefaultSpecialtyName,
|
|
44
|
+
resolveSpecialtiesDir,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Built-in last-resort manifest: used only when the default type itself cannot
|
|
49
|
+
* be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
|
|
50
|
+
* anything unexpected.
|
|
51
|
+
*/
|
|
52
|
+
const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
|
|
53
|
+
kind: Specialty
|
|
54
|
+
metadata:
|
|
55
|
+
name: ${DEFAULT_SPECIALTY_NAME}
|
|
56
|
+
displayName: Specialist Agent
|
|
57
|
+
description: Built-in fallback used when the default specialty manifest cannot be loaded.
|
|
58
|
+
version: 0.0.0
|
|
59
|
+
skills: []
|
|
60
|
+
identity:
|
|
61
|
+
templatesDir: workspace/
|
|
62
|
+
crons: []
|
|
63
|
+
plugins: []
|
|
64
|
+
tools: []
|
|
65
|
+
env:
|
|
66
|
+
required: []
|
|
67
|
+
optional: []
|
|
68
|
+
members: []
|
|
69
|
+
repos: []
|
|
70
|
+
chat: true
|
|
71
|
+
voice: true
|
|
72
|
+
`;
|
|
37
73
|
|
|
38
74
|
/** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
|
|
39
75
|
type ManifestReader = (specialty: string) => string;
|
|
@@ -54,51 +90,6 @@ export interface SpecialtyOption {
|
|
|
54
90
|
displayName: string;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
/**
|
|
58
|
-
* Repo root resolved relative to this module (admin/src/ → ../../). The
|
|
59
|
-
* specialties/ directory lives at the repo root alongside admin/.
|
|
60
|
-
*/
|
|
61
|
-
const REPO_ROOT = join(import.meta.dir, "..", "..");
|
|
62
|
-
const BUILT_IN_SPECIALTIES_DIR = join(REPO_ROOT, "specialties");
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The specialties directory: SHIPWRIGHT_SPECIALTIES_DIR when set, else the
|
|
66
|
-
* repo-root specialties/ (today's location).
|
|
67
|
-
*/
|
|
68
|
-
export function resolveSpecialtiesDir(
|
|
69
|
-
env: Record<string, string | undefined> = process.env,
|
|
70
|
-
): string {
|
|
71
|
-
return env.SHIPWRIGHT_SPECIALTIES_DIR || BUILT_IN_SPECIALTIES_DIR;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The universal fallback type: SHIPWRIGHT_DEFAULT_AGENT_TYPE when set, else
|
|
76
|
-
* "coding".
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* The actionable error for a default specialty that cannot be loaded. The
|
|
80
|
-
* platform ships no specialty of its own, so this is a configuration problem:
|
|
81
|
-
* the operator must point the two env vars at a real specialties directory.
|
|
82
|
-
*/
|
|
83
|
-
export function missingDefaultSpecialtyError(
|
|
84
|
-
defaultTypeName: string,
|
|
85
|
-
typesDir: string,
|
|
86
|
-
cause?: unknown,
|
|
87
|
-
): Error {
|
|
88
|
-
const detail = cause instanceof Error ? ` (${cause.message})` : "";
|
|
89
|
-
return new Error(
|
|
90
|
-
`SpecialtyRegistry: the default specialty "${defaultTypeName}" could not be loaded from "${typesDir}"${detail}. ` +
|
|
91
|
-
"Set SHIPWRIGHT_SPECIALTIES_DIR to a directory containing <type>/manifest.yaml and " +
|
|
92
|
-
"SHIPWRIGHT_DEFAULT_AGENT_TYPE to the name of a specialty in it.",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function resolveDefaultSpecialtyName(
|
|
97
|
-
env: Record<string, string | undefined> = process.env,
|
|
98
|
-
): string {
|
|
99
|
-
return env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || DEFAULT_SPECIALTY_NAME;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
93
|
/** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
|
|
103
94
|
export function defaultManifestReader(
|
|
104
95
|
specialty: string,
|
|
@@ -122,18 +113,21 @@ export function defaultTypeDirLister(
|
|
|
122
113
|
*/
|
|
123
114
|
export interface SpecialtyManifestResolver {
|
|
124
115
|
/**
|
|
125
|
-
* Resolve `specialty` to its parsed manifest. Falls back to the
|
|
126
|
-
* manifest (with a warning) when the type is unknown
|
|
127
|
-
*
|
|
116
|
+
* Resolve `specialty` to its parsed manifest. Falls back to the default
|
|
117
|
+
* type's manifest (with a warning) when the type is unknown, and to a
|
|
118
|
+
* built-in empty-crons manifest (with a warning) if the default cannot load
|
|
119
|
+
* either. Never throws.
|
|
128
120
|
*/
|
|
129
121
|
getManifest(specialty: string): SpecialtyManifest;
|
|
130
122
|
/**
|
|
131
123
|
* Resolve `specialty` to its parsed manifest WITHOUT falling back to
|
|
132
|
-
*
|
|
124
|
+
* the default type — returns undefined for an unknown type instead (the one exception
|
|
125
|
+
* is the built-in default type when no specialty is discoverable, matching the
|
|
126
|
+
* picker's fallback option). No warning is
|
|
133
127
|
* logged (an unknown type here is an expected, callers-handle-it outcome,
|
|
134
128
|
* not a degraded-boot condition). Used by POST /admin/agents to reject an
|
|
135
129
|
* unknown requested `type` with 400 rather than silently seeding a
|
|
136
|
-
*
|
|
130
|
+
* default-type agent under a different label.
|
|
137
131
|
*/
|
|
138
132
|
tryGetManifest(specialty: string): SpecialtyManifest | undefined;
|
|
139
133
|
/**
|
|
@@ -142,9 +136,17 @@ export interface SpecialtyManifestResolver {
|
|
|
142
136
|
* type picker. A directory whose manifest fails to load or parse is
|
|
143
137
|
* skipped (not thrown) — the picker still renders every other valid type
|
|
144
138
|
* rather than 500ing the whole /admin/agents/new page over one bad
|
|
145
|
-
* manifest. Order matches the underlying `list` fn's order.
|
|
139
|
+
* manifest. Order matches the underlying `list` fn's order. Never throws:
|
|
140
|
+
* a missing type dir is treated as empty (with a warning), and when no type
|
|
141
|
+
* is discoverable the built-in default option is returned.
|
|
146
142
|
*/
|
|
147
143
|
listTypes(): SpecialtyOption[];
|
|
144
|
+
/**
|
|
145
|
+
* True when an AdminExtension declares `specialty` via agentTypes() — a type
|
|
146
|
+
* with no manifest that POST /admin/agents must still accept. Optional: the
|
|
147
|
+
* disk-backed registry has none.
|
|
148
|
+
*/
|
|
149
|
+
hasExtensionType?(specialty: string): boolean;
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
@@ -157,8 +159,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
157
159
|
|
|
158
160
|
/**
|
|
159
161
|
* `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
|
|
160
|
-
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to
|
|
161
|
-
* and "
|
|
162
|
+
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
|
|
163
|
+
* and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
|
|
162
164
|
*/
|
|
163
165
|
constructor(
|
|
164
166
|
opts: {
|
|
@@ -179,42 +181,68 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
getManifest(specialty: string): SpecialtyManifest {
|
|
182
|
-
const resolved = this.
|
|
184
|
+
const resolved = this.tryLoadSafe(specialty);
|
|
183
185
|
if (resolved) return resolved;
|
|
184
186
|
|
|
185
|
-
// Unknown type — warn once and fall back to "coding". The fallback load is
|
|
186
|
-
// deliberately NOT wrapped in tryLoad: if "coding" can't load, that's a
|
|
187
|
-
// hard startup bug and must surface, not silently become a no-op.
|
|
188
187
|
this.warn(
|
|
189
188
|
`SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
|
|
190
189
|
`falling back to "${this.defaultTypeName}" manifest`,
|
|
191
190
|
);
|
|
192
|
-
if (specialty
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
return this.loadOrThrow(this.defaultTypeName);
|
|
198
|
-
} catch (err) {
|
|
199
|
-
throw missingDefaultSpecialtyError(
|
|
200
|
-
this.defaultTypeName,
|
|
201
|
-
this.typesDir,
|
|
202
|
-
err,
|
|
203
|
-
);
|
|
191
|
+
if (specialty !== this.defaultTypeName) {
|
|
192
|
+
const fallback = this.tryLoadSafe(this.defaultTypeName);
|
|
193
|
+
if (fallback) return fallback;
|
|
204
194
|
}
|
|
195
|
+
// Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
|
|
196
|
+
// SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
|
|
197
|
+
// rather than failing boot; the warning names the knobs to fix.
|
|
198
|
+
this.warn(
|
|
199
|
+
`SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
|
|
200
|
+
"using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
|
|
201
|
+
);
|
|
202
|
+
return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
205
203
|
}
|
|
206
204
|
|
|
207
205
|
/**
|
|
208
|
-
* Resolve `specialty` without falling back to
|
|
206
|
+
* Resolve `specialty` without falling back to the default type — see the interface
|
|
209
207
|
* doc comment. Thin public wrapper around the existing tryLoad() used
|
|
210
208
|
* internally by getManifest().
|
|
211
209
|
*/
|
|
212
210
|
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
213
|
-
|
|
211
|
+
const manifest = this.tryLoad(specialty);
|
|
212
|
+
if (manifest || specialty !== DEFAULT_SPECIALTY_NAME) return manifest;
|
|
213
|
+
// The picker offers the built-in default when nothing is discoverable, so
|
|
214
|
+
// creating an agent with it must resolve too.
|
|
215
|
+
return this.discoverOptions().length === 0
|
|
216
|
+
? parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML)
|
|
217
|
+
: undefined;
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
listTypes(): SpecialtyOption[] {
|
|
217
|
-
const
|
|
221
|
+
const options = this.discoverOptions();
|
|
222
|
+
if (options.length > 0) return options;
|
|
223
|
+
// Nothing discoverable: offer the built-in default so the picker is never empty.
|
|
224
|
+
const fallback = parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
name: fallback.metadata.name,
|
|
228
|
+
displayName: fallback.metadata.displayName,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every loadable specialty found on disk; empty when the dir is missing or has none. */
|
|
234
|
+
private discoverOptions(): SpecialtyOption[] {
|
|
235
|
+
let specialties: string[];
|
|
236
|
+
try {
|
|
237
|
+
specialties = this.list();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// Missing dir (e.g. npm consumers, where agent-types/ is not shipped).
|
|
240
|
+
this.warn(
|
|
241
|
+
`SpecialtyRegistry: could not list specialties under "${this.typesDir}" — ` +
|
|
242
|
+
`treating as empty (${err instanceof Error ? err.message : String(err)})`,
|
|
243
|
+
);
|
|
244
|
+
specialties = [];
|
|
245
|
+
}
|
|
218
246
|
const options: SpecialtyOption[] = [];
|
|
219
247
|
for (const specialty of specialties) {
|
|
220
248
|
const manifest = this.tryLoad(specialty);
|
|
@@ -227,6 +255,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
227
255
|
return options;
|
|
228
256
|
}
|
|
229
257
|
|
|
258
|
+
/** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
|
|
259
|
+
private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
|
|
260
|
+
try {
|
|
261
|
+
return this.tryLoad(specialty);
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
230
267
|
/** Load + cache a manifest, returning undefined only if the file is missing. */
|
|
231
268
|
private tryLoad(specialty: string): SpecialtyManifest | undefined {
|
|
232
269
|
const cached = this.cache.get(specialty);
|
|
@@ -240,13 +277,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
240
277
|
return this.parseAndCache(specialty, raw);
|
|
241
278
|
}
|
|
242
279
|
|
|
243
|
-
/** Load + cache a manifest, propagating any read/parse error. */
|
|
244
|
-
private loadOrThrow(specialty: string): SpecialtyManifest {
|
|
245
|
-
const cached = this.cache.get(specialty);
|
|
246
|
-
if (cached) return cached;
|
|
247
|
-
return this.parseAndCache(specialty, this.read(specialty));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
280
|
private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
|
|
251
281
|
const manifest = parseSpecialtyManifest(raw);
|
|
252
282
|
this.cache.set(specialty, manifest);
|