@app-vitals/agent-admin 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/account-agent-create.ts +1 -1
- package/src/account-agent-create.unit.test.ts +2 -2
- package/src/admin-ui-pages.ts +2 -2
- package/src/admin-ui-pages.unit.test.ts +3 -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/specialty-manifest-loader.fixture.integration.test.ts +7 -3
- package/src/specialty-manifest-loader.ts +80 -90
- package/src/specialty-manifest-loader.unit.test.ts +46 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.16.
|
|
46
|
+
"@app-vitals/agent-lib": "0.16.1",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
|
@@ -14,7 +14,7 @@ import { resolveDefaultSpecialtyName } from "./specialty-manifest-loader.ts";
|
|
|
14
14
|
/**
|
|
15
15
|
* The only specialty account users may create: SHIPWRIGHT_SELF_SERVE_AGENT_TYPE
|
|
16
16
|
* when set, else the platform's default specialty (SHIPWRIGHT_DEFAULT_AGENT_TYPE,
|
|
17
|
-
* "
|
|
17
|
+
* "specialist" when unset).
|
|
18
18
|
*/
|
|
19
19
|
export function getSelfServeSpecialty(
|
|
20
20
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -7,8 +7,8 @@ import { describe, expect, it } from "bun:test";
|
|
|
7
7
|
import { getSelfServeSpecialty } from "./account-agent-create.ts";
|
|
8
8
|
|
|
9
9
|
describe("getSelfServeSpecialty", () => {
|
|
10
|
-
it("defaults to
|
|
11
|
-
expect(getSelfServeSpecialty({})).toBe("
|
|
10
|
+
it("defaults to specialist", () => {
|
|
11
|
+
expect(getSelfServeSpecialty({})).toBe("specialist");
|
|
12
12
|
expect(
|
|
13
13
|
getSelfServeSpecialty({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "fixture" }),
|
|
14
14
|
).toBe("fixture");
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -80,7 +80,7 @@ export interface AgentDetail {
|
|
|
80
80
|
* Rendered as a checkbox on both the create and edit forms.
|
|
81
81
|
*/
|
|
82
82
|
restrictSlackToMembers: boolean;
|
|
83
|
-
/** The agent's type name (e.g. "
|
|
83
|
+
/** The agent's type name (e.g. "specialist"), resolved at creation time. */
|
|
84
84
|
specialty: string;
|
|
85
85
|
/**
|
|
86
86
|
* Required env keys declared by the agent's type manifest with no
|
|
@@ -488,7 +488,7 @@ export function renderNewLocalAgentPage(
|
|
|
488
488
|
/**
|
|
489
489
|
* Set for self-serve account users (SSP-4.2): shows the account's
|
|
490
490
|
* remaining agent quota and the prerequisites, and pins the type to
|
|
491
|
-
*
|
|
491
|
+
* the self-serve specialty (the server forces it regardless).
|
|
492
492
|
*/
|
|
493
493
|
selfServe?: { remaining: number; max: number; contactEmail: string };
|
|
494
494
|
},
|
|
@@ -4289,7 +4289,9 @@ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () =>
|
|
|
4289
4289
|
const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
|
|
4290
4290
|
selfServe,
|
|
4291
4291
|
});
|
|
4292
|
-
expect(html).toContain(
|
|
4292
|
+
expect(html).toContain(
|
|
4293
|
+
'<input type="hidden" name="type" value="specialist"',
|
|
4294
|
+
);
|
|
4293
4295
|
});
|
|
4294
4296
|
});
|
|
4295
4297
|
|
package/src/admin-ui.ts
CHANGED
|
@@ -1511,7 +1511,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
1511
1511
|
return c.redirect("/admin/agents/new", 302);
|
|
1512
1512
|
}
|
|
1513
1513
|
const connectSlack = connectSlackRaw === "true";
|
|
1514
|
-
// SSP-4.2: account users always create
|
|
1514
|
+
// SSP-4.2: account users always create self-serve-specialty agents in their own
|
|
1515
1515
|
// account — any submitted type/accountId is ignored.
|
|
1516
1516
|
if (accountId !== null) specialty = getSelfServeSpecialty();
|
|
1517
1517
|
// Absent/unrecognized runtime means self-hosted — the historical behavior
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -8,20 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
SpecialtyRegistry,
|
|
14
|
-
missingDefaultSpecialtyError,
|
|
15
|
-
resolveDefaultSpecialtyName,
|
|
16
|
-
resolveSpecialtiesDir,
|
|
17
|
-
} from "./specialty-manifest-loader.ts";
|
|
11
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
12
|
+
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
18
13
|
import type {
|
|
19
14
|
SpecialtyDefaultCron,
|
|
20
15
|
SpecialtyDefaultsResolver,
|
|
21
16
|
} from "./specialty-defaults.ts";
|
|
17
|
+
import {
|
|
18
|
+
type SpecialtyManifestResolver,
|
|
19
|
+
SpecialtyRegistry,
|
|
20
|
+
} from "./specialty-manifest-loader.ts";
|
|
22
21
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
23
|
-
import { type Clock, SystemClock } from "./clock.ts";
|
|
24
|
-
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
24
|
* The shape of a single cron template resolved from a specialty's manifest.
|
|
@@ -448,11 +445,10 @@ export class AgentCronJobService {
|
|
|
448
445
|
* The cron list is resolved from the agent's type manifest — the agent's
|
|
449
446
|
* stored `specialty` is looked up via the injected SpecialtyRegistry's
|
|
450
447
|
* `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
|
|
451
|
-
* deliberately WITHOUT the
|
|
452
|
-
* loadable manifest logs an error and reconciles nothing
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* startup bug. This replaced the former hardcoded
|
|
448
|
+
* deliberately WITHOUT the default-type fallback: any type (the default
|
|
449
|
+
* included) with no loadable manifest logs an error and reconciles nothing
|
|
450
|
+
* (no seeding of another type's crons, no deletion of existing system
|
|
451
|
+
* crons). This replaced the former hardcoded
|
|
456
452
|
* SYSTEM_CRONS array as the source of truth (ATS-3.2).
|
|
457
453
|
*
|
|
458
454
|
* Two passes so parent/child links resolve regardless of the manifest's
|
|
@@ -488,9 +484,9 @@ export class AgentCronJobService {
|
|
|
488
484
|
async reconcileSystemCrons(
|
|
489
485
|
agentId: string,
|
|
490
486
|
): Promise<{ created: number; updated: number; deleted: number }> {
|
|
491
|
-
// Resolve the agent's type manifest → its declared cron list. No
|
|
492
|
-
// fallback: seeding
|
|
493
|
-
//
|
|
487
|
+
// Resolve the agent's type manifest → its declared cron list. No default-type
|
|
488
|
+
// fallback: seeding another type's crons on an agent is worse than
|
|
489
|
+
// leaving its crons untouched.
|
|
494
490
|
const agent = await this.prisma.agent.findUnique({
|
|
495
491
|
where: { id: agentId },
|
|
496
492
|
select: { specialty: true },
|
|
@@ -505,14 +501,6 @@ export class AgentCronJobService {
|
|
|
505
501
|
? undefined
|
|
506
502
|
: this.specialtyRegistry.tryGetManifest(specialty);
|
|
507
503
|
if (!extensionCrons && !manifest) {
|
|
508
|
-
const defaultName = resolveDefaultSpecialtyName();
|
|
509
|
-
if (specialty === defaultName) {
|
|
510
|
-
throw missingDefaultSpecialtyError(
|
|
511
|
-
defaultName,
|
|
512
|
-
resolveSpecialtiesDir(),
|
|
513
|
-
new Error(`reconcileSystemCrons for agent ${agentId}`),
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
504
|
console.error(
|
|
517
505
|
`reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
|
|
518
506
|
);
|
|
@@ -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 () => {
|
|
@@ -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,19 @@ 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. No warning is
|
|
133
125
|
* logged (an unknown type here is an expected, callers-handle-it outcome,
|
|
134
126
|
* not a degraded-boot condition). Used by POST /admin/agents to reject an
|
|
135
127
|
* unknown requested `type` with 400 rather than silently seeding a
|
|
136
|
-
*
|
|
128
|
+
* default-type agent under a different label.
|
|
137
129
|
*/
|
|
138
130
|
tryGetManifest(specialty: string): SpecialtyManifest | undefined;
|
|
139
131
|
/**
|
|
@@ -157,8 +149,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
157
149
|
|
|
158
150
|
/**
|
|
159
151
|
* `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
|
|
160
|
-
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to
|
|
161
|
-
* and "
|
|
152
|
+
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
|
|
153
|
+
* and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
|
|
162
154
|
*/
|
|
163
155
|
constructor(
|
|
164
156
|
opts: {
|
|
@@ -179,33 +171,29 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
179
171
|
}
|
|
180
172
|
|
|
181
173
|
getManifest(specialty: string): SpecialtyManifest {
|
|
182
|
-
const resolved = this.
|
|
174
|
+
const resolved = this.tryLoadSafe(specialty);
|
|
183
175
|
if (resolved) return resolved;
|
|
184
176
|
|
|
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
177
|
this.warn(
|
|
189
178
|
`SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
|
|
190
179
|
`falling back to "${this.defaultTypeName}" manifest`,
|
|
191
180
|
);
|
|
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
|
-
);
|
|
181
|
+
if (specialty !== this.defaultTypeName) {
|
|
182
|
+
const fallback = this.tryLoadSafe(this.defaultTypeName);
|
|
183
|
+
if (fallback) return fallback;
|
|
204
184
|
}
|
|
185
|
+
// Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
|
|
186
|
+
// SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
|
|
187
|
+
// rather than failing boot; the warning names the knobs to fix.
|
|
188
|
+
this.warn(
|
|
189
|
+
`SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
|
|
190
|
+
"using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
|
|
191
|
+
);
|
|
192
|
+
return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
205
193
|
}
|
|
206
194
|
|
|
207
195
|
/**
|
|
208
|
-
* Resolve `specialty` without falling back to
|
|
196
|
+
* Resolve `specialty` without falling back to the default type — see the interface
|
|
209
197
|
* doc comment. Thin public wrapper around the existing tryLoad() used
|
|
210
198
|
* internally by getManifest().
|
|
211
199
|
*/
|
|
@@ -227,6 +215,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
227
215
|
return options;
|
|
228
216
|
}
|
|
229
217
|
|
|
218
|
+
/** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
|
|
219
|
+
private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
|
|
220
|
+
try {
|
|
221
|
+
return this.tryLoad(specialty);
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
230
227
|
/** Load + cache a manifest, returning undefined only if the file is missing. */
|
|
231
228
|
private tryLoad(specialty: string): SpecialtyManifest | undefined {
|
|
232
229
|
const cached = this.cache.get(specialty);
|
|
@@ -240,13 +237,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
240
237
|
return this.parseAndCache(specialty, raw);
|
|
241
238
|
}
|
|
242
239
|
|
|
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
240
|
private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
|
|
251
241
|
const manifest = parseSpecialtyManifest(raw);
|
|
252
242
|
this.cache.set(specialty, manifest);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* admin/src/specialty-manifest-loader.unit.test.ts
|
|
3
3
|
* Unit tests for SpecialtyRegistry — the disk-backed resolver from an agent's
|
|
4
|
-
* specialty to its parsed manifest, with the unknown-type
|
|
4
|
+
* specialty to its parsed manifest, with the unknown-type default-type fallback.
|
|
5
5
|
*
|
|
6
6
|
* Pure logic against an injected reader/warner — no filesystem, no DB. See
|
|
7
7
|
* docs/testing.md for the unit-layer contract.
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
11
|
import {
|
|
12
|
-
SpecialtyRegistry,
|
|
13
12
|
DEFAULT_SPECIALTY_NAME,
|
|
14
|
-
resolveSpecialtiesDir,
|
|
15
13
|
resolveDefaultSpecialtyName,
|
|
14
|
+
resolveSpecialtiesDir,
|
|
15
|
+
SpecialtyRegistry,
|
|
16
16
|
} from "./specialty-manifest-loader.ts";
|
|
17
17
|
|
|
18
18
|
/** A minimal valid Specialty manifest YAML for a given type name. */
|
|
@@ -49,17 +49,17 @@ describe("SpecialtyRegistry", () => {
|
|
|
49
49
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
50
50
|
read: (t) => manifestYaml(t),
|
|
51
51
|
});
|
|
52
|
-
const manifest = registry.getManifest("
|
|
53
|
-
expect(manifest.metadata.name).toBe("
|
|
54
|
-
expect(manifest.crons[0]?.name).toBe("
|
|
52
|
+
const manifest = registry.getManifest("specialist");
|
|
53
|
+
expect(manifest.metadata.name).toBe("specialist");
|
|
54
|
+
expect(manifest.crons[0]?.name).toBe("specialist-cron");
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it("falls back to the
|
|
57
|
+
it("falls back to the default (specialist) manifest with a warning for an unknown specialty", () => {
|
|
58
58
|
const warnings: string[] = [];
|
|
59
59
|
const registry = new SpecialtyRegistry({
|
|
60
60
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
61
61
|
read: (t) => {
|
|
62
|
-
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("
|
|
62
|
+
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
|
|
63
63
|
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
64
64
|
},
|
|
65
65
|
warn: (m) => warnings.push(m),
|
|
@@ -67,51 +67,49 @@ describe("SpecialtyRegistry", () => {
|
|
|
67
67
|
|
|
68
68
|
const manifest = registry.getManifest("renamed-type");
|
|
69
69
|
|
|
70
|
-
expect(manifest.metadata.name).toBe("
|
|
70
|
+
expect(manifest.metadata.name).toBe("specialist");
|
|
71
71
|
expect(warnings).toHaveLength(1);
|
|
72
72
|
expect(warnings[0]).toContain("renamed-type");
|
|
73
|
-
expect(warnings[0]).toContain("
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("throws (does not silently no-op) when the coding fallback itself cannot load", () => {
|
|
77
|
-
const registry = new SpecialtyRegistry({
|
|
78
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
79
|
-
read: () => {
|
|
80
|
-
throw new Error("ENOENT: coding manifest missing");
|
|
81
|
-
},
|
|
82
|
-
warn: () => {},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// Requesting the default type when it can't load must surface a hard error
|
|
86
|
-
// rather than degrade into an empty cron set that would strip system crons.
|
|
87
|
-
expect(() => registry.getManifest("coding")).toThrow();
|
|
73
|
+
expect(warnings[0]).toContain("specialist");
|
|
88
74
|
});
|
|
89
75
|
|
|
90
|
-
it("
|
|
76
|
+
it("never throws: an unloadable default degrades to an empty-crons manifest with warnings naming the env knobs", () => {
|
|
77
|
+
const warnings: string[] = [];
|
|
91
78
|
const registry = new SpecialtyRegistry({
|
|
92
79
|
read: () => {
|
|
93
80
|
throw new Error("ENOENT: no manifest");
|
|
94
81
|
},
|
|
95
|
-
warn: () =>
|
|
82
|
+
warn: (m) => warnings.push(m),
|
|
96
83
|
typesDir: "/nonexistent/specialties",
|
|
97
84
|
defaultTypeName: "absent",
|
|
98
85
|
});
|
|
99
86
|
|
|
100
87
|
for (const specialty of ["absent", "some-other-type"]) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(
|
|
108
|
-
expect(
|
|
109
|
-
expect(
|
|
110
|
-
expect(message).toContain("SHIPWRIGHT_DEFAULT_AGENT_TYPE");
|
|
111
|
-
expect(message).not.toContain("startup-time bug");
|
|
88
|
+
warnings.length = 0;
|
|
89
|
+
const manifest = registry.getManifest(specialty);
|
|
90
|
+
expect(manifest.crons).toEqual([]);
|
|
91
|
+
expect(manifest.plugins).toEqual([]);
|
|
92
|
+
const all = warnings.join("\n");
|
|
93
|
+
expect(all).toContain('default specialty "absent"');
|
|
94
|
+
expect(all).toContain("/nonexistent/specialties");
|
|
95
|
+
expect(all).toContain("SHIPWRIGHT_SPECIALTIES_DIR");
|
|
96
|
+
expect(all).toContain("SHIPWRIGHT_DEFAULT_AGENT_TYPE");
|
|
112
97
|
}
|
|
113
98
|
});
|
|
114
99
|
|
|
100
|
+
it("uses the default manifest silently when the default type itself is requested and loads", () => {
|
|
101
|
+
const warnings: string[] = [];
|
|
102
|
+
const registry = new SpecialtyRegistry({
|
|
103
|
+
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
104
|
+
read: (t) => manifestYaml(t),
|
|
105
|
+
warn: (m) => warnings.push(m),
|
|
106
|
+
});
|
|
107
|
+
expect(registry.getManifest(DEFAULT_SPECIALTY_NAME).metadata.name).toBe(
|
|
108
|
+
"specialist",
|
|
109
|
+
);
|
|
110
|
+
expect(warnings).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
115
113
|
it("caches a resolved manifest — the reader is called once per type", () => {
|
|
116
114
|
let reads = 0;
|
|
117
115
|
const registry = new SpecialtyRegistry({
|
|
@@ -122,15 +120,15 @@ describe("SpecialtyRegistry", () => {
|
|
|
122
120
|
},
|
|
123
121
|
});
|
|
124
122
|
|
|
125
|
-
registry.getManifest("
|
|
126
|
-
registry.getManifest("
|
|
123
|
+
registry.getManifest("specialist");
|
|
124
|
+
registry.getManifest("specialist");
|
|
127
125
|
|
|
128
126
|
expect(reads).toBe(1);
|
|
129
127
|
});
|
|
130
128
|
|
|
131
129
|
// ─── tryGetManifest — non-fallback lookup ────────────────────────────────
|
|
132
130
|
//
|
|
133
|
-
// Unlike getManifest(), tryGetManifest() must NOT fall back to
|
|
131
|
+
// Unlike getManifest(), tryGetManifest() must NOT fall back to the default type for
|
|
134
132
|
// an unknown specialty — it returns undefined instead. This is what lets
|
|
135
133
|
// POST /agents distinguish "resolved to the exact requested type" from
|
|
136
134
|
// "silently degraded", which the cron-reconcile boot path (getManifest())
|
|
@@ -141,8 +139,8 @@ describe("SpecialtyRegistry", () => {
|
|
|
141
139
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
142
140
|
read: (t) => manifestYaml(t),
|
|
143
141
|
});
|
|
144
|
-
const manifest = registry.tryGetManifest("
|
|
145
|
-
expect(manifest?.metadata.name).toBe("
|
|
142
|
+
const manifest = registry.tryGetManifest("specialist");
|
|
143
|
+
expect(manifest?.metadata.name).toBe("specialist");
|
|
146
144
|
});
|
|
147
145
|
|
|
148
146
|
it("tryGetManifest returns undefined for an unknown specialty — no fallback, no warning", () => {
|
|
@@ -150,7 +148,7 @@ describe("SpecialtyRegistry", () => {
|
|
|
150
148
|
const registry = new SpecialtyRegistry({
|
|
151
149
|
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
152
150
|
read: (t) => {
|
|
153
|
-
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("
|
|
151
|
+
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
|
|
154
152
|
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
155
153
|
},
|
|
156
154
|
warn: (m) => warnings.push(m),
|
|
@@ -172,8 +170,8 @@ describe("SpecialtyRegistry", () => {
|
|
|
172
170
|
},
|
|
173
171
|
});
|
|
174
172
|
|
|
175
|
-
registry.getManifest("
|
|
176
|
-
registry.tryGetManifest("
|
|
173
|
+
registry.getManifest("specialist");
|
|
174
|
+
registry.tryGetManifest("specialist");
|
|
177
175
|
|
|
178
176
|
expect(reads).toBe(1);
|
|
179
177
|
});
|
|
@@ -273,9 +271,9 @@ voice: true
|
|
|
273
271
|
});
|
|
274
272
|
|
|
275
273
|
describe("specialties option resolution", () => {
|
|
276
|
-
it("defaults to
|
|
277
|
-
expect(resolveDefaultSpecialtyName({})).toBe("
|
|
278
|
-
expect(resolveSpecialtiesDir({})).toMatch(/
|
|
274
|
+
it("defaults to specialist and the shipped agent-types/ with no env set", () => {
|
|
275
|
+
expect(resolveDefaultSpecialtyName({})).toBe("specialist");
|
|
276
|
+
expect(resolveSpecialtiesDir({})).toMatch(/agent-types$/);
|
|
279
277
|
});
|
|
280
278
|
|
|
281
279
|
it("honours SHIPWRIGHT_DEFAULT_AGENT_TYPE and SHIPWRIGHT_SPECIALTIES_DIR", () => {
|