From 58c23b2d8301bbef33853c6bd4eb68685a9ab8d5 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 11:21:13 -0700 Subject: [PATCH] done --- libs/cli/langgraph_cli/cli.py | 39 ++++---- libs/cli/langgraph_cli/engine_runtime_mode.py | 69 +++++++++++++ libs/cli/tests/unit_tests/cli/test_cli.py | 6 ++ .../unit_tests/test_engine_runtime_mode.py | 99 +++++++++++++++++++ 4 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 libs/cli/langgraph_cli/engine_runtime_mode.py create mode 100644 libs/cli/tests/unit_tests/test_engine_runtime_mode.py diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index f67b7a2a5..1d7d63fe3 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -27,6 +27,7 @@ from langgraph_cli.api_version import resolve_langgraph_api_version from langgraph_cli.config import Config from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.docker import DockerCapabilities +from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.host_backend import HostBackendClient, HostBackendError from langgraph_cli.progress import Progress @@ -292,8 +293,8 @@ OPT_API_VERSION = click.option( OPT_ENGINE_RUNTIME_MODE = click.option( "--engine-runtime-mode", type=click.Choice(["combined_queue_worker", "distributed"]), - default="combined_queue_worker", - help="Runtime mode. 'distributed' uses separate executor and orchestrator containers.", + default=None, + help="Runtime mode. 'distributed' uses separate executor and orchestrator containers. Defaults to distributed.", ) @@ -349,11 +350,14 @@ def up( debugger_base_url: str | None, postgres_uri: str | None, api_version: str | None, - engine_runtime_mode: str, + engine_runtime_mode: str | None, image: str | None, base_image: str | None, ): api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) click.secho("Starting LangGraph API server...", fg="green") click.secho( """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. @@ -553,13 +557,16 @@ def build( docker_build_args: Sequence[str], base_image: str | None, api_version: str | None, - engine_runtime_mode: str, + engine_runtime_mode: str | None, pull: bool, tag: str, install_command: str | None, build_command: str | None, ): api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) if install_command and langgraph_cli.config.has_disallowed_build_command_content( install_command ): @@ -692,7 +699,8 @@ def deploy( no_wait: bool, docker_build_args: Sequence[str], ): - deploy_api_version = resolve_langgraph_api_version(config, api_version) + api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode(config, api_version, None) click.secho( "Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.", fg="yellow", @@ -720,12 +728,6 @@ def deploy( secrets = _secrets_from_env(env_vars) - # Determine language and runtime mode from config for host backend - is_python = bool(config_json.get("python_version")) or not config_json.get( - "node_version" - ) - deploy_engine_runtime_mode = "distributed" if is_python else "combined_queue_server" - # Use buildx to cross-compile for amd64 when running on a non-x86_64 host # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient. needs_buildx = platform.machine() != "x86_64" @@ -875,10 +877,10 @@ def deploy( "source_config": {"deployment_type": deployment_type}, "source_revision_config": {}, "secrets": secrets, - "engine_runtime_mode": deploy_engine_runtime_mode, + "engine_runtime_mode": engine_runtime_mode, } - if deploy_api_version: - payload["deployed_api_version"] = deploy_api_version + if api_version: + payload["deployed_api_version"] = api_version created = client.create_deployment(payload) created_id = created.get("id") if isinstance(created, dict) else None if not isinstance(created_id, str) or not created_id: @@ -991,8 +993,8 @@ def deploy( deployment_id, remote_image, secrets=secrets, - engine_runtime_mode=deploy_engine_runtime_mode, - deployed_api_version=deploy_api_version, + engine_runtime_mode=engine_runtime_mode, + deployed_api_version=api_version, ) tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None if tenant_id: @@ -1181,9 +1183,12 @@ def dockerfile( add_docker_compose: bool, base_image: str | None = None, api_version: str | None = None, - engine_runtime_mode: str = "combined_queue_worker", + engine_runtime_mode: str | None = None, ) -> None: api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") config_json = langgraph_cli.config.validate_config_file(config) diff --git a/libs/cli/langgraph_cli/engine_runtime_mode.py b/libs/cli/langgraph_cli/engine_runtime_mode.py new file mode 100644 index 000000000..55cfe28f6 --- /dev/null +++ b/libs/cli/langgraph_cli/engine_runtime_mode.py @@ -0,0 +1,69 @@ +"""Resolve the LangGraph engine runtime mode.""" + +import json +import pathlib + +import click + +_DISTRIBUTED_MIN_VERSION = (0, 7, 68) + + +def resolve_engine_runtime_mode( + config_path: pathlib.Path, + api_version: str, + engine_runtime_mode_cli_param: str | None, +) -> str: + """Resolve the engine runtime mode. + + *api_version* must already be resolved to a patch-level semver string. + + Returns ``"distributed"`` or ``"combined_queue_worker"``. + + Raises `click.ClickException` when distributed mode is requested but + not supported (JavaScript project or api_version <= 0.7.67). + """ + requires_combined = _requires_combined(config_path, api_version) + + if engine_runtime_mode_cli_param == "distributed": + if requires_combined: + reasons = _constraint_reasons(config_path, api_version) + raise click.ClickException( + f"Distributed runtime is not supported for {' and '.join(reasons)}." + ) + return "distributed" + + if engine_runtime_mode_cli_param == "combined_queue_worker": + return "combined_queue_worker" + + # No explicit choice → default to distributed + return "distributed" + + +def _requires_combined(config_path: pathlib.Path, api_version: str) -> bool: + return _is_javascript_project(config_path) or _version_too_old(api_version) + + +def _version_too_old(api_version: str) -> bool: + try: + parts = tuple(int(x) for x in api_version.split(".")) + except (ValueError, AttributeError): + return True + return parts < _DISTRIBUTED_MIN_VERSION + + +def _is_javascript_project(config_path: pathlib.Path) -> bool: + try: + with open(config_path) as f: + cfg = json.load(f) + except (OSError, json.JSONDecodeError): + return False + return bool(cfg.get("node_version")) and not cfg.get("python_version") + + +def _constraint_reasons(config_path: pathlib.Path, api_version: str) -> list[str]: + reasons: list[str] = [] + if _is_javascript_project(config_path): + reasons.append("JavaScript projects") + if _version_too_old(api_version): + reasons.append(f"API version {api_version} (<= 0.7.67)") + return reasons diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index ee1c807ca..245dddaa6 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -568,6 +568,8 @@ def test_build_generate_proper_build_context(): "test-image", "--config", str(temp_dir / "config.json"), + "--engine-runtime-mode", + "combined_queue_worker", ], catch_exceptions=True, ) @@ -603,6 +605,8 @@ def test_dockerfile_command_with_api_version() -> None: str(temp_dir / "config.json"), "--api-version", "0.2.74", + "--engine-runtime-mode", + "combined_queue_worker", ], ) @@ -719,6 +723,8 @@ def test_build_command_with_api_version() -> None: str(temp_dir / "config.json"), "--api-version", "0.2.74", + "--engine-runtime-mode", + "combined_queue_worker", "--no-pull", # Avoid pulling non-existent images ], catch_exceptions=True, diff --git a/libs/cli/tests/unit_tests/test_engine_runtime_mode.py b/libs/cli/tests/unit_tests/test_engine_runtime_mode.py new file mode 100644 index 000000000..a225f47b3 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_engine_runtime_mode.py @@ -0,0 +1,99 @@ +import json +import pathlib + +import click +import pytest + +from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode + + +def _write_config( + tmp_path: pathlib.Path, + *, + python_version: str | None = "3.11", + node_version: str | None = None, +) -> pathlib.Path: + cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}} + if python_version is not None: + cfg["python_version"] = python_version + if node_version is not None: + cfg["node_version"] = node_version + path = tmp_path / "langgraph.json" + path.write_text(json.dumps(cfg)) + return path + + +class TestResolveEngineRuntimeMode: + # -- cli_param == "distributed" ------------------------------------------- + + def test_distributed_explicit_new_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed" + ) + + def test_distributed_explicit_old_version_raises( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + with pytest.raises(click.ClickException, match="0.7.67"): + resolve_engine_runtime_mode(path, "0.7.67", "distributed") + + def test_distributed_explicit_js_raises(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + with pytest.raises(click.ClickException, match="JavaScript"): + resolve_engine_runtime_mode(path, "0.8.0", "distributed") + + def test_distributed_explicit_js_and_old_version_raises( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + with pytest.raises(click.ClickException, match="JavaScript.*0.7.60"): + resolve_engine_runtime_mode(path, "0.7.60", "distributed") + + # -- cli_param == "combined_queue_worker" ---------------------------------- + + def test_combined_explicit(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.8.0", "combined_queue_worker") + == "combined_queue_worker" + ) + + def test_combined_explicit_old_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.67", "combined_queue_worker") + == "combined_queue_worker" + ) + + # -- cli_param is None (default) ------------------------------------------- + + def test_default_is_distributed(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed" + + def test_default_old_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert resolve_engine_runtime_mode(path, "0.7.67", None) == "distributed" + + def test_default_js(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed" + + # -- edge: version boundary ------------------------------------------------ + + def test_version_boundary_0_7_67_blocks_distributed( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + with pytest.raises(click.ClickException): + resolve_engine_runtime_mode(path, "0.7.67", "distributed") + + def test_version_boundary_0_7_68_allows_distributed( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed" + )