@app-vitals/agent-admin 0.16.2 → 1.1.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 +9 -9
- package/e2e/test-server.ts +6 -6
- package/openapi.json +3 -3
- package/openapi.platform.json +3 -3
- package/package.json +3 -5
- package/src/account-agent-create.ts +1 -1
- package/src/admin-boot.ts +6 -6
- package/src/admin-extension.ts +16 -15
- package/src/admin-extension.unit.test.ts +2 -2
- 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 +27 -39
- 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/agents-api.integration.test.ts +20 -31
- 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 +21 -31
- package/src/agents.ts +27 -34
- package/src/agents.unit.test.ts +35 -55
- package/src/api.smoke.test.ts +2 -2
- package/src/api.ts +1 -1
- package/src/chat-service-provisioning-client.integration.test.ts +1 -1
- 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 -285
- package/src/specialty-manifest-loader.unit.test.ts +0 -364
- 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
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/CLAUDE.md.template +0 -0
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/IDENTITY.md.template +0 -0
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/SOUL.md.template +0 -0
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* admin/src/specialty-manifest-loader.unit.test.ts
|
|
3
|
-
* Unit tests for SpecialtyRegistry — the disk-backed resolver from an agent's
|
|
4
|
-
* specialty to its parsed manifest, with the unknown-type default-type fallback.
|
|
5
|
-
*
|
|
6
|
-
* Pure logic against an injected reader/warner — no filesystem, no DB. See
|
|
7
|
-
* docs/testing.md for the unit-layer contract.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, expect, it } from "bun:test";
|
|
11
|
-
import {
|
|
12
|
-
DEFAULT_SPECIALTY_NAME,
|
|
13
|
-
resolveDefaultSpecialtyName,
|
|
14
|
-
resolveSpecialtiesDir,
|
|
15
|
-
SpecialtyRegistry,
|
|
16
|
-
} from "./specialty-manifest-loader.ts";
|
|
17
|
-
|
|
18
|
-
/** A minimal valid Specialty manifest YAML for a given type name. */
|
|
19
|
-
function manifestYaml(name: string): string {
|
|
20
|
-
return `apiVersion: shipwright.dev/v1alpha1
|
|
21
|
-
kind: Specialty
|
|
22
|
-
metadata:
|
|
23
|
-
name: ${name}
|
|
24
|
-
displayName: ${name}
|
|
25
|
-
description: test manifest
|
|
26
|
-
version: 1.0.0
|
|
27
|
-
skills: []
|
|
28
|
-
identity:
|
|
29
|
-
templatesDir: workspace/
|
|
30
|
-
crons:
|
|
31
|
-
- name: ${name}-cron
|
|
32
|
-
schedule: "* * * * *"
|
|
33
|
-
prompt: /noop
|
|
34
|
-
plugins: []
|
|
35
|
-
tools: []
|
|
36
|
-
env:
|
|
37
|
-
required: []
|
|
38
|
-
optional: []
|
|
39
|
-
members: []
|
|
40
|
-
repos: []
|
|
41
|
-
chat: true
|
|
42
|
-
voice: true
|
|
43
|
-
`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe("SpecialtyRegistry", () => {
|
|
47
|
-
it("resolves a known specialty to its parsed manifest", () => {
|
|
48
|
-
const registry = new SpecialtyRegistry({
|
|
49
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
50
|
-
read: (t) => manifestYaml(t),
|
|
51
|
-
});
|
|
52
|
-
const manifest = registry.getManifest("specialist");
|
|
53
|
-
expect(manifest.metadata.name).toBe("specialist");
|
|
54
|
-
expect(manifest.crons[0]?.name).toBe("specialist-cron");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("falls back to the default (specialist) manifest with a warning for an unknown specialty", () => {
|
|
58
|
-
const warnings: string[] = [];
|
|
59
|
-
const registry = new SpecialtyRegistry({
|
|
60
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
61
|
-
read: (t) => {
|
|
62
|
-
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
|
|
63
|
-
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
64
|
-
},
|
|
65
|
-
warn: (m) => warnings.push(m),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const manifest = registry.getManifest("renamed-type");
|
|
69
|
-
|
|
70
|
-
expect(manifest.metadata.name).toBe("specialist");
|
|
71
|
-
expect(warnings).toHaveLength(1);
|
|
72
|
-
expect(warnings[0]).toContain("renamed-type");
|
|
73
|
-
expect(warnings[0]).toContain("specialist");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("never throws: an unloadable default degrades to an empty-crons manifest with warnings naming the env knobs", () => {
|
|
77
|
-
const warnings: string[] = [];
|
|
78
|
-
const registry = new SpecialtyRegistry({
|
|
79
|
-
read: () => {
|
|
80
|
-
throw new Error("ENOENT: no manifest");
|
|
81
|
-
},
|
|
82
|
-
warn: (m) => warnings.push(m),
|
|
83
|
-
typesDir: "/nonexistent/specialties",
|
|
84
|
-
defaultTypeName: "absent",
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
for (const specialty of ["absent", "some-other-type"]) {
|
|
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");
|
|
97
|
-
}
|
|
98
|
-
});
|
|
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
|
-
|
|
113
|
-
it("caches a resolved manifest — the reader is called once per type", () => {
|
|
114
|
-
let reads = 0;
|
|
115
|
-
const registry = new SpecialtyRegistry({
|
|
116
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
117
|
-
read: (t) => {
|
|
118
|
-
reads++;
|
|
119
|
-
return manifestYaml(t);
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
registry.getManifest("specialist");
|
|
124
|
-
registry.getManifest("specialist");
|
|
125
|
-
|
|
126
|
-
expect(reads).toBe(1);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// ─── tryGetManifest — non-fallback lookup ────────────────────────────────
|
|
130
|
-
//
|
|
131
|
-
// Unlike getManifest(), tryGetManifest() must NOT fall back to the default type for
|
|
132
|
-
// an unknown specialty — it returns undefined instead. This is what lets
|
|
133
|
-
// POST /agents distinguish "resolved to the exact requested type" from
|
|
134
|
-
// "silently degraded", which the cron-reconcile boot path (getManifest())
|
|
135
|
-
// deliberately does not need to distinguish.
|
|
136
|
-
|
|
137
|
-
it("tryGetManifest resolves a known specialty to its parsed manifest", () => {
|
|
138
|
-
const registry = new SpecialtyRegistry({
|
|
139
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
140
|
-
read: (t) => manifestYaml(t),
|
|
141
|
-
});
|
|
142
|
-
const manifest = registry.tryGetManifest("specialist");
|
|
143
|
-
expect(manifest?.metadata.name).toBe("specialist");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("tryGetManifest returns undefined for an unknown specialty — no fallback, no warning", () => {
|
|
147
|
-
const warnings: string[] = [];
|
|
148
|
-
const registry = new SpecialtyRegistry({
|
|
149
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
150
|
-
read: (t) => {
|
|
151
|
-
if (t === DEFAULT_SPECIALTY_NAME) return manifestYaml("specialist");
|
|
152
|
-
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
153
|
-
},
|
|
154
|
-
warn: (m) => warnings.push(m),
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const manifest = registry.tryGetManifest("nonexistent-type");
|
|
158
|
-
|
|
159
|
-
expect(manifest).toBeUndefined();
|
|
160
|
-
expect(warnings).toHaveLength(0);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("tryGetManifest resolves the built-in default type when no specialty is discoverable", () => {
|
|
164
|
-
const registry = new SpecialtyRegistry({
|
|
165
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
166
|
-
list: () => {
|
|
167
|
-
throw new Error("ENOENT: no such file or directory");
|
|
168
|
-
},
|
|
169
|
-
read: (t) => {
|
|
170
|
-
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
171
|
-
},
|
|
172
|
-
warn: () => {},
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)?.metadata.name).toBe(
|
|
176
|
-
DEFAULT_SPECIALTY_NAME,
|
|
177
|
-
);
|
|
178
|
-
expect(registry.tryGetManifest("nonexistent-type")).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("tryGetManifest does not synthesize the default type when other specialties are discoverable", () => {
|
|
182
|
-
const registry = new SpecialtyRegistry({
|
|
183
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
184
|
-
list: () => ["coding"],
|
|
185
|
-
read: (t) => {
|
|
186
|
-
if (t === "coding") return manifestYaml("coding");
|
|
187
|
-
throw new Error(`ENOENT: no manifest for ${t}`);
|
|
188
|
-
},
|
|
189
|
-
warn: () => {},
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)).toBeUndefined();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("tryGetManifest shares the cache with getManifest — reader called once per type", () => {
|
|
196
|
-
let reads = 0;
|
|
197
|
-
const registry = new SpecialtyRegistry({
|
|
198
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
199
|
-
read: (t) => {
|
|
200
|
-
reads++;
|
|
201
|
-
return manifestYaml(t);
|
|
202
|
-
},
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
registry.getManifest("specialist");
|
|
206
|
-
registry.tryGetManifest("specialist");
|
|
207
|
-
|
|
208
|
-
expect(reads).toBe(1);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// ─── listTypes — populates the new-specialty picker ─────────────────────
|
|
212
|
-
//
|
|
213
|
-
// Discovers every directory under specialties/ via the injected `list` fn,
|
|
214
|
-
// then resolves each one's manifest via the existing `read` path — so
|
|
215
|
-
// listTypes() shares the same disk-free-in-tests DI contract as
|
|
216
|
-
// getManifest()/tryGetManifest().
|
|
217
|
-
|
|
218
|
-
it("listTypes lists every discovered type as {name, displayName}", () => {
|
|
219
|
-
const registry = new SpecialtyRegistry({
|
|
220
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
221
|
-
list: () => ["coding"],
|
|
222
|
-
read: (t) => manifestYaml(t),
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
const types = registry.listTypes();
|
|
226
|
-
|
|
227
|
-
expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("listTypes resolves displayName from each manifest's metadata.displayName", () => {
|
|
231
|
-
const registry = new SpecialtyRegistry({
|
|
232
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
233
|
-
list: () => ["coding"],
|
|
234
|
-
read: () => `apiVersion: shipwright.dev/v1alpha1
|
|
235
|
-
kind: Specialty
|
|
236
|
-
metadata:
|
|
237
|
-
name: coding
|
|
238
|
-
displayName: Coding Agent
|
|
239
|
-
description: test manifest
|
|
240
|
-
version: 1.0.0
|
|
241
|
-
skills: []
|
|
242
|
-
identity:
|
|
243
|
-
templatesDir: workspace/
|
|
244
|
-
crons: []
|
|
245
|
-
plugins: []
|
|
246
|
-
tools: []
|
|
247
|
-
env:
|
|
248
|
-
required: []
|
|
249
|
-
optional: []
|
|
250
|
-
members: []
|
|
251
|
-
repos: []
|
|
252
|
-
chat: true
|
|
253
|
-
voice: true
|
|
254
|
-
`,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const types = registry.listTypes();
|
|
258
|
-
|
|
259
|
-
expect(types).toEqual([{ name: "coding", displayName: "Coding Agent" }]);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("listTypes lists multiple discovered types", () => {
|
|
263
|
-
const registry = new SpecialtyRegistry({
|
|
264
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
265
|
-
list: () => ["coding", "research"],
|
|
266
|
-
read: (t) => manifestYaml(t),
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
const types = registry.listTypes();
|
|
270
|
-
|
|
271
|
-
expect(types).toEqual([
|
|
272
|
-
{ name: "coding", displayName: "coding" },
|
|
273
|
-
{ name: "research", displayName: "research" },
|
|
274
|
-
]);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("listTypes skips a directory whose manifest fails to load or parse", () => {
|
|
278
|
-
const warnings: string[] = [];
|
|
279
|
-
const registry = new SpecialtyRegistry({
|
|
280
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
281
|
-
list: () => ["coding", "broken"],
|
|
282
|
-
read: (t) => {
|
|
283
|
-
if (t === "broken") throw new Error("ENOENT: broken manifest");
|
|
284
|
-
return manifestYaml(t);
|
|
285
|
-
},
|
|
286
|
-
warn: (m) => warnings.push(m),
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
const types = registry.listTypes();
|
|
290
|
-
|
|
291
|
-
expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("listTypes falls back to the built-in default option when no type directories are discovered", () => {
|
|
295
|
-
const registry = new SpecialtyRegistry({
|
|
296
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
297
|
-
list: () => [],
|
|
298
|
-
read: (t) => manifestYaml(t),
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
expect(registry.listTypes()).toEqual([
|
|
302
|
-
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
303
|
-
]);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("listTypes warns and returns the built-in default when the list fn throws (missing dir)", () => {
|
|
307
|
-
const warnings: string[] = [];
|
|
308
|
-
const registry = new SpecialtyRegistry({
|
|
309
|
-
defaultTypeName: DEFAULT_SPECIALTY_NAME,
|
|
310
|
-
warn: (m) => warnings.push(m),
|
|
311
|
-
list: () => {
|
|
312
|
-
throw new Error("ENOENT: no such file or directory");
|
|
313
|
-
},
|
|
314
|
-
read: (t) => manifestYaml(t),
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
expect(registry.listTypes()).toEqual([
|
|
318
|
-
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
319
|
-
]);
|
|
320
|
-
expect(warnings).toHaveLength(1);
|
|
321
|
-
expect(warnings[0]).toContain("ENOENT");
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("listTypes does not throw on a nonexistent typesDir with the real lister", () => {
|
|
325
|
-
const warnings: string[] = [];
|
|
326
|
-
const registry = new SpecialtyRegistry({
|
|
327
|
-
typesDir: "/nonexistent/agent-types",
|
|
328
|
-
warn: (m) => warnings.push(m),
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
expect(registry.listTypes()).toEqual([
|
|
332
|
-
{ name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
|
|
333
|
-
]);
|
|
334
|
-
expect(warnings.length).toBeGreaterThan(0);
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
describe("specialties option resolution", () => {
|
|
339
|
-
it("defaults to specialist and the shipped agent-types/ with no env set", () => {
|
|
340
|
-
expect(resolveDefaultSpecialtyName({})).toBe("specialist");
|
|
341
|
-
expect(resolveSpecialtiesDir({})).toMatch(/agent-types$/);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("honours SHIPWRIGHT_DEFAULT_AGENT_TYPE and SHIPWRIGHT_SPECIALTIES_DIR", () => {
|
|
345
|
-
expect(
|
|
346
|
-
resolveDefaultSpecialtyName({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "base" }),
|
|
347
|
-
).toBe("base");
|
|
348
|
-
expect(
|
|
349
|
-
resolveSpecialtiesDir({ SHIPWRIGHT_SPECIALTIES_DIR: "/tmp/types" }),
|
|
350
|
-
).toBe("/tmp/types");
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("falls back to the configured defaultTypeName for an unknown type", () => {
|
|
354
|
-
const registry = new SpecialtyRegistry({
|
|
355
|
-
read: (name) => {
|
|
356
|
-
if (name === "base") return manifestYaml("base");
|
|
357
|
-
throw new Error("ENOENT");
|
|
358
|
-
},
|
|
359
|
-
warn: () => {},
|
|
360
|
-
defaultTypeName: "base",
|
|
361
|
-
});
|
|
362
|
-
expect(registry.getManifest("missing").metadata.name).toBe("base");
|
|
363
|
-
});
|
|
364
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* admin/src/specialty-registry.schema.unit.test.ts
|
|
3
|
-
* Regenerates the Specialty JSON Schema in-memory from its Zod source and
|
|
4
|
-
* diffs it (deep-equal, not string-equal) against the committed
|
|
5
|
-
* docs/schemas/specialty.schema.json artifact. This guarantees the
|
|
6
|
-
* committed artifact can never silently drift from the Zod source —
|
|
7
|
-
* `scripts/generate-specialty-schema.ts` must be re-run (and its output
|
|
8
|
-
* re-committed) whenever SpecialtyManifestSchema changes.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { describe, expect, it } from "bun:test";
|
|
14
|
-
import { buildSpecialtyJsonSchema } from "./specialty-registry.ts";
|
|
15
|
-
|
|
16
|
-
// docs/schemas/ in the monorepo, agent-types/ in the extracted layout.
|
|
17
|
-
const committedSchemaPath = [
|
|
18
|
-
join(import.meta.dir, "..", "..", "docs", "schemas", "specialty.schema.json"),
|
|
19
|
-
join(import.meta.dir, "..", "..", "agent-types", "specialty.schema.json"),
|
|
20
|
-
].find((candidate) => existsSync(candidate)) as string;
|
|
21
|
-
|
|
22
|
-
describe("docs/schemas/specialty.schema.json", () => {
|
|
23
|
-
it("matches the JSON Schema regenerated from SpecialtyManifestSchema", () => {
|
|
24
|
-
const regenerated = buildSpecialtyJsonSchema();
|
|
25
|
-
const committedRaw = readFileSync(committedSchemaPath, "utf-8");
|
|
26
|
-
const committed = JSON.parse(committedRaw);
|
|
27
|
-
expect(regenerated).toEqual(committed);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("ends with exactly one trailing newline (matches the generator's write format)", () => {
|
|
31
|
-
const committedRaw = readFileSync(committedSchemaPath, "utf-8");
|
|
32
|
-
expect(committedRaw.endsWith("\n")).toBe(true);
|
|
33
|
-
expect(committedRaw.endsWith("\n\n")).toBe(false);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* admin/src/specialty-registry.ts
|
|
3
|
-
*
|
|
4
|
-
* Specialty manifest schema — the single source of truth for the
|
|
5
|
-
* `shipwright.dev/v1alpha1` / `Specialty` YAML manifest that declares an
|
|
6
|
-
* specialty's identity, crons, plugins, tools, env contract, membership,
|
|
7
|
-
* repos, and chat/voice toggles.
|
|
8
|
-
*
|
|
9
|
-
* Named "specialty-registry" (not "manifest") to avoid colliding with the
|
|
10
|
-
* two existing "manifest" concepts in this repo: the Kubernetes manifests in
|
|
11
|
-
* ./agent-manifest.ts and the Slack app manifest. This module is unrelated
|
|
12
|
-
* to either — it's a declarative spec fed into scripts/CLI tooling, not a
|
|
13
|
-
* wire type, so it uses plain `zod` rather than `@hono/zod-openapi`'s
|
|
14
|
-
* `.openapi()`-augmented `z` (kept simple for JSON Schema conversion).
|
|
15
|
-
*
|
|
16
|
-
* Manifests are treated as a trust boundary: they may originate from
|
|
17
|
-
* third-party contributors, so every object schema is `.strict()` (unknown
|
|
18
|
-
* keys rejected, not silently stripped) and the env contract is
|
|
19
|
-
* deliberately keys-only — no `value`/`default` field is permitted on an
|
|
20
|
-
* env entry, so a manifest can declare *that* a var is needed without ever
|
|
21
|
-
* carrying a real value.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { isOrgRepo } from "@app-vitals/agent-lib/org-repo";
|
|
25
|
-
import { parse as parseYaml } from "yaml";
|
|
26
|
-
import { z } from "zod";
|
|
27
|
-
|
|
28
|
-
/** RFC1123 label pattern (see admin/src/agent-manifest.ts's sanitizeAgentName). */
|
|
29
|
-
const RFC1123_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
|
|
30
|
-
|
|
31
|
-
// ─── metadata.skills[] (A2A-aligned) ───────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Minimal A2A (Agent2Agent) "skill" shape: what the specialty can do, as a
|
|
35
|
-
* small catalog entry. Kept intentionally narrow — id/name/description plus
|
|
36
|
-
* optional free-form tags — since this repo has no existing A2A convention
|
|
37
|
-
* to align with beyond the general shape of an agent capability card.
|
|
38
|
-
*/
|
|
39
|
-
const SpecialtySkillSchema = z
|
|
40
|
-
.object({
|
|
41
|
-
id: z.string().min(1),
|
|
42
|
-
name: z.string().min(1),
|
|
43
|
-
description: z.string().min(1),
|
|
44
|
-
tags: z.array(z.string()).optional(),
|
|
45
|
-
})
|
|
46
|
-
.strict();
|
|
47
|
-
|
|
48
|
-
// ─── metadata ───────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
const SpecialtyMetadataSchema = z
|
|
51
|
-
.object({
|
|
52
|
-
name: z
|
|
53
|
-
.string()
|
|
54
|
-
.regex(RFC1123_NAME, "metadata.name must be a valid RFC1123 label"),
|
|
55
|
-
displayName: z.string().min(1),
|
|
56
|
-
description: z.string().min(1),
|
|
57
|
-
version: z.string().min(1),
|
|
58
|
-
skills: z.array(SpecialtySkillSchema),
|
|
59
|
-
})
|
|
60
|
-
.strict();
|
|
61
|
-
|
|
62
|
-
// ─── identity ───────────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
const SpecialtyIdentitySchema = z
|
|
65
|
-
.object({
|
|
66
|
-
templatesDir: z.string().min(1),
|
|
67
|
-
})
|
|
68
|
-
.strict();
|
|
69
|
-
|
|
70
|
-
// ─── crons[] ────────────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* A single cron template declared inside a Specialty manifest. Distinct
|
|
74
|
-
* from the runtime, DB-backed AgentCronJobSchema (admin/src/openapi-schemas.ts)
|
|
75
|
-
* and CreateAgentCronJobInput (admin/src/agent-cron-jobs.ts) — those describe
|
|
76
|
-
* per-agent-instance rows already created in Postgres; this describes a
|
|
77
|
-
* *template* inside a versioned spec. `parentCron` here is a name reference
|
|
78
|
-
* resolved within this manifest's own crons[] array (validated by the
|
|
79
|
-
* top-level schema's superRefine below), not a DB id reference.
|
|
80
|
-
*/
|
|
81
|
-
const SpecialtyCronSchema = z
|
|
82
|
-
.object({
|
|
83
|
-
name: z.string().min(1),
|
|
84
|
-
schedule: z.string().min(1),
|
|
85
|
-
prompt: z.string().min(1),
|
|
86
|
-
preCheck: z.string().optional(),
|
|
87
|
-
silent: z.boolean().optional().default(false),
|
|
88
|
-
enabled: z.boolean().optional().default(true),
|
|
89
|
-
parentCron: z.string().optional(),
|
|
90
|
-
})
|
|
91
|
-
.strict();
|
|
92
|
-
|
|
93
|
-
// ─── env contract ───────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* A single env contract entry. Keys-only by design: a manifest declares that
|
|
97
|
-
* a var is needed (and whether it's secret) — it never carries a `value` or
|
|
98
|
-
* `default`. `.strict()` ensures an unexpected `value` key is rejected, not
|
|
99
|
-
* silently stripped, since manifests are potential third-party input.
|
|
100
|
-
*/
|
|
101
|
-
const SpecialtyEnvEntrySchema = z
|
|
102
|
-
.object({
|
|
103
|
-
key: z.string().min(1),
|
|
104
|
-
description: z.string().min(1),
|
|
105
|
-
secret: z.boolean(),
|
|
106
|
-
})
|
|
107
|
-
.strict();
|
|
108
|
-
|
|
109
|
-
const SpecialtyEnvSchema = z
|
|
110
|
-
.object({
|
|
111
|
-
required: z.array(SpecialtyEnvEntrySchema),
|
|
112
|
-
optional: z.array(SpecialtyEnvEntrySchema),
|
|
113
|
-
})
|
|
114
|
-
.strict();
|
|
115
|
-
|
|
116
|
-
// ─── resources override (optional) ─────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Optional override of the default agent container resources (see
|
|
120
|
-
* resolveAgentContainerResources in admin/src/agent-manifest.ts). All fields
|
|
121
|
-
* optional — a manifest may override just one of requests/limits.
|
|
122
|
-
*
|
|
123
|
-
* Not yet wired to any provisioner — parsed as part of
|
|
124
|
-
* SpecialtyManifestSchema.resources but inert until a future task consumes it
|
|
125
|
-
* ahead of resolveAgentContainerResources's env-override layer (see that
|
|
126
|
-
* function's precedence-rule doc comment).
|
|
127
|
-
*/
|
|
128
|
-
const SpecialtyResourceListSchema = z
|
|
129
|
-
.object({
|
|
130
|
-
cpu: z.string().optional(),
|
|
131
|
-
memory: z.string().optional(),
|
|
132
|
-
"ephemeral-storage": z.string().optional(),
|
|
133
|
-
})
|
|
134
|
-
.strict();
|
|
135
|
-
|
|
136
|
-
const SpecialtyResourcesSchema = z
|
|
137
|
-
.object({
|
|
138
|
-
requests: SpecialtyResourceListSchema.optional(),
|
|
139
|
-
limits: SpecialtyResourceListSchema.optional(),
|
|
140
|
-
})
|
|
141
|
-
.strict();
|
|
142
|
-
|
|
143
|
-
// ─── Top-level Specialty manifest ──────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
export const SpecialtyManifestSchema = z
|
|
146
|
-
.object({
|
|
147
|
-
apiVersion: z.literal("shipwright.dev/v1alpha1"),
|
|
148
|
-
kind: z.literal("Specialty"),
|
|
149
|
-
metadata: SpecialtyMetadataSchema,
|
|
150
|
-
identity: SpecialtyIdentitySchema,
|
|
151
|
-
crons: z.array(SpecialtyCronSchema),
|
|
152
|
-
plugins: z.array(z.string()),
|
|
153
|
-
tools: z.array(z.string()),
|
|
154
|
-
env: SpecialtyEnvSchema,
|
|
155
|
-
members: z.array(z.string()),
|
|
156
|
-
repos: z.array(
|
|
157
|
-
z.string().refine(isOrgRepo, {
|
|
158
|
-
message: "each repo must be in org/repo format",
|
|
159
|
-
}),
|
|
160
|
-
),
|
|
161
|
-
chat: z.boolean(),
|
|
162
|
-
voice: z.boolean(),
|
|
163
|
-
resources: SpecialtyResourcesSchema.optional(),
|
|
164
|
-
})
|
|
165
|
-
.strict()
|
|
166
|
-
.superRefine((manifest, ctx) => {
|
|
167
|
-
const cronNames = new Set(manifest.crons.map((c) => c.name));
|
|
168
|
-
manifest.crons.forEach((cron, index) => {
|
|
169
|
-
if (cron.parentCron === undefined) return;
|
|
170
|
-
if (cron.parentCron === cron.name) {
|
|
171
|
-
ctx.addIssue({
|
|
172
|
-
code: "custom",
|
|
173
|
-
path: ["crons", index, "parentCron"],
|
|
174
|
-
message: `parentCron "${cron.parentCron}" cannot reference its own cron entry`,
|
|
175
|
-
});
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
if (!cronNames.has(cron.parentCron)) {
|
|
179
|
-
ctx.addIssue({
|
|
180
|
-
code: "custom",
|
|
181
|
-
path: ["crons", index, "parentCron"],
|
|
182
|
-
message: `parentCron "${cron.parentCron}" does not match any cron name in this manifest`,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
export type SpecialtyManifest = z.infer<typeof SpecialtyManifestSchema>;
|
|
189
|
-
|
|
190
|
-
// ─── Parsing ────────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Parse and validate a YAML Specialty manifest string.
|
|
194
|
-
*
|
|
195
|
-
* Pure — no I/O. `content` is the already-read YAML text; throws a
|
|
196
|
-
* descriptive Error (with the underlying ZodError's field-path issues
|
|
197
|
-
* embedded in `message`, and the ZodError itself reachable via `cause`) if
|
|
198
|
-
* the manifest fails schema validation.
|
|
199
|
-
*/
|
|
200
|
-
export function parseSpecialtyManifest(content: string): SpecialtyManifest {
|
|
201
|
-
const raw: unknown = parseYaml(content);
|
|
202
|
-
const result = SpecialtyManifestSchema.safeParse(raw);
|
|
203
|
-
if (!result.success) {
|
|
204
|
-
const details = result.error.issues
|
|
205
|
-
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
206
|
-
.join("; ");
|
|
207
|
-
throw new Error(`invalid Specialty manifest — ${details}`, {
|
|
208
|
-
cause: result.error,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
return result.data;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ─── JSON Schema generation ─────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Build the JSON-serializable JSON Schema object for SpecialtyManifestSchema.
|
|
218
|
-
*
|
|
219
|
-
* Pure — no file I/O — so both scripts/generate-specialty-schema.ts's CLI
|
|
220
|
-
* entrypoint and its content test can call this directly instead of shelling
|
|
221
|
-
* out. Uses zod 4's built-in `z.toJSONSchema` (draft-07 target, to stay
|
|
222
|
-
* contract-compatible with the previous zod-to-json-schema output).
|
|
223
|
-
*/
|
|
224
|
-
export function buildSpecialtyJsonSchema(): Record<string, unknown> {
|
|
225
|
-
const { $schema, ...definition } = z.toJSONSchema(SpecialtyManifestSchema, {
|
|
226
|
-
target: "draft-7",
|
|
227
|
-
io: "input",
|
|
228
|
-
});
|
|
229
|
-
// Wrap as a named definition (`$ref` + `definitions`) — the shape the
|
|
230
|
-
// previous zod-to-json-schema output used, so consumers see no change.
|
|
231
|
-
return {
|
|
232
|
-
$ref: "#/definitions/SpecialtyManifest",
|
|
233
|
-
definitions: { SpecialtyManifest: definition },
|
|
234
|
-
$schema,
|
|
235
|
-
};
|
|
236
|
-
}
|