Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/ui/data-table/columnWidths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { harnessEndpointColumns } from "../../HarnessEndpointPicker";
import { harnessColumns } from "../../HarnessPicker";
import { harnessVersionColumns } from "../../HarnessVersionPicker";
import { memoryColumns } from "../../MemoryPicker";

Check failure on line 6 in src/components/ui/data-table/columnWidths.test.ts

View workflow job for this annotation

GitHub Actions / check / check

oxlint

Identifier `memoryColumns` has already been declared
import { runtimeEndpointColumns } from "../../RuntimeEndpointPicker";
import { runtimeColumns } from "../../RuntimePicker";
import { runtimeVersionColumns } from "../../RuntimeVersionPicker";
Expand Down
155 changes: 155 additions & 0 deletions src/core/dev/codezip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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 type { ProjectRuntime } from "../project/schema";
import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types";
import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io";
import { CodeZipDevRunner } from "./codezip";

type ProcessCall = {
command: string[];
options: StreamProcessOptions;
};

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

function runtime(
overrides: { entrypoint?: string; protocol?: ProjectRuntime["protocol"] } = {},
): ProjectRuntime {
return {
name: "hello_world",
build: "CodeZip",
entrypoint: "main.py",
codeLocation: "app/hello-world",
protocol: "HTTP",
...overrides,
} as ProjectRuntime;
}

async function projectRoot(withNodeModules = false): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "agentcore-codezip-"));
tempDirectories.push(root);
await mkdir(join(root, "app", "hello-world"), { recursive: true });
if (withNodeModules) {
await mkdir(join(root, "app", "hello-world", "node_modules"));
}
return root;
}

function harness(output: ProcessEvent[] = []) {
const calls: ProcessCall[] = [];
const fakeStreamProcess: ProcessStreamer = async function* (command, options) {
calls.push({ command, options });
yield* output;
};
return {
calls,
runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess }),
};
}

function input(root: string, projectRuntime: ProjectRuntime): DevServerInput {
return {
runtime: projectRuntime,
projectRoot: root,
port: 9000,
env: { CUSTOM_ENV: "value" },
signal: new AbortController().signal,
};
}

async function collect(events: AsyncIterable<DevEvent>): Promise<DevEvent[]> {
const collected: DevEvent[] = [];
for await (const event of events) collected.push(event);
return collected;
}

describe("CodeZipDevRunner", () => {
test("rejects a missing runtime code directory", async () => {
const root = await mkdtemp(join(tmpdir(), "agentcore-codezip-"));
tempDirectories.push(root);

await expect(collect(harness().runner.run(input(root, runtime())))).rejects.toThrow(
/runtime code directory not found/,
);
});

test("runs HTTP Python entrypoints with uvicorn", async () => {
const root = await projectRoot();
const { calls, runner } = harness([{ type: "stdout", line: "server output" }]);
const events = await collect(
runner.run(input(root, runtime({ entrypoint: "src/main.py:application" }))),
);

expect(calls).toHaveLength(1);
expect(calls[0]?.command).toEqual([
"uv",
"run",
"uvicorn",
"src.main:application",
"--reload",
"--host",
"127.0.0.1",
"--port",
"9000",
]);
expect(calls[0]?.options).toMatchObject({
cwd: join(root, "app", "hello-world"),
env: { CUSTOM_ENV: "value", PORT: "9000", LOCAL_DEV: "1" },
});
expect(events).toEqual([
{ type: "status", message: "Starting development server" },
{ type: "stdout", line: "server output" },
]);
});

test.each(["MCP", "A2A", "AGUI"] as const)(
"runs %s Python entrypoints directly",
async (protocol) => {
const root = await projectRoot();
const { calls, runner } = harness();

await collect(runner.run(input(root, runtime({ protocol, entrypoint: "main.py:handler" }))));

expect(calls[0]?.command).toEqual(["uv", "run", "python", "main.py"]);
expect(calls[0]?.options.env?.FASTMCP_PORT).toBe(protocol === "MCP" ? "9000" : undefined);
},
);

test("installs missing Node dependencies before starting tsx", async () => {
const root = await projectRoot();
const { calls, runner } = harness([{ type: "stderr", line: "0 errors" }]);
const events = await collect(
runner.run(input(root, runtime({ entrypoint: "src/index.ts:handler" }))),
);

expect(calls.map(({ command }) => command)).toEqual([
["npm", "install"],
["npm", "exec", "--", "tsx", "watch", "src/index.ts"],
]);
expect(events).toEqual([
{ type: "status", message: "Installing Node dependencies with npm" },
{ type: "stderr", line: "0 errors" },
{ type: "status", message: "Starting development server" },
{ type: "stderr", line: "0 errors" },
]);
});

test("starts tsx directly when Node dependencies exist", async () => {
const root = await projectRoot(true);
const { calls, runner } = harness();

await collect(runner.run(input(root, runtime({ entrypoint: "index.js" }))));

expect(calls.map(({ command }) => command)).toEqual([
["npm", "exec", "--", "tsx", "watch", "index.js"],
]);
});
});
91 changes: 91 additions & 0 deletions src/core/dev/codezip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { InputValidationError } from "../../errors";
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io";

