@app-vitals/agent-admin 0.16.2 → 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.
Files changed (42) hide show
  1. package/e2e/agents-page.e2e.ts +2 -2
  2. package/openapi.json +3 -3
  3. package/openapi.platform.json +3 -3
  4. package/package.json +3 -5
  5. package/src/account-agent-create.ts +1 -1
  6. package/src/admin-boot.ts +6 -6
  7. package/src/admin-extension.ts +16 -15
  8. package/src/admin-extension.unit.test.ts +2 -2
  9. package/src/admin-local-agent.smoke.test.ts +8 -5
  10. package/src/admin-ui-pages.ts +4 -4
  11. package/src/admin-ui.smoke.test.ts +27 -39
  12. package/src/admin-ui.ts +12 -12
  13. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  14. package/src/agent-cron-jobs.integration.test.ts +23 -21
  15. package/src/agent-cron-jobs.ts +30 -40
  16. package/src/agent-cron-jobs.unit.test.ts +51 -39
  17. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  18. package/src/agent-manifest.ts +2 -6
  19. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  20. package/src/agents-api.integration.test.ts +20 -31
  21. package/src/agents-api.sentry.smoke.test.ts +2 -2
  22. package/src/agents-api.smoke.test.ts +17 -30
  23. package/src/agents-api.ts +13 -13
  24. package/src/agents.integration.test.ts +21 -31
  25. package/src/agents.ts +27 -34
  26. package/src/agents.unit.test.ts +35 -55
  27. package/src/api.smoke.test.ts +2 -2
  28. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  29. package/src/server.smoke.test.ts +2 -2
  30. package/src/specialty-defaults.ts +4 -4
  31. package/src/specialty-types.ts +92 -0
  32. package/src/specialty-types.unit.test.ts +67 -0
  33. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  34. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  35. package/src/coding-task-store-provisioning-client.ts +0 -156
  36. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  37. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  38. package/src/specialty-manifest-loader.ts +0 -285
  39. package/src/specialty-manifest-loader.unit.test.ts +0 -364
  40. package/src/specialty-registry.schema.unit.test.ts +0 -35
  41. package/src/specialty-registry.ts +0 -236
  42. package/src/specialty-registry.unit.test.ts +0 -272
@@ -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
- }