Skip to content

Commit 41f0419

Browse files
authored
feat: add --daemon to codegen run (#659)
1 parent 77150a8 commit 41f0419

File tree

21 files changed

+663
-250
lines changed

21 files changed

+663
-250
lines changed

Dockerfile-runner

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ RUN apt-get update && apt-get install -y \
2121
# Cleanup apt cache to reduce image size
2222
&& rm -rf /var/lib/apt/lists/*
2323

24+
# Set git config
25+
RUN git config --global user.email "[email protected]" \
26+
&& git config --global user.name "codegen-bot"
27+
2428
# Install nvm and Node.js
2529
SHELL ["/bin/bash", "-c"]
2630
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash \

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ dependencies = [
7575
"docker>=6.1.3",
7676
"urllib3>=2.0.0",
7777
"datasets",
78+
"colorlog>=6.9.0",
7879
]
7980

8081
license = { text = "Apache-2.0" }

src/codegen/cli/commands/run/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,22 @@
1414
@requires_init
1515
@click.argument("label", required=True)
1616
@click.option("--web", is_flag=True, help="Run the function on the web service instead of locally")
17+
@click.option("--daemon", is_flag=True, help="Run the function against a running daemon")
1718
@click.option("--diff-preview", type=int, help="Show a preview of the first N lines of the diff")
1819
@click.option("--arguments", type=str, help="Arguments as a json string to pass as the function's 'arguments' parameter")
1920
def run_command(
2021
session: CodegenSession,
2122
label: str,
2223
web: bool = False,
24+
daemon: bool = False,
2325
diff_preview: int | None = None,
2426
arguments: str | None = None,
2527
):
2628
"""Run a codegen function by its label."""
29+
if web and daemon:
30+
msg = "Cannot enable run on both the web and daemon"
31+
raise ValueError(msg)
32+
2733
# Ensure venv is initialized
2834
venv = VenvManager(session.codegen_dir)
2935
if not venv.is_initialized():
@@ -54,6 +60,10 @@ def run_command(
5460
from codegen.cli.commands.run.run_cloud import run_cloud
5561

5662
run_cloud(session, codemod, diff_preview=diff_preview)
63+
elif daemon:
64+
from codegen.cli.commands.run.run_daemon import run_daemon
65+
66+
run_daemon(session, codemod, diff_preview=diff_preview)
5767
else:
5868
from codegen.cli.commands.run.run_local import run_local
5969

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import rich
2+
import rich_click as click
3+
from rich.panel import Panel
4+
5+
from codegen.cli.auth.session import CodegenSession
6+
from codegen.cli.commands.start.docker_container import DockerContainer
7+
from codegen.cli.errors import ServerError
8+
from codegen.cli.rich.codeblocks import format_command
9+
from codegen.cli.rich.spinners import create_spinner
10+
from codegen.runner.clients.docker_client import DockerClient
11+
from codegen.runner.enums.warmup_state import WarmupState
12+
13+
14+
def run_daemon(session: CodegenSession, function, diff_preview: int | None = None):
15+
"""Run a function on the cloud service.
16+
17+
Args:
18+
session: The current codegen session
19+
function: The function to run
20+
diff_preview: Number of lines of diff to preview (None for all)
21+
"""
22+
with create_spinner(f"Running {function.name}...") as status:
23+
try:
24+
client = _get_docker_client(session)
25+
run_output = client.run_function(function, commit=not diff_preview)
26+
rich.print(f"✅ Ran {function.name} successfully")
27+
28+
if run_output.logs:
29+
rich.print("")
30+
panel = Panel(run_output.logs, title="[bold]Logs[/bold]", border_style="blue", padding=(1, 2), expand=False)
31+
rich.print(panel)
32+
33+
if run_output.error:
34+
rich.print("")
35+
panel = Panel(run_output.error, title="[bold]Error[/bold]", border_style="red", padding=(1, 2), expand=False)
36+
rich.print(panel)
37+
38+
if run_output.observation:
39+
# Only show diff preview if requested
40+
if diff_preview:
41+
rich.print("") # Add some spacing
42+
43+
# Split and limit diff to requested number of lines
44+
diff_lines = run_output.observation.splitlines()
45+
truncated = len(diff_lines) > diff_preview
46+
limited_diff = "\n".join(diff_lines[:diff_preview])
47+
48+
if truncated:
49+
limited_diff += "\n\n...\n\n[yellow]diff truncated to {diff_preview} lines, view the full change set on your local file system after using run with `--apply-local`[/yellow]"
50+
51+
panel = Panel(limited_diff, title="[bold]Diff Preview[/bold]", border_style="blue", padding=(1, 2), expand=False)
52+
rich.print(panel)
53+
else:
54+
rich.print("")
55+
rich.print("[yellow] No changes were produced by this codemod[/yellow]")
56+
57+
if diff_preview:
58+
rich.print("[green]✓ Changes have been applied to your local filesystem[/green]")
59+
rich.print("[yellow]→ Don't forget to commit your changes:[/yellow]")
60+
rich.print(format_command("git add ."))
61+
rich.print(format_command("git commit -m 'Applied codemod changes'"))
62+
63+
except ServerError as e:
64+
status.stop()
65+
raise click.ClickException(str(e))
66+
67+
68+
def _get_docker_client(session: CodegenSession) -> DockerClient:
69+
repo_name = session.config.repository.name
70+
if (container := DockerContainer.get(repo_name)) is None:
71+
msg = f"Codegen runner does not exist for {repo_name}. Please run 'codegen start' from {session.config.repository.path}."
72+
raise click.ClickException(msg)
73+
74+
if not container.is_running():
75+
msg = f"Codegen runner for {repo_name} is not running. Please run 'codegen start' from {session.config.repository.path}."
76+
raise click.ClickException(msg)
77+
78+
client = DockerClient(container)
79+
if not client.is_running():
80+
msg = "Codebase server is not running. Please stop the container and restart."
81+
raise click.ClickException(msg)
82+
83+
if client.server_info().warmup_state != WarmupState.COMPLETED:
84+
msg = "Runner has not finished parsing the codebase. Please wait a moment and try again."
85+
raise click.ClickException(msg)
86+
87+
return client

src/codegen/cli/commands/start/docker_container.py

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,74 @@
1+
from functools import cached_property
2+
13
import docker
4+
from docker import DockerClient
5+
from docker.errors import APIError, NotFound
6+
from docker.models.containers import Container
27

38

49
class DockerContainer:
5-
_client: docker.DockerClient
6-
host: str | None
7-
port: int | None
8-
name: str
10+
_client: DockerClient
11+
_container: Container | None
912

10-
def __init__(self, client: docker.DockerClient, name: str, port: int | None = None, host: str | None = None):
13+
def __init__(self, client: DockerClient, container: Container) -> None:
1114
self._client = client
12-
self.host = host
13-
self.port = port
14-
self.name = name
15+
self._container = container
16+
17+
@classmethod
18+
def get(cls, name: str) -> "DockerContainer | None":
19+
try:
20+
client = docker.from_env()
21+
container = client.containers.get(name)
22+
return cls(client=client, container=container)
23+
except NotFound:
24+
return None
25+
26+
@cached_property
27+
def name(self) -> str:
28+
return self._container.name
29+
30+
@cached_property
31+
def host(self) -> str | None:
32+
if not self.is_running():
33+
return None
34+
35+
host_config = next(iter(self._container.ports.values()))[0]
36+
return host_config["HostIp"]
37+
38+
@cached_property
39+
def port(self) -> int | None:
40+
if not self.is_running():
41+
return None
42+
43+
host_config = next(iter(self._container.ports.values()))[0]
44+
return host_config["HostPort"]
1545

1646
def is_running(self) -> bool:
1747
try:
18-
container = self._client.containers.get(self.name)
19-
return container.status == "running"
20-
except docker.errors.NotFound:
48+
return self._container.status == "running"
49+
except NotFound:
2150
return False
2251

2352
def start(self) -> bool:
2453
try:
25-
container = self._client.containers.get(self.name)
26-
container.start()
54+
self._container.start()
55+
return True
56+
except (NotFound, APIError):
57+
return False
58+
59+
def stop(self) -> bool:
60+
try:
61+
self._container.stop()
62+
return True
63+
except (NotFound, APIError):
64+
return False
65+
66+
def remove(self) -> bool:
67+
try:
68+
self.stop()
69+
self._container.remove()
2770
return True
28-
except (docker.errors.NotFound, docker.errors.APIError):
71+
except (NotFound, APIError):
2972
return False
3073

3174
def __str__(self) -> str:

src/codegen/cli/commands/start/docker_fleet.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import docker
2+
from docker.errors import NotFound
23

34
from codegen.cli.commands.start.docker_container import DockerContainer
45

@@ -15,19 +16,14 @@ def __init__(self, containers: list[DockerContainer]):
1516
def load(cls) -> "DockerFleet":
1617
try:
1718
client = docker.from_env()
18-
containers = client.containers.list(all=True, filters={"ancestor": CODEGEN_RUNNER_IMAGE})
19-
codegen_containers = []
20-
for container in containers:
19+
filters = {"ancestor": CODEGEN_RUNNER_IMAGE}
20+
containers = []
21+
for container in client.containers.list(all=True, filters=filters):
2122
if container.attrs["Config"]["Image"] == CODEGEN_RUNNER_IMAGE:
22-
if container.status == "running":
23-
host_config = next(iter(container.ports.values()))[0]
24-
codegen_container = DockerContainer(client=client, host=host_config["HostIp"], port=host_config["HostPort"], name=container.name)
25-
else:
26-
codegen_container = DockerContainer(client=client, name=container.name)
27-
codegen_containers.append(codegen_container)
28-
29-
return cls(containers=codegen_containers)
30-
except docker.errors.NotFound:
23+
containers.append(DockerContainer(client=client, container=container))
24+
25+
return cls(containers=containers)
26+
except NotFound:
3127
return cls(containers=[])
3228

3329
@property

src/codegen/cli/commands/start/main.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from rich.panel import Panel
99

1010
from codegen.cli.commands.start.docker_container import DockerContainer
11-
from codegen.cli.commands.start.docker_fleet import CODEGEN_RUNNER_IMAGE, DockerFleet
11+
from codegen.cli.commands.start.docker_fleet import CODEGEN_RUNNER_IMAGE
1212
from codegen.configs.models.secrets import SecretsConfig
1313
from codegen.git.repo_operator.local_git_repo import LocalGitRepo
1414
from codegen.git.schemas.repo_config import RepoConfig
@@ -20,13 +20,19 @@
2020
@click.command(name="start")
2121
@click.option("--platform", "-t", type=click.Choice(["linux/amd64", "linux/arm64", "linux/amd64,linux/arm64"]), default="linux/amd64,linux/arm64", help="Target platform(s) for the Docker image")
2222
@click.option("--port", "-p", type=int, default=None, help="Port to run the server on")
23-
def start_command(port: int | None, platform: str):
23+
@click.option("--detached", "-d", is_flag=True, help="Run the server in detached mode")
24+
@click.option("--skip-build", is_flag=True, help="Skip building the Docker image")
25+
@click.option("--force", "-f", is_flag=True, help="Force start the server even if it is already running")
26+
def start_command(port: int | None, platform: str, detached: bool = False, skip_build: bool = False, force: bool = False) -> None:
2427
"""Starts a local codegen server"""
2528
repo_path = Path.cwd().resolve()
2629
repo_config = RepoConfig.from_repo_path(str(repo_path))
27-
fleet = DockerFleet.load()
28-
if (container := fleet.get(repo_config.name)) is not None:
29-
return _handle_existing_container(repo_config, container)
30+
if (container := DockerContainer.get(repo_config.name)) is not None:
31+
if force:
32+
rich.print(f"[yellow]Removing existing runner {repo_config.name} to force restart[/yellow]")
33+
container.remove()
34+
else:
35+
return _handle_existing_container(repo_config, container, force)
3036

3137
codegen_version = version("codegen")
3238
rich.print(f"[bold green]Codegen version:[/bold green] {codegen_version}")
@@ -35,10 +41,11 @@ def start_command(port: int | None, platform: str):
3541
port = get_free_port()
3642

3743
try:
38-
rich.print("[bold blue]Building Docker image...[/bold blue]")
39-
_build_docker_image(codegen_root, platform)
44+
if not skip_build:
45+
rich.print("[bold blue]Building Docker image...[/bold blue]")
46+
_build_docker_image(codegen_root, platform)
4047
rich.print("[bold blue]Starting Docker container...[/bold blue]")
41-
_run_docker_container(repo_config, port)
48+
_run_docker_container(repo_config, port, detached)
4249
rich.print(Panel(f"[green]Server started successfully![/green]\nAccess the server at: [bold]http://{_default_host}:{port}[/bold]", box=ROUNDED, title="Codegen Server"))
4350
# TODO: memory snapshot here
4451
except subprocess.CalledProcessError as e:
@@ -49,7 +56,7 @@ def start_command(port: int | None, platform: str):
4956
raise click.Abort()
5057

5158

52-
def _handle_existing_container(repo_config: RepoConfig, container: DockerContainer) -> None:
59+
def _handle_existing_container(repo_config: RepoConfig, container: DockerContainer, force: bool) -> None:
5360
if container.is_running():
5461
rich.print(
5562
Panel(
@@ -86,19 +93,26 @@ def _build_docker_image(codegen_root: Path, platform: str) -> None:
8693
subprocess.run(build_cmd, check=True)
8794

8895

89-
def _run_docker_container(repo_config: RepoConfig, port: int) -> None:
96+
def _run_docker_container(repo_config: RepoConfig, port: int, detached: bool) -> None:
9097
container_repo_path = f"/app/git/{repo_config.name}"
9198
name_args = ["--name", f"{repo_config.name}"]
9299
envvars = {
93100
"REPOSITORY_LANGUAGE": repo_config.language.value,
94101
"REPOSITORY_OWNER": LocalGitRepo(repo_config.repo_path).owner,
95102
"REPOSITORY_PATH": container_repo_path,
96103
"GITHUB_TOKEN": SecretsConfig().github_token,
104+
"PYTHONUNBUFFERED": "1", # Ensure Python output is unbuffered
97105
}
98106
envvars_args = [arg for k, v in envvars.items() for arg in ("--env", f"{k}={v}")]
99107
mount_args = ["-v", f"{repo_config.repo_path}:{container_repo_path}"]
100-
entry_point = f"uv run --frozen uvicorn codegen.runner.sandbox.server:app --host {_default_host} --port {port}"
101-
run_cmd = ["docker", "run", "-d", "-p", f"{port}:{port}", *name_args, *mount_args, *envvars_args, CODEGEN_RUNNER_IMAGE, entry_point]
108+
entry_point = f"uv run --frozen uvicorn codegen.runner.servers.local_daemon:app --host {_default_host} --port {port}"
109+
port_args = ["-p", f"{port}:{port}"]
110+
detached_args = ["-d"] if detached else []
111+
run_cmd = ["docker", "run", *detached_args, *port_args, *name_args, *mount_args, *envvars_args, CODEGEN_RUNNER_IMAGE, entry_point]
102112

103113
rich.print(f"run_cmd: {str.join(' ', run_cmd)}")
104114
subprocess.run(run_cmd, check=True)
115+
116+
if detached:
117+
rich.print("[yellow]Container started in detached mode. To view logs, run:[/yellow]")
118+
rich.print(f"[bold]docker logs -f {repo_config.name}[/bold]")

src/codegen/git/repo_operator/repo_operator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ def commit_changes(self, message: str, verify: bool = False) -> bool:
471471
staged_changes = self.git_cli.git.diff("--staged")
472472
if staged_changes:
473473
commit_args = ["-m", message]
474+
if self.bot_commit:
475+
commit_args.append(f"--author='{CODEGEN_BOT_NAME} <{CODEGEN_BOT_EMAIL}>'")
474476
if not verify:
475477
commit_args.append("--no-verify")
476478
self.git_cli.git.commit(*commit_args)

src/codegen/git/schemas/repo_config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from pydantic import BaseModel
66

7+
from codegen.configs.models.repository import RepositoryConfig
78
from codegen.git.schemas.enums import RepoVisibility
89
from codegen.shared.enums.programming_language import ProgrammingLanguage
910

@@ -24,6 +25,16 @@ class RepoConfig(BaseModel):
2425
base_path: str | None = None # root directory of the codebase within the repo
2526
subdirectories: list[str] | None = None
2627

28+
@classmethod
29+
def from_envs(cls) -> "RepoConfig":
30+
default_repo_config = RepositoryConfig()
31+
return RepoConfig(
32+
name=default_repo_config.name,
33+
full_name=default_repo_config.full_name,
34+
base_dir=os.path.dirname(default_repo_config.path),
35+
language=ProgrammingLanguage(default_repo_config.language.upper()),
36+
)
37+
2738
@classmethod
2839
def from_repo_path(cls, repo_path: str) -> "RepoConfig":
2940
name = os.path.basename(repo_path)

0 commit comments

Comments
 (0)