From 334bb293974864eb9e10d1172469f49032e63ff6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 16:53:48 -0400 Subject: [PATCH 01/16] feat(project): add hello-world-python-container template Same agent code as the CodeZip template packaged for a Container build: a static uv-based Dockerfile (no render-time conditionals), a .dockerignore rendered from dockerignore.template via the existing ignore-file mechanism, and a Container runtime entry (with dockerfile) in agentcore.json. --- .../hello-world-python-container/Dockerfile | 28 +++++++++++ .../dockerignore.template | 14 ++++++ .../hello-world-python-container/main.py | 17 +++++++ .../pyproject.toml | 19 ++++++++ .../__snapshots__/manager.test.ts.snap | 24 +++++++++- src/core/project/compose.ts | 2 +- src/core/project/manager.test.ts | 46 ++++++++++++++----- src/core/project/templates.ts | 15 ++++++ src/handlers/project/types.ts | 1 + 9 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 src/assets/templates/hello-world-python-container/Dockerfile create mode 100644 src/assets/templates/hello-world-python-container/dockerignore.template create mode 100644 src/assets/templates/hello-world-python-container/main.py create mode 100644 src/assets/templates/hello-world-python-container/pyproject.toml diff --git a/src/assets/templates/hello-world-python-container/Dockerfile b/src/assets/templates/hello-world-python-container/Dockerfile new file mode 100644 index 000000000..8ffc39dbd --- /dev/null +++ b/src/assets/templates/hello-world-python-container/Dockerfile @@ -0,0 +1,28 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim-trixie + +RUN pip install --no-cache-dir uv + +WORKDIR /app + +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/app/.venv/bin:$PATH" + +RUN useradd -m -u 1000 bedrock_agentcore + +# Install dependencies first so code changes don't invalidate the layer. +COPY pyproject.toml ./ +RUN uv sync --no-dev --no-install-project + +COPY --chown=bedrock_agentcore:bedrock_agentcore . . +RUN uv sync --no-dev + +USER bedrock_agentcore + +# AgentCore Runtime service contract port (HTTP mode). +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +EXPOSE 8080 + +CMD ["python", "main.py"] diff --git a/src/assets/templates/hello-world-python-container/dockerignore.template b/src/assets/templates/hello-world-python-container/dockerignore.template new file mode 100644 index 000000000..7ed0444da --- /dev/null +++ b/src/assets/templates/hello-world-python-container/dockerignore.template @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ diff --git a/src/assets/templates/hello-world-python-container/main.py b/src/assets/templates/hello-world-python-container/main.py new file mode 100644 index 000000000..182bfd57e --- /dev/null +++ b/src/assets/templates/hello-world-python-container/main.py @@ -0,0 +1,17 @@ +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from strands import Agent + +app = BedrockAgentCoreApp() +agent = Agent(system_prompt="You are a helpful assistant.") + + +@app.entrypoint +async def invoke(payload, context): + """Stream the agent's response to the caller's prompt.""" + prompt = payload.get("prompt", "Hello!") + async for event in agent.stream_async(prompt): + yield event + + +if __name__ == "__main__": + app.run() diff --git a/src/assets/templates/hello-world-python-container/pyproject.toml b/src/assets/templates/hello-world-python-container/pyproject.toml new file mode 100644 index 000000000..09070bf8e --- /dev/null +++ b/src/assets/templates/hello-world-python-container/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hello-world" +version = "0.1.0" +description = "AgentCore Runtime application using the Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "aws-opentelemetry-distro", + "bedrock-agentcore >= 1.9.1", + "botocore[crt] >= 1.35.0", + "strands-agents >= 1.15.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 87d602318..ec64db873 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -1,6 +1,6 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`FsProjectManager.create scaffolds the expected file tree into a fresh directory 1`] = ` +exports[`FsProjectManager.create scaffolds the expected file tree for hello-world-python into a fresh directory 1`] = ` [ "agentcore/agentcore.json", "agentcore/aws-targets.json", @@ -19,3 +19,25 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d "app/hello-world/pyproject.toml", ] `; + +exports[`FsProjectManager.create scaffolds the expected file tree for hello-world-python-container into a fresh directory 1`] = ` +[ + "agentcore/agentcore.json", + "agentcore/aws-targets.json", + "agentcore/cdk/.gitignore", + "agentcore/cdk/.npmignore", + "agentcore/cdk/.prettierrc", + "agentcore/cdk/README.md", + "agentcore/cdk/bin/cdk.ts", + "agentcore/cdk/cdk.json", + "agentcore/cdk/jest.config.js", + "agentcore/cdk/lib/cdk-stack.ts", + "agentcore/cdk/package.json", + "agentcore/cdk/test/cdk.test.ts", + "agentcore/cdk/tsconfig.json", + "app/hello-world/.dockerignore", + "app/hello-world/Dockerfile", + "app/hello-world/main.py", + "app/hello-world/pyproject.toml", +] +`; diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts index 06b3239c6..ef25b550a 100644 --- a/src/core/project/compose.ts +++ b/src/core/project/compose.ts @@ -41,7 +41,7 @@ async function expandDir(src: AssetSource, assetDir: string): Promise { - test("scaffolds the expected file tree into a fresh directory", async () => { - const directory = await inTempDirectory(); - await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + test.each(Object.values(PROJECT_TEMPLATES))( + "scaffolds the expected file tree for %s into a fresh directory", + async (template) => { + const directory = await inTempDirectory(); + await manager().create({ name: "example", template }); - const projectRoot = join(directory, "example"); - const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => - relative(projectRoot, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), - ) - .sort(); + const projectRoot = join(directory, "example"); + const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(projectRoot, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); - expect(manifest).toMatchSnapshot(); - }); + expect(manifest).toMatchSnapshot(); + }, + ); test("writes a deploy-ready agentcore.json registering the template agent", async () => { const directory = await inTempDirectory(); @@ -62,6 +65,25 @@ describe("FsProjectManager.create", () => { expect(await Bun.file(join(configDir, "aws-targets.json")).json()).toEqual([]); }); + test("registers a Container runtime with its Dockerfile for the container template", async () => { + const directory = await inTempDirectory(); + await manager().create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER, + }); + + const spec = await Bun.file(join(directory, "example", "agentcore", "agentcore.json")).json(); + expect(spec.runtimes).toEqual([ + { + name: "hello_world", + build: "Container", + entrypoint: "main.py", + codeLocation: "app/hello-world", + dockerfile: "Dockerfile", + }, + ]); + }); + test("refuses to overwrite an existing project", async () => { await inTempDirectory(); const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }; diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 558b0d531..b86a0dd0f 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -34,4 +34,19 @@ export const TEMPLATES: Record = { ], }, }, + [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER]: { + appDir: "hello-world", + assetDir: "templates/hello-world-python-container", + spec: { + runtimes: [ + { + name: "hello_world", + build: "Container", + entrypoint: "main.py", + codeLocation: "app/hello-world", + dockerfile: "Dockerfile", + }, + ], + }, + }, }; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 458d562c4..beffd64ce 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -3,6 +3,7 @@ import z from "zod"; /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { HELLO_WORLD_PYTHON: "hello-world-python", + HELLO_WORLD_PYTHON_CONTAINER: "hello-world-python-container", } as const; export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; From 2b40df6c0c1be954ad346bc27f2e3db4ab726097 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 16:55:09 -0400 Subject: [PATCH 02/16] feat(project): scaffold root .gitignore and agentcore/.env.local Every template now gets a project-root .gitignore (env files, Python/Node artifacts, CLI state, cdk.out) and a commented agentcore/.env.local that agentcore dev will load. Both render from shared *.template assets because npm strips real dotfiles when publishing. --- src/assets/templates/shared/env.local.template | 7 +++++++ src/assets/templates/shared/gitignore.template | 17 +++++++++++++++++ .../project/__snapshots__/manager.test.ts.snap | 4 ++++ src/core/project/compose.ts | 2 ++ 4 files changed, 30 insertions(+) create mode 100644 src/assets/templates/shared/env.local.template create mode 100644 src/assets/templates/shared/gitignore.template diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template new file mode 100644 index 000000000..30a18b616 --- /dev/null +++ b/src/assets/templates/shared/env.local.template @@ -0,0 +1,7 @@ +# Environment variables for local development. +# `agentcore dev` loads this file into your agent's process. Values here +# override anything the CLI injects. This file is gitignored — keep secrets +# out of version control, but they are safe here. +# +# Example: +# MY_API_KEY=... diff --git a/src/assets/templates/shared/gitignore.template b/src/assets/templates/shared/gitignore.template new file mode 100644 index 000000000..d00650afc --- /dev/null +++ b/src/assets/templates/shared/gitignore.template @@ -0,0 +1,17 @@ +# Local environment (loaded by `agentcore dev`; never commit secrets) +.env.local +.env*.local + +# Python +__pycache__/ +*.py[cod] +.venv/ + +# Node +node_modules/ + +# AgentCore CLI state +agentcore/.cli/ + +# CDK +agentcore/cdk/cdk.out/ diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index ec64db873..bbf58aea5 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -2,6 +2,8 @@ exports[`FsProjectManager.create scaffolds the expected file tree for hello-world-python into a fresh directory 1`] = ` [ + ".gitignore", + "agentcore/.env.local", "agentcore/agentcore.json", "agentcore/aws-targets.json", "agentcore/cdk/.gitignore", @@ -22,6 +24,8 @@ exports[`FsProjectManager.create scaffolds the expected file tree for hello-worl exports[`FsProjectManager.create scaffolds the expected file tree for hello-world-python-container into a fresh directory 1`] = ` [ + ".gitignore", + "agentcore/.env.local", "agentcore/agentcore.json", "agentcore/aws-targets.json", "agentcore/cdk/.gitignore", diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts index ef25b550a..a24ac18dc 100644 --- a/src/core/project/compose.ts +++ b/src/core/project/compose.ts @@ -69,10 +69,12 @@ export async function projectTree( ): Promise { const { appDir, assetDir } = TEMPLATES[template]; return dir(".", [ + file(".gitignore", () => src.read("templates/shared/gitignore.template")), dir("agentcore", [ dir("cdk", await expandDir(src, "cdk")), file("agentcore.json", async () => json(agentcoreSpec(name, template))), file("aws-targets.json", async () => json([])), + file(".env.local", () => src.read("templates/shared/env.local.template")), ]), dir("app", [dir(appDir, await expandDir(src, assetDir))]), ]); From 54297e9d27d8bda5f7c41155d837eee1e98e58e3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 17:31:24 -0400 Subject: [PATCH 03/16] feat(project): implement project resolution and pin it via withProject FsProjectManager.resolve now walks up from the given path for the agentcore/agentcore.json marker, validates the spec against the new ProjectSpecSchema (loose, so specs carrying sections we don't read yet still resolve), and returns the widened Project model (rootPath + runtimes). A missing project resolves to undefined; a present-but-broken config throws InvalidProjectConfigError instead of masquerading as 'no project'. withProject now throws a typed NoProjectError and is mounted on every project subcommand except create, which runs where no project exists yet. The search root prefers INIT_CWD since package-manager scripts change process.cwd(). --- src/core/project/manager.test.ts | 84 +++++++++++++++++++++++++++- src/core/project/manager.tsx | 44 ++++++++++++--- src/errors/errors.tsx | 10 ++++ src/errors/index.tsx | 2 + src/handlers/project/index.ts | 23 ++++++-- src/handlers/project/project.test.ts | 9 +++ src/handlers/project/types.ts | 25 +++++++++ src/middleware/index.tsx | 1 + src/middleware/withProject.test.ts | 52 ++++++++++++----- src/middleware/withProject.tsx | 4 +- 10 files changed, 219 insertions(+), 35 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 5cf7e9a7e..f1541676a 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { NestedProjectError, ProjectFileExistsError } from "../../errors"; +import { + InvalidProjectConfigError, + NestedProjectError, + ProjectFileExistsError, +} from "../../errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; @@ -14,7 +18,9 @@ async function inTempDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-")); tempDirectories.push(directory); process.chdir(directory); - return directory; + // cwd() resolves symlinks (macOS /var -> /private/var); use the canonical + // path so assertions against manager-returned paths compare equal. + return process.cwd(); } afterEach(async () => { @@ -102,3 +108,75 @@ describe("FsProjectManager.create", () => { ).rejects.toBeInstanceOf(NestedProjectError); }); }); + +describe("FsProjectManager.resolve", () => { + test.each(Object.values(PROJECT_TEMPLATES))( + "round-trips a created %s project from a nested subdirectory", + async (template) => { + const directory = await inTempDirectory(); + const created = await manager().create({ name: "example", template }); + + const resolved = await manager().resolve({ + filePath: join(directory, "example", "app", "hello-world"), + }); + + expect(resolved).toEqual(created); + expect(resolved?.rootPath).toBe(join(directory, "example")); + }, + ); + + test("returns undefined when no project encloses the path", async () => { + const directory = await inTempDirectory(); + expect(await manager().resolve({ filePath: directory })).toBeUndefined(); + }); + + test("throws InvalidProjectConfigError for malformed JSON", async () => { + const directory = await inTempDirectory(); + await mkdir(join(directory, "agentcore"), { recursive: true }); + await Bun.write(join(directory, "agentcore", "agentcore.json"), "{ not json"); + + await expect(manager().resolve({ filePath: directory })).rejects.toBeInstanceOf( + InvalidProjectConfigError, + ); + }); + + test("throws InvalidProjectConfigError when the spec fails validation", async () => { + const directory = await inTempDirectory(); + await mkdir(join(directory, "agentcore"), { recursive: true }); + await Bun.write( + join(directory, "agentcore", "agentcore.json"), + JSON.stringify({ name: "example", runtimes: [{ name: "broken" }] }), + ); + + await expect(manager().resolve({ filePath: directory })).rejects.toBeInstanceOf( + InvalidProjectConfigError, + ); + }); + + test("resolves a spec carrying sections the CLI does not read", async () => { + const directory = await inTempDirectory(); + await mkdir(join(directory, "agentcore"), { recursive: true }); + await Bun.write( + join(directory, "agentcore", "agentcore.json"), + JSON.stringify({ + name: "legacy", + version: 1, + managedBy: "CDK", + runtimes: [ + { + name: "agent", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/agent", + protocol: "HTTP", + }, + ], + memories: [{ name: "m1" }], + gateways: [], + }), + ); + + const resolved = await manager().resolve({ filePath: directory }); + expect(resolved?.runtimes[0]?.name).toBe("agent"); + }); +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c4f14ece7..573a4b8b3 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,14 +1,18 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; -import { NestedProjectError } from "../../errors"; -import type { - CreateProjectInput, - ResolveProjectInput, - Project, - ProjectManager, +import z from "zod"; +import { DeserializationError, InvalidProjectConfigError, NestedProjectError } from "../../errors"; +import { + ProjectSpecSchema, + type CreateProjectInput, + type ResolveProjectInput, + type Project, + type ProjectManager, } from "../../handlers/project/types"; +import { FsReadWriteJson, type ReadWriteJson } from "../../io"; import type { Logger } from "../../logging"; import { projectTree } from "./compose"; +import { TEMPLATES } from "./templates"; import { defaultSource, type AssetSource } from "./source"; import { writeTree } from "./tree"; @@ -27,6 +31,7 @@ function enclosingProjectRoot(directory: string): string | undefined { type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime + json?: ReadWriteJson; }; /** @@ -35,14 +40,31 @@ type ProjectManagerConfig = { export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; + private readonly json: ReadWriteJson; constructor(config: ProjectManagerConfig) { this.logger = config.logger; this.source = config.source ?? defaultSource(); + this.json = config.json ?? new FsReadWriteJson({ logger: this.logger }); } - public resolve(_input: ResolveProjectInput): Promise { - throw new Error(`ProjectManager.resolve is not implemented yet`); + public async resolve(input: ResolveProjectInput): Promise { + const rootPath = enclosingProjectRoot(input.filePath); + if (!rootPath) { + return undefined; + } + + const configPath = join(rootPath, "agentcore", "agentcore.json"); + let spec; + try { + spec = await this.json.read(configPath, ProjectSpecSchema); + } catch (e) { + if (!(e instanceof DeserializationError)) throw e; + const detail = e.cause instanceof z.ZodError ? z.prettifyError(e.cause) : "not valid JSON"; + throw new InvalidProjectConfigError(configPath, detail); + } + + return { name: spec.name, rootPath, runtimes: spec.runtimes }; } public async create(input: CreateProjectInput): Promise { @@ -57,6 +79,10 @@ export class FsProjectManager implements ProjectManager { const tree = await projectTree(input.name, input.template, this.source); await writeTree(tree, destination); - return { name: input.name }; + return { + name: input.name, + rootPath: destination, + runtimes: TEMPLATES[input.template].spec.runtimes ?? [], + }; } } diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 6e327d9a3..a3ef7bd84 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -69,6 +69,16 @@ export class InputValidationError extends AgentCoreCLIError { } } +/** Error raised when a command requires an AgentCore project and none encloses the working directory. */ +export class NoProjectError extends AgentCoreCLIError { + constructor(searchPath: string, options?: Omit) { + super( + `no AgentCore project found at ${searchPath} or any parent directory; run \`agentcore project create\` to make one`, + { ...options, source: ERROR_SOURCE.USER, meta: { ...options?.meta, searchPath } }, + ); + } +} + /** Error raised when a command or operation has not been implemented yet. */ export class NotImplementedError extends AgentCoreCLIError { constructor(message?: string, options?: Omit) { diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 2c95483cd..98f5c8001 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -4,7 +4,9 @@ export { EmbeddedAssetNotFoundError, InputValidationError, InvalidEnvironmentError, + InvalidProjectConfigError, NestedProjectError, + NoProjectError, NotImplementedError, ProjectFileExistsError, RuntimeInvokeInterruptedError, diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 8dc35a487..31700f9f1 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,3 +1,4 @@ +import { withProject } from "../../middleware"; import { Router } from "../../router"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; @@ -10,18 +11,28 @@ import type { ProjectManager } from "./types"; type ProjectHandlerConfig = { projectManager: ProjectManager; + /** Directory project-scoped commands resolve the project from. */ + cwd?: string; }; export function createProjectHandler(config: ProjectHandlerConfig): Router { const project = new Router("project", "manage an AgentCore project"); + // npm/bun scripts change process.cwd() to the package root; INIT_CWD + // preserves the directory the user actually ran the command from. + const cwd = config.cwd ?? process.env.INIT_CWD ?? process.cwd(); + + // Commands that operate on an existing project get it resolved onto the + // context. `create` stays unwrapped — it runs where no project exists yet. + const inProject = withProject({ projectManager: config.projectManager, cwd }); + project.handler(createCreateProjectHandler({ projectManager: config.projectManager })); - project.handler(createAddProjectHandler()); - project.handler(createRemoveProjectHandler()); - project.handler(createDevProjectHandler()); - project.handler(createDeployProjectHandler()); - project.handler(createStatusProjectHandler()); - project.handler(createBuildProjectHandler()); + project.handler(inProject(createAddProjectHandler())); + project.handler(inProject(createRemoveProjectHandler())); + project.handler(inProject(createDevProjectHandler())); + project.handler(inProject(createDeployProjectHandler())); + project.handler(inProject(createStatusProjectHandler())); + project.handler(inProject(createBuildProjectHandler())); return project; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 7e1d13c0f..a4bc30a63 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -21,7 +21,16 @@ async function run(args: string[]): Promise { } describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { + test("refuses to run outside a project", async () => { + await inTempDirectory(); + await expect(run([command])).rejects.toThrow(/no AgentCore project found/); + }); + test("throws because it is not implemented yet", async () => { + const directory = await inTempDirectory(); + await run(["create", "--project-name", "MyAgent"]); + process.chdir(join(directory, "MyAgent")); + await expect(run([command])).rejects.toThrow(/not implemented/); }); }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index beffd64ce..c70eb0bf8 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -96,8 +96,33 @@ export type ResolveProjectInput = { filePath: string; }; +/** + * The slice of agentcore.json the CLI consumes. Parsing is loose so projects + * carrying sections we don't read yet (memories, gateways, ...) still resolve. + */ +export const ProjectSpecSchema = z.looseObject({ + name: z.string().min(1), + runtimes: z + .array( + z.looseObject({ + name: z.string().min(1), + build: z.enum(["CodeZip", "Container"]), + entrypoint: z.string().min(1), + codeLocation: z.string().min(1), + dockerfile: z.string().optional(), + }), + ) + .default([]), +}); + +export type ProjectRuntime = z.infer["runtimes"][number]; + export type Project = { name: string; + /** Absolute path to the project root (the parent of agentcore/). */ + rootPath: string; + /** The runtimes registered in agentcore.json. */ + runtimes: ProjectRuntime[]; }; /** diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e77355898..4838ad322 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,3 +3,4 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withProject } from "./withProject"; diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index 852e75bf3..d507a5ff5 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -1,23 +1,45 @@ import { test, expect, describe } from "bun:test"; -import { Router, createHandler } from "../router"; -import { createSilentLogger } from "../testing"; +import { NoProjectError } from "../errors"; +import type { Project, ProjectManager } from "../handlers/project/types"; +import { ProjectKey, Router, createHandler } from "../router"; import { withProject } from "./withProject"; -import { FsProjectManager } from "../core/project"; + +function app(projectManager: ProjectManager, onProject: (project: Project | undefined) => void) { + const router = new Router("app", "test"); + router.use(withProject({ projectManager, cwd: "/some/path" })); + router.handler( + createHandler({ + name: "check", + description: "noop", + handle: async (ctx) => onProject(ctx.value(ProjectKey)), + }), + ); + return router; +} describe("withProject", () => { - test("throws not implemented", async () => { - const projectManager = new FsProjectManager({ logger: createSilentLogger() }); + test("pins the resolved project on the context", async () => { + const project: Project = { name: "example", rootPath: "/some/path", runtimes: [] }; + const projectManager = { + resolve: async () => project, + create: async () => project, + }; + + let seen: Project | undefined; + await app(projectManager, (p) => (seen = p)).route(["node", "app", "check"]); + expect(seen).toEqual(project); + }); - const app = new Router("app", "test"); - app.use(withProject({ projectManager, cwd: "/some/path" })); - app.handler( - createHandler({ - name: "check", - description: "noop", - handle: async () => {}, - }), - ); + test("throws NoProjectError when no project encloses the working directory", async () => { + const projectManager = { + resolve: async () => undefined, + create: async (): Promise => { + throw new Error("unused"); + }, + }; - await expect(app.route(["node", "app", "check"])).rejects.toThrow(/not implemented/); + await expect( + app(projectManager, () => {}).route(["node", "app", "check"]), + ).rejects.toBeInstanceOf(NoProjectError); }); }); diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index 288d227f7..301b5645f 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -1,3 +1,4 @@ +import { NoProjectError } from "../errors"; import type { Project, ProjectManager } from "../handlers/project/types"; import { ProjectKey, type Middleware } from "../router"; @@ -22,8 +23,7 @@ export function withProject(config: WithProjectConfig): Middleware { children: () => h.children(), handle: async (ctx, flags, args) => { const project = await config.projectManager.resolve({ filePath: config.cwd }); - // TODO: swap this for a typed error. - if (!project) throw new Error(`Unable to find project at path ${config.cwd}`); + if (!project) throw new NoProjectError(config.cwd); await h.handle(ctx.withValue(ProjectKey, project), flags, args); }, }); From e1727dd9d9166c75e98e9cde5cae172a4da7771b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 17:44:49 -0400 Subject: [PATCH 04/16] refactor(project): drop unused cwd override from project handler config --- src/handlers/project/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 31700f9f1..22f36c87e 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -11,8 +11,6 @@ import type { ProjectManager } from "./types"; type ProjectHandlerConfig = { projectManager: ProjectManager; - /** Directory project-scoped commands resolve the project from. */ - cwd?: string; }; export function createProjectHandler(config: ProjectHandlerConfig): Router { @@ -20,7 +18,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { // npm/bun scripts change process.cwd() to the package root; INIT_CWD // preserves the directory the user actually ran the command from. - const cwd = config.cwd ?? process.env.INIT_CWD ?? process.cwd(); + const cwd = process.env.INIT_CWD ?? process.cwd(); // Commands that operate on an existing project get it resolved onto the // context. `create` stays unwrapped — it runs where no project exists yet. From 97f97d6f41f61d781bbe338e39c0872c7dfbc90d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 17:46:23 -0400 Subject: [PATCH 05/16] feat(project): vend a README with both templates pyproject.toml declares readme = README.md; without the file, uv sync fails the hatchling build inside the container image. A starter README is also just better scaffolding. --- .../hello-world-python-container/README.md | 27 +++++++++++++++++++ .../templates/hello-world-python/README.md | 27 +++++++++++++++++++ .../__snapshots__/manager.test.ts.snap | 2 ++ 3 files changed, 56 insertions(+) create mode 100644 src/assets/templates/hello-world-python-container/README.md create mode 100644 src/assets/templates/hello-world-python/README.md diff --git a/src/assets/templates/hello-world-python-container/README.md b/src/assets/templates/hello-world-python-container/README.md new file mode 100644 index 000000000..637390bb0 --- /dev/null +++ b/src/assets/templates/hello-world-python-container/README.md @@ -0,0 +1,27 @@ +# hello-world + +An AgentCore Runtime agent built with the [Strands](https://strandsagents.com) +SDK. Scaffolded by `agentcore project create`. + +## Layout + +- `main.py` — the agent: a `BedrockAgentCoreApp` entrypoint that streams + responses from a Strands `Agent`. +- `pyproject.toml` — dependencies, installed with [uv](https://docs.astral.sh/uv/). + +## Develop + +Run the agent locally from the project root: + +```bash +agentcore project dev +``` + +Environment variables for local development go in `agentcore/.env.local` +(gitignored). + +## Deploy + +```bash +agentcore project deploy +``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md new file mode 100644 index 000000000..637390bb0 --- /dev/null +++ b/src/assets/templates/hello-world-python/README.md @@ -0,0 +1,27 @@ +# hello-world + +An AgentCore Runtime agent built with the [Strands](https://strandsagents.com) +SDK. Scaffolded by `agentcore project create`. + +## Layout + +- `main.py` — the agent: a `BedrockAgentCoreApp` entrypoint that streams + responses from a Strands `Agent`. +- `pyproject.toml` — dependencies, installed with [uv](https://docs.astral.sh/uv/). + +## Develop + +Run the agent locally from the project root: + +```bash +agentcore project dev +``` + +Environment variables for local development go in `agentcore/.env.local` +(gitignored). + +## Deploy + +```bash +agentcore project deploy +``` diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index bbf58aea5..4316eac51 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -17,6 +17,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree for hello-worl "agentcore/cdk/package.json", "agentcore/cdk/test/cdk.test.ts", "agentcore/cdk/tsconfig.json", + "app/hello-world/README.md", "app/hello-world/main.py", "app/hello-world/pyproject.toml", ] @@ -41,6 +42,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree for hello-worl "agentcore/cdk/tsconfig.json", "app/hello-world/.dockerignore", "app/hello-world/Dockerfile", + "app/hello-world/README.md", "app/hello-world/main.py", "app/hello-world/pyproject.toml", ] From c26b8f00775c07a61658b603e003aa28e3798061 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 18:56:10 -0400 Subject: [PATCH 06/16] refactor(project): type template runtimes, trim redundant assertions TemplateSpec.runtimes is now ProjectRuntime[] so template/schema drift is a compile error and create() returns the spec directly instead of re-parsing it through zod. --- src/core/project/templates.ts | 8 ++++++-- src/middleware/withProject.test.ts | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index b86a0dd0f..cff08e574 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,7 +1,11 @@ -import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; +import { + PROJECT_TEMPLATES, + type ProjectRuntime, + type ProjectTemplate, +} from "../../handlers/project/types"; type TemplateSpec = { - runtimes?: unknown[]; + runtimes?: ProjectRuntime[]; memories?: unknown[]; harnesses?: unknown[]; }; diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index d507a5ff5..9878d412a 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -33,9 +33,7 @@ describe("withProject", () => { test("throws NoProjectError when no project encloses the working directory", async () => { const projectManager = { resolve: async () => undefined, - create: async (): Promise => { - throw new Error("unused"); - }, + create: async () => ({}) as Project, }; await expect( From 1e2c46d5be9ca6ff0bb7ae5501084d56373b7dd7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 20:05:35 -0400 Subject: [PATCH 07/16] refactor(errors): promote DeserializationError to src/errors It was a private bare-Error subclass inside FsReadWriteJson (with a TODO to model it properly). It now extends AgentCoreCLIError alongside the other typed errors, and FsProjectManager.resolve catches it by type instead of sniffing error.cause. --- src/errors/errors.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index a3ef7bd84..df96bd648 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -69,6 +69,19 @@ export class InputValidationError extends AgentCoreCLIError { } } +/** Error raised when a JSON file cannot be read, parsed, or validated against its schema. */ +export class DeserializationError extends AgentCoreCLIError { + constructor( + public readonly path: string, + options?: AgentCoreCLIErrorOptions, + ) { + super(`Failed to deserialize JSON at "${path}"`, { + ...options, + meta: { ...options?.meta, path }, + }); + } +} + /** Error raised when a command requires an AgentCore project and none encloses the working directory. */ export class NoProjectError extends AgentCoreCLIError { constructor(searchPath: string, options?: Omit) { From f7342b67f426550bd2b26bee0dda6423bfde53db Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 12:54:15 -0400 Subject: [PATCH 08/16] refactor(errors): consolidate every error class into src/errors NestedProjectError, InvalidProjectConfigError, ProjectFileExistsError, EmbeddedAssetNotFoundError, InvalidEnvironmentError, and the runtime invoke errors were defined next to their throw sites. All typed errors now live in src/errors like the telemetry model expects; the invoke errors.ts file is gone. --- src/errors/errors.tsx | 57 +++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index df96bd648..e8ae5c9a6 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -69,6 +69,27 @@ export class InputValidationError extends AgentCoreCLIError { } } +/** Error raised when a command or operation has not been implemented yet. */ +export class NotImplementedError extends AgentCoreCLIError { + constructor(message?: string, options?: Omit) { + super(message ?? "not implemented yet", { ...options, source: ERROR_SOURCE.INTERNAL }); + } +} + +/** Error raised when detecting an invalid environment */ +export class InvalidEnvironmentError extends AgentCoreCLIError { + constructor(message?: string, options?: Omit) { + super(message, { ...options, source: ERROR_SOURCE.USER }); + } +} + +export class SourceResolutionError extends InputValidationError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "SourceResolutionError"; + } +} + /** Error raised when a JSON file cannot be read, parsed, or validated against its schema. */ export class DeserializationError extends AgentCoreCLIError { constructor( @@ -92,32 +113,16 @@ export class NoProjectError extends AgentCoreCLIError { } } -/** Error raised when a command or operation has not been implemented yet. */ -export class NotImplementedError extends AgentCoreCLIError { - constructor(message?: string, options?: Omit) { - super(message ?? "not implemented yet", { ...options, source: ERROR_SOURCE.INTERNAL }); - } -} - -/** Error raised when detecting an invalid environment */ -export class InvalidEnvironmentError extends AgentCoreCLIError { - constructor(message?: string, options?: Omit) { - super(message, { ...options, source: ERROR_SOURCE.USER }); - } -} - -export class SourceResolutionError extends InputValidationError { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "SourceResolutionError"; - } -} - -// 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"; +/** Thrown when a project's agentcore.json exists but cannot be parsed or fails validation. */ +export class InvalidProjectConfigError extends AgentCoreCLIError { + constructor( + public readonly configPath: string, + detail: string, + ) { + super(`invalid project config at ${configPath}: ${detail}`, { + source: ERROR_SOURCE.USER, + meta: { configPath }, + }); } } From 3a296552ffb434d77f13474487371f72325eec8f Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 3 Aug 2026 12:25:09 -0400 Subject: [PATCH 09/16] chore(project): drop test helper comment --- src/core/project/manager.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index f1541676a..7b394f217 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -18,8 +18,6 @@ async function inTempDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-")); tempDirectories.push(directory); process.chdir(directory); - // cwd() resolves symlinks (macOS /var -> /private/var); use the canonical - // path so assertions against manager-returned paths compare equal. return process.cwd(); } From 6c1eba72d280db40de55301e72c03bf086e85ba4 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 3 Aug 2026 13:45:33 -0400 Subject: [PATCH 10/16] refactor(project): one strict schema owns the agentcore.json format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create previously wrote the spec as an untyped object while resolve validated a hand-rolled loose slice — the two were held together only by the round-trip test. agentcoreSpec() now parses through ProjectSpecSchema before writing, and the schema is strict: version and managedBy are modeled, unknown keys (including typos like runtmes) fail loudly instead of parsing as an empty project. New sections get added to the schema as the CLI grows support for them. --- src/core/project/compose.ts | 13 +++++++++---- src/core/project/manager.test.ts | 21 ++++++--------------- src/core/project/manager.tsx | 10 +++------- src/core/project/templates.ts | 3 +-- src/handlers/project/types.ts | 14 ++++++++++---- 5 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts index a24ac18dc..49e07a1a0 100644 --- a/src/core/project/compose.ts +++ b/src/core/project/compose.ts @@ -2,7 +2,11 @@ import type { DirNode, ProjectNode } from "./tree"; import { dir, file } from "./tree"; import type { AssetSource } from "./source"; import { TEMPLATES } from "./templates"; -import type { ProjectTemplate } from "../../handlers/project/types"; +import { + ProjectSpecSchema, + type ProjectSpec, + type ProjectTemplate, +} from "../../handlers/project/types"; /** Serializes a value as pretty-printed JSON with a trailing newline. */ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; @@ -48,14 +52,15 @@ function renderName(filename: string): string { /** * Builds the agentcore.json spec by adding the template's resource sections to the shared base. * The base fields and template sections never overlap so this is a plain spread. + * Parsed through {@link ProjectSpecSchema} so create can never write a file resolve can't read. */ -function agentcoreSpec(name: string, template: ProjectTemplate): unknown { - return { +export function agentcoreSpec(name: string, template: ProjectTemplate): ProjectSpec { + return ProjectSpecSchema.parse({ name, version: 1, managedBy: "CDK", ...TEMPLATES[template].spec, - }; + }); } /** diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 7b394f217..ada3c88d9 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -151,30 +151,21 @@ describe("FsProjectManager.resolve", () => { ); }); - test("resolves a spec carrying sections the CLI does not read", async () => { + test("rejects a spec carrying keys the schema does not model", async () => { const directory = await inTempDirectory(); await mkdir(join(directory, "agentcore"), { recursive: true }); await Bun.write( join(directory, "agentcore", "agentcore.json"), JSON.stringify({ - name: "legacy", + name: "example", version: 1, managedBy: "CDK", - runtimes: [ - { - name: "agent", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/agent", - protocol: "HTTP", - }, - ], - memories: [{ name: "m1" }], - gateways: [], + runtmes: [], }), ); - const resolved = await manager().resolve({ filePath: directory }); - expect(resolved?.runtimes[0]?.name).toBe("agent"); + await expect(manager().resolve({ filePath: directory })).rejects.toBeInstanceOf( + InvalidProjectConfigError, + ); }); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 573a4b8b3..b663fc021 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -11,8 +11,7 @@ import { } from "../../handlers/project/types"; import { FsReadWriteJson, type ReadWriteJson } from "../../io"; import type { Logger } from "../../logging"; -import { projectTree } from "./compose"; -import { TEMPLATES } from "./templates"; +import { agentcoreSpec, projectTree } from "./compose"; import { defaultSource, type AssetSource } from "./source"; import { writeTree } from "./tree"; @@ -79,10 +78,7 @@ export class FsProjectManager implements ProjectManager { const tree = await projectTree(input.name, input.template, this.source); await writeTree(tree, destination); - return { - name: input.name, - rootPath: destination, - runtimes: TEMPLATES[input.template].spec.runtimes ?? [], - }; + const spec = agentcoreSpec(input.name, input.template); + return { name: spec.name, rootPath: destination, runtimes: spec.runtimes }; } } diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index cff08e574..839bb4c89 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -4,10 +4,9 @@ import { type ProjectTemplate, } from "../../handlers/project/types"; +// The resource sections a template may contribute; grows with ProjectSpecSchema. type TemplateSpec = { runtimes?: ProjectRuntime[]; - memories?: unknown[]; - harnesses?: unknown[]; }; /** diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c70eb0bf8..6084285bd 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -97,14 +97,18 @@ export type ResolveProjectInput = { }; /** - * The slice of agentcore.json the CLI consumes. Parsing is loose so projects - * carrying sections we don't read yet (memories, gateways, ...) still resolve. + * The agentcore.json file format. `create` writes through this schema and + * `resolve` parses with it, so the two can never drift apart. Strict: unknown + * keys are errors. New sections (memory, gateway, ...) are added here as the + * CLI grows support for them. */ -export const ProjectSpecSchema = z.looseObject({ +export const ProjectSpecSchema = z.strictObject({ name: z.string().min(1), + version: z.literal(1), + managedBy: z.literal("CDK"), runtimes: z .array( - z.looseObject({ + z.strictObject({ name: z.string().min(1), build: z.enum(["CodeZip", "Container"]), entrypoint: z.string().min(1), @@ -115,6 +119,8 @@ export const ProjectSpecSchema = z.looseObject({ .default([]), }); +export type ProjectSpec = z.infer; + export type ProjectRuntime = z.infer["runtimes"][number]; export type Project = { From 2b84b78f5604abd45638c4bc6b9c21ae30cc0e54 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 3 Aug 2026 14:01:25 -0400 Subject: [PATCH 11/16] chore(project): trim middleware comment --- src/handlers/project/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 22f36c87e..5a80cc3c9 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -20,8 +20,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { // preserves the directory the user actually ran the command from. const cwd = process.env.INIT_CWD ?? process.cwd(); - // Commands that operate on an existing project get it resolved onto the - // context. `create` stays unwrapped — it runs where no project exists yet. + // Commands that operate on an existing project get it resolved onto the context. const inProject = withProject({ projectManager: config.projectManager, cwd }); project.handler(createCreateProjectHandler({ projectManager: config.projectManager })); From cad8ab073b22e43bcf533629994eac82e68ddd3d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 10:35:54 -0400 Subject: [PATCH 12/16] feat(dev): CodeZip dev runner and process lifecycle core The first executable slice of agentcore project dev. CodeZipDevRunner bootstraps a uv venv (or node_modules) on first run, then serves the entrypoint with uvicorn --reload (or tsx watch). spawnServer owns process lifecycle: line-streamed logs, SIGTERM->SIGKILL escalation on stop, and a process-group reaper so a detached server can't outlive the CLI and hold the port. The DevRunner interface lives with its consumer (handlers/project/dev), per the dependency-inversion convention. Command execution and server spawning are injectable, so runner tests never start real uv or uvicorn; process tests use real short-lived node processes because process handling is the thing under test. run.ts duplicates the CommandRunner shape from #1872 deliberately; fold into src/io/exec.ts once that lands. --- src/core/dev/codezip.test.ts | 134 ++++++++++++++++++++++++++++++ src/core/dev/codezip.ts | 126 ++++++++++++++++++++++++++++ src/core/dev/index.ts | 3 + src/core/dev/process.test.ts | 68 +++++++++++++++ src/core/dev/process.ts | 103 +++++++++++++++++++++++ src/core/dev/run.ts | 60 +++++++++++++ src/handlers/project/dev/types.ts | 34 ++++++++ 7 files changed, 528 insertions(+) create mode 100644 src/core/dev/codezip.test.ts create mode 100644 src/core/dev/codezip.ts create mode 100644 src/core/dev/index.ts create mode 100644 src/core/dev/process.test.ts create mode 100644 src/core/dev/process.ts create mode 100644 src/core/dev/run.ts create mode 100644 src/handlers/project/dev/types.ts diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts new file mode 100644 index 000000000..6cd61ef1d --- /dev/null +++ b/src/core/dev/codezip.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach } from "bun:test"; +import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types"; +import type { ProjectRuntime } from "../../handlers/project/types"; +import { createSilentLogger } from "../../testing"; +import { CodeZipDevRunner, MissingCodeDirectoryError, entrypointToAsgiApp } from "./codezip"; +import type { SpawnServerInput } from "./process"; + +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function projectWith(paths: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-dev-")); + tempDirectories.push(root); + for (const path of paths) { + await mkdir(join(root, path), { recursive: true }); + } + return root; +} + +const pythonRuntime: ProjectRuntime = { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", +}; + +type Harness = { + runner: CodeZipDevRunner; + commands: string[][]; + spawned: SpawnServerInput[]; + logs: [DevLogLevel, string][]; +}; + +function harness(): Harness { + const commands: string[][] = []; + const spawned: SpawnServerInput[] = []; + const logs: [DevLogLevel, string][] = []; + const handle: DevServerHandle = { stop() {}, exited: Promise.resolve(0) }; + + const runner = new CodeZipDevRunner({ + logger: createSilentLogger(), + run: async (command) => { + commands.push(command); + }, + spawn: (input) => { + spawned.push(input); + return handle; + }, + }); + return { runner, commands, spawned, logs }; +} + +function startInput(root: string, h: Harness, runtime: ProjectRuntime = pythonRuntime) { + return { + runtime, + projectRoot: root, + port: 8080, + onLog: (level: DevLogLevel, message: string) => h.logs.push([level, message]), + }; +} + +describe("CodeZipDevRunner", () => { + test("throws when the runtime's code directory is missing", async () => { + const root = await projectWith([]); + const h = harness(); + await expect(h.runner.start(startInput(root, h))).rejects.toBeInstanceOf( + MissingCodeDirectoryError, + ); + }); + + test("bootstraps the venv then serves with uvicorn --reload", async () => { + const root = await projectWith(["app/hello-world"]); + const h = harness(); + await h.runner.start(startInput(root, h)); + + expect(h.commands).toEqual([ + ["uv", "venv"], + ["uv", "sync"], + ]); + const [spawn] = h.spawned; + expect(spawn?.command[0]).toContain("uvicorn"); + expect(spawn?.command).toContain("main:app"); + expect(spawn?.command).toContain("--reload"); + expect(spawn?.cwd).toBe(join(root, "app/hello-world")); + expect(spawn?.env.PORT).toBe("8080"); + expect(spawn?.env.LOCAL_DEV).toBe("1"); + }); + + test("skips dependency install when the venv already has uvicorn", async () => { + const root = await projectWith(["app/hello-world"]); + const uvicornDir = + process.platform === "win32" ? "app/hello-world/.venv/Scripts" : "app/hello-world/.venv/bin"; + await projectFile(root, uvicornDir, process.platform === "win32" ? "uvicorn.exe" : "uvicorn"); + + const h = harness(); + await h.runner.start(startInput(root, h)); + + expect(h.commands).toEqual([]); + expect(h.logs.filter(([level]) => level === "system")).toEqual([]); + }); + + test("runs TypeScript entrypoints under tsx watch after npm install", async () => { + const root = await projectWith(["app/hello-world"]); + const h = harness(); + await h.runner.start(startInput(root, h, { ...pythonRuntime, entrypoint: "main.ts" })); + + expect(h.commands).toEqual([["npm", "install"]]); + expect(h.spawned[0]?.command).toEqual(["npx", "tsx", "watch", "main.ts"]); + }); +}); + +async function projectFile(root: string, directory: string, name: string): Promise { + await mkdir(join(root, directory), { recursive: true }); + await Bun.write(join(root, directory, name), ""); +} + +describe("entrypointToAsgiApp", () => { + test.each([ + ["main.py", "main:app"], + ["main.py:application", "main:application"], + ["src/agent.py", "src.agent:app"], + ])("%s -> %s", (entrypoint, expected) => { + expect(entrypointToAsgiApp(entrypoint)).toBe(expected); + }); +}); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts new file mode 100644 index 000000000..b6abbc137 --- /dev/null +++ b/src/core/dev/codezip.ts @@ -0,0 +1,126 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; +import type { + DevRunner, + DevServerHandle, + StartDevServerInput, +} from "../../handlers/project/dev/types"; +import type { Logger } from "../../logging"; +import { runCommand, type CommandRunner } from "./run"; +import { spawnServer, type SpawnServerInput } from "./process"; + +/** Error raised when a runtime's code directory is missing from the project. */ +export class MissingCodeDirectoryError extends AgentCoreCLIError { + constructor(directory: string) { + super(`runtime code directory not found: ${directory}`, { + source: ERROR_SOURCE.USER, + meta: { directory }, + }); + } +} + +type CodeZipDevRunnerConfig = { + logger: Logger; + /** Injectable process seams so tests never spawn uv or a real server. */ + run?: CommandRunner; + spawn?: (input: SpawnServerInput) => DevServerHandle; +}; + +/** + * Runs a CodeZip runtime locally. Python entrypoints get a uv-managed venv + * and uvicorn with hot reload; TypeScript entrypoints run under tsx watch. + */ +export class CodeZipDevRunner implements DevRunner { + private readonly logger: Logger; + private readonly run: CommandRunner; + private readonly spawn: (input: SpawnServerInput) => DevServerHandle; + + constructor(config: CodeZipDevRunnerConfig) { + this.logger = config.logger; + this.run = config.run ?? runCommand; + this.spawn = config.spawn ?? spawnServer; + } + + public async start(input: StartDevServerInput): Promise { + const directory = join(input.projectRoot, input.runtime.codeLocation); + if (!existsSync(directory)) { + throw new MissingCodeDirectoryError(directory); + } + + const python = input.runtime.entrypoint.endsWith(".py"); + if (python) { + await this.ensureVenv(directory, input); + } else { + await this.ensureNodeModules(directory, input); + } + + const env = { + ...process.env, + ...input.env, + PORT: String(input.port), + LOCAL_DEV: "1", + }; + + return this.spawn({ + command: python ? uvicornCommand(input) : tsxCommand(input), + cwd: directory, + env, + onLog: input.onLog, + }); + } + + /** Creates the venv and installs dependencies on first run; cheap no-op after. */ + private async ensureVenv(directory: string, input: StartDevServerInput): Promise { + if (existsSync(venvBin(directory, "uvicorn"))) return; + + input.onLog("system", "Setting up Python environment..."); + const onOutput = (chunk: string) => this.logger.debug(chunk.trim()); + if (!existsSync(join(directory, ".venv"))) { + await this.run(["uv", "venv"], { cwd: directory, onOutput }); + } + await this.run(["uv", "sync"], { cwd: directory, onOutput }); + input.onLog("system", "Python environment ready"); + } + + private async ensureNodeModules(directory: string, input: StartDevServerInput): Promise { + if (existsSync(join(directory, "node_modules"))) return; + + input.onLog("system", "Installing Node dependencies..."); + await this.run(["npm", "install"], { + cwd: directory, + onOutput: (chunk) => this.logger.debug(chunk.trim()), + }); + input.onLog("system", "Node dependencies ready"); + } +} + +function uvicornCommand(input: StartDevServerInput): string[] { + const directory = join(input.projectRoot, input.runtime.codeLocation); + return [ + venvBin(directory, "uvicorn"), + entrypointToAsgiApp(input.runtime.entrypoint), + "--reload", + "--host", + "127.0.0.1", + "--port", + String(input.port), + ]; +} + +function tsxCommand(input: StartDevServerInput): string[] { + return ["npx", "tsx", "watch", input.runtime.entrypoint]; +} + +/** Path to an executable inside a directory's venv, per platform layout. */ +function venvBin(directory: string, executable: string): string { + return process.platform === "win32" + ? join(directory, ".venv", "Scripts", `${executable}.exe`) + : join(directory, ".venv", "bin", executable); +} + +/** Converts "main.py" / "main.py:app" to uvicorn's "main:app" module form. */ +export function entrypointToAsgiApp(entrypoint: string): string { + const [file, attribute = "app"] = entrypoint.split(":"); + return `${file!.replace(/\.py$/, "").replaceAll("/", ".")}:${attribute}`; +} diff --git a/src/core/dev/index.ts b/src/core/dev/index.ts new file mode 100644 index 000000000..7f5c8c978 --- /dev/null +++ b/src/core/dev/index.ts @@ -0,0 +1,3 @@ +export { CodeZipDevRunner, MissingCodeDirectoryError } from "./codezip"; +export { spawnServer } from "./process"; +export { runCommand, CommandFailedError, type CommandRunner } from "./run"; diff --git a/src/core/dev/process.test.ts b/src/core/dev/process.test.ts new file mode 100644 index 000000000..519263e3e --- /dev/null +++ b/src/core/dev/process.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import type { DevLogLevel } from "../../handlers/project/dev/types"; +import { spawnServer } from "./process"; + +function collect() { + const logs: [DevLogLevel, string][] = []; + return { logs, onLog: (level: DevLogLevel, message: string) => logs.push([level, message]) }; +} + +// Real short-lived processes: the seam under test IS process handling. +describe("spawnServer", () => { + test("streams stdout lines and resolves exited with the exit code", async () => { + const { logs, onLog } = collect(); + const handle = spawnServer({ + command: ["node", "-e", "console.log('one'); console.log('two')"], + cwd: process.cwd(), + env: process.env, + onLog, + }); + + expect(await handle.exited).toBe(0); + expect(logs).toEqual([ + ["info", "one"], + ["info", "two"], + ]); + }); + + test("classifies stderr lines by content", async () => { + const { logs, onLog } = collect(); + const handle = spawnServer({ + command: ["node", "-e", "console.error('ERROR: boom'); console.error('routine log')"], + cwd: process.cwd(), + env: process.env, + onLog, + }); + + await handle.exited; + expect(logs).toContainEqual(["error", "ERROR: boom"]); + expect(logs).toContainEqual(["info", "routine log"]); + }); + + test("stop() terminates a long-running process", async () => { + const { onLog } = collect(); + const handle = spawnServer({ + command: ["node", "-e", "setInterval(() => {}, 1000)"], + cwd: process.cwd(), + env: process.env, + onLog, + }); + + handle.stop(); + // Killed by signal -> null exit code. + expect(await handle.exited).toBeNull(); + }); + + test("resolves exited when the executable does not exist", async () => { + const { logs, onLog } = collect(); + const handle = spawnServer({ + command: ["definitely-not-a-real-binary-xyz"], + cwd: process.cwd(), + env: process.env, + onLog, + }); + + expect(await handle.exited).toBeNull(); + expect(logs.some(([level]) => level === "error")).toBe(true); + }); +}); diff --git a/src/core/dev/process.ts b/src/core/dev/process.ts new file mode 100644 index 000000000..594e34e0b --- /dev/null +++ b/src/core/dev/process.ts @@ -0,0 +1,103 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types"; + +const isWindows = process.platform === "win32"; + +/** How long a SIGTERM'd server gets to exit before SIGKILL. */ +const KILL_GRACE_MS = 2000; + +export type SpawnServerInput = { + command: string[]; + cwd: string; + env: Record; + onLog: (level: DevLogLevel, message: string) => void; +}; + +/** Spawns a long-running server process and returns a handle that streams its + * output, escalates SIGTERM→SIGKILL on stop, and reaps the process group if + * the CLI itself exits first (POSIX: the child is its own group leader). */ +export function spawnServer({ command, cwd, env, onLog }: SpawnServerInput): DevServerHandle { + const [executable, ...args] = command; + const child = spawn(executable!, args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + // Own process group so stop() can signal uvicorn's reloader and its + // workers together. Not supported on Windows. + detached: !isWindows, + shell: isWindows, + }); + + streamLines(child, "stdout", (line) => onLog("info", line)); + streamLines(child, "stderr", (line) => onLog(levelOf(line), line)); + + // If the CLI exits without stop() (e.g. an uncaught error path), the + // detached child would outlive us and hold the port. 'exit' always fires, + // so use it as a last-resort reaper. + const reap = () => signalTree(child, "SIGKILL"); + process.once("exit", reap); + + const exited = new Promise((resolve) => { + child.once("error", () => { + process.removeListener("exit", reap); + onLog("error", `failed to start: ${command.join(" ")}`); + resolve(null); + }); + child.once("exit", (code) => { + process.removeListener("exit", reap); + resolve(code); + }); + }); + + return { + exited, + stop() { + if (child.exitCode !== null || child.killed) return; + signalTree(child, "SIGTERM"); + setTimeout(() => signalTree(child, "SIGKILL"), KILL_GRACE_MS).unref(); + }, + }; +} + +/** Signals the child's whole process group, falling back to the child alone. */ +function signalTree(child: ChildProcess, signal: NodeJS.Signals): void { + if (child.exitCode !== null || !child.pid) return; + try { + if (isWindows) { + child.kill(signal); + } else { + process.kill(-child.pid, signal); + } + } catch { + child.kill(signal); + } +} + +/** Forwards a stdio stream to `onLine`, one complete line at a time. */ +function streamLines( + child: ChildProcess, + stream: "stdout" | "stderr", + onLine: (line: string) => void, +): void { + let buffer = ""; + child[stream]?.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop()!; + for (const line of lines) { + if (line.trim()) onLine(line); + } + }); + child[stream]?.on("end", () => { + if (buffer.trim()) onLine(buffer); + }); +} + +/** Classifies a stderr line: servers log routinely to stderr, so only lines + * that look like problems are surfaced as such. */ +function levelOf(line: string): DevLogLevel { + const lower = line.toLowerCase(); + if (lower.includes("error")) return "error"; + if (lower.includes("warning")) return "warn"; + return "info"; +} diff --git a/src/core/dev/run.ts b/src/core/dev/run.ts new file mode 100644 index 000000000..cb403960f --- /dev/null +++ b/src/core/dev/run.ts @@ -0,0 +1,60 @@ +import { spawn } from "node:child_process"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; + +// NOTE: shape-compatible with src/io/exec.ts from #1872; fold into that +// module once it lands so the CLI has one CommandRunner. + +/** Error raised when a subprocess exits non-zero, carrying its captured output. */ +export class CommandFailedError extends AgentCoreCLIError { + constructor(command: string[], cwd: string, exitCode: number | null, output: string) { + super( + `'${command.join(" ")}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n${output.trim()}`, + { + source: ERROR_SOURCE.USER, + meta: { command, cwd, exitCode }, + }, + ); + } +} + +export type RunCommandOptions = { + /** Working directory the command runs in. */ + cwd: string; + /** Receives each chunk of combined stdout/stderr as it streams. */ + onOutput?: (chunk: string) => void; +}; + +/** Runs a command to completion. Injectable so tests never spawn real processes. */ +export type CommandRunner = (command: string[], options: RunCommandOptions) => Promise; + +/** + * Runs a command, streaming combined stdout/stderr to `onOutput` while also + * capturing it; rejects with {@link CommandFailedError} on a non-zero exit. + */ +export const runCommand: CommandRunner = ([executable, ...args], { cwd, onOutput }) => { + return new Promise((resolve, reject) => { + // shell on win32 so PATHEXT resolution (npm.cmd etc.) works. + const child = spawn(executable!, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + let output = ""; + const collect = (chunk: Buffer) => { + const text = chunk.toString(); + output += text; + onOutput?.(text); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + + child.on("error", (error) => { + reject(new CommandFailedError([executable!, ...args], cwd, null, String(error))); + }); + child.on("close", (exitCode) => { + if (exitCode === 0) resolve(); + else reject(new CommandFailedError([executable!, ...args], cwd, exitCode, output)); + }); + }); +}; diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts new file mode 100644 index 000000000..047e8c73d --- /dev/null +++ b/src/handlers/project/dev/types.ts @@ -0,0 +1,34 @@ +import type { ProjectRuntime } from "../types"; + +/** Severity of a line emitted by a dev server. `system` marks CLI-originated + * status messages (venv setup, restarts) as opposed to agent output. */ +export type DevLogLevel = "info" | "warn" | "error" | "system"; + +export type StartDevServerInput = { + /** The runtime to run, as registered in agentcore.json. */ + runtime: ProjectRuntime; + /** Absolute path to the project root (parent of agentcore/). */ + projectRoot: string; + /** Port the server binds to. */ + port: number; + /** Extra environment variables for the agent process. */ + env?: Record; + /** Receives every output line from the server and the CLI's own status messages. */ + onLog: (level: DevLogLevel, message: string) => void; +}; + +/** A running dev server process. */ +export interface DevServerHandle { + /** Stops the server: SIGTERM to the process group, SIGKILL if it lingers. */ + stop(): void; + /** Resolves with the exit code once the process ends (null when killed by signal). */ + readonly exited: Promise; +} + +/** + * Starts a local dev server for a runtime. Implementations cover the build + * types (CodeZip today, Container next). + */ +export interface DevRunner { + start(input: StartDevServerInput): Promise; +} From d40820d74ee4c08de68d00b398f2dda041db3ea6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 10:42:00 -0400 Subject: [PATCH 13/16] =?UTF-8?q?refactor(dev):=20ponytail=20cuts=20?= =?UTF-8?q?=E2=80=94=20uv=20sync=20creates=20the=20venv,=20pass=20director?= =?UTF-8?q?y=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/dev/codezip.test.ts | 5 +---- src/core/dev/codezip.ts | 15 +++++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 6cd61ef1d..d92d6642c 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -82,10 +82,7 @@ describe("CodeZipDevRunner", () => { const h = harness(); await h.runner.start(startInput(root, h)); - expect(h.commands).toEqual([ - ["uv", "venv"], - ["uv", "sync"], - ]); + expect(h.commands).toEqual([["uv", "sync"]]); const [spawn] = h.spawned; expect(spawn?.command[0]).toContain("uvicorn"); expect(spawn?.command).toContain("main:app"); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index b6abbc137..abf809cd9 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -63,7 +63,7 @@ export class CodeZipDevRunner implements DevRunner { }; return this.spawn({ - command: python ? uvicornCommand(input) : tsxCommand(input), + command: python ? uvicornCommand(directory, input) : tsxCommand(input), cwd: directory, env, onLog: input.onLog, @@ -75,11 +75,11 @@ export class CodeZipDevRunner implements DevRunner { if (existsSync(venvBin(directory, "uvicorn"))) return; input.onLog("system", "Setting up Python environment..."); - const onOutput = (chunk: string) => this.logger.debug(chunk.trim()); - if (!existsSync(join(directory, ".venv"))) { - await this.run(["uv", "venv"], { cwd: directory, onOutput }); - } - await this.run(["uv", "sync"], { cwd: directory, onOutput }); + // uv sync creates the venv itself when missing. + await this.run(["uv", "sync"], { + cwd: directory, + onOutput: (chunk) => this.logger.debug(chunk.trim()), + }); input.onLog("system", "Python environment ready"); } @@ -95,8 +95,7 @@ export class CodeZipDevRunner implements DevRunner { } } -function uvicornCommand(input: StartDevServerInput): string[] { - const directory = join(input.projectRoot, input.runtime.codeLocation); +function uvicornCommand(directory: string, input: StartDevServerInput): string[] { return [ venvBin(directory, "uvicorn"), entrypointToAsgiApp(input.runtime.entrypoint), From 356b55509484b00f0d40638d254230cb5259b264 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 10:45:53 -0400 Subject: [PATCH 14/16] refactor(dev): move CommandFailedError and MissingCodeDirectoryError to src/errors All typed errors live in src/errors; the dev module was defining its own inline. --- src/core/dev/codezip.test.ts | 3 ++- src/core/dev/codezip.ts | 12 +----------- src/core/dev/index.ts | 4 ++-- src/core/dev/run.ts | 15 +-------------- src/errors/errors.tsx | 23 +++++++++++++++++++++++ src/errors/index.tsx | 2 ++ 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index d92d6642c..ed60b68bf 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -6,7 +6,8 @@ import { afterEach } from "bun:test"; import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types"; import type { ProjectRuntime } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; -import { CodeZipDevRunner, MissingCodeDirectoryError, entrypointToAsgiApp } from "./codezip"; +import { MissingCodeDirectoryError } from "../../errors"; +import { CodeZipDevRunner, entrypointToAsgiApp } from "./codezip"; import type { SpawnServerInput } from "./process"; const tempDirectories: string[] = []; diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index abf809cd9..bfb950bb4 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; +import { MissingCodeDirectoryError } from "../../errors"; import type { DevRunner, DevServerHandle, @@ -10,16 +10,6 @@ import type { Logger } from "../../logging"; import { runCommand, type CommandRunner } from "./run"; import { spawnServer, type SpawnServerInput } from "./process"; -/** Error raised when a runtime's code directory is missing from the project. */ -export class MissingCodeDirectoryError extends AgentCoreCLIError { - constructor(directory: string) { - super(`runtime code directory not found: ${directory}`, { - source: ERROR_SOURCE.USER, - meta: { directory }, - }); - } -} - type CodeZipDevRunnerConfig = { logger: Logger; /** Injectable process seams so tests never spawn uv or a real server. */ diff --git a/src/core/dev/index.ts b/src/core/dev/index.ts index 7f5c8c978..f91c3ee6a 100644 --- a/src/core/dev/index.ts +++ b/src/core/dev/index.ts @@ -1,3 +1,3 @@ -export { CodeZipDevRunner, MissingCodeDirectoryError } from "./codezip"; +export { CodeZipDevRunner } from "./codezip"; export { spawnServer } from "./process"; -export { runCommand, CommandFailedError, type CommandRunner } from "./run"; +export { runCommand, type CommandRunner } from "./run"; diff --git a/src/core/dev/run.ts b/src/core/dev/run.ts index cb403960f..7e985e240 100644 --- a/src/core/dev/run.ts +++ b/src/core/dev/run.ts @@ -1,22 +1,9 @@ import { spawn } from "node:child_process"; -import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; +import { CommandFailedError } from "../../errors"; // NOTE: shape-compatible with src/io/exec.ts from #1872; fold into that // module once it lands so the CLI has one CommandRunner. -/** Error raised when a subprocess exits non-zero, carrying its captured output. */ -export class CommandFailedError extends AgentCoreCLIError { - constructor(command: string[], cwd: string, exitCode: number | null, output: string) { - super( - `'${command.join(" ")}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n${output.trim()}`, - { - source: ERROR_SOURCE.USER, - meta: { command, cwd, exitCode }, - }, - ); - } -} - export type RunCommandOptions = { /** Working directory the command runs in. */ cwd: string; diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index e8ae5c9a6..14fcca6b4 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -103,6 +103,29 @@ export class DeserializationError extends AgentCoreCLIError { } } +/** Error raised when a subprocess exits non-zero, carrying its captured output. */ +export class CommandFailedError extends AgentCoreCLIError { + constructor(command: string[], cwd: string, exitCode: number | null, output: string) { + super( + `'${command.join(" ")}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n${output.trim()}`, + { + source: ERROR_SOURCE.USER, + meta: { command, cwd, exitCode }, + }, + ); + } +} + +/** Error raised when a runtime's code directory is missing from the project. */ +export class MissingCodeDirectoryError extends AgentCoreCLIError { + constructor(public readonly directory: string) { + super(`runtime code directory not found: ${directory}`, { + source: ERROR_SOURCE.USER, + meta: { directory }, + }); + } +} + /** Error raised when a command requires an AgentCore project and none encloses the working directory. */ export class NoProjectError extends AgentCoreCLIError { constructor(searchPath: string, options?: Omit) { diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 98f5c8001..650349375 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,10 +1,12 @@ export { AgentCoreCLIError, + CommandFailedError, DeserializationError, EmbeddedAssetNotFoundError, InputValidationError, InvalidEnvironmentError, InvalidProjectConfigError, + MissingCodeDirectoryError, NestedProjectError, NoProjectError, NotImplementedError, From 04cd9938ef9ff598614a108fbaad64854e00bb4b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 3 Aug 2026 17:21:49 -0400 Subject: [PATCH 15/16] fix(dev): resolve exited on close so Windows stdio flushes first The exit event can fire before the shell-mode child's piped output is drained on Windows, so tests saw exited resolve with zero log lines. close waits for stdio to flush. The missing-binary test now asserts the shared cross-platform contract: POSIX fails spawn outright while the win32 shell exits non-zero with stderr output. --- src/core/dev/process.test.ts | 8 ++++++-- src/core/dev/process.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/dev/process.test.ts b/src/core/dev/process.test.ts index 519263e3e..b6886242d 100644 --- a/src/core/dev/process.test.ts +++ b/src/core/dev/process.test.ts @@ -62,7 +62,11 @@ describe("spawnServer", () => { onLog, }); - expect(await handle.exited).toBeNull(); - expect(logs.some(([level]) => level === "error")).toBe(true); + // POSIX: spawn fails ('error' event, null exit, error log). Windows runs + // through a shell, which starts fine and exits non-zero with a "not + // recognized" line on stderr. Common contract: not a clean exit, and the + // failure surfaces to the log callback. + expect(await handle.exited).not.toBe(0); + expect(logs.length).toBeGreaterThan(0); }); }); diff --git a/src/core/dev/process.ts b/src/core/dev/process.ts index 594e34e0b..f40663239 100644 --- a/src/core/dev/process.ts +++ b/src/core/dev/process.ts @@ -43,7 +43,9 @@ export function spawnServer({ command, cwd, env, onLog }: SpawnServerInput): Dev onLog("error", `failed to start: ${command.join(" ")}`); resolve(null); }); - child.once("exit", (code) => { + // 'close', not 'exit': close fires after the stdio streams have flushed, + // so no output line can arrive after exited resolves. + child.once("close", (code) => { process.removeListener("exit", reap); resolve(code); }); From 970d94499b54abe06a98b574c961681fbd1640f6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 3 Aug 2026 17:40:50 -0400 Subject: [PATCH 16/16] refactor(dev): supervise processes without a shell, parse entrypoints once Applies the full set of review findings on this PR: - No shell: true. Windows argument boundaries were corrupted going through cmd.exe. Commands are now ProcessCommand records with extension-qualified executables (windowsExecutable), spawned directly. - parseEntrypoint interprets an entrypoint exactly once. main.py:application was classified as TypeScript by the old endsWith check and launched under tsx watch. - ProcessSupervisor owns all children with one CLI-exit reaper; Windows termination goes through taskkill /T for the whole tree. - stop() is awaitable and exited resolves as a structured ProcessExit (exited/signaled/spawn-error) after stdio flushes. - readline splits output lines, handling CRLF and chunk-split characters. - Node installs detect pnpm/yarn/npm from the lockfile. --- src/core/dev/codezip.test.ts | 110 +++++++++++++++------ src/core/dev/codezip.ts | 105 ++++++++++++-------- src/core/dev/index.ts | 10 +- src/core/dev/process.test.ts | 81 ++++++++-------- src/core/dev/process.ts | 155 +++++++++++++++++------------- src/core/dev/run.ts | 4 +- src/handlers/project/dev/types.ts | 14 ++- 7 files changed, 299 insertions(+), 180 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index ed60b68bf..201ede3b2 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,14 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach } from "bun:test"; +import { MissingCodeDirectoryError } from "../../errors"; import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types"; import type { ProjectRuntime } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; -import { MissingCodeDirectoryError } from "../../errors"; -import { CodeZipDevRunner, entrypointToAsgiApp } from "./codezip"; -import type { SpawnServerInput } from "./process"; +import { CodeZipDevRunner, nodePackageManager, parseEntrypoint, serverCommand } from "./codezip"; +import { ProcessSupervisor, type ProcessCommand } from "./process"; const tempDirectories: string[] = []; @@ -27,6 +26,11 @@ async function projectWith(paths: string[]): Promise { return root; } +async function projectFile(root: string, directory: string, name: string): Promise { + await mkdir(join(root, directory), { recursive: true }); + await Bun.write(join(root, directory, name), ""); +} + const pythonRuntime: ProjectRuntime = { name: "hello_world", build: "CodeZip", @@ -37,25 +41,31 @@ const pythonRuntime: ProjectRuntime = { type Harness = { runner: CodeZipDevRunner; commands: string[][]; - spawned: SpawnServerInput[]; + spawned: ProcessCommand[]; logs: [DevLogLevel, string][]; }; function harness(): Harness { const commands: string[][] = []; - const spawned: SpawnServerInput[] = []; + const spawned: ProcessCommand[] = []; const logs: [DevLogLevel, string][] = []; - const handle: DevServerHandle = { stop() {}, exited: Promise.resolve(0) }; + const handle: DevServerHandle = { + exited: Promise.resolve({ kind: "exited", code: 0 }), + stop: () => Promise.resolve({ kind: "exited", code: 0 }), + }; + + const supervisor = new ProcessSupervisor(); + supervisor.spawn = (command) => { + spawned.push(command); + return handle; + }; const runner = new CodeZipDevRunner({ logger: createSilentLogger(), run: async (command) => { commands.push(command); }, - spawn: (input) => { - spawned.push(input); - return handle; - }, + supervisor, }); return { runner, commands, spawned, logs }; } @@ -83,11 +93,11 @@ describe("CodeZipDevRunner", () => { const h = harness(); await h.runner.start(startInput(root, h)); - expect(h.commands).toEqual([["uv", "sync"]]); + expect(h.commands.map((c) => c[1])).toEqual(["sync"]); const [spawn] = h.spawned; - expect(spawn?.command[0]).toContain("uvicorn"); - expect(spawn?.command).toContain("main:app"); - expect(spawn?.command).toContain("--reload"); + expect(spawn?.executable).toContain("uvicorn"); + expect(spawn?.args).toContain("main:app"); + expect(spawn?.args).toContain("--reload"); expect(spawn?.cwd).toBe(join(root, "app/hello-world")); expect(spawn?.env.PORT).toBe("8080"); expect(spawn?.env.LOCAL_DEV).toBe("1"); @@ -106,27 +116,71 @@ describe("CodeZipDevRunner", () => { expect(h.logs.filter(([level]) => level === "system")).toEqual([]); }); + test("a handler-qualified Python entrypoint serves with uvicorn, not tsx", async () => { + const root = await projectWith(["app/hello-world"]); + const h = harness(); + await h.runner.start( + startInput(root, h, { ...pythonRuntime, entrypoint: "main.py:application" }), + ); + + expect(h.spawned[0]?.executable).toContain("uvicorn"); + expect(h.spawned[0]?.args).toContain("main:application"); + }); + test("runs TypeScript entrypoints under tsx watch after npm install", async () => { const root = await projectWith(["app/hello-world"]); const h = harness(); await h.runner.start(startInput(root, h, { ...pythonRuntime, entrypoint: "main.ts" })); - expect(h.commands).toEqual([["npm", "install"]]); - expect(h.spawned[0]?.command).toEqual(["npx", "tsx", "watch", "main.ts"]); + expect(h.commands.map((c) => c[1])).toEqual(["install"]); + expect(h.commands[0]?.[0]).toContain("npm"); + expect(h.spawned[0]?.args).toEqual(["tsx", "watch", "main.ts"]); + }); + + test("installs with the package manager the lockfile names", async () => { + const root = await projectWith(["app/hello-world"]); + await projectFile(root, "app/hello-world", "pnpm-lock.yaml"); + + const h = harness(); + await h.runner.start(startInput(root, h, { ...pythonRuntime, entrypoint: "main.ts" })); + + expect(h.commands[0]?.[0]).toContain("pnpm"); }); }); -async function projectFile(root: string, directory: string, name: string): Promise { - await mkdir(join(root, directory), { recursive: true }); - await Bun.write(join(root, directory, name), ""); -} +describe("parseEntrypoint", () => { + test.each([ + ["main.py", "main.py", "app", "python"], + ["main.py:application", "main.py", "application", "python"], + ["src/agent.py", "src/agent.py", "app", "python"], + ["main.ts", "main.ts", "app", "typescript"], + ] as const)("%s", (entrypoint, file, handler, language) => { + expect(parseEntrypoint(entrypoint)).toEqual({ file, handler, language }); + }); +}); -describe("entrypointToAsgiApp", () => { +describe("nodePackageManager", () => { test.each([ - ["main.py", "main:app"], - ["main.py:application", "main:application"], - ["src/agent.py", "src.agent:app"], - ])("%s -> %s", (entrypoint, expected) => { - expect(entrypointToAsgiApp(entrypoint)).toBe(expected); + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], + ] as const)("%s -> %s", async (lockfile, expected) => { + const root = await projectWith(["app"]); + await projectFile(root, "app", lockfile); + expect(nodePackageManager(join(root, "app"))).toBe(expected); + }); +}); + +describe("serverCommand", () => { + test("renders nested Python entrypoints in uvicorn module form", async () => { + const root = await projectWith(["app/hello-world"]); + const command = serverCommand(parseEntrypoint("src/agent.py:handler"), root, { + runtime: pythonRuntime, + projectRoot: root, + port: 9001, + onLog: () => {}, + }); + expect(command.args).toContain("src.agent:handler"); + expect(command.args).toContain("9001"); }); }); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index bfb950bb4..77d219dba 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -8,13 +8,38 @@ import type { } from "../../handlers/project/dev/types"; import type { Logger } from "../../logging"; import { runCommand, type CommandRunner } from "./run"; -import { spawnServer, type SpawnServerInput } from "./process"; +import { ProcessSupervisor, windowsExecutable, type ProcessCommand } from "./process"; + +/** An entrypoint interpreted exactly once: "main.py:application" is the file + * "main.py", handler "application", language python. */ +export type Entrypoint = { + file: string; + handler: string; + language: "python" | "typescript"; +}; + +/** Parses a runtime's entrypoint string into its parts. */ +export function parseEntrypoint(entrypoint: string): Entrypoint { + const [file, handler = "app"] = entrypoint.split(":"); + return { + file: file!, + handler, + language: file!.endsWith(".py") ? "python" : "typescript", + }; +} + +/** Detects the package manager for a Node project from its lockfile. */ +export function nodePackageManager(directory: string): "npm" | "pnpm" | "yarn" { + if (existsSync(join(directory, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(directory, "yarn.lock"))) return "yarn"; + return "npm"; +} type CodeZipDevRunnerConfig = { logger: Logger; /** Injectable process seams so tests never spawn uv or a real server. */ run?: CommandRunner; - spawn?: (input: SpawnServerInput) => DevServerHandle; + supervisor?: ProcessSupervisor; }; /** @@ -24,12 +49,12 @@ type CodeZipDevRunnerConfig = { export class CodeZipDevRunner implements DevRunner { private readonly logger: Logger; private readonly run: CommandRunner; - private readonly spawn: (input: SpawnServerInput) => DevServerHandle; + private readonly supervisor: ProcessSupervisor; constructor(config: CodeZipDevRunnerConfig) { this.logger = config.logger; this.run = config.run ?? runCommand; - this.spawn = config.spawn ?? spawnServer; + this.supervisor = config.supervisor ?? new ProcessSupervisor(); } public async start(input: StartDevServerInput): Promise { @@ -38,26 +63,14 @@ export class CodeZipDevRunner implements DevRunner { throw new MissingCodeDirectoryError(directory); } - const python = input.runtime.entrypoint.endsWith(".py"); - if (python) { + const entrypoint = parseEntrypoint(input.runtime.entrypoint); + if (entrypoint.language === "python") { await this.ensureVenv(directory, input); } else { await this.ensureNodeModules(directory, input); } - const env = { - ...process.env, - ...input.env, - PORT: String(input.port), - LOCAL_DEV: "1", - }; - - return this.spawn({ - command: python ? uvicornCommand(directory, input) : tsxCommand(input), - cwd: directory, - env, - onLog: input.onLog, - }); + return this.supervisor.spawn(serverCommand(entrypoint, directory, input), input.onLog); } /** Creates the venv and installs dependencies on first run; cheap no-op after. */ @@ -66,7 +79,7 @@ export class CodeZipDevRunner implements DevRunner { input.onLog("system", "Setting up Python environment..."); // uv sync creates the venv itself when missing. - await this.run(["uv", "sync"], { + await this.run([windowsExecutable("uv", ".exe"), "sync"], { cwd: directory, onOutput: (chunk) => this.logger.debug(chunk.trim()), }); @@ -76,8 +89,9 @@ export class CodeZipDevRunner implements DevRunner { private async ensureNodeModules(directory: string, input: StartDevServerInput): Promise { if (existsSync(join(directory, "node_modules"))) return; - input.onLog("system", "Installing Node dependencies..."); - await this.run(["npm", "install"], { + const packageManager = nodePackageManager(directory); + input.onLog("system", `Installing Node dependencies with ${packageManager}...`); + await this.run([windowsExecutable(packageManager), "install"], { cwd: directory, onOutput: (chunk) => this.logger.debug(chunk.trim()), }); @@ -85,20 +99,34 @@ export class CodeZipDevRunner implements DevRunner { } } -function uvicornCommand(directory: string, input: StartDevServerInput): string[] { - return [ - venvBin(directory, "uvicorn"), - entrypointToAsgiApp(input.runtime.entrypoint), - "--reload", - "--host", - "127.0.0.1", - "--port", - String(input.port), - ]; -} +/** Builds the server command for an entrypoint. Pure: no process knowledge. */ +export function serverCommand( + entrypoint: Entrypoint, + directory: string, + input: StartDevServerInput, +): ProcessCommand { + const env = { + ...process.env, + ...input.env, + PORT: String(input.port), + LOCAL_DEV: "1", + }; + + if (entrypoint.language === "python") { + return { + executable: venvBin(directory, "uvicorn"), + args: [asgiApp(entrypoint), "--reload", "--host", "127.0.0.1", "--port", String(input.port)], + cwd: directory, + env, + }; + } -function tsxCommand(input: StartDevServerInput): string[] { - return ["npx", "tsx", "watch", input.runtime.entrypoint]; + return { + executable: windowsExecutable("npx"), + args: ["tsx", "watch", entrypoint.file], + cwd: directory, + env, + }; } /** Path to an executable inside a directory's venv, per platform layout. */ @@ -108,8 +136,7 @@ function venvBin(directory: string, executable: string): string { : join(directory, ".venv", "bin", executable); } -/** Converts "main.py" / "main.py:app" to uvicorn's "main:app" module form. */ -export function entrypointToAsgiApp(entrypoint: string): string { - const [file, attribute = "app"] = entrypoint.split(":"); - return `${file!.replace(/\.py$/, "").replaceAll("/", ".")}:${attribute}`; +/** Renders an entrypoint in uvicorn's "module:attribute" form. */ +function asgiApp(entrypoint: Entrypoint): string { + return `${entrypoint.file.replace(/\.py$/, "").replaceAll("/", ".")}:${entrypoint.handler}`; } diff --git a/src/core/dev/index.ts b/src/core/dev/index.ts index f91c3ee6a..bc5bd5e9d 100644 --- a/src/core/dev/index.ts +++ b/src/core/dev/index.ts @@ -1,3 +1,9 @@ -export { CodeZipDevRunner } from "./codezip"; -export { spawnServer } from "./process"; +export { + CodeZipDevRunner, + nodePackageManager, + parseEntrypoint, + serverCommand, + type Entrypoint, +} from "./codezip"; +export { ProcessSupervisor, windowsExecutable, type ProcessCommand } from "./process"; export { runCommand, type CommandRunner } from "./run"; diff --git a/src/core/dev/process.test.ts b/src/core/dev/process.test.ts index b6886242d..dae988f54 100644 --- a/src/core/dev/process.test.ts +++ b/src/core/dev/process.test.ts @@ -1,24 +1,31 @@ import { describe, expect, test } from "bun:test"; import type { DevLogLevel } from "../../handlers/project/dev/types"; -import { spawnServer } from "./process"; +import { ProcessSupervisor, windowsExecutable } from "./process"; function collect() { const logs: [DevLogLevel, string][] = []; return { logs, onLog: (level: DevLogLevel, message: string) => logs.push([level, message]) }; } -// Real short-lived processes: the seam under test IS process handling. -describe("spawnServer", () => { - test("streams stdout lines and resolves exited with the exit code", async () => { - const { logs, onLog } = collect(); - const handle = spawnServer({ - command: ["node", "-e", "console.log('one'); console.log('two')"], +function spawnNode(script: string, onLog: (level: DevLogLevel, message: string) => void) { + return new ProcessSupervisor().spawn( + { + executable: windowsExecutable("node", ".exe"), + args: ["-e", script], cwd: process.cwd(), env: process.env, - onLog, - }); + }, + onLog, + ); +} + +// Real short-lived processes: the seam under test IS process handling. +describe("ProcessSupervisor", () => { + test("streams stdout lines and resolves exited after output flushes", async () => { + const { logs, onLog } = collect(); + const handle = spawnNode("console.log('one'); console.log('two')", onLog); - expect(await handle.exited).toBe(0); + expect(await handle.exited).toEqual({ kind: "exited", code: 0 }); expect(logs).toEqual([ ["info", "one"], ["info", "two"], @@ -27,46 +34,44 @@ describe("spawnServer", () => { test("classifies stderr lines by content", async () => { const { logs, onLog } = collect(); - const handle = spawnServer({ - command: ["node", "-e", "console.error('ERROR: boom'); console.error('routine log')"], - cwd: process.cwd(), - env: process.env, - onLog, - }); + const handle = spawnNode("console.error('ERROR: boom'); console.error('routine log')", onLog); await handle.exited; expect(logs).toContainEqual(["error", "ERROR: boom"]); expect(logs).toContainEqual(["info", "routine log"]); }); - test("stop() terminates a long-running process", async () => { + test("stop() resolves with how the process ended", async () => { const { onLog } = collect(); - const handle = spawnServer({ - command: ["node", "-e", "setInterval(() => {}, 1000)"], - cwd: process.cwd(), - env: process.env, - onLog, - }); + const handle = spawnNode("setInterval(() => {}, 1000)", onLog); - handle.stop(); - // Killed by signal -> null exit code. - expect(await handle.exited).toBeNull(); + const exit = await handle.stop(); + // POSIX reports the terminating signal; Windows taskkill surfaces as a + // non-zero exit code. + expect(exit.kind === "signaled" || (exit.kind === "exited" && exit.code !== 0)).toBe(true); }); - test("resolves exited when the executable does not exist", async () => { + test("resolves spawn-error when the executable does not exist", async () => { const { logs, onLog } = collect(); - const handle = spawnServer({ - command: ["definitely-not-a-real-binary-xyz"], - cwd: process.cwd(), - env: process.env, + const handle = new ProcessSupervisor().spawn( + { + executable: "definitely-not-a-real-binary-xyz", + args: [], + cwd: process.cwd(), + env: process.env, + }, onLog, - }); + ); + + expect((await handle.exited).kind).toBe("spawn-error"); + expect(logs.some(([level]) => level === "error")).toBe(true); + }); + + test("stop() after exit resolves the same result", async () => { + const { onLog } = collect(); + const handle = spawnNode("console.log('done')", onLog); - // POSIX: spawn fails ('error' event, null exit, error log). Windows runs - // through a shell, which starts fine and exits non-zero with a "not - // recognized" line on stderr. Common contract: not a clean exit, and the - // failure surfaces to the log callback. - expect(await handle.exited).not.toBe(0); - expect(logs.length).toBeGreaterThan(0); + const first = await handle.exited; + expect(await handle.stop()).toEqual(first); }); }); diff --git a/src/core/dev/process.ts b/src/core/dev/process.ts index f40663239..7e9cb4c61 100644 --- a/src/core/dev/process.ts +++ b/src/core/dev/process.ts @@ -1,72 +1,104 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types"; +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { DevLogLevel, DevServerHandle, ProcessExit } from "../../handlers/project/dev/types"; const isWindows = process.platform === "win32"; -/** How long a SIGTERM'd server gets to exit before SIGKILL. */ +/** How long a gracefully-signaled server gets to exit before the hard kill. */ const KILL_GRACE_MS = 2000; -export type SpawnServerInput = { - command: string[]; +/** A fully-resolved command: no shell involved, so argument boundaries are + * preserved exactly. Windows executables must already carry their extension + * (npm.cmd, uvicorn.exe) — see {@link windowsExecutable}. */ +export type ProcessCommand = { + executable: string; + args: string[]; cwd: string; env: Record; - onLog: (level: DevLogLevel, message: string) => void; }; -/** Spawns a long-running server process and returns a handle that streams its - * output, escalates SIGTERM→SIGKILL on stop, and reaps the process group if - * the CLI itself exits first (POSIX: the child is its own group leader). */ -export function spawnServer({ command, cwd, env, onLog }: SpawnServerInput): DevServerHandle { - const [executable, ...args] = command; - const child = spawn(executable!, args, { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - // Own process group so stop() can signal uvicorn's reloader and its - // workers together. Not supported on Windows. - detached: !isWindows, - shell: isWindows, - }); - - streamLines(child, "stdout", (line) => onLog("info", line)); - streamLines(child, "stderr", (line) => onLog(levelOf(line), line)); +/** Appends the Windows extension to a bare tool name so it can be spawned + * without a shell. No-op elsewhere and for paths that already carry one. */ +export function windowsExecutable(tool: string, extension = ".cmd"): string { + return isWindows && !/\.[a-z0-9]+$/i.test(tool) ? `${tool}${extension}` : tool; +} - // If the CLI exits without stop() (e.g. an uncaught error path), the - // detached child would outlive us and hold the port. 'exit' always fires, - // so use it as a last-resort reaper. - const reap = () => signalTree(child, "SIGKILL"); - process.once("exit", reap); +/** + * Owns every dev server child of this CLI process: spawning, line-streamed + * output, graceful shutdown, and a single process-exit reaper so children + * cannot outlive the CLI and squat on their ports. + */ +export class ProcessSupervisor { + private readonly children = new Set(); + private readonly reap = () => { + for (const child of this.children) killTree(child, "SIGKILL"); + }; - const exited = new Promise((resolve) => { - child.once("error", () => { - process.removeListener("exit", reap); - onLog("error", `failed to start: ${command.join(" ")}`); - resolve(null); + /** Spawns a long-running server and returns its lifecycle handle. */ + public spawn( + command: ProcessCommand, + onLog: (level: DevLogLevel, message: string) => void, + ): DevServerHandle { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + env: command.env, + stdio: ["ignore", "pipe", "pipe"], + // Own process group on POSIX so a graceful signal reaches uvicorn's + // reloader and its workers together. + detached: !isWindows, }); - // 'close', not 'exit': close fires after the stdio streams have flushed, - // so no output line can arrive after exited resolves. - child.once("close", (code) => { - process.removeListener("exit", reap); - resolve(code); + this.watch(child); + + streamLines(child.stdout, (line) => onLog("info", line)); + streamLines(child.stderr, (line) => onLog(levelOf(line), line)); + + const exited = new Promise((resolve) => { + child.once("error", (error) => { + this.unwatch(child); + onLog("error", `failed to start: ${command.executable}`); + resolve({ kind: "spawn-error", error }); + }); + // 'close', not 'exit': close fires after the stdio streams have flushed, + // so no output line can arrive after exited resolves. + child.once("close", (code, signal) => { + this.unwatch(child); + resolve(signal ? { kind: "signaled", signal } : { kind: "exited", code: code ?? 0 }); + }); }); - }); - return { - exited, - stop() { - if (child.exitCode !== null || child.killed) return; - signalTree(child, "SIGTERM"); - setTimeout(() => signalTree(child, "SIGKILL"), KILL_GRACE_MS).unref(); - }, - }; + return { + exited, + stop() { + if (child.exitCode === null && !child.killed) { + killTree(child, "SIGTERM"); + const escalate = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS); + escalate.unref(); + void exited.finally(() => clearTimeout(escalate)); + } + return exited; + }, + }; + } + + private watch(child: ChildProcess): void { + // One CLI-exit reaper for all children, attached only while any are alive. + if (this.children.size === 0) process.once("exit", this.reap); + this.children.add(child); + } + + private unwatch(child: ChildProcess): void { + this.children.delete(child); + if (this.children.size === 0) process.removeListener("exit", this.reap); + } } -/** Signals the child's whole process group, falling back to the child alone. */ -function signalTree(child: ChildProcess, signal: NodeJS.Signals): void { +/** Terminates the child's whole process tree. */ +function killTree(child: ChildProcess, signal: NodeJS.Signals): void { if (child.exitCode !== null || !child.pid) return; try { if (isWindows) { - child.kill(signal); + // Windows has no process groups; taskkill /T walks the child tree. + execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: "ignore" }); } else { process.kill(-child.pid, signal); } @@ -75,23 +107,12 @@ function signalTree(child: ChildProcess, signal: NodeJS.Signals): void { } } -/** Forwards a stdio stream to `onLine`, one complete line at a time. */ -function streamLines( - child: ChildProcess, - stream: "stdout" | "stderr", - onLine: (line: string) => void, -): void { - let buffer = ""; - child[stream]?.on("data", (chunk: Buffer) => { - buffer += chunk.toString(); - const lines = buffer.split("\n"); - buffer = lines.pop()!; - for (const line of lines) { - if (line.trim()) onLine(line); - } - }); - child[stream]?.on("end", () => { - if (buffer.trim()) onLine(buffer); +/** Forwards a stdio stream to `onLine`, one complete line at a time. + * readline handles CRLF and multi-byte characters split across chunks. */ +function streamLines(stream: NodeJS.ReadableStream | null, onLine: (line: string) => void): void { + if (!stream) return; + createInterface({ input: stream }).on("line", (line) => { + if (line.trim()) onLine(line); }); } diff --git a/src/core/dev/run.ts b/src/core/dev/run.ts index 7e985e240..e4ac53db8 100644 --- a/src/core/dev/run.ts +++ b/src/core/dev/run.ts @@ -20,11 +20,11 @@ export type CommandRunner = (command: string[], options: RunCommandOptions) => P */ export const runCommand: CommandRunner = ([executable, ...args], { cwd, onOutput }) => { return new Promise((resolve, reject) => { - // shell on win32 so PATHEXT resolution (npm.cmd etc.) works. + // No shell: callers pass extension-qualified executables on Windows + // (npm.cmd, uv.exe), so argument boundaries are preserved exactly. const child = spawn(executable!, args, { cwd, stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", }); let output = ""; diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts index 047e8c73d..3bd1d079a 100644 --- a/src/handlers/project/dev/types.ts +++ b/src/handlers/project/dev/types.ts @@ -4,6 +4,12 @@ import type { ProjectRuntime } from "../types"; * status messages (venv setup, restarts) as opposed to agent output. */ export type DevLogLevel = "info" | "warn" | "error" | "system"; +/** How a dev server process ended. */ +export type ProcessExit = + | { kind: "exited"; code: number } + | { kind: "signaled"; signal: string } + | { kind: "spawn-error"; error: Error }; + export type StartDevServerInput = { /** The runtime to run, as registered in agentcore.json. */ runtime: ProjectRuntime; @@ -19,10 +25,10 @@ export type StartDevServerInput = { /** A running dev server process. */ export interface DevServerHandle { - /** Stops the server: SIGTERM to the process group, SIGKILL if it lingers. */ - stop(): void; - /** Resolves with the exit code once the process ends (null when killed by signal). */ - readonly exited: Promise; + /** Resolves once the process ends and its output streams have flushed. */ + readonly exited: Promise; + /** Stops the server (graceful signal, then hard kill) and resolves once it has fully ended. */ + stop(): Promise; } /**