diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index 3767c88aa..c8ece4891 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -11,7 +11,7 @@ import { type ProcessStreamer, type StreamProcessOptions, } from "../../io"; -import type { ProjectRuntime } from "../project/schema"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; import { ContainerDevRunner } from "./container"; type ProcessCall = { diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index a355dc4b4..27085ea2c 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { InputValidationError, ProjectStateError } from "../../errors/errors"; +import { DeserializationError, ProjectStateError } from "../../errors/errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES, @@ -327,7 +327,32 @@ describe("FsProjectManager.resolve", () => { await writeFile(join(root, "agentcore", "agentcore.json"), "{ not valid json"); await expect(manager().manager.resolve({ filePath: root })).rejects.toBeInstanceOf( - InputValidationError, + DeserializationError, + ); + }); + + 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( + "runtimeVersion is required for CodeZip builds", ); }); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c23f4d960..0859d34c3 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -19,7 +19,7 @@ import { defaultSource, type AssetSource } from "./source"; import { createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; -import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { ProjectStateError } from "../../errors/errors"; type ProjectManagerConfig = { logger: Logger; @@ -52,23 +52,13 @@ export class FsProjectManager implements ProjectManager { if (!rootPath) return undefined; const configPath = join(rootPath, "agentcore", "agentcore.json"); - try { - const spec = await this.json.read(configPath, ProjectSpecSchema); - return { - name: spec.name, - rootPath, - managedBy: spec.managedBy, - runtimes: spec.runtimes, - }; - } 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 error; - } + const spec = await this.json.read(configPath, ProjectSpecSchema); + return { + name: spec.name, + rootPath, + managedBy: spec.managedBy, + runtimes: spec.runtimes, + }; } public async *create(input: CreateProjectInput): AsyncGenerator { diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 262ac8d4d..7fae0222a 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -90,11 +90,13 @@ export class SourceResolutionError extends InputValidationError { } } -// TODO: attach telemetry metadata to this error class. -export class DeserializationError extends Error { - constructor(path: string, options?: { cause?: unknown }) { - super(`Failed to deserialize JSON at "${path}"`, options); - this.name = "DeserializationError"; +export class DeserializationError extends AgentCoreCLIError { + constructor(path: string, options?: { cause?: unknown; detail?: string }) { + const detail = options?.detail ? `\n${options.detail}` : ""; + super(`Failed to deserialize JSON at "${path}"${detail}`, { + ...options, + source: ERROR_SOURCE.USER, + }); } } diff --git a/src/io/json.ts b/src/io/json.ts index 082f72b71..064b1481c 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type z from "zod"; +import { prettifyError } from "zod"; import { DeserializationError } from "../errors"; import type { Logger } from "../logging"; @@ -52,7 +53,10 @@ export class FsReadWriteJson implements ReadWriteJson { errorMessage: parseResult.error.message, }) .error(`failed to validate parsed json file`); - throw new DeserializationError(filePath, { cause: parseResult.error }); + throw new DeserializationError(filePath, { + cause: parseResult.error, + detail: prettifyError(parseResult.error), + }); } return parseResult.data; 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({