Skip to content

Commit 52a1a97

Browse files
authored
Merge branch 'feat/extension-production-install' into feat/extension-pilot-arxiv
2 parents f7fd89f + 5697cdd commit 52a1a97

20 files changed

Lines changed: 1136 additions & 59 deletions

File tree

.github/workflows/extension-migration-checks.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
- "src/lfx/src/lfx/extension/**"
1010
- "src/lfx/src/lfx/components/**"
1111
- "src/bundles/**"
12-
- "src/backend/base/langflow/api/v1/extensions.py"
12+
- "src/backend/base/langflow/api/**.py"
1313
- ".github/workflows/extension-migration-checks.yml"
1414

1515
jobs:
@@ -65,3 +65,22 @@ jobs:
6565
run: |
6666
base_ref="origin/${GITHUB_BASE_REF:-main}"
6767
python scripts/migrate/check_bundle_api_changelog.py --base "$base_ref"
68+
69+
router-trust:
70+
name: Router trust - no install/uninstall under /api/v1/extensions
71+
runs-on: ubuntu-latest
72+
# Trigger on any change to API modules so the guard catches a new
73+
# router file that mounts /extensions, not just edits to the existing
74+
# extensions.py. The guard itself filters down to in-scope files.
75+
if: ${{ github.event_name == 'pull_request' }}
76+
steps:
77+
- name: Checkout code
78+
uses: actions/checkout@v6
79+
80+
- name: Set up Python
81+
uses: actions/setup-python@v5
82+
with:
83+
python-version: "3.13"
84+
85+
- name: Run check_router_trust.py
86+
run: python scripts/migrate/check_router_trust.py

BUNDLE_API.md

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@ requires a `BUNDLE_API_VERSION` bump.
116116
| `lfx extension validate` (CLI) | `lfx.cli._extension_commands` |
117117
| `lfx extension schema` (CLI) | `lfx.cli._extension_commands` |
118118
| `lfx extension init` (CLI) | `lfx.cli._extension_commands` |
119-
| `lfx extension dev register` / `unregister` / `list` (CLI) | `lfx.cli._extension_commands` |
119+
| `lfx extension dev` (CLI -- registers a local path and execs `langflow run`) | `lfx.cli._extension_commands` |
120120
| `lfx extension list` (CLI) | `lfx.cli._extension_commands` |
121121
| `lfx extension reload` (CLI) | `lfx.cli._extension_commands` |
122+
| `register_dev_extension` / `unregister_dev_extension` (Python API) | `lfx.extension.dev_registry` |
122123

123124
### Migration
124125

@@ -147,10 +148,16 @@ v0 contract:
147148

148149
---
149150

150-
## Pilot recommendation
151+
## Pilot bundle: `lfx-duckduckgo`
151152

152-
For LE-1023 (B1 pilot migration), the recommended target is
153-
**`duckduckgo`**. Rationale:
153+
The shipped LE-1023 pilot is **`duckduckgo`**, extracted into the
154+
standalone distribution
155+
[`lfx-duckduckgo`](src/bundles/duckduckgo/) under `src/bundles/duckduckgo/`
156+
with its own `pyproject.toml`. `langflow`'s own `pyproject.toml`
157+
declares `lfx-duckduckgo>=0.1.0` as a regular dependency so a flat
158+
`pip install langflow` continues to ship the bundle as before.
159+
160+
Why this bundle:
154161

155162
- Single component (`DuckDuckGoSearchComponent`) in a single file
156163
(`duck_duck_go_search_run.py`).
@@ -161,8 +168,12 @@ For LE-1023 (B1 pilot migration), the recommended target is
161168
- Class name is globally unique across `src/lfx/src/lfx/components/**`, so the
162169
bare-name migration entry is allowed by `check_bare_names.py`.
163170

164-
This is a recommendation, not a decision — the engineer who picks up B1 owns
165-
the call.
171+
The runtime half of the M1 proof-of-delivery gate (save a flow on
172+
pre-migration Langflow, upgrade, confirm it loads AND runs identically)
173+
lives in the dogfood checklist at
174+
[`src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md`](src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md);
175+
the deserialize half is covered by
176+
`src/lfx/tests/integration/extension/test_pilot_duckduckgo_upgrade.py`.
166177

