diff --git a/docker/build_and_push_backend.Dockerfile b/docker/build_and_push_backend.Dockerfile
index 2fae03032d..58d585df5d 100644
--- a/docker/build_and_push_backend.Dockerfile
+++ b/docker/build_and_push_backend.Dockerfile
@@ -30,6 +30,12 @@ RUN apt-get update \
COPY ./src/backend ./src/backend
COPY ./src/lfx ./src/lfx
COPY ./src/sdk ./src/sdk
+# Workspace bundles (LE-1023 pilot+): each Bundle is shipped as a
+# separate distribution that langflow-base depends on by name (e.g.
+# ``lfx-duckduckgo``). Without copying the source tree, the install
+# below cannot resolve the path-based bundle deps and ends up with a
+# Langflow image missing components that previously lived in lfx.
+COPY ./src/bundles ./src/bundles
# Create venv and install langflow-base with dependencies
# Using uv pip instead of uv sync to avoid workspace complexities
@@ -37,9 +43,15 @@ RUN uv venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
-# Install langflow-base with all extras except dev (which includes Playwright)
+# Install langflow-base with all extras except dev (which includes Playwright).
+# Each pilot-extracted bundle is installed alongside so the runtime image
+# keeps shipping the same component set users had before LE-1023.
RUN --mount=type=cache,target=/root/.cache/uv \
- uv pip install ./src/sdk ./src/lfx "./src/backend/base[complete,postgresql]"
+ uv pip install \
+ ./src/sdk \
+ ./src/lfx \
+ ./src/bundles/duckduckgo \
+ "./src/backend/base[complete,postgresql]"
################################
# RUNTIME
diff --git a/docker/build_and_push_base.Dockerfile b/docker/build_and_push_base.Dockerfile
index 670f6c0fbf..ac0f8c32f8 100644
--- a/docker/build_and_push_base.Dockerfile
+++ b/docker/build_and_push_base.Dockerfile
@@ -79,6 +79,19 @@ RUN --mount=type=cache,target=/root/.cache/uv \
RUSTFLAGS='--cfg reqwest_unstable' \
uv sync --frozen --no-dev --no-editable --extra postgresql
+# Pilot Bundle re-attach (LE-1023): ``langflow-base`` no longer pulls in
+# DuckDuckGo (it moved to the standalone ``lfx-duckduckgo`` distribution
+# whose pyproject lives at ``src/bundles/duckduckgo``). The base image
+# was the user-facing path for that component before the move; install
+# every extracted bundle so the runtime image keeps the same component
+# set. ``--no-deps`` is intentional: the bundle's runtime deps are
+# already in the langflow-base lockfile (lfx, langchain-community,
+# ddgs); avoid pulling a parallel ``lfx`` install.
+RUN --mount=type=cache,target=/root/.cache/uv \
+ RUSTFLAGS='--cfg reqwest_unstable' \
+ uv pip install --no-deps /app/src/bundles/duckduckgo \
+ && uv pip install ddgs
+
################################
# RUNTIME
# Setup user, utilities and copy the virtual environment only
diff --git a/src/backend/base/langflow/api/v1/extensions.py b/src/backend/base/langflow/api/v1/extensions.py
index cfa0958a94..3ac9df0203 100644
--- a/src/backend/base/langflow/api/v1/extensions.py
+++ b/src/backend/base/langflow/api/v1/extensions.py
@@ -14,6 +14,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from lfx.extension.bundle_registry import get_default_registry
+from lfx.extension.errors import ExtensionError
from lfx.extension.reload import ReloadInProgressError, reload_bundle
from lfx.log.logger import logger
from lfx.services.deps import get_settings_service
@@ -23,6 +24,18 @@ from langflow.services.auth.utils import get_current_active_user
router = APIRouter(prefix="/extensions", tags=["Extensions"])
+def _typed_http_exception(*, status_code: int, error: ExtensionError) -> HTTPException:
+ """Wrap an :class:`ExtensionError` in a FastAPI HTTPException.
+
+ The ``detail`` body is the full ``{code, message, location, content,
+ hint, ref_url}`` envelope, matching the typed-error contract documented
+ in the extensions error guide. Returning anything narrower here would
+ drop the fix hint and the docs link from the client surface, which the
+ palette + CLI consumers depend on.
+ """
+ return HTTPException(status_code=status_code, detail=error.to_dict())
+
+
def _require_extension_reload_enabled() -> None:
"""Per-request guard for the Mode A reload route.
@@ -31,20 +44,27 @@ def _require_extension_reload_enabled() -> None:
to happen after the env file is loaded. Operators on Mode B/C
deployments leave ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` unset; the
guard returns 404 so the route is indistinguishable from "not
- mounted" for those operators.
+ mounted" for those operators, with the typed error body so the
+ client renders the same envelope shape it does for any other
+ reload error.
"""
settings = get_settings_service().settings
if not getattr(settings, "enable_extension_reload", False):
- raise HTTPException(
+ raise _typed_http_exception(
status_code=status.HTTP_404_NOT_FOUND,
- detail={
- "code": "extension-reload-disabled",
- "message": (
- "Extension reload is disabled. Set "
- "LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a "
- "local-development install (Mode A)."
+ error=ExtensionError(
+ code="extension-reload-disabled",
+ message=(
+ "Extension reload is disabled on this server. "
+ "Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on "
+ "a local-development install (Mode A)."
),
- },
+ hint=(
+ "Local development: ``lfx extension dev`` sets the flag "
+ "for you. Self-hosted: export LANGFLOW_ENABLE_EXTENSION_RELOAD=true "
+ "or include it in your --env-file."
+ ),
+ ),
)
@@ -75,14 +95,22 @@ async def reload_extension_bundle(extension_id: str, bundle_name: str) -> dict:
if record is not None and record.extension_id != extension_id:
# Bundle name is registered but under a different extension -- treat as
# a 404 so a typo in the URL doesn't mutate someone else's bundle.
- raise HTTPException(
+ raise _typed_http_exception(
status_code=status.HTTP_404_NOT_FOUND,
- detail={
- "code": "reload-bundle-not-installed",
- "message": (
- f"Bundle {bundle_name!r} is registered to extension {record.extension_id!r}, not {extension_id!r}."
+ error=ExtensionError(
+ code="reload-bundle-not-installed",
+ message=(
+ f"Bundle {bundle_name!r} is registered to extension "
+ f"{record.extension_id!r}, not {extension_id!r}."
),
- },
+ location=f"{extension_id}/{bundle_name}",
+ content=bundle_name,
+ hint=(
+ "Use the bundle's actual extension id (typically the pip "
+ "distribution name); ``GET /api/v1/extensions`` will list "
+ "the registered (extension, bundle) pairs."
+ ),
+ ),
)
try:
@@ -90,13 +118,18 @@ async def reload_extension_bundle(extension_id: str, bundle_name: str) -> dict:
except ReloadInProgressError as exc:
# 409 is the conventional "in-progress / conflicting state" code.
logger.info("extension reload-in-progress collision for %s", exc.bundle)
- raise HTTPException(
+ raise _typed_http_exception(
status_code=status.HTTP_409_CONFLICT,
- detail={
- "code": "reload-in-progress",
- "message": str(exc),
- "bundle": exc.bundle,
- },
+ error=ExtensionError(
+ code="reload-in-progress",
+ message=str(exc),
+ location=exc.bundle,
+ content=exc.bundle,
+ hint=(
+ "Wait for the in-flight reload to finish before retrying; "
+ "another tab or worker is already swapping this bundle."
+ ),
+ ),
) from exc
return result.to_dict()
diff --git a/src/backend/base/langflow/api/v1/schemas/__init__.py b/src/backend/base/langflow/api/v1/schemas/__init__.py
index e25a42189c..29ade0d336 100644
--- a/src/backend/base/langflow/api/v1/schemas/__init__.py
+++ b/src/backend/base/langflow/api/v1/schemas/__init__.py
@@ -380,6 +380,13 @@ class BaseConfigResponse(BaseModel):
voice_mode_available: bool
frontend_timeout: int
mcp_base_url: str
+ # Mode A only: gates the palette Bundle-header Reload action. Surfaced
+ # at runtime so the packaged frontend (built once with the env var
+ # default) can still light up the button when an operator turns the
+ # backend reload route on -- the build-time Vite flag gates first-paint,
+ # but ``lfx extension dev`` and ``--env-file LANGFLOW_ENABLE_EXTENSION_RELOAD=true``
+ # opt in after the build is frozen, so the UI consults this field too.
+ enable_extension_reload: bool
class PublicConfigResponse(BaseConfigResponse):
@@ -409,6 +416,7 @@ class PublicConfigResponse(BaseConfigResponse):
voice_mode_available=settings.voice_mode_available,
frontend_timeout=settings.frontend_timeout,
mcp_base_url=settings.mcp_base_url,
+ enable_extension_reload=settings.enable_extension_reload,
allow_custom_components=settings.allow_custom_components,
)
@@ -463,6 +471,7 @@ class ConfigResponse(BaseConfigResponse):
event_delivery=settings.event_delivery,
voice_mode_available=settings.voice_mode_available,
mcp_base_url=settings.mcp_base_url,
+ enable_extension_reload=settings.enable_extension_reload,
webhook_auth_enable=auth_settings.WEBHOOK_AUTH_ENABLE,
default_folder_name=DEFAULT_FOLDER_NAME,
hide_getting_started_progress=os.getenv("HIDE_GETTING_STARTED_PROGRESS", "").lower() == "true",
diff --git a/src/backend/tests/unit/api/v1/test_extensions_route_guard.py b/src/backend/tests/unit/api/v1/test_extensions_route_guard.py
index 88c9ca3dd8..229a990edf 100644
--- a/src/backend/tests/unit/api/v1/test_extensions_route_guard.py
+++ b/src/backend/tests/unit/api/v1/test_extensions_route_guard.py
@@ -26,7 +26,13 @@ def _make_settings(*, enable: bool):
def test_guard_raises_404_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
- """Guard must return 404 with the typed code when the flag is off."""
+ """Guard must return 404 with the FULL typed-error envelope when the flag is off.
+
+ The body must include ``code``, ``message``, ``hint``, and ``ref_url``
+ fields so palette / CLI consumers render the same shape they do for
+ every other reload error. An ad-hoc ``{code, message}`` body would
+ drop the hint that tells the user how to enable the flag.
+ """
monkeypatch.setattr(
"langflow.api.v1.extensions.get_settings_service",
lambda: _make_settings(enable=False),
@@ -36,8 +42,14 @@ def test_guard_raises_404_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None
_require_extension_reload_enabled()
assert exc.value.status_code == status.HTTP_404_NOT_FOUND
- assert exc.value.detail["code"] == "extension-reload-disabled"
- assert "LANGFLOW_ENABLE_EXTENSION_RELOAD" in exc.value.detail["message"]
+ detail = exc.value.detail
+ assert isinstance(detail, dict)
+ # Full ExtensionError envelope -- code + hint + docs link must all
+ # survive the trip through HTTPException.
+ assert detail["code"] == "extension-reload-disabled"
+ assert "LANGFLOW_ENABLE_EXTENSION_RELOAD" in detail["message"]
+ assert detail["hint"]
+ assert detail["ref_url"].endswith("#extension-reload-disabled")
def test_guard_passes_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index 3946db3d1e..1d5ffe605e 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -217,7 +217,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1083,7 +1082,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@chakra-ui/shared-utils": "2.0.5",
"csstype": "^3.1.2",
@@ -1095,7 +1093,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@chakra-ui/color-mode": "2.2.0",
"@chakra-ui/object-utils": "2.1.0",
@@ -1202,7 +1199,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -1239,7 +1235,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -1249,7 +1244,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz",
"integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
@@ -1345,7 +1339,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -1369,7 +1362,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -6015,7 +6007,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -6422,7 +6413,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -7134,7 +7124,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -7145,7 +7134,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -7161,8 +7149,7 @@
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@@ -7794,7 +7781,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8295,7 +8281,6 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -8564,7 +8549,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -8957,7 +8941,6 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -9398,7 +9381,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -9975,7 +9957,6 @@
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -10254,7 +10235,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -11387,7 +11367,6 @@
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -11540,7 +11519,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
@@ -12100,7 +12078,6 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "30.3.0",
"@jest/types": "30.3.0",
@@ -13687,7 +13664,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -13737,7 +13713,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -13777,7 +13752,6 @@
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 10.16.0"
}
@@ -16273,7 +16247,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -16696,7 +16669,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16790,7 +16762,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16821,7 +16792,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -17515,7 +17485,6 @@
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -18034,8 +18003,7 @@
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/source-map": {
"version": "0.5.7",
@@ -18214,7 +18182,6 @@
"integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.1",
@@ -18589,7 +18556,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -19186,7 +19152,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19700,7 +19665,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -20685,7 +20649,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/frontend/src/controllers/API/queries/config/use-get-config.ts b/src/frontend/src/controllers/API/queries/config/use-get-config.ts
index fec19a5695..5ee7e1a7cb 100644
--- a/src/frontend/src/controllers/API/queries/config/use-get-config.ts
+++ b/src/frontend/src/controllers/API/queries/config/use-get-config.ts
@@ -21,6 +21,10 @@ interface BaseConfig {
voice_mode_available: boolean;
allow_custom_components: boolean;
mcp_base_url: string;
+ // Mode A only: backend's ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` mirrored
+ // through to the frontend so a packaged build can light up the palette
+ // Reload button without a rebuild. See utilityStore.enableExtensionReload.
+ enable_extension_reload: boolean;
}
// Public config = base config (unauthenticated users get only base fields)
@@ -84,6 +88,9 @@ export const useGetConfig: useQueryFunctionType<
(state) => state.setAllowCustomComponents,
);
const setMcpBaseUrl = useUtilityStore((state) => state.setMcpBaseUrl);
+ const setEnableExtensionReload = useUtilityStore(
+ (state) => state.setEnableExtensionReload,
+ );
const { query } = UseRequestProcessor();
@@ -107,6 +114,7 @@ export const useGetConfig: useQueryFunctionType<
const allowCustomComponents = data.allow_custom_components ?? true;
setAllowCustomComponents(allowCustomComponents);
setMcpBaseUrl(data.mcp_base_url ?? "");
+ setEnableExtensionReload(Boolean(data.enable_extension_reload));
recomputeComponentsToUpdateIfNeeded();
// Set authenticated-only fields if present (full config)
diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/__tests__/bundleHeaderActions.test.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/__tests__/bundleHeaderActions.test.tsx
index 3e8482010a..5f62928b00 100644
--- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/__tests__/bundleHeaderActions.test.tsx
+++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/__tests__/bundleHeaderActions.test.tsx
@@ -29,6 +29,17 @@ jest.mock("@/stores/alertStore", () => ({
selector({ setSuccessData, setErrorData, setNoticeData }),
}));
+// The runtime gate (mirrored from /config) must be on for the rendered-UI
+// tests; the off-path is exercised separately below.
+interface UtilitySlice {
+ enableExtensionReload: boolean;
+}
+let runtimeReloadEnabled = true;
+jest.mock("@/stores/utilityStore", () => ({
+ useUtilityStore: (selector: (state: UtilitySlice) => unknown) =>
+ selector({ enableExtensionReload: runtimeReloadEnabled }),
+}));
+
// Captured between tests so we can poke the latest onSuccess / onError
// directly without round-tripping through MSW.
type ReloadVars = { extensionId: string; bundleName: string };
@@ -175,6 +186,13 @@ describe("BundleHeaderActions", () => {
setNoticeData.mockReset();
lastOptions = {};
pending = false;
+ runtimeReloadEnabled = true;
+ });
+
+ it("renders nothing when the runtime backend flag is off", () => {
+ runtimeReloadEnabled = false;
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
});
it("renders nothing when extensionId is missing", () => {
diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleHeaderActions.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleHeaderActions.tsx
index 80cd2a2c42..760fbad634 100644
--- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleHeaderActions.tsx
+++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleHeaderActions.tsx
@@ -14,6 +14,7 @@ import type {
import { useReloadBundle } from "@/controllers/API/queries/extensions";
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
import useAlertStore from "@/stores/alertStore";
+import { useUtilityStore } from "@/stores/utilityStore";
const RELOAD_VALUE = "reload" as const;
@@ -153,9 +154,15 @@ const BundleHeaderActionsInner = ({
[],
);
+ // Defense-in-depth gate: parents already check both flags but a future
+ // caller that bypasses BundleItem / CategoryDisclosure must not surface
+ // the action against a backend that has reload disabled.
+ const enableReloadRuntime = useUtilityStore(
+ (state) => state.enableExtensionReload,
+ );
const visible = useMemo(
- () => ENABLE_EXTENSION_RELOAD && Boolean(extensionId),
- [extensionId],
+ () => ENABLE_EXTENSION_RELOAD && enableReloadRuntime && Boolean(extensionId),
+ [enableReloadRuntime, extensionId],
);
if (!visible) {
diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleItems.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleItems.tsx
index 4fc3715aed..c4f9c64ff9 100644
--- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleItems.tsx
+++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/bundleItems.tsx
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/disclosure";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
+import { useUtilityStore } from "@/stores/utilityStore";
import { deriveBundleExtensionId } from "../helpers/derive-bundle-extension-id";
import type { BundleItemProps } from "../types";
import BundleHeaderActions from "./bundleHeaderActions";
@@ -45,12 +46,21 @@ export const BundleItem = memo(
[item.extension_id, item.name, dataFilter],
);
- // The actions menu is only meaningful when both the feature flag is
- // on AND the bundle was loaded from a manifest-shipping Extension; the
- // BundleHeaderActions component itself handles those checks but we
- // also use the same predicate here to avoid registering a context-menu
- // capture handler that has nothing to show.
- const showActions = Boolean(ENABLE_EXTENSION_RELOAD && extensionId);
+ // The actions menu is only meaningful when:
+ // * the build-time kill switch is on (corporate Mode B/C builds can
+ // dead-code-eliminate the UI by setting it to false),
+ // * the backend has the reload route enabled (runtime mirror of
+ // ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` from ``/config``), AND
+ // * the bundle was loaded from a manifest-shipping Extension (we
+ // have an extensionId to send to the reload endpoint).
+ // BundleHeaderActions itself re-checks; the predicate here also gates
+ // the context-menu capture handler.
+ const enableReloadRuntime = useUtilityStore(
+ (state) => state.enableExtensionReload,
+ );
+ const showActions = Boolean(
+ ENABLE_EXTENSION_RELOAD && enableReloadRuntime && extensionId,
+ );
// Right-click on the Bundle header opens the same overflow menu as
// the kebab icon to the right of the chevron. Implemented by
diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/categoryDisclouse.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/categoryDisclouse.tsx
index 91c874a1cc..b4b6f3973d 100644
--- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/categoryDisclouse.tsx
+++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/categoryDisclouse.tsx
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/disclosure";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
+import { useUtilityStore } from "@/stores/utilityStore";
import type { APIClassType } from "@/types/api";
import { deriveBundleExtensionId } from "../helpers/derive-bundle-extension-id";
import BundleHeaderActions from "./bundleHeaderActions";
@@ -68,7 +69,14 @@ export const CategoryDisclosure = memo(function CategoryDisclosure({
() => deriveBundleExtensionId(item.name, dataFilter),
[item.name, dataFilter],
);
- const showActions = Boolean(ENABLE_EXTENSION_RELOAD && extensionId);
+ // Mirror the BundleItem gate: build-time kill switch AND runtime
+ // backend-reload flag (from /config) AND a derivable extension id.
+ const enableReloadRuntime = useUtilityStore(
+ (state) => state.enableExtensionReload,
+ );
+ const showActions = Boolean(
+ ENABLE_EXTENSION_RELOAD && enableReloadRuntime && extensionId,
+ );
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
if (!showActions) return;
diff --git a/src/frontend/src/stores/utilityStore.ts b/src/frontend/src/stores/utilityStore.ts
index 7fa0cdd75f..650ce67324 100644
--- a/src/frontend/src/stores/utilityStore.ts
+++ b/src/frontend/src/stores/utilityStore.ts
@@ -66,4 +66,10 @@ export const useUtilityStore = create((set, get) => ({
set({ allowCustomComponents }),
mcpBaseUrl: "",
setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }),
+ // Default ``false`` so a misconfigured store (no ``/config`` reply yet)
+ // matches the backend default of "reload disabled". The /config query
+ // overwrites this on first load.
+ enableExtensionReload: false,
+ setEnableExtensionReload: (enableExtensionReload: boolean) =>
+ set({ enableExtensionReload }),
}));
diff --git a/src/frontend/src/types/zustand/utility/index.ts b/src/frontend/src/types/zustand/utility/index.ts
index 9bf67e3e04..b8219eed9e 100644
--- a/src/frontend/src/types/zustand/utility/index.ts
+++ b/src/frontend/src/types/zustand/utility/index.ts
@@ -40,4 +40,17 @@ export type UtilityStoreType = {
setAllowCustomComponents: (allowCustomComponents: boolean) => void;
mcpBaseUrl: string;
setMcpBaseUrl: (mcpBaseUrl: string) => void;
+ /**
+ * Mode A only: gates the palette Bundle-header Reload action at runtime.
+ * Sourced from the backend ``/config`` response (mirrors
+ * ``settings.enable_extension_reload``) so a ``langflow run`` started
+ * with ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (or via ``--env-file``,
+ * or by ``lfx extension dev``) lights up the Reload button without a
+ * frontend rebuild. The build-time Vite flag (ENABLE_EXTENSION_RELOAD)
+ * still gates first paint; the UI consults BOTH so Mode B/C deployments
+ * with the build flag off keep the button hidden even if a misconfigured
+ * backend turns it on.
+ */
+ enableExtensionReload: boolean;
+ setEnableExtensionReload: (enableExtensionReload: boolean) => void;
};
diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts
index 93d96607ef..87b13bcba2 100644
--- a/src/frontend/vite.config.mts
+++ b/src/frontend/vite.config.mts
@@ -57,15 +57,18 @@ export default defineConfig(({ mode }) => {
"import.meta.env.LANGFLOW_MCP_COMPOSER_ENABLED": JSON.stringify(
envLangflow.LANGFLOW_MCP_COMPOSER_ENABLED ?? "true",
),
- // Mode A only: gates the palette Bundle-header Reload action.
- // ``feature-flags.ts`` reads this via ``import.meta.env``; without an
- // entry here Vite never substitutes the value at build time, so the
- // flag stays undefined for both ``vite dev`` and ``vite build`` and
- // the Reload UI never appears even when extension templates carry
- // the metadata. Default off so production builds keep parity with
- // the backend's default-off ``LANGFLOW_ENABLE_EXTENSION_RELOAD``.
+ // Compile-time hard kill switch for the palette Bundle-header
+ // Reload action. The actual user-facing gate is the runtime
+ // ``enable_extension_reload`` flag served from ``/config`` (mirrors
+ // ``LANGFLOW_ENABLE_EXTENSION_RELOAD``), so a packaged frontend
+ // built once can still light up the button when an operator opts
+ // the backend in via ``--env-file`` or ``lfx extension dev``.
+ // Default ``true`` here means the bundle SHIPS the UI; corporate
+ // Mode B/C builds that want to drop the code entirely can set
+ // ``LANGFLOW_EXTENSION_RELOAD_ENABLED=false`` in ``.env`` to dead-code-
+ // eliminate the Reload UI at build time.
"import.meta.env.LANGFLOW_EXTENSION_RELOAD_ENABLED": JSON.stringify(
- envLangflow.LANGFLOW_EXTENSION_RELOAD_ENABLED ?? "false",
+ envLangflow.LANGFLOW_EXTENSION_RELOAD_ENABLED ?? "true",
),
},
plugins: [
diff --git a/src/lfx/src/lfx/cli/_extension_commands.py b/src/lfx/src/lfx/cli/_extension_commands.py
index 42e0bb3aad..2998327145 100644
--- a/src/lfx/src/lfx/cli/_extension_commands.py
+++ b/src/lfx/src/lfx/cli/_extension_commands.py
@@ -620,22 +620,15 @@ def _build_dev_launch_env(base_env: os._Environ[str] | dict[str, str]) -> dict[s
- ``LANGFLOW_LAZY_LOAD_COMPONENTS=false`` (always, overriding the
author's shell): dev components must appear in the palette
eagerly so the AC's 5-second budget holds.
- - ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (setdefault): the
- backend route only mounts a usable handler when this is set;
- ``setdefault`` so an author intentionally testing the off path
- can pre-export ``=false``.
- - ``LANGFLOW_EXTENSION_RELOAD_ENABLED=true`` (setdefault): the
- frontend palette gate (``ENABLE_EXTENSION_RELOAD`` in
- ``feature-flags.ts``) is wired through ``import.meta.env`` and
- read by Vite at build time; setting it here only affects a
- ``vite dev`` server launched from the same shell. Listed for
- parity with the backend flag so an author who runs both halves
- locally gets the Reload button without hand-editing ``.env``.
+ - ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (setdefault): turns on
+ the in-process Bundle reload route AND the ``/config`` flag the
+ packaged frontend reads at runtime to surface the palette
+ Reload button. ``setdefault`` so an author intentionally
+ testing the off path can pre-export ``=false``.
"""
env = dict(base_env)
env["LANGFLOW_LAZY_LOAD_COMPONENTS"] = "false"
env.setdefault("LANGFLOW_ENABLE_EXTENSION_RELOAD", "true")
- env.setdefault("LANGFLOW_EXTENSION_RELOAD_ENABLED", "true")
return env
diff --git a/src/lfx/src/lfx/extension/errors.py b/src/lfx/src/lfx/extension/errors.py
index 90eceefb5b..7986cf3b80 100644
--- a/src/lfx/src/lfx/extension/errors.py
+++ b/src/lfx/src/lfx/extension/errors.py
@@ -87,6 +87,10 @@ ERROR_CODES: frozenset[str] = frozenset(
"reload-bundle-not-installed",
"reload-bundle-name-mismatch",
"reload-source-missing",
+ # HTTP route gate (Mode A only): the runtime guard that hides the
+ # reload route on Mode B/C deployments returns this code so the
+ # client gets the same typed envelope as every other reload error.
+ "extension-reload-disabled",
}
)
@@ -261,6 +265,10 @@ _BRANCH_TEMPLATES: dict[str, str] = {
"reload-source-missing": (
"Reload source path {content!r} for bundle {location!r} does not exist or is not a directory."
),
+ "extension-reload-disabled": (
+ "Extension reload is disabled on this server. "
+ "Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a local-development install (Mode A)."
+ ),
}
diff --git a/src/lfx/src/lfx/extension/loader/_plugins.py b/src/lfx/src/lfx/extension/loader/_plugins.py
index eaff2dcab5..6f435caae8 100644
--- a/src/lfx/src/lfx/extension/loader/_plugins.py
+++ b/src/lfx/src/lfx/extension/loader/_plugins.py
@@ -53,37 +53,157 @@ def _distribution_manifest_path(dist: importlib_metadata.Distribution) -> Path |
extension.json wins on collision; pyproject.toml is accepted only when
it declares ``[tool.langflow.extension]``.
+
+ Editable installs (``pip install -e``, ``uv pip install --editable``)
+ typically expose only dist-info files via ``dist.files`` -- the source
+ tree lives outside the site-packages and is reached at import time via
+ a ``.pth`` shim. When the dist-info pass finds no manifest, fall back
+ to ``direct_url.json`` (PEP 610) to locate the editable project root
+ and look for ``extension.json`` / ``pyproject.toml`` there. Without
+ this fallback, ``lfx-duckduckgo`` and friends installed via ``uv sync``
+ workspace links never reach :func:`load_installed_extensions`.
"""
files = dist.files
- if files is None:
- return None
-
pyproject_candidate: Path | None = None
- for relative in files:
- if not relative.parts:
- continue
- last = relative.parts[-1]
- if last == "extension.json":
- try:
- located = Path(dist.locate_file(relative))
- except (OSError, ValueError):
- # ``locate_file`` may raise on unusual setups (e.g. namespace
- # packages without a concrete root); treat as "not found"
- # without breaking the rest of the scan.
+ if files is not None:
+ for relative in files:
+ if not relative.parts:
continue
- if located.is_file():
- # extension.json wins outright -- skip any pyproject seen later.
- return located
- elif last == "pyproject.toml" and pyproject_candidate is None:
- try:
- located = Path(dist.locate_file(relative))
- except (OSError, ValueError):
- continue
- if located.is_file():
- pyproject_candidate = located
+ last = relative.parts[-1]
+ if last == "extension.json":
+ try:
+ located = Path(dist.locate_file(relative))
+ except (OSError, ValueError):
+ # ``locate_file`` may raise on unusual setups (e.g. namespace
+ # packages without a concrete root); treat as "not found"
+ # without breaking the rest of the scan.
+ continue
+ if located.is_file():
+ # extension.json wins outright -- skip any pyproject seen later.
+ return located
+ elif last == "pyproject.toml" and pyproject_candidate is None:
+ try:
+ located = Path(dist.locate_file(relative))
+ except (OSError, ValueError):
+ continue
+ if located.is_file():
+ pyproject_candidate = located
if pyproject_candidate is not None and _pyproject_has_extension_section(pyproject_candidate):
return pyproject_candidate
+
+ # Fallback: editable install. PEP 610 ``direct_url.json`` records the
+ # source URL; for editable installs the URL is a ``file://`` path to the
+ # project root, which is where the manifest lives.
+ return _editable_manifest_path(dist)
+
+
+def _editable_manifest_path(dist: importlib_metadata.Distribution) -> Path | None:
+ """Resolve the manifest of an editable install whose ``dist.files`` is dist-info-only.
+
+ Tries two fallbacks in order:
+
+ 1. The ``langflow.extensions`` entry-point group. When a bundle
+ declares ``[project.entry-points."langflow.extensions"] foo = "lfx_foo"``
+ the entry-point's value is the dotted package path that ships
+ ``extension.json``; importing that package gives us the source
+ directory regardless of how the dist was installed.
+
+ 2. PEP 610 ``direct_url.json`` for ``editable=true`` distributions.
+ The recorded URL points at the project root; we look for
+ ``extension.json`` (or a ``[tool.langflow.extension]`` pyproject)
+ directly there.
+
+ Returns ``None`` if neither path yields a manifest. Both paths are
+ necessary because (a) installed wheels list the package's
+ ``extension.json`` in ``dist.files`` so they don't reach this fallback,
+ and (b) editable installs that use ``langflow.extensions`` entry-points
+ point at the package, while editable installs that don't may still
+ have a top-level manifest.
+ """
+ manifest = _manifest_via_entry_point(dist)
+ if manifest is not None:
+ return manifest
+ return _manifest_via_direct_url(dist)
+
+
+def _manifest_via_entry_point(dist: importlib_metadata.Distribution) -> Path | None:
+ """Find a manifest via this distribution's ``langflow.extensions`` entry-point.
+
+ Locates the package directory via ``importlib.util.find_spec`` rather
+ than importing the package, so a manifest-discovery pass at startup
+ does not trigger arbitrary side-effects from a bundle's ``__init__``.
+ """
+ import importlib.util
+
+ try:
+ eps = dist.entry_points
+ except (OSError, AttributeError, TypeError):
+ return None
+ if eps is None:
+ return None
+
+ candidate_modules: list[str] = []
+ for ep in eps:
+ if getattr(ep, "group", None) == "langflow.extensions":
+ value = (getattr(ep, "value", "") or "").split(":", 1)[0].strip()
+ if value:
+ candidate_modules.append(value)
+
+ for module_name in candidate_modules:
+ try:
+ spec = importlib.util.find_spec(module_name)
+ except (ImportError, ValueError, ModuleNotFoundError):
+ continue
+ if spec is None or spec.origin is None:
+ continue
+ package_dir = Path(spec.origin).parent
+ manifest = package_dir / "extension.json"
+ if manifest.is_file():
+ return manifest
+ return None
+
+
+def _manifest_via_direct_url(dist: importlib_metadata.Distribution) -> Path | None:
+ """Resolve the manifest from PEP 610 ``direct_url.json`` for editable installs."""
+ import json
+ from urllib.parse import urlparse
+
+ try:
+ raw = dist.read_text("direct_url.json")
+ except (OSError, FileNotFoundError, AttributeError):
+ # ``AttributeError`` covers test doubles that don't implement the
+ # full ``Distribution`` interface; ``OSError`` covers the production
+ # case where the file is missing or unreadable.
+ return None
+ if not raw:
+ return None
+ try:
+ payload = json.loads(raw)
+ except ValueError:
+ return None
+ if not isinstance(payload, dict):
+ return None
+ if not payload.get("dir_info", {}).get("editable"):
+ return None
+
+ url = payload.get("url")
+ if not isinstance(url, str):
+ return None
+ parsed = urlparse(url)
+ if parsed.scheme != "file":
+ return None
+ project_root = Path(parsed.path)
+ if not project_root.is_dir():
+ return None
+
+ manifest = project_root / "extension.json"
+ if manifest.is_file():
+ return manifest
+
+ pyproject = project_root / "pyproject.toml"
+ if pyproject.is_file() and _pyproject_has_extension_section(pyproject):
+ return pyproject
return None
diff --git a/src/lfx/src/lfx/extension/migration/migration_table.json b/src/lfx/src/lfx/extension/migration/migration_table.json
index 5d11ccd06b..521af5f4e1 100644
--- a/src/lfx/src/lfx/extension/migration/migration_table.json
+++ b/src/lfx/src/lfx/extension/migration/migration_table.json
@@ -15,6 +15,11 @@
"import_path": "lfx.components.duckduckgo.DuckDuckGoSearchComponent",
"target": "ext:duckduckgo:DuckDuckGoSearchComponent@official",
"added_in": "1.10.0"
+ },
+ {
+ "legacy_slot": "ext:duckduckgo:DuckDuckGoSearchComponent@official-pre-a",
+ "target": "ext:duckduckgo:DuckDuckGoSearchComponent@official",
+ "added_in": "1.10.0"
}
]
}
diff --git a/src/lfx/tests/unit/extension/loader/test_load_installed.py b/src/lfx/tests/unit/extension/loader/test_load_installed.py
index d1075ab775..fc791fea93 100644
--- a/src/lfx/tests/unit/extension/loader/test_load_installed.py
+++ b/src/lfx/tests/unit/extension/loader/test_load_installed.py
@@ -108,6 +108,70 @@ def test_no_installed_extensions_returns_empty(tmp_path: Path) -> None:
assert results == []
+def test_editable_install_discovered_via_direct_url(tmp_path: Path) -> None:
+ """Editable installs (``pip install -e``) typically expose only dist-info files.
+
+ The dist-info pass finds no ``extension.json``; the loader must fall
+ back to PEP 610 ``direct_url.json`` (``editable=true``) which records
+ the source path. Without this fallback, an ``lfx-duckduckgo`` linked
+ via ``uv sync`` workspace member never reaches
+ :func:`load_installed_extensions` -- the dogfood / dev case skipped
+ the bundle entirely.
+ """
+ # Lay out a real extension on disk to be the "editable source".
+ project_root = tmp_path / "src" / "bundles" / "duckduckgo"
+ bundle_dir = project_root / "components"
+ bundle_dir.mkdir(parents=True)
+ (project_root / "extension.json").write_text(
+ json.dumps(
+ {
+ "id": "lfx-pilot",
+ "version": "1.0.0",
+ "name": "lfx-pilot",
+ "lfx": {"compat": ["1"]},
+ "bundles": [{"name": "pilot", "path": "components"}],
+ }
+ ),
+ encoding="utf-8",
+ )
+ (bundle_dir / "thing.py").write_text(component_source(), encoding="utf-8")
+
+ class _EditableDist(FakeDist):
+ """FakeDist that exposes the same surface as ``importlib.metadata.Distribution``.
+
+ Editable installs leave ``files`` listing only dist-info entries;
+ the manifest is reachable only via ``read_text("direct_url.json")``.
+ """
+
+ def __init__(self, name: str, project_root: Path) -> None:
+ super().__init__(name=name, root=tmp_path, files=[Path("dist-info") / "METADATA"])
+ self._direct_url = json.dumps(
+ {
+ "url": project_root.resolve().as_uri(),
+ "dir_info": {"editable": True},
+ }
+ )
+
+ def read_text(self, name: str) -> str | None:
+ if name == "direct_url.json":
+ return self._direct_url
+ return None
+
+ @property
+ def entry_points(self):
+ return []
+
+ dist = _EditableDist("lfx-pilot", project_root)
+
+ results = load_installed_extensions(distributions=[dist])
+ assert len(results) == 1
+ result = results[0]
+ assert result.ok, [e.code for e in result.errors]
+ assert result.distribution == "lfx-pilot"
+ assert result.source_path == project_root.resolve()
+ assert result.components, "Editable bundle's components must load"
+
+
def test_results_are_lexicographically_ordered(tmp_path: Path) -> None:
"""Order is deterministic so events / logging are reproducible."""
for name in ("lfx-zeta", "lfx-alpha", "lfx-mike"):
diff --git a/src/lfx/tests/unit/extension/test_cli.py b/src/lfx/tests/unit/extension/test_cli.py
index 1e1ffdcd0d..a165861daa 100644
--- a/src/lfx/tests/unit/extension/test_cli.py
+++ b/src/lfx/tests/unit/extension/test_cli.py
@@ -264,11 +264,12 @@ def test_dev_refuses_non_directory(
def test_dev_launch_env_enables_reload_and_eager_loading() -> None:
"""The launched langflow inherits flags that make 'edit -> Reload' work.
- Without ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` the backend route is
- not registered as a usable handler and the documented author loop
- 404s. Without ``LANGFLOW_LAZY_LOAD_COMPONENTS=false`` dev components
- miss the palette's 5-second budget. Both must be set unconditionally
- or via setdefault per the helper's contract.
+ Without ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` the backend reload
+ handler returns 404 (the runtime guard reads
+ ``settings.enable_extension_reload``) AND the ``/config`` payload
+ reports the flag as off, which keeps the packaged frontend from
+ showing the Reload button. Without ``LANGFLOW_LAZY_LOAD_COMPONENTS=false``
+ dev components miss the palette's 5-second budget.
"""
from lfx.cli._extension_commands import _build_dev_launch_env
@@ -276,7 +277,6 @@ def test_dev_launch_env_enables_reload_and_eager_loading() -> None:
assert env["LANGFLOW_LAZY_LOAD_COMPONENTS"] == "false"
assert env["LANGFLOW_ENABLE_EXTENSION_RELOAD"] == "true"
- assert env["LANGFLOW_EXTENSION_RELOAD_ENABLED"] == "true"
def test_dev_launch_env_overrides_author_lazy_loading() -> None:
@@ -295,19 +295,13 @@ def test_dev_launch_env_overrides_author_lazy_loading() -> None:
def test_dev_launch_env_respects_author_reload_off_path() -> None:
"""An author testing the off path can pre-export the disable flag.
- The reload flags use setdefault so an explicit ``=false`` exported
+ The reload flag uses setdefault so an explicit ``=false`` exported
in the shell survives the helper's defaulting.
"""
from lfx.cli._extension_commands import _build_dev_launch_env
- env = _build_dev_launch_env(
- {
- "LANGFLOW_ENABLE_EXTENSION_RELOAD": "false",
- "LANGFLOW_EXTENSION_RELOAD_ENABLED": "false",
- }
- )
+ env = _build_dev_launch_env({"LANGFLOW_ENABLE_EXTENSION_RELOAD": "false"})
assert env["LANGFLOW_ENABLE_EXTENSION_RELOAD"] == "false"
- assert env["LANGFLOW_EXTENSION_RELOAD_ENABLED"] == "false"
# ---------------------------------------------------------------------------
diff --git a/src/lfx/tests/unit/extension/test_errors.py b/src/lfx/tests/unit/extension/test_errors.py
index d8726c140d..0c3b717396 100644
--- a/src/lfx/tests/unit/extension/test_errors.py
+++ b/src/lfx/tests/unit/extension/test_errors.py
@@ -100,6 +100,7 @@ _FIRST_LINE_EXPECTATIONS: dict[str, str] = {
"reload-bundle-not-installed": "error[reload-bundle-not-installed]: Cannot reload bundle 'content': it is not registered. Install the extension first or pass an explicit source path.", # noqa: E501
"reload-bundle-name-mismatch": "error[reload-bundle-name-mismatch]: Reload source at loc declares bundle name 'content', which does not match the registered bundle being reloaded.", # noqa: E501
"reload-source-missing": "error[reload-source-missing]: Reload source path 'content' for bundle 'loc' does not exist or is not a directory.", # noqa: E501
+ "extension-reload-disabled": "error[extension-reload-disabled]: Extension reload is disabled on this server. Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a local-development install (Mode A).", # noqa: E501
}