fix(extensions): discover editable bundles; clean up ticket refs and reload UX (#13219)

* fix(extensions): discover editable installs via langflow.extensions entry-point

`lfx extension list` was silently dropping bundles installed via `uv pip
install -e` / `pip install -e`. Editable installs surface only
`dist-info/` entries in `dist.files`, so the wheel-shaped scan in
`_distribution_manifest_path` finds no `extension.json`. The bundle
pyproject.toml comment already promised an entry-point fallback for this
case, but it was never implemented in discovery.py.

Add `_distribution_manifest_path_via_entry_points` that resolves the
package via `importlib.util.find_spec` (no module body execution) and
locates the manifest under the resulting package directory. The fallback
runs only when `dist.files` produces no manifest, so wheel installs are
unaffected.

Tests:
- editable distribution with dist-info-only files is discovered
- entry-point pointing at an unimportable module yields no record
- wheel-install path never consults find_spec (guards against
  re-importing every bundle package on startup)

* chore(extensions): drop internal ticket refs; relax reload --bundle and --all

Two related changes:

1. Strip internal `LE-NNNN` ticket references from the extension source,
   bundles, tests, and public docs. The references were not actionable
   outside the project and surfaced in user-visible CLI messages and the
   author guide.

2. Relax `lfx extension reload` CLI ergonomics now that local discovery
   (`discover_all_extensions`) gives us the install map without needing
   an HTTP list endpoint:

   - `lfx extension reload <ext_id>`: `--bundle` is now optional; when
     omitted, the bundle name is resolved from local discovery. Explicit
     `--bundle` still wins for cases where the local install isn't
     visible to the running server.
   - `lfx extension reload --all`: iterates over every locally-discovered
     bundle, POSTs reload to each, renders per-bundle status, and exits 1
     if any reload fails. Previously hard-errored as "not yet wired".
   - `--all` is mutually exclusive with a positional id / `--bundle`
     (exit 2 with a clear message).
   - Missing both `extension_id` and `--all` exits 2 pointing at both.

Updated `test_reload_requires_explicit_bundle` (which enforced the old
"--bundle is mandatory" behavior) to cover the new resolution paths.

* docs(bundle-api): changelog entries for editable-install discovery + reload CLI

Satisfies the BUNDLE_API surface-change gate for the editable-install
discovery fallback (entry-point lookup in discovery.py) and the relaxed
`lfx extension reload` CLI (`--bundle` optional, `--all` implemented).

No additional behavior change in this commit -- it only documents the
two changes already shipped in this branch.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Debojit Kaushik <Kaushik.debojit@gmail.com>
This commit is contained in:
Eric Hare
2026-05-20 11:10:59 -07:00
committed by GitHub
parent 651d9e71a8
commit ec3aea7ff8
50 changed files with 706 additions and 267 deletions

View File

@ -333,3 +333,29 @@ the deserialize half is covered by
/ ``load_seed_extensions`` moved to
``lfx.extension.loader._startup``. Both are re-exported from
their previous import paths so external imports are unchanged.
- **Editable installs are discovered via the entry-point fallback.**
``_distribution_manifest_path`` now falls back to the
``langflow.extensions`` entry-point group when ``dist.files`` only
surfaces ``dist-info/`` entries (the ``pip install -e`` /
``uv pip install -e`` case). The entry-point value is resolved
via ``importlib.util.find_spec`` -- which runs import-system
finders but never executes the module body -- and the resulting
package directory is scanned for ``extension.json`` or a
``[tool.langflow.extension]`` pyproject. Wheel installs are
unaffected: the fallback only fires when the primary ``dist.files``
scan finds no manifest. Previously, editable-installed bundles
were silently dropped by ``lfx extension list`` and the registry,
even though the bundle pyproject already declared the
entry-point.
- **Reload CLI: ``--bundle`` is optional; ``--all`` is implemented.**
``lfx extension reload <ext_id>`` now resolves the bundle name
from local ``discover_all_extensions`` when ``--bundle`` is
omitted; explicit ``--bundle`` still wins for cases where the
local install is not visible to the running server.
``lfx extension reload --all`` iterates every locally-discovered
bundle, POSTs reload to each, and exits non-zero if any reload
fails (previously hard-errored as "not yet wired"). ``--all`` is
mutually exclusive with a positional id / ``--bundle`` (exit 2).
The HTTP wire contract (``POST /api/v1/extensions/{id}/bundles/
{name}/reload`` per-bundle) is unchanged; this is a CLI-only
surface change.

View File

@ -1,9 +1,8 @@
# Investigation: Deprecated macOS Support — Impact on Langflow
**Jira:** LE-265
**Date:** 2026-04-02
**Status:** Complete
**Related:** LE-172 (PyTorch macOS x86_64 + Python 3.13 resolved in PR #12469)
**Related:** PyTorch macOS x86_64 + Python 3.13 (resolved in PR #12469)
---
@ -303,7 +302,5 @@ Line 345: gassist → sys_platform == 'win32'
| Reference | Description | Status |
|-----------|-------------|--------|
| LE-172 | PyTorch macOS x86_64 + Python 3.13 CI failure | ✅ Resolved (PR #12469) |
| LE-265 | Broader deprecated macOS support investigation | This document |
| [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) | PyTorch RFC: Deprecate macOS x86_64 builds | Implemented |
| PR #12469 | Exclude torch-dependent extras on macOS x86_64 | Merged/Open |
| [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) | PyTorch RFC: Deprecate macOS x86_64 builds | Implemented |

View File

@ -1,8 +1,7 @@
# Investigation: PyTorch on macOS AMD64 (x86_64) + Python 3.13
**Jira:** LE-172
**Date:** 2026-04-02
**Status:** Complete
**Date:** 2026-04-02
**Status:** Complete
---

View File

@ -8,7 +8,7 @@ slug: /deployment-extensions-production
This guide describes the production install pattern for Langflow Extensions in Modes A, B, and C of the Bundle Separation iteration. The mechanism is intentionally simple: **Python distributions installed into the environment are the primary install path**, and an optional **seed directory** is supported for Docker images that prefer an explicit on-disk layout.
:::info First-delivery scope
This page documents the first-delivery slice of the Bundle Separation epic (LE-905). It covers discovery and registration of installed Extensions; the corresponding loader, dev server, and reload features ship in the same first-delivery cycle. Mutation verbs (`enable`, `disable`, `install`, `uninstall`) ship in B3/B4 follow-up epics; **runtime mutation of installed Extensions is intentionally not supported**.
This page documents the first-delivery slice of the Bundle Separation work. It covers discovery and registration of installed Extensions; the corresponding loader, dev server, and reload features ship in the same first-delivery cycle. Mutation verbs (`enable`, `disable`, `install`, `uninstall`) ship in follow-up work; **runtime mutation of installed Extensions is intentionally not supported**.
:::
## How discovery works
@ -22,7 +22,7 @@ Each Extension registered via these paths is **read-only at runtime**:
* `autoUpdate` is forced to `false`.
* Mutation through the registry service raises a typed error with code `installed-extension-immutable` or `seed-directory-immutable`.
* No HTTP route exists for installing or uninstalling Extensions; the LE-1017 router-trust CI guard blocks any attempt to add one.
* No HTTP route exists for installing or uninstalling Extensions; the router-trust CI guard blocks any attempt to add one.
To change which Extensions are installed in Mode B/C, **rebuild the image** (or modify the seed directory layout) and restart the server.

View File

@ -8,9 +8,9 @@ slug: /extensions-author-guide
This guide covers how to design, build, and ship a Langflow Extension Bundle from scratch, plus the conventions that make the result safe to maintain alongside the rest of Langflow. If you just want to get something running, start with [Build your first Langflow Extension](./extensions-quickstart.mdx) — that's the three-minute path. Read this when you're ready to publish, port a component out of `lfx.components.<provider>`, or evaluate whether your extension idea fits the v0 contract.
:::info First-delivery scope
This page documents the first-delivery slice of the Bundle Separation epic (LE-905). It covers the foundation (manifest, single-Bundle loader, atomic-swap reload) plus the pilot migration pattern. The wider Extension System vision — services, routes, registries, partner handover — is described in the project's internal design docs and ships in later epics.
This page documents the first-delivery slice of the Bundle Separation work. It covers the foundation (manifest, single-Bundle loader, atomic-swap reload) plus the pilot migration pattern. The wider Extension System vision — services, routes, registries, partner handover — ships in later phases.
The structured **events pipeline** (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook, `bundle_reloaded` / `flow-migrated` events) is deferred to LE-1017. Reload and migration emit through the standard logger today and the HTTP response carries the typed result body; the events stream is the additive surface that lands on top.
The structured **events pipeline** (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook, `bundle_reloaded` / `flow-migrated` events) is deferred. Reload and migration emit through the standard logger today and the HTTP response carries the typed result body; the events stream is the additive surface that lands on top.
:::
## Concepts
@ -109,7 +109,7 @@ Atomic-swap reload (Mode A only) is what makes the dev loop tolerable. The five
2. **Stage 2 — validate** the staging load. Any error aborts here; the live bundle is unchanged.
3. **Stage 3 — swap** under the registry write-lock. New flows resolve to the new class; in-flight flows keep their pre-swap class reference.
4. **Stage 4 — clean up** the staging namespace.
5. **Stage 5 — notify**. Today this is a logger emission of the added/removed component lists plus a registered post-swap hook that refreshes the component cache; the typed `bundle_reloaded` events pipeline (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook) is deferred to LE-1017. The reload route's HTTP response body already carries the typed result, so the palette's reload action gets immediate feedback through the existing mutation hook ([`use-reload-bundle.ts`](https://github.com/langflow-ai/langflow/blob/main/src/frontend/src/controllers/API/queries/extensions/use-reload-bundle.ts)) until the events stream lands.
5. **Stage 5 — notify**. Today this is a logger emission of the added/removed component lists plus a registered post-swap hook that refreshes the component cache; the typed `bundle_reloaded` events pipeline (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook) is deferred. The reload route's HTTP response body already carries the typed result, so the palette's reload action gets immediate feedback through the existing mutation hook ([`use-reload-bundle.ts`](https://github.com/langflow-ai/langflow/blob/main/src/frontend/src/controllers/API/queries/extensions/use-reload-bundle.ts)) until the events stream lands.
:::note First reload after boot
On the **first** reload after a fresh server boot, `components_added` and `components_removed` are always empty: the bundle is already registered at startup, so the post-swap diff is against an identical pre-swap snapshot. This is expected — non-empty deltas only appear once you edit a component (add/remove a class, rename, etc.) and reload again.
@ -119,7 +119,7 @@ Reload is **not** a trust boundary. Bundle code was trusted at install time; the
## Porting an in-tree component
The mechanical steps for extracting `src/lfx/src/lfx/components/<provider>/` into a standalone bundle live in [`src/bundles/PORTING.md`](https://github.com/langflow-ai/langflow/blob/main/src/bundles/PORTING.md). That's the canonical recipe; this page covers the design choices around it. The DuckDuckGo extraction (LE-1023) is the reference implementation — diff `src/bundles/duckduckgo/` against any in-tree component to see the shape.
The mechanical steps for extracting `src/lfx/src/lfx/components/<provider>/` into a standalone bundle live in [`src/bundles/PORTING.md`](https://github.com/langflow-ai/langflow/blob/main/src/bundles/PORTING.md). That's the canonical recipe; this page covers the design choices around it. The DuckDuckGo extraction is the reference implementation — diff `src/bundles/duckduckgo/` against any in-tree component to see the shape.
A few things that surprise people:

View File

@ -6,7 +6,7 @@ slug: /extensions-quickstart
This guide walks through building, validating, and running your first Langflow Extension Bundle in roughly three minutes. The result is a single-component extension you can pip-install or load via `lfx extension dev` and reload from the palette.
:::info First-delivery scope
This page documents the first-delivery slice of the Bundle Separation epic (LE-905). Multi-bundle extensions, services and routes, remote install, and the curated registry are deferred to later milestones — see [Author guide for Langflow Extensions](./extensions-author-guide.mdx) for the full scope picture.
This page documents the first-delivery slice of the Bundle Separation work. Multi-bundle extensions, services and routes, remote install, and the curated registry are deferred to later milestones — see [Author guide for Langflow Extensions](./extensions-author-guide.mdx) for the full scope picture.
:::
## Prerequisites

View File

@ -19,10 +19,10 @@ maintainers = [
dependencies = [
"langflow-base[complete]>=0.10.0",
# langflow-extensions:bundle-deps-start
# Bundle pilot (LE-1023): lfx-duckduckgo ships the DuckDuckGoSearchComponent
# Bundle pilot: lfx-duckduckgo ships the DuckDuckGoSearchComponent
# as a standalone Extension distribution. Listed as a regular dep so
# ``pip install langflow`` still pulls the component in -- the AC for B1
# is "no user-visible change at install time".
# ``pip install langflow`` still pulls the component in -- there must
# be no user-visible change at install time.
"lfx-duckduckgo>=0.1.0",
# Second pilot port (validates src/bundles/PORTING.md): lfx-arxiv ships
# ArXivComponent as a standalone Bundle. Same regular-dep rationale --

View File

@ -1,13 +1,13 @@
"""Extension System HTTP surface (LE-1018: reload).
"""Extension System HTTP surface: reload.
Currently exposes a single endpoint, ``POST /extensions/{extension_id}/bundles/{bundle_name}/reload``,
which drives the LE-1018 atomic-swap pipeline against the process-default
which drives the atomic-swap reload pipeline against the process-default
:class:`~lfx.extension.bundle_registry.BundleRegistry`.
LE-1019 (UI) and LE-1020 (migration) will add list / status / migrate
endpoints alongside this one. Mode A only -- in Mode B/C the path is to
rebuild the Docker image, and the runtime guard at the request layer
short-circuits with 404 when ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` is off.
Future list / status / migrate endpoints will live alongside this one.
Mode A only -- in Mode B/C the path is to rebuild the Docker image, and
the runtime guard at the request layer short-circuits with 404 when
``LANGFLOW_ENABLE_EXTENSION_RELOAD`` is off.
"""
from __future__ import annotations

View File

@ -111,7 +111,7 @@ def load_plugin_routes(app: FastAPI) -> None:
wrapper = _PluginAppWrapper(app, reserved)
raw_eps = list(entry_points(group="langflow.plugins"))
# Manifest-first precedence (LE-1015): if a distribution ships an
# Manifest-first precedence: if a distribution ships an
# extension manifest, its COMPONENT-typed entry-points are skipped here;
# the Extension System loader registers those components via the
# manifest. Non-component entry-points (route registrars like the ones

View File

@ -325,14 +325,14 @@ spider = ["spider-client>=0.0.27,<1.0.0"]
# ALTK (Agent Lifecycle Toolkit)
# Updated to 0.10.1+ for PyTorch 2.6.0 compatibility (addresses torch.load RCE vulnerability)
# Upper bound prevents breaking changes in future major versions
# Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2 (see LE-172)
# Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2
altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; (sys_platform != 'darwin' or platform_machine != 'x86_64') and python_version < '3.14'"]
# Toolguard Integration
toolguard = ["toolguard>=0.2.16,<1.0.0"]
# Additional LangChain integrations
# Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels (see LE-172)
# Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels
langchain-huggingface = ["langchain-huggingface~=1.2.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
langchain-unstructured = ["langchain-unstructured~=1.0.0"]
graph-retriever = ["langchain-graph-retriever==0.8.0", "graph-retriever==0.8.0"]
@ -430,8 +430,8 @@ complete = [
"langflow-base[google-api]",
"langflow-base[wikipedia]",
"langflow-base[fake-useragent]",
# duckduckgo was an extra of langflow-base; the LE-1023 pilot moved it
# to the standalone lfx-duckduckgo bundle (now a regular dep of langflow).
# duckduckgo was an extra of langflow-base; it has been moved to
# the standalone lfx-duckduckgo bundle (now a regular dep of langflow).
"langflow-base[yfinance]",
"langflow-base[wolframalpha]",
"langflow-base[pyarrow]",

View File

@ -867,7 +867,7 @@ class TestWebhookEventsStreamAuth:
):
"""Regression test: cross-user access to /webhook-events/{id} returns 404, not 403.
This ensures no existence oracle (LE-639 compliance).
This ensures no existence oracle is exposed.
"""
flow_id = added_webhook_test["id"]
endpoint = f"api/v1/webhook-events/{flow_id}"

View File

@ -1,11 +1,12 @@
# M1 dogfood checklist — `lfx-duckduckgo` pilot bundle
> **Status:** :hourglass: **OPEN** — the runtime half of LE-1023 has not been signed off.
> A non-Extension-team engineer must complete the steps below and paste the
> filled-in checklist into the PR before merge. Until that happens, the M1
> proof-of-delivery gate is incomplete even if every automated check is green.
> **Status:** :hourglass: **OPEN** — the runtime half of the DuckDuckGo
> pilot has not been signed off. A non-Extension-team engineer must
> complete the steps below and paste the filled-in checklist into the
> PR before merge. Until that happens, the M1 proof-of-delivery gate is
> incomplete even if every automated check is green.
Per the [Bundle Separation Developer Guide §6 LE-1023], the M1 proof-of-delivery
Per the Bundle Separation Developer Guide, the M1 proof-of-delivery
gate has two halves:
1. **Deserialize + build pipeline (automated)** — lives in
@ -36,7 +37,7 @@ look materially the same between the pre- and post-migration releases?"
A unit-style integration test cannot answer that because it would have
to span a Python environment swap and touch the live DuckDuckGo
backend. A real dogfood run by an engineer who is *not* on the
Extension team is what actually closes LE-1023.
Extension team is what actually closes the DuckDuckGo pilot.
The checklist below is intentionally a template — fill it in, do not edit it
in advance. A pre-checked checklist is not evidence.
@ -95,10 +96,8 @@ in advance. A pre-checked checklist is not evidence.
not "search results are deterministic."
6. **Sign-off.**
- [ ] Paste this completed checklist as a comment on the LE-1023
ticket
- [ ] Cross-link the comment in the PR description under
"M1 dogfood evidence"
- [ ] Cross-link the completed checklist in the PR description
under "M1 dogfood evidence"
---

View File

@ -2,10 +2,10 @@
DuckDuckGo Search component as a standalone Langflow Extension Bundle.
This is the LE-1023 (B1) pilot — the first provider extracted from
`lfx.components.<provider>` into a separate distribution. The bundle
ships a single component, `DuckDuckGoSearchComponent`, which performs
DuckDuckGo web searches via `langchain-community`.
This is the first provider extracted from `lfx.components.<provider>`
into a separate distribution. The bundle ships a single component,
`DuckDuckGoSearchComponent`, which performs DuckDuckGo web searches
via `langchain-community`.
## Install

View File

@ -1,7 +1,7 @@
[project]
name = "lfx-duckduckgo"
version = "0.1.0"
description = "DuckDuckGo Search component as a standalone Langflow Extension Bundle (LE-1023 pilot)."
description = "DuckDuckGo Search component as a standalone Langflow Extension Bundle."
readme = "README.md"
requires-python = ">=3.10,<3.15"
license = { text = "MIT" }
@ -26,9 +26,11 @@ Documentation = "https://docs.langflow.org/extensions"
Repository = "https://github.com/langflow-ai/langflow"
# Manifest-shipping distributions are discovered via the
# ``langflow.extensions`` entry-point (LE-1022). The value is the dotted
# path to the package containing ``extension.json`` -- the loader walks
# ``importlib.metadata.files()`` from there to find the manifest.
# ``langflow.extensions`` entry-point. The value is the dotted path
# to the package containing ``extension.json`` -- the loader walks
# ``importlib.metadata.files()`` from there to find the manifest, and
# falls back to this entry-point for editable installs whose dist.files
# only surfaces dist-info entries.
[project.entry-points."langflow.extensions"]
lfx-duckduckgo = "lfx_duckduckgo"

View File

@ -5,8 +5,8 @@ Langflow's loader discovers ``extension.json`` shipped alongside this
``__init__.py`` and registers ``DuckDuckGoSearchComponent`` under the
namespaced ID ``ext:duckduckgo:DuckDuckGoSearchComponent@official``.
LE-1023 (B1) pilot: the first provider extracted from
``lfx.components.<provider>`` into a standalone Bundle.
The first provider extracted from ``lfx.components.<provider>`` into
a standalone Bundle.
"""
from lfx_duckduckgo.components.duckduckgo.duck_duck_go_search_run import (

View File

@ -3,7 +3,7 @@
"id": "lfx-duckduckgo",
"version": "0.1.0",
"name": "DuckDuckGo Search",
"description": "DuckDuckGo Search component as a standalone Langflow Extension Bundle (LE-1023 pilot).",
"description": "DuckDuckGo Search component as a standalone Langflow Extension Bundle.",
"lfx": {
"compat": ["1"]
},

View File

@ -518,8 +518,8 @@ describe("cleanEdges", () => {
expect(result3.brokenEdges.length).toBe(1);
});
it("should preserve edge when EmbeddingModel has empty input_types and edge expects Embeddings (LE-278)", () => {
// This tests the fix for embedding model input type (LE-278)
it("should preserve edge when EmbeddingModel has empty input_types and edge expects Embeddings", () => {
// This tests the fix for embedding model input type
const sourceNode: AllNodeType = {
id: "EmbeddingModelComponent-123",
type: "genericNode",

View File

@ -138,9 +138,9 @@ def list_command(
"""Print every Extension currently visible to production discovery.
Read-only. Mutation verbs (enable, disable, install, uninstall) are
intentionally absent in this milestone; they ship in B3/B4 follow-up
epics, and the router-trust CI guard (LE-1017) blocks them from
sneaking in via HTTP routes.
intentionally absent in this milestone; they ship in follow-up work,
and the router-trust CI guard blocks them from sneaking in via HTTP
routes.
Output (text mode) is one row per Extension::
@ -271,21 +271,21 @@ def list_command(
@extension_app.command(
name="reload",
help="Trigger an atomic-swap reload for an installed Bundle (LE-1018).",
help="Trigger an atomic-swap reload for an installed Bundle.",
)
def reload_command(
extension_id: str = typer.Argument(
...,
help="ID of the extension whose Bundle should be reloaded (e.g. lfx-pilot).",
extension_id: str | None = typer.Argument(
None,
help=("ID of the extension whose Bundle should be reloaded (e.g. lfx-arxiv). Omit when passing --all."),
),
bundle: str | None = typer.Option(
None,
"--bundle",
"-b",
help=(
"Bundle name to reload. Required until the LE-1019 list endpoint "
"ships -- the conventional shape is extension id 'lfx-<provider>' "
"with bundle name '<provider>', so we cannot safely default."
"Bundle name to reload. Optional: when omitted, the bundle "
"name is resolved from the local extension discovery (the "
"same source ``lfx extension list`` uses)."
),
),
target: str | None = typer.Option(
@ -308,44 +308,55 @@ def reload_command(
reload_all: bool = typer.Option(
False, # noqa: FBT003 - typer Option requires positional default
"--all",
help=(
"Reload every installed Bundle. Requires the LE-1019 list endpoint and is not yet wired in this milestone."
),
help="Reload every locally-discovered Bundle.",
),
) -> None:
"""POST to the reload endpoint and surface the typed result.
Resolution strategy:
* ``--all`` reloads every Bundle visible to local discovery
(``discover_all_extensions``); ``extension_id`` is ignored.
* Otherwise, ``extension_id`` is required. If ``--bundle`` is
omitted, the bundle name is looked up from local discovery.
* ``--bundle`` always wins when explicitly passed -- useful when
the local install isn't visible to the running server, or when
you want to target a bundle by name without re-running discovery.
Exit codes:
0 -- reload succeeded and the registry now reflects the new code.
1 -- reload failed (broken bundle, source missing, name mismatch,
or the dev server returned a typed error).
2 -- argument error (e.g. ``--all`` requested before LE-1019 lands).
0 -- reload(s) succeeded.
1 -- one or more reloads failed (broken bundle, source missing,
name mismatch, transport error, or local discovery turned up
nothing for the requested extension).
2 -- argument error (e.g. neither ``extension_id`` nor ``--all``).
"""
from lfx.cli._extension_reload_client import reload_via_http
from lfx.extension.errors import ExtensionError, format_extension_error
if reload_all:
typer.echo(
"extension reload --all requires the LE-1019 list endpoint and is not yet implemented in this milestone.",
err=True,
)
raise typer.Exit(code=2)
if output_format not in {"text", "json"}:
typer.echo("Invalid --format. Expected one of: text, json.", err=True)
raise typer.Exit(code=2)
if not bundle:
if reload_all:
if extension_id is not None or bundle is not None:
typer.echo(
"extension reload --all does not take an extension id or --bundle; "
"pass only --all to reload every locally-discovered Bundle.",
err=True,
)
raise typer.Exit(code=2)
_reload_all(target=target, api_key=api_key, output_format=output_format)
return
if extension_id is None:
typer.echo(
"extension reload requires --bundle until the LE-1019 list endpoint "
"ships. Pass --bundle <name>; conventional shape is "
"lfx-<provider> ext_id with <provider> as the bundle name.",
"extension reload requires an extension id (e.g. `lfx extension reload lfx-arxiv`), "
"or pass --all to reload every locally-discovered Bundle.",
err=True,
)
raise typer.Exit(code=2)
bundle_name = bundle
response = reload_via_http(
bundle_name = bundle or _resolve_bundle_from_discovery(extension_id)
if bundle_name is None:
raise typer.Exit(code=1)
response = _post_reload(
target=target,
api_key=api_key,
extension_id=extension_id,
@ -356,15 +367,75 @@ def reload_command(
typer.echo(json.dumps(response.payload, indent=2, sort_keys=True))
raise typer.Exit(code=response.exit_code())
_render_reload_text(response, extension_id=extension_id, bundle_name=bundle_name)
raise typer.Exit(code=response.exit_code())
def _resolve_bundle_from_discovery(extension_id: str) -> str | None:
"""Look up the single bundle name for ``extension_id`` via local discovery.
Returns the bundle name on success, or ``None`` and prints a typed
error on stderr when the extension is not locally installed or when
discovery surfaces multiple bundles (a future-shape we do not yet
support; v0 ships one bundle per extension).
"""
from lfx.extension import discover_all_extensions
extensions, _errors = discover_all_extensions()
matches = [ext for ext in extensions if ext.extension_id == extension_id]
if not matches:
typer.echo(
f"extension reload: no locally-discovered extension {extension_id!r}.\n"
" - Run `lfx extension list` to see what is installed locally.\n"
" - If the bundle is installed only on the remote server, "
"pass --bundle <name> explicitly.",
err=True,
)
return None
if len(matches) > 1:
# Can happen if two seed roots ship the same id; registry usually
# de-dupes via shadow-error, but be defensive at the CLI layer.
bundles = sorted({ext.bundle_name for ext in matches})
typer.echo(
f"extension reload: extension {extension_id!r} resolves to multiple bundles "
f"({', '.join(bundles)}); pass --bundle <name> to disambiguate.",
err=True,
)
return None
return matches[0].bundle_name
def _post_reload(
*,
target: str | None,
api_key: str | None,
extension_id: str,
bundle_name: str,
):
"""Thin wrapper around :func:`reload_via_http` for symmetry with --all."""
from lfx.cli._extension_reload_client import reload_via_http
return reload_via_http(
target=target,
api_key=api_key,
extension_id=extension_id,
bundle_name=bundle_name,
)
def _render_reload_text(response, *, extension_id: str, bundle_name: str) -> None:
"""Render a single reload response in text mode (success or failure)."""
from lfx.extension.errors import ExtensionError, format_extension_error
if response.ok:
added = response.payload.get("components_added") or []
removed = response.payload.get("components_removed") or []
typer.echo(f"reload: ok bundle={bundle_name}")
typer.echo(f"reload: ok extension={extension_id} bundle={bundle_name}")
if added:
typer.echo(f" added: {', '.join(added)}")
if removed:
typer.echo(f" removed: {', '.join(removed)}")
raise typer.Exit(code=0)
return
# Failure path: render typed errors when present, fall back to status text.
raw_errors = response.payload.get("errors") or []
@ -397,10 +468,81 @@ def reload_command(
rendered_any = True
if not rendered_any:
typer.echo(
f"reload: failed (HTTP {response.status} bundle={bundle_name})",
f"reload: failed (HTTP {response.status} extension={extension_id} bundle={bundle_name})",
err=True,
)
raise typer.Exit(code=1)
def _reload_all(*, target: str | None, api_key: str | None, output_format: str) -> None:
"""Reload every locally-discovered Bundle, aggregating results.
Sequential by design: a parallel sweep would race for the registry's
per-bundle reload lock, and the cost of N sequential POSTs is dominated
by per-bundle import time on the server, not by round-trip latency.
Exits 0 when every reload succeeds (or there were no bundles to reload);
exits 1 if any reload failed.
"""
from lfx.extension import discover_all_extensions
extensions, _errors = discover_all_extensions()
if output_format == "json":
results = []
any_failed = False
for ext in extensions:
response = _post_reload(
target=target,
api_key=api_key,
extension_id=ext.extension_id,
bundle_name=ext.bundle_name,
)
results.append(
{
"extension_id": ext.extension_id,
"bundle_name": ext.bundle_name,
"status": response.status,
"ok": response.ok,
"payload": response.payload,
}
)
if not response.ok:
any_failed = True
typer.echo(json.dumps({"results": results}, indent=2, sort_keys=True))
raise typer.Exit(code=1 if any_failed else 0)
if not extensions:
typer.echo(
"extension reload --all: no locally-discovered bundles to reload.\n"
" - Run `lfx extension list` to confirm what is installed.",
)
return
any_failed = False
successes = 0
failures = 0
for ext in extensions:
response = _post_reload(
target=target,
api_key=api_key,
extension_id=ext.extension_id,
bundle_name=ext.bundle_name,
)
_render_reload_text(
response,
extension_id=ext.extension_id,
bundle_name=ext.bundle_name,
)
if response.ok:
successes += 1
else:
failures += 1
any_failed = True
typer.echo("")
typer.echo(f"extension reload --all: {successes} ok, {failures} failed.")
if any_failed:
raise typer.Exit(code=1)
@extension_app.command(
@ -475,7 +617,7 @@ def init_command(
) -> None:
"""Create a runnable extension skeleton you can iterate on with ``dev``.
Acceptance criteria for this command (LE-1016):
Acceptance criteria for this command:
- ``init <target>`` followed immediately by ``validate <target>``
passes with zero errors.

View File

@ -1,4 +1,4 @@
"""HTTP client helper for ``lfx extension reload`` (LE-1018).
"""HTTP client helper for ``lfx extension reload``.
Kept separate from the typer command so the request/response shape can be
unit-tested without spinning up a full CLI invocation. The CLI command

View File

@ -1,4 +1,4 @@
"""In-memory component registry for installed Bundles (LE-1018).
"""In-memory component registry for installed Bundles.
The :class:`BundleRegistry` is the single piece of mutable shared state the
Extension System owns at runtime. It maps a Bundle name to a frozen record
@ -35,8 +35,8 @@ Slot semantics
The registry stores both ``official`` and ``extra`` slot bundles in the
same map keyed by bundle name. Bundle names are unique across slots in
v0; the LE-1015 loader and ``discover_inline_bundles`` both enforce this
upstream so the registry does not need to disambiguate by slot.
v0; the component loader and ``discover_inline_bundles`` both enforce
this upstream so the registry does not need to disambiguate by slot.
"""
from __future__ import annotations
@ -268,8 +268,8 @@ class BundleRegistry:
Tolerates filesystem errors silently: the index file is a cache,
not a source of truth, and a transient write failure must not
abort an otherwise-successful registry mutation. The CLI's
``extension status`` (LE-1019 territory) surfaces stale indexes.
abort an otherwise-successful registry mutation. A future
``extension status`` CLI surfaces stale indexes.
"""
if self._index_path is None:
return
@ -320,9 +320,9 @@ def get_default_registry() -> BundleRegistry:
"""Return the lazily-created process-wide registry.
The HTTP endpoint and CLI both target this registry by default; tests
construct fresh registries directly so they do not bleed state. LE-1022
will replace this lazy initializer with a startup-time install at the
@official slot; until then the registry begins empty.
construct fresh registries directly so they do not bleed state. A
future startup-time install at the @official slot will replace this
lazy initializer; until then the registry begins empty.
"""
global _default_registry # noqa: PLW0603
with _default_registry_lock:

View File

@ -1,4 +1,4 @@
"""Local dev-extension registry for ``lfx extension dev`` (LE-1016).
"""Local dev-extension registry for ``lfx extension dev``.
When an author types ``lfx extension dev <path>``, we don't ship the
Extension via pip; instead we record the absolute path in a small JSON
@ -25,16 +25,17 @@ Concurrency: the state file is rewritten atomically (tempfile + rename).
We do not take a long-lived lock; the file is read-mostly and the only
writer is the ``extension dev`` CLI invoked synchronously by a developer.
Scope notes (kept here so the next milestone reads them):
Scope notes:
- LE-1016 only writes / reads / lists the state file and is
consumed by Langflow's startup hook. The runtime "remove" path
(``extension dev --unregister``) is a deliberate non-goal for v0;
authors clean up by deleting the state file or the directory.
- LE-1018 (reload) reuses :func:`load_dev_extensions` to refresh
- This module only writes / reads / lists the state file; the actual
registration is consumed by Langflow's startup hook. The runtime
"remove" path (``extension dev --unregister``) is a deliberate
non-goal for v0; authors clean up by deleting the state file or
the directory.
- The reload pipeline reuses :func:`load_dev_extensions` to refresh
the @official slot mid-run; the reload UX itself ships there.
- LE-1022 (installed-package discovery) keeps its own primitives
and is orthogonal -- both flows produce ``LoadResult`` lists that
- Installed-package discovery keeps its own primitives and is
orthogonal -- both flows produce ``LoadResult`` lists that
Langflow startup merges in the same step.
"""
@ -117,8 +118,8 @@ class DevExtensionEntry:
path: Path
registered_at: str
"""ISO-8601 UTC timestamp ('YYYY-MM-DDTHH:MM:SSZ') of when this entry
was added. Surfaced for ``extension list`` (LE-1022) so authors can
audit what's registered."""
was added. Surfaced for ``extension list`` so authors can audit
what's registered."""
def _utcnow_iso() -> str:
@ -314,9 +315,9 @@ def load_dev_extensions(*, state_dir: Path | None = None) -> list[LoadResult]:
Missing entries (the directory was renamed, deleted, or moved) are
surfaced as :class:`LoadResult` instances containing a single
``local-extension-missing`` warning so the events pipeline (LE-1017)
can report them without aborting startup. The author sees a
typed warning rather than a stack trace.
``local-extension-missing`` warning so the events pipeline can
report them without aborting startup. The author sees a typed
warning rather than a stack trace.
The dev registry is never silently mutated by this function: stale
entries stay in the file so the author can fix the underlying path

View File

@ -1,4 +1,4 @@
"""Production-install discovery for the Langflow Extension System (LE-1022).
"""Production-install discovery for the Langflow Extension System.
This module owns the two production install sources for Modes A, B, and C:
@ -22,7 +22,7 @@ what actually pins these into the runtime; ``register_installed`` and
``register_seed`` consume the same :class:`DiscoveredExtension` shape.
This is the production install path, not the loading path. Component
loading (LE-1015) is downstream of this; the Extension records here are
loading is downstream of this; the Extension records here are
what the loader ultimately resolves to a concrete Bundle directory.
Errors are surfaced as :class:`~lfx.extension.errors.ExtensionError`
@ -213,43 +213,112 @@ def _distribution_manifest_path(dist: importlib_metadata.Distribution) -> Path |
if present; otherwise we fall back to a ``pyproject.toml`` that
declares a ``[tool.langflow.extension]`` table.
For editable installs whose ``dist.files`` only surfaces ``dist-info/``
entries (the ``pip install -e`` / ``uv pip install -e`` case), we fall
back to the ``langflow.extensions`` entry-point group declared in the
distribution's ``pyproject.toml``.
A missing or unreadable file iteration is treated as "no manifest"
rather than as an error: the caller's scan should keep walking.
"""
files = dist.files
if files is None:
if files is not 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):
continue
if located.is_file():
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:
try:
if _pyproject_declares_extension(pyproject_candidate):
return pyproject_candidate
except OSError:
# Unreadable pyproject that might declare an extension. Surface
# the candidate path so the downstream manifest-unreadable check
# fires with an actionable error instead of silently dropping
# the distribution.
return pyproject_candidate
return _distribution_manifest_path_via_entry_points(dist)
def _distribution_manifest_path_via_entry_points(
dist: importlib_metadata.Distribution,
) -> Path | None:
"""Resolve the manifest of an editable install via its entry-point.
Editable installs (``pip install -e``, ``uv pip install -e``) record only
``dist-info/`` entries in ``dist.files``: the package's source tree lives
behind a ``.pth`` file rather than under a wheel-installed directory, so
the ``files`` scan above never sees ``extension.json``. The
``langflow.extensions`` entry-point points at the package that ships the
manifest; we resolve it via :func:`importlib.util.find_spec` (which runs
the import-system finders but **never executes the module's body**) and
look for the manifest in the resulting package directory.
Returns ``None`` when the distribution has no usable entry-point, the
referenced module cannot be located on ``sys.path``, or the located
directory does not contain a manifest.
"""
import importlib.util
try:
eps = dist.entry_points
except (OSError, AttributeError, TypeError):
return None
if eps 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):
continue
if located.is_file():
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
try:
selected = list(eps.select(group="langflow.extensions"))
except AttributeError:
# Older importlib.metadata returns a plain tuple of EntryPoint with
# no .select() helper; filter manually.
selected = [ep for ep in eps if getattr(ep, "group", None) == "langflow.extensions"]
if pyproject_candidate is not None:
for ep in selected:
module_name = (getattr(ep, "value", "") or "").split(":", 1)[0].strip()
if not module_name:
continue
try:
if _pyproject_declares_extension(pyproject_candidate):
return pyproject_candidate
except OSError:
# Unreadable pyproject that might declare an extension. Surface
# the candidate path so the downstream manifest-unreadable check
# fires with an actionable error instead of silently dropping
# the distribution.
return pyproject_candidate
spec = importlib.util.find_spec(module_name)
except (ImportError, ValueError, ModuleNotFoundError):
continue
if spec is None:
continue
candidate_dirs: list[Path] = []
if spec.submodule_search_locations:
candidate_dirs.extend(Path(loc) for loc in spec.submodule_search_locations)
elif spec.origin and spec.origin not in {"built-in", "frozen"}:
candidate_dirs.append(Path(spec.origin).parent)
for package_dir in candidate_dirs:
extension_json = package_dir / "extension.json"
if extension_json.is_file():
return extension_json
pyproject = package_dir / "pyproject.toml"
if pyproject.is_file():
try:
if _pyproject_declares_extension(pyproject):
return pyproject
except OSError:
return pyproject
return None

View File

@ -70,7 +70,7 @@ ERROR_CODES: frozenset[str] = frozenset(
"inline-path-missing",
"inline-path-unreadable",
"bundle-json-invalid",
# init / dev CLI codes (LE-1016)
# init / dev CLI codes
"extension-target-exists",
"extension-target-invalid",
"local-extension-missing",
@ -80,14 +80,14 @@ ERROR_CODES: frozenset[str] = frozenset(
"migration-table-invalid",
"component-not-found-with-hint",
"component-name-ambiguous",
# Production install / discovery (LE-1022)
# Production install / discovery
"installed-extension-immutable",
"seed-directory-immutable",
"seed-directory-not-found",
"seed-bundle-shadowed",
"bundle-shadowed",
"duplicate-extension-id",
# Reload-specific codes (LE-1018)
# Reload-specific codes
"reload-in-progress",
"reload-bundle-not-installed",
"reload-bundle-name-mismatch",

View File

@ -1,4 +1,4 @@
"""Scaffolder for ``lfx extension init`` (LE-1016).
"""Scaffolder for ``lfx extension init``.
Writes the *basic* single-Bundle template at the requested directory:
@ -21,9 +21,9 @@ non-trivial lives here so the CLI is a thin shell.
Design constraints honored:
- The generated extension MUST validate clean against the LE-1014
validator with zero errors. The basic template is exercised by an
AC-level test that runs ``init`` then immediately ``validate``.
- The generated extension MUST validate clean against the manifest
validator with zero errors. The basic template is exercised by a
test that runs ``init`` then immediately ``validate``.
- The generated test file MUST be syntactically valid Python and
written so a future ``extension test`` runner can collect and
execute it without modification. The test file uses ``pytest``
@ -276,7 +276,7 @@ A Langflow Extension generated by `lfx extension init`.
```
{options.target.name}/
├── extension.json # v0 manifest (LE-1014)
├── extension.json # v0 manifest
├── README.md
├── components/
│ └── {options.bundle_name}/

View File

@ -97,7 +97,7 @@ def iter_bundle_python_files(bundle_root: Path) -> Iterator[Path]:
DEFAULT_MODULE_NAMESPACE: str = "_lfx_ext"
"""Default top-level package name for bundle modules in ``sys.modules``.
Reload (LE-1018) overrides this with ``__reload_staging__.<reload_id>`` so a
Reload overrides this with ``__reload_staging__.<reload_id>`` so a
parallel load lands in an isolated namespace and Stage 3 can swap it in
atomically without ever touching the live modules.
"""
@ -117,7 +117,7 @@ def module_name_for(
the leading underscore-prefixed package keeps these out of the regular
import namespace so a bundle named ``json`` doesn't shadow the stdlib.
Reload supplies a ``__reload_staging__.<id>`` namespace so Stage 1 lands
in an isolated subtree of ``sys.modules`` (LE-1018).
in an isolated subtree of ``sys.modules``.
"""
rel = file_path.relative_to(bundle_root).with_suffix("")
dotted = ".".join(rel.parts)
@ -153,8 +153,8 @@ def import_bundle_module(module_name: str, file_path: Path) -> tuple[types.Modul
# entry under the same synthetic ``_lfx_ext.<slot>.<bundle>...`` name
# with no cleanup. Consumers holding a previous LoadedComponent.klass
# keep the old class object across reloads, so isinstance checks against
# newly-loaded instances will return False. LE-1018 reload owns the
# invalidation: it must scrub registry entries (and the matching
# newly-loaded instances will return False. The reload pipeline owns
# the invalidation: it must scrub registry entries (and the matching
# ``_lfx_ext.<slot>.<bundle>.*`` sys.modules namespace) before calling
# back into this loader. Absolute imports only between bundle modules;
# relative imports unsupported.

View File

@ -278,9 +278,9 @@ def load_extension(
tree before pip install).
module_namespace: Top-level package name used when registering bundle
modules in ``sys.modules``. Defaults to ``_lfx_ext`` for normal
loads. Reload (LE-1018) passes ``__reload_staging__.<id>`` so
Stage 1 lands in an isolated namespace; do not override this in
normal application code.
loads. The reload pipeline passes ``__reload_staging__.<id>``
so Stage 1 lands in an isolated namespace; do not override
this in normal application code.
Returns:
A :class:`LoadResult`. ``ok`` is False on any structural failure;
@ -291,8 +291,8 @@ def load_extension(
Calling this function a second time for the same bundle overwrites
the prior bundle's entries in ``sys.modules`` with no cleanup;
previously-issued :class:`LoadedComponent` ``klass`` references
remain bound to the old class objects. The reload pipeline (LE-1018)
is responsible for scrubbing registry entries AND the matching
remain bound to the old class objects. The reload pipeline is
responsible for scrubbing registry entries AND the matching
``_lfx_ext.<slot>.<bundle>.*`` namespace before re-invoking this
loader; direct callers should not rely on this function to refresh
already-loaded bundles.

View File

@ -52,11 +52,11 @@ class LoadedComponent:
folders from ``LANGFLOW_COMPONENTS_PATH``, never pip-installed. The
reverse (``@official`` with ``distribution=None``) is permitted because
``load_extension`` is used for dev-mode loads against a working tree
*before* the package gets installed (the LE-1016 ``extension dev``
flow); only ``load_installed_extensions`` is required to carry a
canonical PEP-503 name through. Enforced in :meth:`__post_init__` so
the rule travels with the type when LE-1018 reload and LE-1022 startup
discovery start constructing :class:`LoadedComponent` directly.
*before* the package gets installed (the ``extension dev`` flow);
only ``load_installed_extensions`` is required to carry a canonical
PEP-503 name through. Enforced in :meth:`__post_init__` so the rule
travels with the type when reload and startup discovery construct
:class:`LoadedComponent` directly.
"""
extension_id: str
@ -116,7 +116,7 @@ class LoadResult:
successfully-registered classes from sibling files). Callers that only
branch on ``ok`` get strict success; callers that want to surface
"partial X of Y loaded" diagnostics should consume ``components`` and
``errors`` together. The events pipeline (LE-1017) is expected to fan
``errors`` together. The future events pipeline is expected to fan
out as: ``extension_loaded`` when ``ok``, ``extension_error`` when not,
plus per-component ``component_registered`` events for everything in
``components`` regardless of ``ok``.

View File

@ -17,8 +17,8 @@ Public surface:
- :func:`load_migration_table` -- read and validate the canonical JSON.
- :func:`migrate_flow_payload` -- rewrite a flow payload in place.
- :class:`MigrationTable` / :class:`MigrationEntry` -- Pydantic models.
- :class:`MigrationReport` -- per-node rewrite outcomes; will feed the
``flow-migrated`` event once :data:`LE-1017` lands.
- :class:`MigrationReport` -- per-node rewrite outcomes; will feed
the ``flow-migrated`` event once the events pipeline lands.
"""
from lfx.extension.migration.loader import (

View File

@ -260,11 +260,11 @@ def _rewrite_one_node(
# the ambiguous-bare-names list. A bare class name that exists in
# 2+ bundles cannot have a regular auto-rewrite entry (the CI guard
# rejects it), so its only surface is the explicit ambiguity
# marker. Surfacing ``component-name-ambiguous`` here -- with the
# candidate targets enumerated -- is the LE-1020 contract:
# marker. Surfacing ``component-name-ambiguous`` here -- with
# the candidate targets enumerated -- is the migration contract:
# we will not silently load an ambiguous name into the wrong
# bundle, and we tell the operator exactly which targets they have
# to choose between.
# bundle, and we tell the operator exactly which targets they
# have to choose between.
ambig_marker = table.lookup_ambiguous_bare(legacy_value)
if ambig_marker is not None:
target_list = ", ".join(ambig_marker.candidates)
@ -408,7 +408,7 @@ def migrate_flow_payload(
if record.error is not None:
report.errors.append(record.error)
# TODO(LE-1017): when the events pipeline (ExtensionEventsService) lands,
# TODO: when the events pipeline (ExtensionEventsService) lands,
# emit a single ``flow-migrated`` event per flow per session here when
# ``report.any_rewritten`` is True. The event payload should be:
# {

View File

@ -13,7 +13,7 @@ legacy reference shapes are mapped:
3. ``bare_class_name`` -- the unqualified class name (``OpenAIEmbeddings``).
Added only if the class name is globally unique across every Bundle in
this Langflow release; CI guards this with
``scripts/migrate/check_bare_names.py`` (LE-1023).
``scripts/migrate/check_bare_names.py``.
The table is a single JSON file at a canonical in-repo path. Every Langflow
release adds entries; **no entry is ever removed**. CI rejects removals so a

View File

@ -1,4 +1,4 @@
"""Read-only Extension registry for production-install sources (LE-1022).
"""Read-only Extension registry for production-install sources.
Where :mod:`lfx.extension.discovery` *finds* manifest-shipping packages
and seed directories, this module *registers* them: it owns the in-memory
@ -24,10 +24,9 @@ update) raises :class:`ExtensionImmutableError` carrying a typed
``installed-extension-immutable`` or ``seed-directory-immutable``.
Mutation verbs are exposed at the *service* layer here so the invariant
is testable today (LE-1022 acceptance), but the **CLI** uninstall surface
ships in B4 follow-up. Routes that try to mount these as HTTP handlers
are blocked by the router-trust CI guard (LE-1017); see
``langflow-extension-system-design.md`` for the full trust model.
is testable today, but the **CLI** uninstall surface is a follow-up.
Routes that try to mount these as HTTP handlers are blocked by the
router-trust CI guard.
"""
from __future__ import annotations
@ -55,11 +54,11 @@ if TYPE_CHECKING:
class LoadStatus(str, Enum):
"""Lifecycle state surfaced through ``lfx extension list``.
Discovery alone produces ``DISCOVERED``; the loader (LE-1015) flips
Discovery alone produces ``DISCOVERED``; the component loader flips
entries to ``LOADED`` once their components register, and to
``FAILED`` on any per-bundle import error. Keeping the enum here --
rather than in the loader -- means ``extension list`` can render the
full life-cycle without a circular import once both subsystems land.
full life-cycle without a circular import.
"""
DISCOVERED = "discovered"
@ -276,7 +275,7 @@ class ExtensionRegistry:
def mark_loaded(self, extension_id: str) -> Extension:
"""Flip an entry's :attr:`load_status` to ``LOADED``.
Used by the loader (LE-1015) once a bundle's components have
Used by the component loader once a bundle's components have
successfully registered. This is *not* a mutation of the
immutability surface -- the install record itself is unchanged;
we're recording the side effect of loading the install.
@ -345,7 +344,7 @@ class ExtensionRegistry:
Production install in this milestone is ``pip install`` in a
Dockerfile, never a runtime call. The router-trust CI guard
(LE-1017) blocks any HTTP handler that tries to mount this verb.
blocks any HTTP handler that tries to mount this verb.
"""
self._refuse_mutation(extension_id, verb="install")

View File

@ -1,4 +1,4 @@
"""Atomic-swap Bundle reload pipeline (LE-1018).
"""Atomic-swap Bundle reload pipeline.
The :func:`reload_bundle` function is the single entry point for replacing
a Bundle's component set in-place under load. It runs five clearly-marked
@ -9,14 +9,14 @@ The five stages
---------------
1. **Stage 1 -- parallel load** into a per-reload staging namespace
(``__reload_staging__.<reload_id>``). Uses the LE-1015 loader with
(``__reload_staging__.<reload_id>``). Uses the component loader with
``module_namespace`` overridden so that the new modules land in
``sys.modules`` without colliding with the live ``_lfx_ext.*`` entries.
2. **Stage 2 -- validate** the staging result. Any error from the loader
(manifest invalid, import failure, duplicate class names, etc.) aborts
the reload here. The live registry is untouched and a
``bundle_reload_failed`` event is emitted (currently stubbed; LE-1017).
``bundle_reload_failed`` event is emitted (currently stubbed).
3. **Stage 3 -- atomic swap** under the registry's write lock. This is
the only stage that mutates shared state. Old ``sys.modules`` entries
@ -30,8 +30,7 @@ The five stages
5. **Stage 5 -- emit** a ``bundle_reloaded`` event with the
``components_added`` / ``components_removed`` deltas. Currently a
structured-log shim; LE-1017 will swap the body to the real
``ExtensionEventsService``.
structured-log shim; a future ``ExtensionEventsService`` will own this.
Concurrency invariants
----------------------
@ -58,7 +57,8 @@ This pipeline only applies to Mode A (in-process Python install). In
Mode B/C the bundle code lives in a different container image; reload
there means rebuilding the image, not calling this function. The HTTP
endpoint and CLI both gate on Mode A; if you find yourself reaching into
this module from a non-Mode-A path, stop and re-read LE-905 first.
this module from a non-Mode-A path, stop and re-read the Bundle
Separation design notes first.
"""
from __future__ import annotations
@ -586,18 +586,19 @@ def _failure(
# ---------------------------------------------------------------------------
# TODO(LE-1017): replace this stub with a call to
# ExtensionEventsService.emit(...) once the events pipeline ticket lands.
# The shape of the payload below is what the events service will consume:
# the ReloadResult is serializable and carries everything needed for the
# bundle_reloaded / bundle_reload_failed discriminants.
# TODO: replace this stub with a call to ExtensionEventsService.emit(...)
# once the events pipeline lands. The shape of the payload below is what
# the events service will consume: the ReloadResult is serializable and
# carries everything needed for the bundle_reloaded / bundle_reload_failed
# discriminants.
def _emit_bundle_reload_event(result: ReloadResult) -> None:
"""Stub for the LE-1017 events pipeline.
"""Stub for the extension events pipeline.
Currently logs a structured event; LE-1017 will swap the body to
``ExtensionEventsService.emit("bundle_reloaded" | "bundle_reload_failed", payload)``.
Tests can monkey-patch this symbol to capture emissions without
waiting for the events service to land.
Currently logs a structured event; a future ``ExtensionEventsService``
will swap the body to ``.emit("bundle_reloaded" |
"bundle_reload_failed", payload)``. Tests can monkey-patch this
symbol to capture emissions without waiting for the events service
to land.
"""
if result.ok:
logger.info(

View File

@ -1206,7 +1206,7 @@ class Graph:
migration_error.hint,
migration_error.message,
)
# TODO(LE-1017): when the extension events pipeline lands, emit a
# TODO: when the extension events pipeline lands, emit a
# single ``flow-migrated`` event per flow per session here using
# ExtensionEventsService, plus one event per ``ExtensionError`` in
# ``migration_report.errors`` so the frontend can surface the

View File

@ -707,9 +707,9 @@ def _decorate_template_with_extension(
def _emit_extension_diagnostics(results: list[LoadResult]) -> None:
"""Surface typed errors/warnings from a batch of LoadResults to the logger.
The events pipeline (LE-1017) will replace this with structured emission;
until then we want operators to see what the loader rejected without
silently dropping the typed payload.
The future events pipeline will replace this with structured
emission; until then we want operators to see what the loader
rejected without silently dropping the typed payload.
"""
for result in results:
for err in result.errors:
@ -962,9 +962,9 @@ async def import_extension_components(
exc,
)
continue
# Register under the namespaced ID per AC: ``ext:<bundle>:<Class>@<slot>``.
# This is the canonical address saved flows reference after the
# LE-1020 migration table rewrites legacy class-name lookups.
# Register under the namespaced ID: ``ext:<bundle>:<Class>@<slot>``.
# This is the canonical address saved flows reference after
# the migration table rewrites legacy class-name lookups.
bundle_dict[loaded.namespaced_id] = _decorate_template_with_extension(
template,
extension_id=loaded.extension_id,

View File

@ -745,7 +745,7 @@ class Settings(BaseSettings):
env_value = os.getenv("LANGFLOW_COMPONENTS_PATH")
if env_value:
logger.debug("Adding LANGFLOW_COMPONENTS_PATH to components_path")
# LE-1015: split on os.pathsep so multi-entry env vars
# Split on os.pathsep so multi-entry env vars
# ("/path/A:/path/B" on POSIX, "C:\\a;D:\\b" on Windows) are
# parsed as multiple components paths instead of one literal
# non-existent path. Empty segments (e.g. trailing pathsep) are

View File

@ -140,10 +140,10 @@ def _is_editable_install(dist: importlib_metadata.Distribution) -> bool:
def test_lfx_arxiv_ships_manifest() -> None:
"""``importlib.metadata`` can find ``extension.json`` for the installed dist.
This is the contract LE-1022's :func:`load_installed_extensions` reads
at server startup; if the wheel doesn't include the manifest, the
bundle never registers and the AC ("pip install langflow still pulls
in the pilot bundle as before") fails silently.
This is the contract :func:`load_installed_extensions` reads at
server startup; if the wheel doesn't include the manifest, the bundle
never registers and ``pip install langflow`` silently fails to pull
in the pilot bundle.
"""
try:
dist = importlib_metadata.distribution("lfx-arxiv")

View File

@ -1,4 +1,4 @@
"""Integration test for the LE-1023 pilot: legacy DuckDuckGo flows upgrade cleanly.
"""Integration test for the DuckDuckGo pilot: legacy flows upgrade cleanly.
Verifies the save/upgrade/load contract for flows referencing
``DuckDuckGoSearchComponent`` from the pre-extraction Langflow:
@ -400,10 +400,10 @@ def _is_editable_install(dist: importlib_metadata.Distribution) -> bool:
def test_lfx_duckduckgo_ships_manifest() -> None:
"""``importlib.metadata`` can find ``extension.json`` for the installed dist.
This is the contract LE-1022's :func:`load_installed_extensions` reads
at server startup; if the wheel doesn't include the manifest, the
bundle never registers and the AC ("pip install langflow still pulls
in the pilot bundle as before") fails silently.
This is the contract :func:`load_installed_extensions` reads at
server startup; if the wheel doesn't include the manifest, the bundle
never registers and ``pip install langflow`` silently fails to pull
in the pilot bundle.
Editable installs (``pip install -e``) hide package files from
``dist.files`` -- only ``dist-info`` entries appear -- so for editable
@ -431,7 +431,7 @@ def test_lfx_duckduckgo_ships_manifest() -> None:
)
else:
# Non-editable wheel install: assert dist.files surfaces the manifest
# (the path LE-1022's loader walks at runtime).
# (the path the loader walks at runtime).
files = dist.files or []
manifests = [f for f in files if f.parts and f.parts[-1] == "extension.json"]
assert manifests, (

View File

@ -3,10 +3,9 @@
``pip install`` three local bundles and verify ``lfx extension list``
shows all 3 at @official; plus a Dockerfile-style seed-directory case.
This is the AC item from LE-1022:
> Integration test: pip install three local wheel bundles into the
> test environment; extension list shows all 3 at @official.
Acceptance: integration test that pip-installs three local wheel
bundles into the test environment; ``extension list`` shows all 3 at
@official.
The test builds three minimal wheel-shaped source trees, runs
``pip install --target <isolated dir>`` against each, and points
@ -44,7 +43,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "{distribution_name}"
version = "{version}"
description = "Test bundle for LE-1022 production-install discovery"
description = "Test bundle for production-install discovery"
requires-python = ">=3.10"
[tool.setuptools]

View File

@ -104,13 +104,13 @@ def test_extension_app_help_smoke(runner: CliRunner) -> None:
assert result.exit_code == 0
assert "validate" in result.stdout
assert "schema" in result.stdout
# LE-1016 commands also appear in help.
# Authoring commands also appear in help.
assert "init" in result.stdout
assert "dev" in result.stdout
# ---------------------------------------------------------------------------
# init (LE-1016)
# init
# ---------------------------------------------------------------------------
@ -180,7 +180,7 @@ def test_init_accepts_explicit_id_and_name(runner: CliRunner, tmp_path: Path) ->
# ---------------------------------------------------------------------------
# dev (LE-1016)
# dev
# ---------------------------------------------------------------------------
@ -305,20 +305,50 @@ def test_dev_launch_env_respects_author_reload_off_path() -> None:
# ---------------------------------------------------------------------------
# extension reload (LE-1018) -- argument validation
# extension reload -- argument validation
# ---------------------------------------------------------------------------
def test_reload_requires_explicit_bundle(runner: CliRunner) -> None:
"""The CLI must not silently default --bundle to the extension id.
The conventional install shape is ``lfx-<provider>`` ext_id with bundle
name ``<provider>``; defaulting bundle to the extension id would POST
to ``/extensions/lfx-foo/bundles/lfx-foo/reload`` which always 404s.
Until the LE-1019 list endpoint can resolve the single bundle for an
extension, --bundle is mandatory.
"""
result = runner.invoke(app, ["extension", "reload", "lfx-pilot"])
def test_reload_with_neither_id_nor_all_exits_2(runner: CliRunner) -> None:
"""No positional id AND no ``--all`` is a usage error."""
result = runner.invoke(app, ["extension", "reload"])
assert result.exit_code == 2
msg = result.stderr or result.stdout
# The error message should mention both alternatives so the user
# knows what to do next.
assert "--all" in msg
assert "extension id" in msg
def test_reload_all_rejects_extra_args(runner: CliRunner) -> None:
"""``--all`` is mutually exclusive with an explicit id / bundle name."""
result = runner.invoke(app, ["extension", "reload", "--all", "lfx-pilot"])
assert result.exit_code == 2
msg = result.stderr or result.stdout
assert "--all" in msg
def test_reload_id_not_in_local_discovery_errors_clean(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None:
"""Error cleanly when the extension is not locally installed.
With --bundle omitted, the CLI exits 1 with a typed message rather
than POSTing with a guessed bundle name.
"""
# Force discovery to return nothing.
from lfx.cli import _extension_commands as commands
monkeypatch.setattr(
"lfx.extension.discover_all_extensions",
lambda: ([], []),
)
# Defensive: prevent the module-cached reference if any.
monkeypatch.setattr(
commands,
"_post_reload",
lambda **_kwargs: pytest.fail("should not POST when discovery fails"),
)
result = runner.invoke(app, ["extension", "reload", "lfx-not-installed"])
assert result.exit_code == 1
msg = result.stderr or result.stdout
assert "lfx-not-installed" in msg
assert "--bundle" in msg

View File

@ -1,4 +1,4 @@
"""End-to-end CLI tests for ``lfx extension list`` (LE-1022).
"""End-to-end CLI tests for ``lfx extension list``.
The list command reads from the live importlib metadata + the
seed-directory environment. We override the seed directory through the

View File

@ -1,9 +1,9 @@
"""Tests for the LANGFLOW_COMPONENTS_PATH pathsep-split behavior in settings.
LE-1015 requires that ``LANGFLOW_COMPONENTS_PATH=/path/A:/path/B`` is parsed
as two component paths rather than treated as one literal non-existent path.
This test exercises the validator directly so it works without spinning up
the full settings service.
The extension loader requires that ``LANGFLOW_COMPONENTS_PATH=/path/A:/path/B``
is parsed as two component paths rather than treated as one literal
non-existent path. This test exercises the validator directly so it works
without spinning up the full settings service.
"""
from __future__ import annotations

View File

@ -1,4 +1,4 @@
"""Tests for ``lfx.extension.dev_registry`` (LE-1016).
"""Tests for ``lfx.extension.dev_registry``.
Coverage:
- register / list / unregister round-trip

View File

@ -1,6 +1,6 @@
"""Discovery tests for installed-distribution + seed-directory sources (LE-1022).
"""Discovery tests for installed-distribution + seed-directory sources.
Two integration pieces (per the LE-1022 acceptance criteria):
Two integration pieces:
* Three "installed" distributions surfaced via a fake
``importlib.metadata.distributions()`` iterator -- mirrors the
@ -275,6 +275,181 @@ def test_canonicalize_distribution_normalizes_per_pep503() -> None:
assert canonicalize_distribution("lfx--OpenAI") == "lfx-openai"
# ---------------------------------------------------------------------------
# Editable install entry-point fallback
# ---------------------------------------------------------------------------
class _EditableDistribution(importlib_metadata.Distribution):
"""In-memory editable Distribution stub.
Mirrors what ``uv pip install -e`` / ``pip install -e`` produce: the
distribution's ``files`` list contains only ``dist-info/`` entries
(the actual source tree is reached via a ``.pth`` file rather than
listed in RECORD), and the ``langflow.extensions`` entry-point is
what the manifest discovery has to lean on.
"""
def __init__(self, *, name: str, module_name: str) -> None:
self._name = name
self._module_name = module_name
@property
def files(self) -> list[importlib_metadata.PackagePath]: # type: ignore[override]
# Editable installs only surface dist-info entries -- no
# ``extension.json`` and no source ``pyproject.toml``.
slug = self._name.replace("-", "_")
return [
importlib_metadata.PackagePath(f"{slug}-0.1.0.dist-info/METADATA"),
importlib_metadata.PackagePath(f"{slug}-0.1.0.dist-info/RECORD"),
importlib_metadata.PackagePath(f"_editable_impl_{slug}.pth"),
]
def locate_file(self, path: object) -> Path: # type: ignore[override]
return Path(str(path))
def read_text(self, filename: str) -> str | None: # type: ignore[override]
if filename in {"METADATA", "PKG-INFO"}:
return f"Metadata-Version: 2.1\nName: {self._name}\nVersion: 1.0.0\n"
return None
@property
def metadata(self) -> object: # type: ignore[override]
class _Stub(dict):
pass
return _Stub({"Name": self._name})
@property
def entry_points(self) -> list[importlib_metadata.EntryPoint]: # type: ignore[override]
return [
importlib_metadata.EntryPoint(
name=self._name,
value=self._module_name,
group="langflow.extensions",
)
]
def _make_editable_package(
site_root: Path,
*,
module_name: str,
manifest: dict[str, object],
) -> Path:
"""Stand up a real importable package on disk with an ``extension.json``."""
pkg_dir = site_root / module_name
bundle_name = manifest["bundles"][0]["name"] # type: ignore[index]
(pkg_dir / bundle_name).mkdir(parents=True)
(pkg_dir / "__init__.py").write_text("", encoding="utf-8")
(pkg_dir / "extension.json").write_text(json.dumps(manifest), encoding="utf-8")
return pkg_dir
def test_discover_installed_falls_back_to_entry_point_for_editable_install(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Editable installs are discovered via the ``langflow.extensions`` entry-point.
Reproduces the editable-install bug: ``dist.files`` only carries
``dist-info/`` entries, so the file-walk path finds no manifest. The
entry-point fallback resolves the package directory via
:func:`importlib.util.find_spec` and locates ``extension.json`` there.
"""
site = tmp_path / "src"
site.mkdir()
_make_editable_package(
site,
module_name="lfx_editable_bundle",
manifest=_manifest("lfx-editable-bundle", "bundle"),
)
monkeypatch.syspath_prepend(str(site))
# Drop any cached spec from a previous test invocation.
import sys
sys.modules.pop("lfx_editable_bundle", None)
dist = _EditableDistribution(
name="lfx-editable-bundle",
module_name="lfx_editable_bundle",
)
extensions, errors = discover_installed_extensions(distributions=[dist])
assert errors == []
assert len(extensions) == 1
ext = extensions[0]
assert ext.extension_id == "lfx-editable-bundle"
assert ext.source_kind == "installed"
assert ext.bundle_name == "bundle"
assert ext.manifest.kind == "extension.json"
assert ext.extension_root == site / "lfx_editable_bundle"
def test_discover_installed_entry_point_fallback_handles_missing_module(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An entry-point pointing at an unimportable module yields no result, no crash."""
# No package on sys.path corresponds to ``lfx_nonexistent``.
monkeypatch.syspath_prepend(str(tmp_path))
import sys
sys.modules.pop("lfx_nonexistent_bundle", None)
dist = _EditableDistribution(
name="lfx-nonexistent-bundle",
module_name="lfx_nonexistent_bundle",
)
extensions, errors = discover_installed_extensions(distributions=[dist])
# Unresolvable entry-point is treated as "no manifest here", same as a
# regular non-extension package -- no error, no record.
assert extensions == []
assert errors == []
def test_discover_installed_entry_point_fallback_skipped_when_files_have_manifest(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When ``dist.files`` already exposes the manifest, the entry-point path is unused.
Guards the wheel-install codepath (the common case) from being
accidentally rerouted through ``find_spec`` -- which would otherwise
re-import every bundle package on every startup.
"""
site = tmp_path / "site-packages/lfx_wheelish"
_write_extension_json(site, _manifest("lfx-wheelish", "wheelish"))
monkeypatch.syspath_prepend(str(tmp_path / "site-packages"))
# Sentinel: if find_spec is consulted, we'd notice.
import importlib.util as _importlib_util
calls = {"n": 0}
real_find_spec = _importlib_util.find_spec
def _tracking_find_spec(name: str, package: str | None = None) -> object:
calls["n"] += 1
return real_find_spec(name, package)
monkeypatch.setattr(_importlib_util, "find_spec", _tracking_find_spec)
dist = _FakeDistribution(
name="lfx-wheelish",
root=site,
manifest_relative="extension.json",
)
extensions, errors = discover_installed_extensions(distributions=[dist])
assert errors == []
assert len(extensions) == 1
assert calls["n"] == 0, "Wheel-install path should not consult find_spec"
# ---------------------------------------------------------------------------
# Seed-directory discovery
# ---------------------------------------------------------------------------
@ -421,9 +596,9 @@ def test_discover_all_emits_seed_bundle_shadowed_when_ids_collide(
) -> None:
"""A seed bundle with the same id as an installed one is flagged, not silently dropped.
Installed wins by precedence (LE-1022's documented contract); the
operator still gets a typed ``seed-bundle-shadowed`` error so the
shadow is visible instead of disappearing into discovery debug logs.
Installed wins by precedence (documented contract); the operator
still gets a typed ``seed-bundle-shadowed`` error so the shadow is
visible instead of disappearing into discovery debug logs.
"""
# ``fake_installed_distributions`` ships ``lfx-openai``; the seed dir
# below carries the same id from a different on-disk source.

View File

@ -1,8 +1,8 @@
"""Tests for ``lfx.extension.init_template`` (LE-1016).
"""Tests for ``lfx.extension.init_template``.
Coverage:
- Basic template renders a manifest that round-trips through the
LE-1014 validator with zero errors (AC #1).
manifest validator with zero errors (AC #1).
- Generated test file is a syntactically valid pytest module that
imports + executes (AC #2).
- Unknown ``--template`` value (e.g. ``full``) returns a typed

View File

@ -1,4 +1,4 @@
"""Service-layer tests for the read-only Extension registry (LE-1022).
"""Service-layer tests for the read-only Extension registry.
The acceptance criterion this file covers:
@ -167,7 +167,7 @@ def test_duplicate_extension_id_raises_typed_error(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
# Immutability invariant (the core LE-1022 acceptance test)
# Immutability invariant
# ---------------------------------------------------------------------------
_MUTATION_VERBS = ("uninstall", "disable", "enable", "install", "update_entry")
@ -186,9 +186,8 @@ def _invoke_mutation_verb(registry: ExtensionRegistry, verb: str, extension_id:
def test_installed_extension_is_immutable(tmp_path: Path, verb: str) -> None:
"""Every mutation verb on an installed Extension raises immutability.
Drives the LE-1022 acceptance: the invariant is observable today via
the service surface, even though uninstall doesn't ship as a CLI verb
until B4.
The invariant is observable via the service surface, even though
uninstall doesn't ship as a CLI verb yet.
"""
discovered = _make_discovered(
extension_id="lfx-openai",

View File

@ -1,6 +1,6 @@
"""Unit tests for the LE-1018 atomic-swap reload pipeline.
"""Unit tests for the atomic-swap reload pipeline.
Covers the AC items from the ticket:
Covers the AC items:
* rename a component, reload, old name gone, new name present;
* broken bundle (import error in staging) leaves the live registry

View File

@ -1,4 +1,4 @@
"""Tests for the LE-1018 reload CLI: HTTP client + ``lfx extension reload``."""
"""Tests for the reload CLI: HTTP client + ``lfx extension reload``."""
from __future__ import annotations

View File

@ -180,7 +180,7 @@ class TestModelInputRefreshButton:
class TestModelInputEmbeddingType:
"""Test that ModelInput correctly handles embedding model_type (fix for LE-278).
"""Test that ModelInput correctly handles embedding model_type.
input_types should always be set based on model_type:
- "embedding" -> ["Embeddings"]

View File

@ -337,8 +337,8 @@ class TestConnect:
def test_add_connection_is_idempotent(self):
"""Repeating add_connection for the same pair must not duplicate the edge.
Regression for LE-866: connecting components via batch (or after the
edge was already created in the UI) appended a second edge with the
Regression: connecting components via batch (or after the edge
was already created in the UI) appended a second edge with the
same connection, double-wiring the flow at runtime.
"""
flow = _fresh_flow()
@ -352,10 +352,11 @@ class TestConnect:
def test_add_connection_dedupes_against_ui_saved_edges(self):
"""Re-adding connections to a real UI-saved flow must not grow the edge list.
Regression for LE-866 against an actual UI-exported fixture. UI-saved
edges from older Langflow versions use the `xy-edge__` id prefix
instead of `reactflow__edge-`, so dedup must be structural (source,
target, handle name, handle fieldName) rather than by edge id.
Regression against an actual UI-exported fixture. UI-saved edges
from older Langflow versions use the `xy-edge__` id prefix
instead of `reactflow__edge-`, so dedup must be structural
(source, target, handle name, handle fieldName) rather than by
edge id.
"""
fixture = Path(__file__).parent.parent / "data" / "MemoryChatbotNoLLM.json"
flow = json.loads(fixture.read_text())