fix: More review comments addressed

This commit is contained in:
Eric Hare
2026-05-08 17:53:31 -07:00
parent 1d7e366498
commit cb651c343a
21 changed files with 432 additions and 132 deletions

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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",

View File

@ -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:

View File

@ -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"
}

View File

@ -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)

View File

@ -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(<BundleHeaderActions {...baseProps} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when extensionId is missing", () => {

View File

@ -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) {

View File

@ -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

View File

@ -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<HTMLDivElement>) => {
if (!showActions) return;

View File

@ -66,4 +66,10 @@ export const useUtilityStore = create<UtilityStoreType>((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 }),
}));

View File

@ -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;
};

View File

@ -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: [

View File

@ -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

View File

@ -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)."
),
}

View File

@ -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

View File

@ -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"
}
]
}

View File

@ -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"):

View File

@ -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"
# ---------------------------------------------------------------------------

View File

@ -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
}