type CodeZipDevRunnerConfig = {
streamProcess?: ProcessStreamer;
};

export class CodeZipDevRunner implements DevRunner {
private readonly streamProcess: ProcessStreamer;

constructor(config: CodeZipDevRunnerConfig = {}) {
this.streamProcess = config.streamProcess ?? streamProcess;
}

public async *run(input: DevServerInput): AsyncGenerator<DevEvent, void> {
const directory = join(input.projectRoot, input.runtime.codeLocation);
if (!existsSync(directory)) {
throw new InputValidationError(`runtime code directory not found: ${directory}`);
}

const [entrypoint] = input.runtime.entrypoint.split(":");
if (!entrypoint!.endsWith(".py") && !existsSync(join(directory, "node_modules"))) {
yield { type: "status", message: "Installing Node dependencies with npm" };
yield* this.streamProcess(["npm", "install"], {
cwd: directory,
signal: input.signal,
shell: process.platform === "win32",
});
}

yield { type: "status", message: "Starting development server" };
const serverProcess = commandForRuntime(entrypoint!, directory, input);
yield* this.streamProcess(serverProcess.command, serverProcess.options);
}
}

function commandForRuntime(
entrypoint: string,
directory: string,
input: DevServerInput,
): { command: string[]; options: StreamProcessOptions } {
const env: NodeJS.ProcessEnv = {
...process.env,
...input.env,
PORT: String(input.port),
LOCAL_DEV: "1",
};

if (input.runtime.protocol === "MCP") {
env.FASTMCP_PORT = String(input.port);
}

if (!entrypoint.endsWith(".py")) {
return {
command: ["npm", "exec", "--", "tsx", "watch", entrypoint],
options: {
cwd: directory,
env,
signal: input.signal,
shell: process.platform === "win32",
},
};
}

if ((input.runtime.protocol ?? "HTTP") !== "HTTP") {
return {
command: ["uv", "run", "python", entrypoint],
options: { cwd: directory, env, signal: input.signal },
};
}

const [, handler = "app"] = input.runtime.entrypoint.split(":");
const module = entrypoint.replace(/\.py$/, "").replaceAll("/", ".");
return {
command: [
"uv",
"run",
"uvicorn",
`${module}:${handler}`,
"--reload",
"--host",
"127.0.0.1",
"--port",
String(input.port),
],
options: { cwd: directory, env, signal: input.signal },
};
}
18 changes: 18 additions & 0 deletions src/handlers/project/dev/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ProjectRuntime } from "../../../core/project/schema";

export type DevEvent =
| { type: "status"; message: string }
| { type: "stdout"; line: string }
| { type: "stderr"; line: string };

export type DevServerInput = {
runtime: ProjectRuntime;
projectRoot: string;
port: number;
env?: Record<string, string>;
signal: AbortSignal;
};

export interface DevRunner {
run(input: DevServerInput): AsyncGenerator<DevEvent, void>;
}
Loading
Loading