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
186 changes: 186 additions & 0 deletions src/core/dev/codezip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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 { MissingCodeDirectoryError } from "../../errors";
import type { DevLogLevel, DevServerHandle } from "../../handlers/project/dev/types";
import type { ProjectRuntime } from "../../handlers/project/types";
import { createSilentLogger } from "../../testing";
import { CodeZipDevRunner, nodePackageManager, parseEntrypoint, serverCommand } from "./codezip";
import { ProcessSupervisor, type ProcessCommand } 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<string> {
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;
}

async function projectFile(root: string, directory: string, name: string): Promise<void> {
await mkdir(join(root, directory), { recursive: true });
await Bun.write(join(root, directory, name), "");
}

const pythonRuntime: ProjectRuntime = {
name: "hello_world",
build: "CodeZip",
entrypoint: "main.py",
codeLocation: "app/hello-world",
};

type Harness = {
runner: CodeZipDevRunner;
commands: string[][];
spawned: ProcessCommand[];
logs: [DevLogLevel, string][];
};

function harness(): Harness {
const commands: string[][] = [];
const spawned: ProcessCommand[] = [];
const logs: [DevLogLevel, string][] = [];
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);
},
supervisor,
});
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.map((c) => c[1])).toEqual(["sync"]);
const [spawn] = h.spawned;
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");
});

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("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.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");
});
});

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("nodePackageManager", () => {
test.each([
["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");
});
});
142 changes: 142 additions & 0 deletions src/core/dev/codezip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { MissingCodeDirectoryError } from "../../errors";
import type {
DevRunner,
DevServerHandle,
StartDevServerInput,
} from "../../handlers/project/dev/types";
import type { Logger } from "../../logging";
import { runCommand, type CommandRunner } from "./run";
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;
supervisor?: ProcessSupervisor;
};

/**
* 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 supervisor: ProcessSupervisor;

constructor(config: CodeZipDevRunnerConfig) {
this.logger = config.logger;
this.run = config.run ?? runCommand;
this.supervisor = config.supervisor ?? new ProcessSupervisor();
}

public async start(input: StartDevServerInput): Promise<DevServerHandle> {
const directory = join(input.projectRoot, input.runtime.codeLocation);
if (!existsSync(directory)) {
throw new MissingCodeDirectoryError(directory);
}

const entrypoint = parseEntrypoint(input.runtime.entrypoint);
if (entrypoint.language === "python") {
await this.ensureVenv(directory, input);
} else {
await this.ensureNodeModules(directory, input);
}

return this.supervisor.spawn(serverCommand(entrypoint, directory, input), input.onLog);
}

/** Creates the venv and installs dependencies on first run; cheap no-op after. */
private async ensureVenv(directory: string, input: StartDevServerInput): Promise<void> {
if (existsSync(venvBin(directory, "uvicorn"))) return;

input.onLog("system", "Setting up Python environment...");
// uv sync creates the venv itself when missing.
await this.run([windowsExecutable("uv", ".exe"), "sync"], {
cwd: directory,
onOutput: (chunk) => this.logger.debug(chunk.trim()),
});
input.onLog("system", "Python environment ready");
}

private async ensureNodeModules(directory: string, input: StartDevServerInput): Promise<void> {
if (existsSync(join(directory, "node_modules"))) return;

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()),
});
input.onLog("system", "Node dependencies ready");
}
}

/** 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,
};
}

return {
executable: windowsExecutable("npx"),
args: ["tsx", "watch", entrypoint.file],
cwd: directory,
env,
};
}

/** 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);
}

/** Renders an entrypoint in uvicorn's "module:attribute" form. */
function asgiApp(entrypoint: Entrypoint): string {
return `${entrypoint.file.replace(/\.py$/, "").replaceAll("/", ".")}:${entrypoint.handler}`;
}
9 changes: 9 additions & 0 deletions src/core/dev/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
CodeZipDevRunner,
nodePackageManager,
parseEntrypoint,
serverCommand,
type Entrypoint,
} from "./codezip";
export { ProcessSupervisor, windowsExecutable, type ProcessCommand } from "./process";
export { runCommand, type CommandRunner } from "./run";
Loading
Loading