This commit is contained in:
Quanzheng Long
2026-03-10 11:21:13 -07:00
parent 28cb872ca4
commit 58c23b2d83
4 changed files with 196 additions and 17 deletions
+22 -17
View File
@@ -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)
@@ -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
@@ -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,
@@ -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"
)