167178
---
168179

@@ -171,3 +182,33 @@ the call.
171182
### v0 (this release)
172183

173184
- Initial surface enumerated above. Frozen as `BUNDLE_API_VERSION = 1`.
185+
- `BundleRegistry.write_locked()` exposed as a public context manager so the
186+
reload pipeline can hold the registry write lock across both the
187+
`sys.modules` swap and the `BundleRecord` install. Concurrent readers
188+
can no longer observe new modules paired with the old record. No change
189+
to the addressable component contract.
190+
- HTTP reload endpoint (`POST /api/v1/extensions/{id}/bundles/{name}/reload`)
191+
returns `422 Unprocessable Entity` for structural failures (broken
192+
bundle, missing source path, name mismatch) instead of `200 OK` with
193+
`ok=false`. Body is `{...primaryError, result: ReloadResult}` so the
194+
full typed result is preserved under the FastAPI `detail` envelope.
195+
`409 Conflict` for `reload-in-progress` is unchanged.
196+
- CLI table updated to remove the obsolete `dev register` / `dev unregister`
197+
/ `dev list` subcommands; the actual surface is `extension dev <path>`
198+
plus the Python helpers `register_dev_extension` / `unregister_dev_extension`.
199+
- `MigrationTable.ambiguous_bare_names` added. Each entry is
200+
`{name, candidates: [list of canonical IDs]}` and registers a bare
201+
class name that exists in 2+ bundles. The deserializer now surfaces
202+
`component-name-ambiguous` (with the candidate targets) for any bare
203+
name listed here, instead of falling through to the generic
204+
`component-not-found-with-hint`. Seeded with the canonical regression
205+
cases (`MergeDataComponent`, `SplitTextComponent`, `SubFlowComponent`).
206+
`check_bare_names.py` now verifies every Component class found in
207+
2+ bundle folders has a matching marker, so a future bundle move that
208+
introduces a new ambiguity is caught at PR time.
209+
- Router-trust CI guard broadened to scan every `.py` under
210+
`src/backend/base/langflow/api/**` and `src/lfx/src/lfx/**`; a new file
211+
that mounts an `APIRouter(prefix=".../extensions...")` is auto-detected
212+
and checked for forbidden install/uninstall/registry-mutation handlers.
213+
Authors of files with non-literal prefixes can opt in via a
214+
`# router-trust: in-scope` marker.

scripts/migrate/check_bare_names.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
REPO_ROOT / "src" / "bundles",
5353
)
5454

55+
# A class is "ambiguous" if it lives in this many or more bundle folders.
56+
_AMBIGUITY_THRESHOLD = 2
57+
5558

5659
def _iter_component_files(roots: Iterable[Path]) -> Iterable[Path]:
5760
"""Yield every ``.py`` file under each root, skipping caches and dunders."""
@@ -126,6 +129,24 @@ def _bare_name_entries(table: dict) -> list[dict]:
126129
return [e for e in entries if isinstance(e, dict) and e.get("bare_class_name") is not None]
127130

128131

