diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index a355dc4b4..5ba5d1b71 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -330,4 +330,29 @@ describe("FsProjectManager.resolve", () => { InputValidationError, ); }); + + test("names the offending field when the spec fails validation", async () => { + const root = await inTempDirectory(); + await mkdir(join(root, "agentcore"), { recursive: true }); + // Valid JSON, invalid spec: a CodeZip runtime with no runtimeVersion. + await writeFile( + join(root, "agentcore", "agentcore.json"), + JSON.stringify({ + name: "example", + version: 1, + runtimes: [ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ], + }), + ); + + await expect(manager().manager.resolve({ filePath: root })).rejects.toThrow( + "runtimes[0].runtimeVersion: runtimeVersion is required for CodeZip builds", + ); + }); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c23f4d960..9a9de2999 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -20,6 +20,29 @@ import { createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { z } from "zod"; + +/** + * Renders the reasons a spec failed validation, as `runtimes[0].runtimeVersion: `. + * Without this the cause is only reachable from the debug log, leaving the user with + * "invalid project configuration" and no indication of which field to fix. + */ +function describeValidationFailure(cause: unknown): string { + if (!(cause instanceof z.ZodError)) return ""; + return cause.issues + .map((issue) => { + const field = issue.path.reduce( + (rendered, segment) => + typeof segment === "number" ? `${rendered}[${segment}]` : appendKey(rendered, segment), + "", + ); + return `\n - ${field === "" ? "(root)" : field}: ${issue.message}`; + }) + .join(""); +} + +const appendKey = (rendered: string, segment: PropertyKey): string => + rendered === "" ? String(segment) : `${rendered}.${String(segment)}`; type ProjectManagerConfig = { logger: Logger; @@ -63,9 +86,10 @@ export class FsProjectManager implements ProjectManager { } catch (error) { // A malformed agentcore.json is a user-correctable problem, not a crash. if (error instanceof DeserializationError) { - throw new InputValidationError(`invalid project configuration at ${configPath}`, { - cause: error, - }); + throw new InputValidationError( + `invalid project configuration at ${configPath}${describeValidationFailure(error.cause)}`, + { cause: error }, + ); } throw error; } diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 123e9a0c9..8a9b89842 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -8,6 +8,7 @@ const runtime = { build: "CodeZip" as const, entrypoint: "main.py", codeLocation: "./agent", + runtimeVersion: "PYTHON_3_12" as const, endpoints: { LIVE: { version: 1 } }, }; diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index 5af245552..d752c2f4d 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -11,6 +11,7 @@ const codeZipAgent = { build: "CodeZip" as const, entrypoint: "main.py", codeLocation: "./agent", + runtimeVersion: "PYTHON_3_12" as const, }; const containerAgent = { name: "agent", @@ -72,6 +73,15 @@ describe("runtime custom validation", () => { }).success, ).toBe(false); }); + it("requires runtimeVersion for CodeZip builds only", () => { + const { runtimeVersion: _omitted, ...withoutVersion } = codeZipAgent; + const result = ProjectRuntimeSchema.safeParse(withoutVersion); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(["runtimeVersion"]); + + // Container builds take their version from the image. + expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true); + }); it("restricts container-only fields to container builds", () => { for (const field of [ { dockerfile: "Dockerfile" }, diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 61dc95886..f168ab480 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -308,6 +308,17 @@ export const ProjectRuntimeSchema = z path: ["authorizerConfiguration"], }); } + // Mirrors the CDK construct library, which rejects a CodeZip runtime with no + // runtimeVersion: it is the field that selects the packager. Validating it here + // means the CLI reports it against agentcore.json instead of letting synthesis + // fail later with the same rule. + if (data.build !== "Container" && !data.runtimeVersion) { + ctx.addIssue({ + code: "custom", + message: "runtimeVersion is required for CodeZip builds", + path: ["runtimeVersion"], + }); + } for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) { if (data.build !== "Container" && data[field]) { ctx.addIssue({