From cb651c343a87f69eebd33da87cdcdb8e8ae168aa Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 8 May 2026 17:53:31 -0700 Subject: [PATCH] fix: More review comments addressed --- docker/build_and_push_backend.Dockerfile | 16 +- docker/build_and_push_base.Dockerfile | 13 ++ .../base/langflow/api/v1/extensions.py | 75 +++++--- .../base/langflow/api/v1/schemas/__init__.py | 9 + .../api/v1/test_extensions_route_guard.py | 18 +- src/frontend/package-lock.json | 41 +---- .../API/queries/config/use-get-config.ts | 8 + .../__tests__/bundleHeaderActions.test.tsx | 18 ++ .../components/bundleHeaderActions.tsx | 11 +- .../components/bundleItems.tsx | 22 ++- .../components/categoryDisclouse.tsx | 10 +- src/frontend/src/stores/utilityStore.ts | 6 + .../src/types/zustand/utility/index.ts | 13 ++ src/frontend/vite.config.mts | 19 +- src/lfx/src/lfx/cli/_extension_commands.py | 17 +- src/lfx/src/lfx/extension/errors.py | 8 + src/lfx/src/lfx/extension/loader/_plugins.py | 168 +++++++++++++++--- .../extension/migration/migration_table.json | 5 + .../extension/loader/test_load_installed.py | 64 +++++++ src/lfx/tests/unit/extension/test_cli.py | 22 +-- src/lfx/tests/unit/extension/test_errors.py | 1 + 21 files changed, 432 insertions(+), 132 deletions(-) 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 }