132+
def _ambiguous_bare_name_set(table: dict) -> set[str]:
133+
"""Return the set of bare names registered as ``ambiguous_bare_names``.
134+
135+
These are the names the rewriter surfaces ``component-name-ambiguous`` for;
136+
any bare name found in 2+ bundle folders must be either here OR have a
137+
specific import_path entry per variant so saved flows still resolve.
138+
"""
139+
ambig = table.get("ambiguous_bare_names", [])
140+
if not isinstance(ambig, list):
141+
msg = "migration table 'ambiguous_bare_names' must be a list"
142+
raise TypeError(msg)
143+
out: set[str] = set()
144+
for entry in ambig:
145+
if isinstance(entry, dict) and isinstance(entry.get("name"), str):
146+
out.add(entry["name"])
147+
return out
148+
149+
129150
def find_violations(
130151
bare_name_entries: list[dict],
131152
class_to_bundles: dict[str, set[str]],
@@ -156,6 +177,46 @@ def find_violations(
156177
return violations
157178

158179

180+
def find_unregistered_ambiguities(
181+
class_to_bundles: dict[str, set[str]],
182+
ambiguous_names: set[str],
183+
*,
184+
only_components: bool = True,
185+
) -> list[str]:
186+
"""Return one message per ambiguous Component class missing from the marker list.
187+
188+
Per the LE-1020 contract, a bare class name that exists in 2+ bundle
189+
folders must surface ``component-name-ambiguous`` at flow-load time.
190+
The rewriter's only durable signal is the ``ambiguous_bare_names``
191+
list; without an entry there the value falls through to
192+
``component-not-found-with-hint``, which is the wrong code.
193+
194+
When ``only_components`` is true (the default) we restrict the check to
195+
classes whose name ends in ``Component`` -- that's the population a
196+
saved flow would reference. Utility schemas / method enums that share
197+
a class name across bundles are not reachable from a flow JSON, so we
198+
do not require markers for them.
199+
"""
200+
out: list[str] = []
201+
for class_name, bundles in sorted(class_to_bundles.items()):
202+
if len(bundles) < _AMBIGUITY_THRESHOLD:
203+
continue
204+
if only_components and not class_name.endswith("Component"):
205+
continue
206+
if class_name in ambiguous_names:
207+
continue
208+
sorted_bundles = sorted(bundles)
209+
out.append(
210+
f"ambiguous Component class {class_name!r} appears in "
211+
f"{len(bundles)} bundle folders ({', '.join(sorted_bundles)}) "
212+
f"but is not registered in ``ambiguous_bare_names``. Add an "
213+
f"entry so the deserializer surfaces ``component-name-ambiguous`` "
214+
f"with the candidate targets, instead of falling through to "
215+
f"``component-not-found-with-hint``."
216+
)
217+
return out
218+
219+
159220
def main(argv: list[str] | None = None) -> int:
160221
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
161222
parser.add_argument(
@@ -190,35 +251,40 @@ def main(argv: list[str] | None = None) -> int:
190251

191252
try:
192253
bare_entries = _bare_name_entries(table)
254+
ambiguous_names = _ambiguous_bare_name_set(table)
193255
except TypeError as exc:
194256
print(f"error: {exc}", file=sys.stderr)
195257
return 2
196258

197-
if not bare_entries:
198-
print("ok: no bare_class_name entries to check.")
199-
return 0
200-
201259
roots = args.components_root if args.components_root else list(DEFAULT_COMPONENT_ROOTS)
202260
try:
203261
class_to_bundles = build_class_to_bundles(roots)
204262
except RuntimeError as exc:
205263
print(f"error: {exc}", file=sys.stderr)
206264
return 2
207265

208-
violations = find_violations(bare_entries, class_to_bundles)
266+
violations: list[str] = []
267+
if bare_entries:
268+
violations.extend(find_violations(bare_entries, class_to_bundles))
269+
270+
# Always run the ambiguity-coverage check: any Component class found in
271+
# 2+ bundle folders must have an ``ambiguous_bare_names`` entry so the
272+
# deserializer surfaces the right typed code.
273+
violations.extend(find_unregistered_ambiguities(class_to_bundles, ambiguous_names))
274+
209275
if violations:
210276
print(
211-
"error: bare-name migration entries must map to globally-unique classes; refusing the following:",
277+
"error: bare-name migration coverage failed; refusing the following:",
212278
file=sys.stderr,
213279
)
214280
for v in violations:
215281
print(f" - {v}", file=sys.stderr)
216282
return 1
217283

218284
print(
219-
f"ok: every bare_class_name entry maps to a globally-unique class "
220-
f"({len(bare_entries)} entries checked against "
221-
f"{sum(len(b) for b in class_to_bundles.values())} class declarations)."
285+
f"ok: {len(bare_entries)} bare_class_name entries checked, "
286+
f"{len(ambiguous_names)} ambiguous-bare-name markers checked, "
287+
f"against {sum(len(b) for b in class_to_bundles.values())} class declarations."
222288
)
223289
return 0
224290

0 commit comments

Comments
 (0)