merge: upstream/main (sync, brand preserved)

# Conflicts:
#	.github/workflows/docs-update-openapi.yml
#	AGENTS.md
#	AGENTS.md~upstream_main
#	CLAUDE.md
#	CLAUDE.md~upstream_main
#	docker_example/docker-compose.yml
#	src/backend/base/flow/agentic/flows/TranslationFlow.json
#	src/backend/base/flow/agentic/flows/langflow_assistant.py
#	src/backend/base/flow/agentic/helpers/code_security.py
#	src/backend/base/flow/agentic/helpers/input_sanitization.py
#	src/backend/base/flow/agentic/helpers/streaming_retry.py
#	src/backend/base/flow/alembic/versions/8255e9fc18d9_add_name_to_deployment_provider_account.py
#	src/backend/base/flow/alembic/versions/a1b2c3d4e5f6_convert_provider_key_and_deployment_.py
#	src/backend/base/flow/alembic/versions/c0d2ce43b315_add_flow_version_deployment_attachment_.py
#	src/backend/base/flow/alembic/versions/d306e5c17c41_add_api_key_hash_column_to_apikey_table.py
#	src/backend/base/flow/alembic/versions/ef4b036b585d_add_session_metadata_column_to_message_.py
#	src/backend/base/flow/api/utils/flow_utils.py
#	src/backend/base/flow/api/utils/zip_utils.py
#	src/backend/base/flow/api/v1/flow_events.py
#	src/backend/base/flow/api/v1/flows_helpers.py
#	src/backend/base/flow/api/v1/mappers/__init__.py
#	src/backend/base/flow/api/v1/mappers/deployments/RULES.md
#	src/backend/base/flow/api/v1/mappers/deployments/__init__.py
#	src/backend/base/flow/api/v1/mappers/deployments/base.py
#	src/backend/base/flow/api/v1/mappers/deployments/contracts.py
#	src/backend/base/flow/api/v1/mappers/deployments/helpers.py
#	src/backend/base/flow/api/v1/mappers/deployments/registry.py
#	src/backend/base/flow/api/v1/mappers/deployments/sync.py
#	src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/__init__.py
#	src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py
#	src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py
#	src/backend/base/flow/api/v1/projects_files.py
#	src/backend/base/flow/api/v1/projects_mcp_helpers.py
#	src/backend/base/flow/services/cache/disk.py
#	src/backend/base/flow/services/database/models/deployment/exceptions.py
#	src/backend/base/flow/services/database/models/deployment/guards.py
#	src/backend/base/flow/services/database/models/deployment/orm_guards.py
#	src/backend/base/flow/services/database/models/deployment_provider_account/schemas.py
#	src/backend/base/flow/services/database/models/deployment_provider_account/utils.py
#	src/backend/base/flow/services/tracing/span_sorting.py
#	src/backend/base/flow/tests/api/v1/test_deployment_guard_delete_endpoints.py
#	src/backend/base/flow/tests/services/database/models/deployment/test_deployment_guard_triggers.py
#	src/backend/base/flow/tests/services/database/models/deployment/test_exceptions.py
#	src/backend/base/flow/tests/services/database/models/deployment/test_guards.py
#	src/backend/base/flow/tests/services/database/models/deployment_provider_account/test_utils.py
#	src/backend/base/langflow/api/v1/__init__.py
#	src/backend/tests/unit/agentic/flows/test_langflow_assistant.py
#	src/frontend/Dockerfile
#	src/lfx/src/lfx/base/models/unified_models.py
This commit is contained in:
2026-06-02 10:52:30 -07:00
2556 changed files with 295028 additions and 65756 deletions
+225
View File
@@ -0,0 +1,225 @@
---
name: backend-code-review
description: Review backend code for quality, security, maintainability, and best practices based on established checklist rules. Use when the user requests a review, analysis, or improvement of backend files (e.g., `.py`) under the `src/backend/` directory. Do NOT use for frontend files (e.g., `.tsx`, `.ts`, `.js`). Supports pending-change review, code snippets review, and file-focused review.
---
# Backend Code Review
## When to use this skill
Use this skill whenever the user asks to **review, analyze, or improve** backend code (e.g., `.py`) under the `src/backend/` directory. Supports the following review modes:
- **Pending-change review**: when the user asks to review current changes (inspect staged/working-tree files slated for commit to get the changes).
- **Code snippets review**: when the user pastes code snippets (e.g., a function/class/module excerpt) into the chat and asks for a review.
- **File-focused review**: when the user points to specific files and asks for a review of those files (one file or a small, explicit set of files, e.g., `src/backend/base/langflow/api/v1/flows.py`).
Do NOT use this skill when:
- The request is about frontend code or UI (e.g., `.tsx`, `.ts`, `.js`, `src/frontend/`).
- The user is not asking for a review/analysis/improvement of backend code.
- The scope is not under `src/backend/` (unless the user explicitly asks to review backend-related changes outside `src/backend/`).
## How to use this skill
Follow these steps when using this skill:
1. **Identify the review mode** (pending-change vs snippet vs file-focused) based on the user's input. Keep the scope tight: review only what the user provided or explicitly referenced.
2. Follow the rules defined in **Checklist** to perform the review. If no Checklist rule matches, apply **General Review Rules** as a fallback to perform the best-effort review.
3. Compose the final output strictly following the **Required Output Format**.
Notes when using this skill:
- Always include actionable fixes or suggestions (including possible code snippets).
- Use best-effort `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
## Checklist
- db schema design: if the review scope includes code/files under `src/backend/base/langflow/services/database/models/` or Alembic migrations under `src/backend/base/langflow/alembic/versions/`, follow [references/db-schema-rule.md](references/db-schema-rule.md) to perform the review
- architecture: if the review scope involves route/service/model layering, dependency direction, or moving responsibilities across modules, follow [references/architecture-rule.md](references/architecture-rule.md) to perform the review
- service abstraction: if the review scope contains table/model operations (e.g., `select(...)`, `session.execute(...)`, joins, CRUD) and is not already inside a service under `src/backend/base/langflow/services/`, follow [references/repositories-rule.md](references/repositories-rule.md) to perform the review
- sqlalchemy patterns: if the review scope involves SQLAlchemy/SQLModel session/query usage, db transaction/crud usage, `session_scope()` usage, or raw SQL usage, follow [references/sqlalchemy-rule.md](references/sqlalchemy-rule.md) to perform the review
## General Review Rules
### 1. Security Review
Check for:
- SQL injection vulnerabilities (especially raw `text()` queries with string interpolation). Consequence: attacker can read/modify/delete any data in the database.
- Server-Side Request Forgery (SSRF) in component HTTP calls. Consequence: attacker uses the server to scan internal networks or access cloud metadata endpoints.
- Command injection (especially in subprocess or shell-executing components). Consequence: attacker gains shell access to the server.
- Insecure deserialization (pickle, yaml.load without SafeLoader). Consequence: arbitrary code execution on the server.
- Hardcoded secrets/credentials. Consequence: secrets leak via git history and are impossible to fully revoke.
- Improper authentication/authorization (missing `CurrentActiveUser` dependency). Consequence: unauthenticated users can access protected endpoints.
- Insecure direct object references (missing `user_id` scoping on queries). Consequence: user A can read/modify user B's flows, variables, API keys.
- Path traversal in file storage operations. Consequence: attacker reads arbitrary server files (e.g., `/etc/passwd`, `.env`).
### 2. Performance Review
Check for:
- N+1 queries (especially in loops calling `session.execute()`). Consequence: 100 flows = 101 DB queries instead of 2; page load goes from 50ms to 5s.
- Missing database indexes on frequently queried columns. Consequence: full table scans on large datasets; queries degrade from O(log n) to O(n).
- Memory leaks (unbounded caches, retained references in long-lived services). Consequence: server OOM after hours of operation; pods restart in production.
- Blocking operations in async code (`time.sleep()`, synchronous I/O, CPU-bound work without `run_in_executor`). Consequence: entire event loop stalls; all concurrent requests hang until the blocking call completes.
- Missing caching opportunities for expensive computations. Consequence: repeated computation of the same result on every request.
- Large result sets loaded entirely into memory without pagination. Consequence: memory spike + slow response when user has 10K+ flows.
### 3. Code Quality Review
Check for:
- Code forward compatibility with Python 3.10-3.13
- Code duplication (DRY violations — extract when the *exact same business rule* is duplicated in 3+ places)
- Functions doing too much (SRP violations — if you need "and" to describe it, split it)
- Deep nesting / complex conditionals (prefer early returns and guard clauses)
- Magic numbers/strings (extract to named constants or enums)
- Poor naming: unclear abbreviations, misleading names, generic names (`data`, `result`, `obj`, `temp`). Functions should use verbs (`get`, `create`, `validate`). Booleans should use prefixes (`is_`, `has_`, `can_`, `should_`).
- Missing error handling (bare `except`, swallowed exceptions, silent failures)
- Incomplete type coverage (use strong typing, avoid `Any` where a concrete type is known)
- Use Python 3.10+ union syntax (`X | Y` not `Union[X, Y]`, `X | None` not `Optional[X]`)
- Use `TYPE_CHECKING` guard for imports only needed for type annotations (prevents circular imports)
- Use `Annotated[Type, Depends(...)]` with project aliases (`CurrentActiveUser`, `DbSession`, `DbSessionReadOnly`) for FastAPI DI
- Google-style docstrings (enforced by Ruff): `Args:`, `Returns:`, `Raises:` sections for public functions
- Violations of SOLID principles
- YAGNI violations (code that anticipates future needs without a present requirement)
- Line length exceeding 120 characters (project Ruff config)
- Comments that explain WHAT instead of WHY (comments should only explain reasoning, not restate code)
- Commented-out code (use version control instead)
- Boolean parameters that switch function behavior (split into two named functions instead)
- Mutable shared state where immutable alternatives exist (prefer returning new objects over mutation)
### 4. File Structure Review
Check for:
- Production files exceeding ~500 lines of code (excluding imports, types, and docstrings). Files above 600 lines are a red flag and should be split by responsibility. Why: Files above 500 lines have statistically higher defect rates and take longer to review. They signal multiple responsibilities (SRP violation). In Langflow, services like `DatabaseService` that grow beyond this limit should have their CRUD operations extracted to dedicated modules.
- Test files exceeding ~1000 lines. Split by logical grouping if exceeded.
- No more than 5 functions with different responsibilities in a single file (per AGENTS-example.md).
- Each file has a single reason to exist and a single reason to change (SRP).
- No generic file names: `utils.py`, `helpers.py`, `misc.py`, `common.py` as standalone files. Why: A file named `utils.py` becomes a dumping ground for unrelated functions. Within months it has 50+ functions covering formatting, validation, parsing, and HTTP calls — violating SRP. Each function group should be in a file named after its responsibility (`formatting.py`, `validation.py`).
### 5. Testing Review
Check for:
- Missing test coverage for new code paths
- Tests that don't test behavior (testing implementation details)
- Flaky test patterns (time-dependent, order-dependent, external-service-dependent)
- Proper use of `pytest.mark.asyncio` for async tests
- Excessive mocking (prefer real integrations per project conventions)
- Coverage target: 80% (minimum acceptable: 75%)
- Test anti-patterns: The Liar (passes but doesn't verify claimed behavior), The Mirror (asserts exactly what code does), The Giant (50+ lines setup), The Mockery (tests only mock setup), The Inspector (coupled to implementation), The Chain Gang (depends on execution order), The Flaky (inconsistent results)
**Happy path tests are the foundation but are NOT enough.** Tests MUST also challenge the code to find real defects:
- **Unexpected inputs**: `None`, `""`, `[]`, `{}`, `0`, `-1`, `UUID("00000000-0000-0000-0000-000000000000")`
- **Boundary values**: max length strings, exactly at the limit, one past the limit, zero items, max items
- **Malformed data**: missing required fields, extra unexpected fields, wrong types, invalid formats
- **Error states**: what happens when the database is down? When an external API returns 500? When the user doesn't exist?
- **What should NOT happen**: verify that user A CANNOT access user B's flows. Verify that a deleted flow returns 404. Verify that invalid `endpoint_name` is rejected with 422.
- **Error messages and types**: not just that it fails, but that it fails with the RIGHT exception and the RIGHT message
- **Concurrency**: what happens when two requests try to update the same flow simultaneously?
**Write tests based on REQUIREMENTS/SPEC, not on what the source code currently does.** This is how you catch bugs where the code diverges from expected behavior.
**When a test fails:** first ask if the CODE is wrong, not the test. Do NOT silently change a failing assertion to match the current code without understanding WHY.
### 6. Observability Review
Check for:
- Use the async logger from `lfx.log.logger` with `a`-prefixed methods (`adebug`, `ainfo`, `awarning`, `aerror`, `aexception`). Never use `print()` or stdlib `logging`.
- Log at key decision points and boundaries, not inside tight loops
- Include: operation name, relevant IDs, outcome (success/failure), duration if relevant
- Correct log levels: ERROR (broken, needs attention), WARN (degraded but recoverable), INFO (significant events), DEBUG (diagnostic, off in prod)
- **ZERO PII TOLERANCE**: Never log email addresses, user names, phone numbers, tokens, passwords. Only approved identifiers: `user_id`, `flow_id`, `session_id`
- No `print()` statements — these go to production logs
- Use `{e!s}` for string representation of exceptions in log messages
### 7. Pre-Commit Verification
For pending-change reviews, verify the author has run:
- `make format_backend` (Ruff formatter) — inconsistent formatting creates noisy diffs that hide real changes in code review. Format first, review second.
- `make lint` (MyPy type checking) — type errors caught at lint time are 10x cheaper to fix than runtime crashes in production. Langflow services use duck typing via `Service` base class; MyPy catches mismatches early.
- `make unit_tests` (pytest) — a failing test means the change breaks existing behavior. Never merge with failing tests; investigate whether the code or the test is wrong.
## Required Output Format
When this skill is invoked, the response must exactly follow one of the two templates:
### Template A (any findings)
```markdown
# Code Review Summary
Found <X> critical issues need to be fixed:
## 🔴 Critical (Must Fix)
### 1. <brief description of the issue>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<detailed explanation and references of the issue>
#### Suggested Fix
1. <brief description of suggested fix>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each critical issue) ...
Found <Y> suggestions for improvement:
## 🟡 Suggestions (Should Consider)
### 1. <brief description of the suggestion>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<detailed explanation and references of the suggestion>
#### Suggested Fix
1. <brief description of suggested fix>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each suggestion) ...
Found <Z> optional nits:
## 🟢 Nits (Optional)
### 1. <brief description of the nit>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<explanation and references of the optional nit>
#### Suggested Fix
- <minor suggestions>
---
... (repeat for each nits) ...
## ✅ What's Good
- <Positive feedback on good patterns>
```
- If there are no critical issues or suggestions or optional nits or good points, just omit that section.
- If the issue number is more than 10, summarize as "Found 10+ critical issues/suggestions/optional nits" and only output the first 10 items.
- Don't compress the blank lines between sections; keep them as-is for readability.
- If there is any issue that requires code changes, append a brief follow-up question to ask whether the user wants to apply the fix(es) after the structured output. For example: "Would you like me to use the Suggested fix(es) to address these issues?"
### Template B (no issues)
```markdown
## Code Review Summary
✅ No issues found.
```
@@ -0,0 +1,243 @@
# Rule Catalog — Architecture
## Scope
- Covers: route handler/service/model layering, dependency direction, responsibility placement, helper module purity, and observability-friendly flow.
- Key directories:
- Routes: `src/backend/base/langflow/api/v1/`, `src/backend/base/langflow/api/v2/`
- Services: `src/backend/base/langflow/services/`
- Models: `src/backend/base/langflow/services/database/models/`
- Helpers: `src/backend/base/langflow/helpers/`
- Components: `src/backend/base/langflow/components/`
## Rules
### Keep business logic out of route handlers
- Category: maintainability
- Severity: critical
- Description: Route handlers (FastAPI endpoint functions) should parse input, delegate to a service, and return a serialized response. Business decisions inside route handlers make behavior hard to reuse, test, and maintain. Langflow uses FastAPI with `Depends()` for dependency injection and async handlers throughout.
- Suggested fix: Move domain/business logic into the appropriate service under `src/backend/base/langflow/services/`. Keep route handlers thin and orchestration-focused.
- Example:
- Bad:
```python
@router.post("/flows/{flow_id}/publish")
async def publish_flow(
flow_id: UUID,
session: AsyncSession = Depends(injectable_session_scope),
current_user: User = Depends(get_current_active_user),
):
stmt = select(Flow).where(Flow.id == flow_id, Flow.user_id == current_user.id)
flow = (await session.execute(stmt)).scalar_one_or_none()
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
if flow.access_type == AccessTypeEnum.PUBLIC:
raise HTTPException(status_code=400, detail="Already published")
flow.access_type = AccessTypeEnum.PUBLIC
flow.updated_at = datetime.now(timezone.utc)
session.add(flow)
await session.commit()
await session.refresh(flow)
return FlowRead.model_validate(flow, from_attributes=True)
```
- Good:
```python
@router.post("/flows/{flow_id}/publish")
async def publish_flow(
flow_id: UUID,
session: AsyncSession = Depends(injectable_session_scope),
current_user: User = Depends(get_current_active_user),
):
flow = await flow_service.publish_flow(
flow_id=flow_id, user_id=current_user.id, session=session
)
return FlowRead.model_validate(flow, from_attributes=True)
```
### Preserve layer dependency direction
- Category: best practices
- Severity: critical
- Description: Routes may depend on services, and services may depend on models and domain abstractions. Reversing this direction (for example, a model or service importing from `langflow.api`) creates cycles and leaks transport concerns into domain code. The dependency flow must be: Routes -> Services -> Models (never reverse).
- Suggested fix: Extract shared contracts into service-level or model-level modules and make upper layers depend on lower, not the reverse.
- Example:
- Bad:
```python
# src/backend/base/langflow/services/database/models/flow/model.py
from langflow.api.v1.schemas import FlowListCreate # Model importing from API layer
class Flow(FlowBase, table=True):
def to_api_response(self) -> FlowListCreate:
return FlowListCreate(...)
```
- Good:
```python
# src/backend/base/langflow/services/database/models/flow/model.py
class Flow(FlowBase, table=True):
pass # No API-layer imports
# src/backend/base/langflow/api/v1/flows.py (route layer handles serialization)
flow = await get_flow(flow_id, session)
return FlowRead.model_validate(flow, from_attributes=True)
```
### Keep helpers business-agnostic
- Category: maintainability
- Severity: critical
- Description: Modules under `src/backend/base/langflow/helpers/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions. Helpers may contain thin wrappers for user lookups or data transformation but must not implement business policy.
- Suggested fix:
- If business logic appears in `src/backend/base/langflow/helpers/`, extract it into the appropriate service under `src/backend/base/langflow/services/` and keep helpers focused on generic, cross-cutting utilities.
- Keep helper dependencies clean: avoid importing service or route modules into helpers.
- Example:
- Bad:
```python
# src/backend/base/langflow/helpers/flow.py
from langflow.services.variable.service import DatabaseVariableService
def should_archive_flow(flow: Flow, user_id: UUID) -> bool:
# Domain policy and service dependency are leaking into helpers.
service = DatabaseVariableService(get_settings_service())
if service.has_premium_plan(user_id):
return flow.idle_days > 90
return flow.idle_days > 30
```
- Good:
```python
# src/backend/base/langflow/helpers/flow.py (business-agnostic helper)
def is_older_than_days(updated_at: datetime, threshold_days: int) -> bool:
delta = datetime.now(timezone.utc) - updated_at
return delta.days > threshold_days
# src/backend/base/langflow/services/flow_service.py (business logic stays in service)
from langflow.helpers.flow import is_older_than_days
async def should_archive_flow(flow: Flow, user_id: UUID) -> bool:
threshold_days = 90 if await has_premium_plan(user_id) else 30
return is_older_than_days(flow.updated_at, threshold_days)
```
### Domain logic must not import from FastAPI/HTTP layers
- Category: best practices
- Severity: critical
- Description: Service classes and model definitions must never import FastAPI-specific constructs such as `Request`, `Response`, `HTTPException`, `APIRouter`, or `Depends`. This keeps the domain layer transport-agnostic and testable without spinning up an HTTP server. Langflow services inherit from `langflow.services.base.Service` and receive dependencies through their factory's `create()` method or via constructor injection, not through FastAPI's `Depends()`.
- Suggested fix: Raise domain-specific exceptions in services (e.g., `FlowNotFoundError`, `PermissionDeniedError`) and let the route handler translate them into HTTP responses.
- Example:
- Bad:
```python
# src/backend/base/langflow/services/variable/service.py
from fastapi import HTTPException
class DatabaseVariableService(VariableService, Service):
async def get_variable(self, variable_id: UUID, user_id: UUID, session: AsyncSession):
variable = await session.get(Variable, variable_id)
if not variable or variable.user_id != user_id:
raise HTTPException(status_code=404, detail="Variable not found")
return variable
```
- Good:
```python
# src/backend/base/langflow/services/variable/service.py
class VariableNotFoundError(Exception):
pass
class DatabaseVariableService(VariableService, Service):
async def get_variable(self, variable_id: UUID, user_id: UUID, session: AsyncSession):
variable = await session.get(Variable, variable_id)
if not variable or variable.user_id != user_id:
raise VariableNotFoundError(f"Variable {variable_id} not found for user {user_id}")
return variable
# src/backend/base/langflow/api/v1/variables.py (route translates to HTTP)
@router.get("/variables/{variable_id}")
async def get_variable(variable_id: UUID, ...):
try:
return await variable_service.get_variable(variable_id, current_user.id, session)
except VariableNotFoundError:
raise HTTPException(status_code=404, detail="Variable not found")
```
### Components are leaf nodes in the dependency graph
- Category: best practices
- Severity: suggestion
- Description: Components under `src/backend/base/langflow/components/` represent flow nodes and are instantiated dynamically by the graph engine. They should depend on services and models but no other component should import from them. Component class names are stable identifiers used to match components in saved flows; renaming a component class is a breaking change.
- Suggested fix: If shared logic exists between components, extract it into a helper or a base class under `src/backend/base/langflow/custom/` rather than creating cross-component imports.
### Use Google-style docstrings
- Category: best practices
- Severity: suggestion
- Description: The project enforces Google-style docstrings via Ruff (`pydocstyle.convention = "google"`). Public functions and classes should have docstrings with `Args:`, `Returns:`, and `Raises:` sections. Route handlers need at minimum a one-line summary. Private helpers (`_func`) can use simpler docstrings.
- Example:
- Bad:
```python
def timestamp_to_str(timestamp):
# converts timestamp to string
...
```
- Good:
```python
def timestamp_to_str(timestamp: datetime | str) -> str:
"""Convert timestamp to standardized string format.
Args:
timestamp: Input timestamp as datetime object or string.
Returns:
Formatted timestamp string in 'YYYY-MM-DD HH:MM:SS UTC' format.
Raises:
ValueError: If string timestamp is in invalid format.
"""
...
```
### Use the async logger from `lfx.log.logger`
- Category: best practices
- Severity: critical
- Description: Langflow uses an async-aware logger from `lfx.log.logger`. In async code, always use the `a`-prefixed methods (`adebug`, `ainfo`, `awarning`, `aerror`, `aexception`) to avoid blocking the event loop. Never use `print()` or stdlib `logging` directly. Use `aexception` for errors (auto-includes traceback). Use `{e!s}` for string representation of exceptions.
- Example:
- Bad:
```python
import logging
logger = logging.getLogger(__name__)
logger.info(f"Processing flow {flow_id}")
print(f"Error: {e}")
```
- Good:
```python
from lfx.log.logger import logger
await logger.ainfo(f"Processing flow {flow_id}")
await logger.aexception(f"Error processing flow {flow_id}: {e!s}")
await logger.adebug("Skipping environment variable storage.")
await logger.awarning(f"Session rolled back during {var_name} query.")
```
### Use Python 3.10+ type syntax and FastAPI DI aliases
- Category: best practices
- Severity: suggestion
- Description: Use modern Python 3.10+ union syntax (`X | Y` instead of `Union[X, Y]`, `X | None` instead of `Optional[X]`). Use `TYPE_CHECKING` guard for imports only needed for type annotations (prevents circular imports). Use `Annotated[Type, Depends(...)]` for FastAPI dependency injection with project type aliases like `CurrentActiveUser`, `DbSession`, `DbSessionReadOnly`.
- Example:
- Bad:
```python
from typing import Optional, Union
from fastapi import Depends
async def get_flow(
flow_id: UUID,
session: AsyncSession = Depends(injectable_session_scope),
user: User = Depends(get_current_active_user),
) -> Optional[Flow]:
...
```
- Good:
```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from langflow.services.database.models.user.model import User
async def get_flow(
*,
flow_id: UUID,
session: DbSession, # Annotated[AsyncSession, Depends(injectable_session_scope)]
current_user: CurrentActiveUser, # Annotated[User, Depends(get_current_active_user)]
) -> Flow | None:
...
```
@@ -0,0 +1,261 @@
# Rule Catalog — DB Schema Design
## Scope
- Covers: SQLModel model definitions, schema boundaries in model properties, user-scoped schema design, index redundancy checks, dialect portability in models (SQLite + PostgreSQL), and cross-database compatibility in Alembic migrations.
- Key directories:
- Models: `src/backend/base/langflow/services/database/models/`
- Migrations: `src/backend/base/langflow/alembic/versions/`
- Does NOT cover: session lifecycle, transaction boundaries, and query execution patterns (handled by `sqlalchemy-rule.md`).
## Rules
### Do not query other tables inside `@property`
- Category: [maintainability, performance]
- Severity: critical
- Description: A model `@property` must not open sessions or query other tables. This hides dependencies across models, tightly couples schema objects to data access, and can cause N+1 query explosions when iterating collections. Langflow uses SQLModel which combines SQLAlchemy table models with Pydantic validation; properties should only derive values from already-loaded fields.
- Suggested fix:
- Keep model properties pure and local to already-loaded fields.
- Move cross-table data fetching to service methods or CRUD functions under the model's module (e.g., `models/flow/utils.py`).
- For list/batch reads, fetch required related data explicitly (join/preload/bulk query) before rendering derived values.
- Example:
- Bad:
```python
class Flow(FlowBase, table=True):
__tablename__ = "flow"
@property
def folder_name(self) -> str:
from lfx.services.deps import session_scope
import asyncio
loop = asyncio.get_event_loop()
async def _get():
async with session_scope() as session:
folder = (await session.execute(
select(Folder).where(Folder.id == self.folder_id)
)).scalar_one()
return folder.name
return loop.run_until_complete(_get())
```
- Good:
```python
class Flow(FlowBase, table=True):
__tablename__ = "flow"
@property
def display_name(self) -> str:
return self.name or "Untitled Flow"
# Service or CRUD layer performs explicit fetch for related Folder rows.
async def get_flow_with_folder(flow_id: UUID, session: AsyncSession) -> tuple[Flow, Folder]:
stmt = select(Flow, Folder).join(Folder, Flow.folder_id == Folder.id).where(Flow.id == flow_id)
result = (await session.execute(stmt)).one_or_none()
return result
```
### Use `user_id` for user-scoped data
- Category: maintainability
- Severity: suggestion
- Description: Langflow scopes user-owned data by `user_id` (not `tenant_id`). When an entity belongs to a specific user, include `user_id` in the model definition. This improves data isolation safety and keeps future multi-user or partitioning strategies practical. The `user_id` column should reference `user.id` via a foreign key where appropriate.
- Suggested fix:
- Add a `user_id` column and ensure related unique/index constraints include the user dimension when applicable.
- Propagate `user_id` through service interfaces to keep access paths user-scoped.
- Exception: if a table is explicitly designed as globally shared metadata (e.g., system settings), document that design decision clearly.
- Example:
- Bad:
```python
class CustomComponent(SQLModel, table=True):
__tablename__ = "custom_component"
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(index=True)
code: str = Field(sa_column=Column(Text))
# Missing user_id: any user can see/modify any custom component
```
- Good:
```python
class CustomComponent(SQLModel, table=True):
__tablename__ = "custom_component"
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id", index=True)
name: str = Field(index=True)
code: str = Field(sa_column=Column(Text))
```
### Detect and avoid duplicate/redundant indexes
- Category: performance
- Severity: suggestion
- Description: Review index definitions for leftmost-prefix redundancy. For example, index `(a, b, c)` can safely cover most lookups for `(a, b)`. Keeping both may increase write overhead and can mislead the optimizer into suboptimal execution plans. This applies to both SQLModel `Field(index=True)` declarations and explicit `__table_args__` index definitions.
- Suggested fix:
- Before adding an index, compare against existing composite indexes by leftmost-prefix rules.
- Drop or avoid creating redundant prefixes unless there is a proven query-pattern need.
- Apply the same review standard in both model `__table_args__` and Alembic migration index DDL.
- Example:
- Bad:
```python
class Message(SQLModel, table=True):
__tablename__ = "message"
__table_args__ = (
sa.Index("idx_msg_user_flow", "user_id", "flow_id"),
sa.Index("idx_msg_user_flow_created", "user_id", "flow_id", "created_at"),
)
```
- Good:
```python
class Message(SQLModel, table=True):
__tablename__ = "message"
__table_args__ = (
# Keep the wider index unless profiling proves a dedicated short index is needed.
sa.Index("idx_msg_user_flow_created", "user_id", "flow_id", "created_at"),
)
```
### Avoid dialect-specific constructs directly in models; use portable types
- Category: maintainability
- Severity: critical
- Description: Langflow supports both SQLite (development) and PostgreSQL 15+ (production). Model/schema definitions should avoid PostgreSQL-only or SQLite-only constructs directly in business models. When database-specific behavior is required, encapsulate it behind a portable abstraction. SQLModel's `Field()` and `Column()` already provide most portability, but direct use of dialect-specific types (e.g., `postgresql.JSONB`, `postgresql.ARRAY`) breaks SQLite compatibility.
- Suggested fix:
- Use SQLModel's `JSON` column type (which maps to the appropriate dialect type) instead of `postgresql.JSONB`.
- Use `sa.Text` or `sa.String` instead of dialect-specific text types.
- If a dialect-specific feature is truly needed, add a conditional wrapper and document the rationale.
- Example:
- Bad:
```python
from sqlalchemy.dialects.postgresql import JSONB
class ToolConfig(SQLModel, table=True):
__tablename__ = "tool_config"
id: UUID = Field(default_factory=uuid4, primary_key=True)
config: dict = Field(sa_column=Column(JSONB, nullable=False)) # Breaks on SQLite
```
- Good:
```python
from sqlmodel import JSON
class ToolConfig(SQLModel, table=True):
__tablename__ = "tool_config"
id: UUID = Field(default_factory=uuid4, primary_key=True)
config: dict | None = Field(default=None, sa_column=Column(JSON)) # Works on both dialects
```
### Guard migration incompatibilities with dialect checks
- Category: maintainability
- Severity: critical
- Description: Alembic migration scripts under `src/backend/base/langflow/alembic/versions/` must account for SQLite/PostgreSQL incompatibilities explicitly. SQLite has limited ALTER TABLE support (no DROP COLUMN before 3.35, no ADD CONSTRAINT). For dialect-sensitive DDL or defaults, branch on the active dialect or use `op.batch_alter_table()` for SQLite compatibility.
- Suggested fix:
- In migration upgrades/downgrades, bind connection and branch by dialect for incompatible SQL fragments.
- Use `op.batch_alter_table()` for column modifications on SQLite.
- Avoid one-dialect-only migration logic unless there is a documented, deliberate compatibility exception.
- Example:
- Bad:
```python
def upgrade():
op.add_column("flow", sa.Column(
"access_type",
sa.String(50),
server_default=sa.text("'PRIVATE'::character varying"), # PostgreSQL-only cast
nullable=False,
))
```
- Good:
```python
def upgrade():
conn = op.get_bind()
if conn.dialect.name == "postgresql":
default_expr = sa.text("'PRIVATE'::character varying")
else:
default_expr = sa.text("'PRIVATE'")
with op.batch_alter_table("flow") as batch_op:
batch_op.add_column(sa.Column(
"access_type",
sa.String(50),
server_default=default_expr,
nullable=False,
))
```
### SQLModel-specific patterns
- Category: best practices
- Severity: suggestion
- Description: Langflow uses SQLModel which merges SQLAlchemy ORM and Pydantic validation into a single class hierarchy. Follow these conventions for model definitions:
- Use `SQLModel` as the base class with `table=True` for database-backed models.
- Use `SQLModel` without `table=True` for schema/validation-only models (e.g., `FlowCreate`, `FlowRead`, `FlowUpdate`).
- Use `Field()` for column definitions; use `sa_column=Column(...)` only when `Field()` alone cannot express the constraint.
- Use `Relationship()` for ORM relationships; put related model type hints behind `TYPE_CHECKING` to avoid circular imports.
- Suggested fix: Follow the existing patterns in `src/backend/base/langflow/services/database/models/flow/model.py` for reference on how to define base models, table models, and CRUD schema models.
- Example:
- Bad:
```python
# Mixing table model and API schema in one class
class Flow(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str
data: dict | None = None
# API-only fields mixed in
component_count: int = 0 # Not a DB column, computed at API time
```
- Good:
```python
# Table model: only DB columns
class Flow(FlowBase, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id", index=True)
# Schema model: includes computed fields for API responses
class FlowRead(FlowBase):
id: UUID
user_id: UUID
component_count: int = 0
```
### Follow the Base → Table → Create → Read → Update schema pattern
- Category: best practices
- Severity: suggestion
- Description: Langflow uses a layered SQLModel schema pattern. `{Entity}Base(SQLModel)` defines shared fields, validators (`@field_validator`), and serializers (`@field_serializer`). `{Entity}(Base, table=True)` adds primary key and relationships. Separate `Create`, `Read`, and `Update` schemas control API boundaries. Use `model_dump(exclude_unset=True, exclude_none=True)` for partial updates. Use `FlowRead.model_validate(db_flow, from_attributes=True)` to convert ORM→schema while session is active.
- Suggested fix:
- Define Base → Table → Create → Read → Update for every entity.
- Put `@field_validator` and `@field_serializer` on the Base so they apply everywhere.
- Never return the Table model directly from a route — always convert to the Read schema.
- Example:
- Bad:
```python
# Returning the table model directly (leaks internal fields, relationships)
@router.get("/{flow_id}")
async def get_flow(flow_id: UUID, session: DbSession) -> Flow:
return (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
```
- Good:
```python
class FlowBase(SQLModel):
name: str = Field(index=True)
description: str | None = Field(default=None)
@field_validator("endpoint_name")
@classmethod
def validate_endpoint_name(cls, v):
if v is not None and not re.match(r"^[a-zA-Z0-9_-]+$", v):
raise HTTPException(status_code=422, detail="Invalid endpoint name")
return v
@field_serializer("updated_at")
def serialize_datetime(self, value):
if isinstance(value, datetime):
return value.replace(microsecond=0).isoformat()
return value
class Flow(FlowBase, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
class FlowCreate(FlowBase):
user_id: UUID | None = None
folder_id: UUID | None = None
class FlowRead(FlowBase):
id: UUID
user_id: UUID | None = Field()
@router.get("/{flow_id}", response_model=FlowRead)
async def get_flow(*, flow_id: UUID, session: DbSession, current_user: CurrentActiveUser) -> FlowRead:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id, Flow.user_id == current_user.id))).scalar_one()
return FlowRead.model_validate(flow, from_attributes=True)
```
@@ -0,0 +1,217 @@
# Rule Catalog — Service Abstraction
## Scope
- Covers: when to reuse existing service abstractions, when to introduce new services, how to preserve dependency direction between route handlers and service/infrastructure implementations, and the ServiceFactory pattern.
- Key directories:
- Services: `src/backend/base/langflow/services/` (each service has `service.py` and `factory.py`)
- Model CRUD functions: `src/backend/base/langflow/services/database/models/*/crud.py`
- Service base class: `langflow.services.base.Service`
- Service factory base: `langflow.services.factory.ServiceFactory`
- Service registry: `langflow.services.manager.ServiceManager`
- Service types: `langflow.services.schema.ServiceType`
- Dependency helpers: `langflow.services.deps` (provides `get_service()`, `get_xxx_service()` functions)
- Does NOT cover: SQLAlchemy session lifecycle and query-shape specifics (handled by `sqlalchemy-rule.md`), and table schema/migration design (handled by `db-schema-rule.md`).
## Rules
### Use existing services for DB operations; do not bypass with ad-hoc queries
- Category: maintainability
- Severity: suggestion
- Description: Langflow uses a service layer pattern rather than a repository pattern. Each service under `src/backend/base/langflow/services/` encapsulates business logic and data access for its domain. If a service already handles operations for a given model/table, all reads/writes/queries for that table should go through the existing service (or its associated CRUD module). Additionally, many models have CRUD utility functions in `src/backend/base/langflow/services/database/models/<model>/crud.py` that should be reused rather than duplicated.
- Suggested fix:
- First check `src/backend/base/langflow/services/` and `src/backend/base/langflow/services/database/models/<model>/crud.py` to verify whether the table/model already has a service or CRUD abstraction. If it exists, route all operations through it and add missing methods instead of bypassing it with ad-hoc SQLAlchemy queries.
- If no service or CRUD module exists, add methods to the most closely related existing service or CRUD module rather than scattering inline queries across route handlers.
- Example:
- Bad:
```python
# Route handler bypasses the existing variable service with inline queries
@router.get("/variables")
async def list_variables(
session: AsyncSession = Depends(injectable_session_scope_readonly),
current_user: User = Depends(get_current_active_user),
):
stmt = select(Variable).where(Variable.user_id == current_user.id)
variables = (await session.execute(stmt)).scalars().all()
return [VariableRead.model_validate(v, from_attributes=True) for v in variables]
```
- Good:
```python
# Route handler delegates to the variable service
@router.get("/variables")
async def list_variables(
session: AsyncSession = Depends(injectable_session_scope_readonly),
current_user: User = Depends(get_current_active_user),
):
variable_service = get_variable_service()
variables = await variable_service.list_variables(user_id=current_user.id, session=session)
return [VariableRead.model_validate(v, from_attributes=True) for v in variables]
```
### Follow the ServiceFactory pattern for new services
- Category: best practices
- Severity: critical
- Description: Langflow manages services through `ServiceManager` which uses `ServiceFactory` instances to create and configure services. Each service consists of:
1. A base class or protocol (optional, for abstraction) inheriting from `langflow.services.base.Service`
2. A concrete implementation in `service.py`
3. A factory class in `factory.py` inheriting from `langflow.services.factory.ServiceFactory`
4. A `ServiceType` enum entry in `langflow.services.schema`
5. A `get_xxx_service()` function in `langflow.services.deps`
New services must follow this pattern to integrate with the dependency injection system. The factory's `create()` method receives other services as arguments (resolved by name from the ServiceManager).
- Suggested fix: When introducing a new service, create all required files following the existing pattern. Register the factory in `ServiceManager.get_factories()` and add a convenience getter in `langflow.services.deps`.
- Example:
- Bad:
```python
# Ad-hoc singleton without factory integration
class NotificationService:
_instance = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def notify(self, user_id: UUID, message: str):
...
```
- Good:
```python
# src/backend/base/langflow/services/notification/service.py
from langflow.services.base import Service
class NotificationService(Service):
name = "notification_service"
def __init__(self, settings_service: SettingsService):
self.settings_service = settings_service
async def notify(self, user_id: UUID, message: str, session: AsyncSession) -> None:
...
# src/backend/base/langflow/services/notification/factory.py
from langflow.services.factory import ServiceFactory
from langflow.services.notification.service import NotificationService
class NotificationServiceFactory(ServiceFactory):
def __init__(self) -> None:
super().__init__(NotificationService)
def create(self, settings_service: SettingsService):
return NotificationService(settings_service)
# Register in ServiceManager.get_factories() and add getter in deps.py
```
### When to introduce a new service vs. extend an existing one
- Category: best practices
- Severity: suggestion
- Description: Not every new feature needs a new service. Introduce a new service only when:
1. The domain is distinct enough that mixing it into an existing service would violate single responsibility.
2. The service manages its own lifecycle (e.g., connections, background tasks, caches).
3. Multiple other services or route modules need to depend on this functionality.
4. The data access patterns are sufficiently different from existing services.
Otherwise, extend an existing service by adding methods to its `service.py` and corresponding CRUD functions.
- Suggested fix:
- For small, related features: add methods to the closest existing service.
- For distinct domains with their own lifecycle: create a new service following the ServiceFactory pattern.
- When in doubt, start by extending an existing service; extract into a new service later when complexity warrants it (YAGNI principle).
- Example:
- Bad:
```python
# Unnecessary new service for a simple feature that belongs in an existing service
# src/backend/base/langflow/services/flow_stats/service.py
class FlowStatsService(Service):
name = "flow_stats_service"
async def get_flow_component_count(self, flow_id: UUID, session: AsyncSession) -> int:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
return len(flow.data.get("nodes", []))
```
- Good:
```python
# Add to existing flow-related code or a utility function
# src/backend/base/langflow/services/database/models/flow/utils.py
def count_components(flow_data: dict | None) -> int:
if not flow_data:
return 0
return len(flow_data.get("nodes", []))
```
### Access services through `get_service()` or dependency getters, not direct instantiation
- Category: maintainability
- Severity: critical
- Description: Services are singletons managed by `ServiceManager`. They must be accessed through `get_service(ServiceType.XXX)` or the convenience functions in `langflow.services.deps` (e.g., `get_settings_service()`, `get_variable_service()`). Direct instantiation bypasses the factory pattern, ignores lifecycle management, and can create multiple conflicting instances.
- Suggested fix: Always use the provided dependency functions. In route handlers, use FastAPI's `Depends()` with the appropriate getter. In service-to-service calls, use `get_service()` or accept the dependency through the factory's `create()` method.
- Example:
- Bad:
```python
# Direct instantiation bypasses the service manager
from langflow.services.variable.service import DatabaseVariableService
async def some_function():
settings = get_settings_service()
variable_service = DatabaseVariableService(settings) # Wrong: creates a new instance
await variable_service.get_variable(...)
```
- Good:
```python
# Use the dependency getter
from langflow.services.deps import get_variable_service
async def some_function():
variable_service = get_variable_service()
await variable_service.get_variable(...)
# In route handlers, use Depends()
@router.get("/variables/{variable_id}")
async def get_variable(
variable_id: UUID,
session: AsyncSession = Depends(injectable_session_scope_readonly),
current_user: User = Depends(get_current_active_user),
):
variable_service = get_variable_service()
return await variable_service.get_variable(variable_id, current_user.id, session)
```
### CRUD modules complement services, not replace them
- Category: best practices
- Severity: suggestion
- Description: Many Langflow models have a `crud.py` file alongside their model definition (e.g., `models/message/crud.py`, `models/user/crud.py`). These CRUD modules contain reusable async functions for common database operations (get, list, create, update, delete). They are lower-level building blocks that services call internally. Route handlers should prefer calling service methods rather than CRUD functions directly, unless the operation is trivially simple and the model has no associated service.
- Suggested fix:
- Services should call CRUD functions internally to avoid duplicating query logic.
- Route handlers should call services, not CRUD functions, to maintain the layering.
- New CRUD functions should accept an `AsyncSession` parameter and stay stateless (pure data access, no business logic).
- Example:
- Bad:
```python
# Route handler calling CRUD directly, bypassing any business logic layer
from langflow.services.database.models.message.crud import get_messages_by_flow_id
@router.get("/flows/{flow_id}/messages")
async def list_messages(
flow_id: UUID,
session: AsyncSession = Depends(injectable_session_scope_readonly),
current_user: User = Depends(get_current_active_user),
):
# No authorization check, no business logic, direct CRUD call
return await get_messages_by_flow_id(flow_id, session)
```
- Good:
```python
# Service wraps CRUD with authorization and business logic
class ChatService(Service):
async def get_messages(self, flow_id: UUID, user_id: UUID, session: AsyncSession):
# Verify user owns the flow
flow = await get_flow_by_id_and_user(flow_id, user_id, session)
if not flow:
raise FlowNotFoundError(f"Flow {flow_id} not found")
return await get_messages_by_flow_id(flow_id, session)
# Route handler delegates to service
@router.get("/flows/{flow_id}/messages")
async def list_messages(flow_id: UUID, ...):
chat_service = get_chat_service()
return await chat_service.get_messages(flow_id, current_user.id, session)
```
@@ -0,0 +1,222 @@
# Rule Catalog — SQLAlchemy Patterns
## Scope
- Covers: SQLAlchemy/SQLModel async session and transaction lifecycle, query construction, user scoping, raw SQL boundaries, write-path concurrency safeguards, and async-specific pitfalls.
- Key modules:
- Session management (canonical source): `lfx.services.deps.session_scope`, `lfx.services.deps.session_scope_readonly`
- Langflow wrappers (delegates to lfx): `langflow.services.deps.session_scope`, `langflow.services.deps.session_scope_readonly`
- Injectable dependencies: `lfx.services.deps.injectable_session_scope`, `lfx.services.deps.injectable_session_scope_readonly`
- Database service: `langflow.services.database.service.DatabaseService`
- Model CRUD functions: `src/backend/base/langflow/services/database/models/*/crud.py`
- Import preference: Use `from langflow.services.deps import session_scope` in Langflow code for consistency. The Langflow wrappers are thin `@asynccontextmanager` functions that delegate to `lfx.services.deps`. Only import directly from `lfx` when working inside the `lfx` package itself.
- Does NOT cover: table/model schema and migration design details (handled by `db-schema-rule.md`).
## Rules
### Use `session_scope()` context manager with explicit transaction awareness
- Category: best practices
- Severity: critical
- Description: Langflow uses async sessions exclusively. The `session_scope()` context manager provides auto-commit on successful exit and auto-rollback on exception. For read-only paths, use `session_scope_readonly()` which skips commit overhead. In route handlers, use the injectable variants via `Depends(injectable_session_scope)`. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention and deadlock risk.
- Suggested fix:
- For write operations in services/CRUD: use `async with session_scope() as session:` which auto-commits on success.
- For read-only operations: use `async with session_scope_readonly() as session:` to avoid unnecessary commit calls.
- In route handlers: use `session: AsyncSession = Depends(injectable_session_scope)` for writes or `Depends(injectable_session_scope_readonly)` for reads.
- Keep transaction windows short: avoid network I/O, heavy computation, or unrelated work inside the session scope.
- Example:
- Bad:
```python
# Creating a raw session without the context manager
from sqlmodel.ext.asyncio.session import AsyncSession
from langflow.services.deps import get_service
db_service = get_service(ServiceType.DATABASE_SERVICE)
session = AsyncSession(db_service.engine)
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
flow.name = "Updated"
# Missing commit, session never closed properly
```
- Good:
```python
from langflow.services.deps import session_scope
async with session_scope() as session:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
flow.name = "Updated"
# session_scope auto-commits on successful exit
# For read-only operations:
from langflow.services.deps import session_scope_readonly
async with session_scope_readonly() as session:
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
```
### Enforce `user_id` scoping on user-owned queries
- Category: security
- Severity: critical
- Description: Reads and writes against user-owned tables must be scoped by `user_id` to prevent cross-user data leakage or corruption. Langflow uses `user_id` (not `tenant_id`) for user-scoped data isolation. Every query on user-owned entities (flows, variables, folders, messages, API keys) must include a `user_id` filter.
- Suggested fix: Add `user_id` predicate to all user-owned entity queries and propagate user context through service interfaces. The `current_user.id` is available from the `get_current_active_user` dependency in route handlers.
- Example:
- Bad:
```python
# Missing user_id scope: any authenticated user can read any flow
stmt = select(Flow).where(Flow.id == flow_id)
flow = (await session.execute(stmt)).scalar_one_or_none()
```
- Good:
```python
stmt = select(Flow).where(
Flow.id == flow_id,
Flow.user_id == current_user.id,
)
flow = (await session.execute(stmt)).scalar_one_or_none()
```
### Prefer SQLAlchemy/SQLModel expressions over raw SQL
- Category: maintainability
- Severity: suggestion
- Description: Raw SQL via `text()` should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, dialect-portable (SQLite + PostgreSQL), and more consistent with the codebase. Langflow uses `sqlmodel.select()` for queries which provides both type safety and dialect portability.
- Suggested fix: Rewrite straightforward raw SQL into SQLModel `select/update/delete` expressions; keep raw SQL only when required by clear technical constraints (e.g., database-specific administrative queries).
- Example:
- Bad:
```python
from sqlmodel import text
result = await session.execute(
text("SELECT * FROM flow WHERE id = :id AND user_id = :user_id"),
{"id": str(flow_id), "user_id": str(user_id)},
)
row = result.first()
```
- Good:
```python
from sqlmodel import select
stmt = select(Flow).where(
Flow.id == flow_id,
Flow.user_id == user_id,
)
flow = (await session.execute(stmt)).scalar_one_or_none()
```
### Do not run blocking operations inside async session scopes
- Category: performance
- Severity: critical
- Description: Langflow runs on an async event loop (uvicorn + FastAPI). Blocking calls inside a session scope (synchronous I/O, `time.sleep()`, CPU-bound computation, synchronous HTTP requests) block the entire event loop and starve other coroutines. This also extends transaction duration unnecessarily, increasing lock contention.
- Suggested fix:
- Move blocking operations outside the session scope.
- Use `asyncio.to_thread()` or `anyio.to_thread.run_sync()` for CPU-bound or synchronous I/O work.
- Perform external API calls before or after the database transaction, not inside it.
- Example:
- Bad:
```python
async with session_scope() as session:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
# Blocking HTTP call inside transaction scope
import requests
response = requests.post("https://external-api.com/validate", json=flow.data)
flow.validated = response.ok
```
- Good:
```python
async with session_scope_readonly() as session:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
flow_data = flow.data
# External call outside transaction scope
import httpx
async with httpx.AsyncClient() as client:
response = await client.post("https://external-api.com/validate", json=flow_data)
async with session_scope() as session:
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
flow.validated = response.is_success
```
### Protect write paths with concurrency safeguards
- Category: quality
- Severity: critical
- Description: Multi-writer paths without explicit concurrency control can silently overwrite data. Choose the safeguard based on contention level, lock scope, and throughput cost instead of defaulting to one strategy. Langflow's async architecture means multiple coroutines may attempt concurrent writes.
- Suggested fix:
- **Optimistic locking**: Use when contention is usually low and retries are acceptable. Add a version or `updated_at` guard in `WHERE` and treat `rowcount == 0` as a conflict.
- **SELECT ... FOR UPDATE**: Use when contention is high on the same rows and strict in-transaction serialization is required. Keep transactions short to reduce lock wait/deadlock risk.
- In all cases, scope by `user_id` and verify affected row counts for conditional writes.
- Example:
- Bad:
```python
# No user scope, no conflict detection on a contested write path
async with session_scope() as session:
await session.execute(
update(Flow).where(Flow.id == flow_id).values(name="Updated")
)
```
- Good:
```python
# Optimistic lock (low contention, retry on conflict)
from sqlmodel import update
async with session_scope() as session:
result = await session.execute(
update(Flow)
.where(
Flow.id == flow_id,
Flow.user_id == user_id,
Flow.updated_at == expected_updated_at,
)
.values(name="Updated", updated_at=datetime.now(timezone.utc))
)
if result.rowcount == 0:
raise FlowStateConflictError("Flow was modified concurrently, retry")
# Pessimistic lock with SELECT ... FOR UPDATE (high contention)
async with session_scope() as session:
flow = (await session.execute(
select(Flow)
.where(Flow.id == flow_id, Flow.user_id == user_id)
.with_for_update()
)).scalar_one()
flow.name = "Updated"
flow.updated_at = datetime.now(timezone.utc)
```
### Keep transactions short; no I/O inside transactions
- Category: performance
- Severity: suggestion
- Description: Long transactions hold database locks and increase contention, especially on SQLite where write locking is database-wide. Structure code so that data is fetched, external operations are performed, and then the write transaction is opened as late as possible and closed as early as possible.
- Suggested fix:
- Read data in one session scope, perform computation or external calls, then write results in a separate session scope.
- Avoid nesting `session_scope()` calls (the context manager is not reentrant).
- Batch related writes into a single session scope rather than opening multiple sequential scopes for related changes.
- Example:
- Bad:
```python
async with session_scope() as session:
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
for flow in flows:
# Expensive computation inside transaction
analysis = await analyze_flow_complexity(flow.data)
flow.complexity_score = analysis.score
flow.updated_at = datetime.now(timezone.utc)
```
- Good:
```python
# Read phase
async with session_scope_readonly() as session:
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
flow_data = [(f.id, f.data) for f in flows]
# Compute phase (outside transaction)
updates = {}
for flow_id, data in flow_data:
analysis = await analyze_flow_complexity(data)
updates[flow_id] = analysis.score
# Write phase (short transaction)
async with session_scope() as session:
for flow_id, score in updates.items():
await session.execute(
update(Flow)
.where(Flow.id == flow_id, Flow.user_id == user_id)
.values(complexity_score=score, updated_at=datetime.now(timezone.utc))
)
```
@@ -0,0 +1,435 @@
---
name: component-refactoring
description: Refactor high-complexity React components in Langflow frontend. Use when manual complexity assessment shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
---
# Langflow Component Refactoring Skill
Refactor high-complexity React components in the Langflow frontend codebase with the patterns and workflow below.
> **Complexity Threshold**: Components with complexity > 50 (measured manually by counting conditionals, nesting levels, and lines) should be refactored before testing.
## Quick Reference
### Commands (run from `src/frontend/`)
```bash
cd src/frontend
# Lint with Biome
npm run lint
# Type checking
npm run type-check
# Run tests
npm test
```
### Manual Complexity Assessment
Since Langflow does not have automated complexity analysis tools, assess components manually:
1. **Count conditionals**: Each `if/else`, `switch/case`, ternary, `&&`/`||` chain adds +1.
2. **Count nesting levels**: Each level of nesting within conditionals or loops adds +1.
3. **Count total lines**: Target < 300 lines per component file.
4. **Count state hooks**: More than 5 `useState` calls suggests hook extraction.
5. **Count effects**: More than 3 `useEffect` calls suggests effect consolidation.
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | Simple | Ready for testing |
| 26-50 | Medium | Consider minor refactoring |
| 51-75 | Complex | **Refactor before testing** |
| 76-100 | Very Complex | **Must refactor** |
## Core Refactoring Patterns
### Pattern 1: Extract Custom Hooks
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
**Langflow Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`. Langflow uses kebab-case filenames with `use-` prefix.
```typescript
// Before: Complex state logic in component
const FlowPage: FC = () => {
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [buildStatus, setBuildStatus] = useState<BuildStatus>(BuildStatus.IDLE)
// 50+ lines of state management logic...
return <div>...</div>
}
// After: Extract to custom hook
// hooks/use-flow-state.ts
export const useFlowState = (flowId: string) => {
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [buildStatus, setBuildStatus] = useState<BuildStatus>(BuildStatus.IDLE)
// Related state management logic here
return { nodes, setNodes, edges, setEdges, buildStatus, setBuildStatus }
}
// Component becomes cleaner
const FlowPage: FC = () => {
const { nodes, setNodes, edges, setEdges } = useFlowState(flowId)
return <div>...</div>
}
```
**Langflow Examples**:
- `src/frontend/src/hooks/use-add-component.ts`
- `src/frontend/src/hooks/use-unsaved-changes.ts`
- `src/frontend/src/hooks/use-refresh-model-inputs.ts`
### Pattern 2: Extract Sub-Components
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
**Langflow Convention**: Place sub-components in subdirectories or as separate files in the same directory. UI primitives go in `components/ui/`, domain components in `components/core/`, reusable components in `components/common/`.
```typescript
// Before: Monolithic JSX with multiple sections
const GenericNode = () => {
return (
<div>
{/* 100 lines of header UI */}
{/* 100 lines of parameter fields */}
{/* 100 lines of output handles */}
</div>
)
}
// After: Split into focused components
// CustomNodes/GenericNode/
// generic-node.tsx (orchestration only — kebab-case, descriptive name)
// components/
// node-header.tsx
// node-parameters.tsx
// node-outputs.tsx
const GenericNode = () => {
return (
<div>
<NodeHeader nodeData={data} />
<NodeParameters fields={fields} />
<NodeOutputs outputs={outputs} />
</div>
)
}
```
**Langflow Examples**:
- `src/frontend/src/CustomNodes/GenericNode/components/`
- `src/frontend/src/components/core/`
- `src/frontend/src/components/ui/`
### Pattern 3: Simplify Conditional Logic
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
```typescript
// Before: Deeply nested conditionals
const getFieldComponent = (field: InputFieldType) => {
if (field.type === "str") {
if (field.multiline) {
return <TextAreaComponent />
} else if (field.password) {
return <PasswordInput />
} else if (field.options?.length) {
return <Dropdown options={field.options} />
} else {
return <InputComponent />
}
} else if (field.type === "int") {
return <IntComponent />
} else if (field.type === "float") {
return <FloatComponent />
}
return null
}
// After: Use lookup tables + early returns
const FIELD_COMPONENT_MAP: Record<string, FC<FieldProps>> = {
int: IntComponent,
float: FloatComponent,
bool: ToggleComponent,
code: CodeAreaComponent,
}
const STR_VARIANT_MAP: Record<string, FC<FieldProps>> = {
multiline: TextAreaComponent,
password: PasswordInput,
options: Dropdown,
}
const getFieldComponent = (field: InputFieldType) => {
if (field.type !== "str") {
const Component = FIELD_COMPONENT_MAP[field.type]
return Component ? <Component {...field} /> : null
}
const variant = field.multiline ? "multiline"
: field.password ? "password"
: field.options?.length ? "options"
: "default"
const Component = STR_VARIANT_MAP[variant] ?? InputComponent
return <Component {...field} />
}
```
### Pattern 4: Extract API/Data Logic
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Langflow Convention**:
- This skill is for component decomposition, not query/mutation design.
- When refactoring data fetching, use `frontend-query-mutation` for query patterns, `UseRequestProcessor`, cache invalidation, and mutation error handling.
- Do not create thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
- API hooks live in `controllers/API/queries/{domain}/`.
**Langflow Examples**:
- `src/frontend/src/controllers/API/queries/flows/use-post-add-flow.ts`
- `src/frontend/src/controllers/API/queries/variables/use-get-global-variables.ts`
- `src/frontend/src/controllers/API/queries/folders/use-get-folders.ts`
### Pattern 5: Extract Modal/Dialog Management
**When**: Component manages multiple modals with complex open/close states.
**Langflow Convention**: Modals should be extracted with their state management.
```typescript
// Before: Multiple modal states in component
const FlowToolbar = () => {
const [showExportModal, setShowExportModal] = useState(false)
const [showShareModal, setShowShareModal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showApiModal, setShowApiModal] = useState(false)
// 5+ more modal states...
}
// After: Extract to modal management hook
type ModalType = "export" | "share" | "delete" | "api" | null
const useFlowToolbarModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
closeModal,
isOpen: (type: ModalType) => activeModal === type,
}
}
```
### Pattern 6: Extract Form Logic
**When**: Complex form validation, submission handling, or field transformation.
**Langflow Convention**: Extract form state and validation into hooks.
```typescript
// Extract form validation and submission
const useFlowSettingsForm = (initialValues: FlowSettings) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name?.trim()) newErrors.name = "Name is required"
if (values.endpoint_name && !/^[a-z0-9-]+$/.test(values.endpoint_name)) {
newErrors.endpoint_name = "Must be lowercase alphanumeric with hyphens"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues((prev) => ({ ...prev, [field]: value }))
}, [])
return { values, errors, validate, handleChange }
}
```
## Langflow-Specific Refactoring Guidelines
### 1. Zustand Store Selectors
**When**: Component reads many values from a Zustand store, causing unnecessary re-renders.
```typescript
// Before: Selecting too many values
const Component = () => {
const flowStore = useFlowStore()
// Component re-renders on ANY store change
}
// After: Use individual selectors
const Component = () => {
const nodes = useFlowStore((state) => state.nodes)
const edges = useFlowStore((state) => state.edges)
// Component only re-renders when nodes or edges change
}
```
**Langflow Reference**: All stores in `src/frontend/src/stores/` follow this selector pattern.
### 2. Custom Node Components
**When**: Refactoring flow node components (`CustomNodes/GenericNode/`).
**Conventions**:
- Keep node logic in custom hooks
- Extract parameter rendering to separate components
- Use the existing `components/` subdirectory for sub-components
```
CustomNodes/GenericNode/
generic-node.tsx # Node registration and main render (kebab-case, NOT index.tsx)
components/
handle-render.tsx # Handle rendering
node-description.tsx # Node description display
node-input-field.tsx # Input field rendering
node-name.tsx # Node name display
node-output-field.tsx # Output field rendering
node-status.tsx # Build status display
```
### 3. Flow Canvas Components
**When**: Refactoring components related to the flow editor canvas.
**Conventions**:
- `@xyflow/react` v12 is the canvas library
- Keep canvas event handlers separate from UI rendering
- Use `useFlowStore` for flow state management
- Use `useFlowsManagerStore` for multi-flow management
### 4. API Query Hook Components
**When**: Refactoring components that consume API data.
**Conventions**:
- Use existing query hooks from `controllers/API/queries/`
- Access `UseRequestProcessor` for new queries/mutations
- Use query key arrays like `["useGetGlobalVariables"]` for cache management
- Cache invalidation belongs in mutation `onSettled` callbacks
## Refactoring Workflow
### Step 1: Assess Complexity
Manually count:
- Total conditionals (if/else, switch, ternary, &&/||)
- Maximum nesting depth
- Total lines of code
- Number of useState/useEffect hooks
- Number of distinct UI sections
### Step 2: Plan
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| 5+ `useState` hooks with related state | Extract custom hook |
| API calls in component body | Extract to query hook |
| 3+ event handlers with logic | Extract event handlers to hook |
| 300+ lines | Split into sub-components |
| Deep conditional nesting (>3) | Simplify conditional logic |
| Multiple modal states | Extract modal management |
### Step 3: Execute Incrementally
1. **Extract one piece at a time**
2. **Run lint, type-check, and tests after each extraction**
3. **Verify functionality before next step**
```
For each extraction:
1. Extract code
2. Run: npm run lint
3. Run: npm run type-check
4. Run: npm test
5. Test functionality manually
6. PASS? -> Next extraction
FAIL? -> Fix before continuing
```
### Step 4: Verify
After refactoring, re-assess complexity manually:
- Target complexity < 50
- Target line count < 300
- Target max nesting depth <= 3
- Target max function length < 30 lines
## Common Mistakes to Avoid
### Over-Engineering
```typescript
// Too many tiny hooks
const useButtonText = () => useState("Click")
const useButtonDisabled = () => useState(false)
const useButtonLoading = () => useState(false)
// Cohesive hook with related state
const useButtonState = () => {
const [text, setText] = useState("Click")
const [disabled, setDisabled] = useState(false)
const [loading, setLoading] = useState(false)
return { text, setText, disabled, setDisabled, loading, setLoading }
}
```
### Breaking Existing Patterns
- Follow existing directory structures in `components/ui/`, `components/core/`, `components/common/`
- Maintain naming conventions (kebab-case files, PascalCase components)
- Preserve export patterns for compatibility
- Keep Zustand store selector patterns consistent
### Premature Abstraction
- Only extract when there is clear complexity benefit
- Do not create abstractions for single-use code
- Keep refactored code in the same domain area
### Bypassing UseRequestProcessor
- Do not call `useQuery` or `useMutation` directly for API calls
- Always use the `UseRequestProcessor` pattern for consistency with retry and invalidation logic
- See `frontend-query-mutation` skill for API hook patterns
## References
### Langflow Codebase Examples
- **Hook extraction**: `src/frontend/src/hooks/`
- **Component splitting**: `src/frontend/src/CustomNodes/GenericNode/components/`
- **UI components**: `src/frontend/src/components/ui/`
- **Core components**: `src/frontend/src/components/core/`
- **Common components**: `src/frontend/src/components/common/`
- **API query hooks**: `src/frontend/src/controllers/API/queries/`
- **Zustand stores**: `src/frontend/src/stores/`
### Related Skills
- `frontend-query-mutation` - For API query and mutation patterns
- `frontend-testing` - For testing refactored components
@@ -0,0 +1,519 @@
# Complexity Reduction Patterns
This document provides patterns for reducing cognitive complexity in Langflow React components.
## Understanding Complexity
### SonarJS Cognitive Complexity
Langflow does not have automated complexity analysis tools. Assess complexity manually using SonarJS cognitive complexity rules:
- **Total Complexity**: Sum of all functions' complexity in the file
- **Max Complexity**: Highest single function complexity
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
**Before** (complexity: ~15):
```typescript
const getParameterComponent = (field: InputFieldType) => {
if (field.type === "str") {
if (field.multiline) {
return <TextAreaComponent value={field.value} />
} else if (field.password) {
return <PasswordInput value={field.value} />
} else if (field.options?.length) {
return <Dropdown options={field.options} value={field.value} />
} else {
return <InputComponent value={field.value} />
}
}
if (field.type === "int") {
return <IntComponent value={field.value} />
}
if (field.type === "float") {
return <FloatComponent value={field.value} />
}
if (field.type === "bool") {
return <ToggleComponent value={field.value} />
}
if (field.type === "code") {
return <CodeAreaComponent value={field.value} />
}
return null
}
```
**After** (complexity: ~3):
```typescript
// Define lookup table outside component
const FIELD_TYPE_MAP: Record<string, FC<FieldProps>> = {
int: IntComponent,
float: FloatComponent,
bool: ToggleComponent,
code: CodeAreaComponent,
dict: DictComponent,
file: FileComponent,
}
const getStrVariant = (field: InputFieldType): FC<FieldProps> => {
if (field.multiline) return TextAreaComponent
if (field.password) return PasswordInput
if (field.options?.length) return Dropdown
return InputComponent
}
// Clean component logic
const getParameterComponent = (field: InputFieldType) => {
const Component = field.type === "str"
? getStrVariant(field)
: FIELD_TYPE_MAP[field.type]
if (!Component) return null
return <Component value={field.value} />
}
```
## Pattern 2: Use Early Returns
**Before** (complexity: ~10):
```typescript
const handleNodeBuild = () => {
if (isAuthenticated) {
if (hasValidFlow) {
if (!isBuilding) {
if (allInputsConnected) {
startBuild()
} else {
showMissingInputsError()
}
} else {
showBuildInProgressWarning()
}
} else {
showInvalidFlowError()
}
} else {
showAuthError()
}
}
```
**After** (complexity: ~4):
```typescript
const handleNodeBuild = () => {
if (!isAuthenticated) {
showAuthError()
return
}
if (!hasValidFlow) {
showInvalidFlowError()
return
}
if (isBuilding) {
showBuildInProgressWarning()
return
}
if (!allInputsConnected) {
showMissingInputsError()
return
}
startBuild()
}
```
## Pattern 3: Extract Complex Conditions
**Before** (complexity: high):
```typescript
const canRunFlow = (() => {
if (flow.is_component) {
return false
}
if (!nodes.length) {
return false
}
if (isBuilding) {
return false
}
if (nodes.some((n) => n.data?.node?.error)) {
return false
}
if (
edges.some(
(e) =>
!e.sourceHandle ||
!e.targetHandle ||
(e.data?.isInvalid && e.data.isInvalid === true),
)
) {
return false
}
return true
})()
```
**After** (complexity: lower):
```typescript
// Extract to named functions
const hasValidNodes = (nodes: Node[]) => {
return nodes.length > 0 && !nodes.some((n) => n.data?.node?.error)
}
const hasValidEdges = (edges: Edge[]) => {
return !edges.some(
(e) => !e.sourceHandle || !e.targetHandle || e.data?.isInvalid,
)
}
// Clean main logic
const canRunFlow =
!flow.is_component &&
!isBuilding &&
hasValidNodes(nodes) &&
hasValidEdges(edges)
```
## Pattern 4: Replace Chained Ternaries
**Before** (complexity: ~5):
```typescript
const buildStatusIcon = buildStatus === BuildStatus.BUILT
? <CheckCircle className="text-green-500" />
: buildStatus === BuildStatus.BUILDING
? <Loader className="animate-spin text-blue-500" />
: buildStatus === BuildStatus.ERROR
? <XCircle className="text-red-500" />
: <Circle className="text-gray-400" />
```
**After** (complexity: ~2):
```typescript
const BUILD_STATUS_ICONS: Record<BuildStatus, ReactNode> = {
[BuildStatus.BUILT]: <CheckCircle className="text-green-500" />,
[BuildStatus.BUILDING]: <Loader className="animate-spin text-blue-500" />,
[BuildStatus.ERROR]: <XCircle className="text-red-500" />,
[BuildStatus.IDLE]: <Circle className="text-gray-400" />,
}
const buildStatusIcon = BUILD_STATUS_ICONS[buildStatus] ?? BUILD_STATUS_ICONS[BuildStatus.IDLE]
```
## Pattern 5: Flatten Nested Loops
**Before** (complexity: high):
```typescript
const getConnectedNodes = (flow: FlowType) => {
const results: ConnectedNode[] = []
for (const node of flow.data.nodes) {
if (node.data?.node?.template) {
for (const [fieldName, field] of Object.entries(node.data.node.template)) {
if (field.type === "str" && field.load_from_db) {
for (const variable of globalVariables) {
if (variable.name === field.value) {
results.push({
nodeId: node.id,
fieldName,
variableName: variable.name,
})
}
}
}
}
}
}
return results
}
```
**After** (complexity: lower):
```typescript
// Use functional approach
const getConnectedNodes = (flow: FlowType) => {
return flow.data.nodes
.filter((node) => node.data?.node?.template)
.flatMap((node) =>
Object.entries(node.data.node.template)
.filter(([, field]) => field.type === "str" && field.load_from_db)
.flatMap(([fieldName, field]) =>
globalVariables
.filter((variable) => variable.name === field.value)
.map((variable) => ({
nodeId: node.id,
fieldName,
variableName: variable.name,
})),
),
)
}
```
## Pattern 6: Extract Event Handler Logic
**Before** (complexity: high in component):
```typescript
const FlowCanvas = () => {
const handleNodeDrop = useCallback(
(event: DragEvent) => {
event.preventDefault()
const nodeData = JSON.parse(event.dataTransfer.getData("application/json"))
if (!nodeData || !nodeData.type) return
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
})
// Validate the node type exists
const nodeType = types[nodeData.type]
if (!nodeType) {
setErrorData({ title: "Invalid node type" })
return
}
// Check for duplicates if component
if (nodeData.node?.is_component) {
const existingComponent = nodes.find(
(n) => n.data.node?.display_name === nodeData.node.display_name,
)
if (existingComponent) {
// 20 more lines of duplicate handling...
}
}
// Create the new node
const newNode = buildNodeFromData(nodeData, position)
setNodes((prev) => [...prev, newNode])
// 20 more lines of post-drop logic...
},
[nodes, types, screenToFlowPosition],
)
return <div>...</div>
}
```
**After** (complexity: lower):
```typescript
// Extract to hook
const useNodeDrop = (nodes: Node[], types: Record<string, any>) => {
const validateNodeData = useCallback(
(nodeData: any): boolean => {
if (!nodeData?.type) return false
if (!types[nodeData.type]) return false
return true
},
[types],
)
const checkDuplicateComponent = useCallback(
(nodeData: any): Node | undefined => {
if (!nodeData.node?.is_component) return undefined
return nodes.find(
(n) => n.data.node?.display_name === nodeData.node.display_name,
)
},
[nodes],
)
return { validateNodeData, checkDuplicateComponent }
}
// Component becomes cleaner
const FlowCanvas = () => {
const { validateNodeData, checkDuplicateComponent } = useNodeDrop(nodes, types)
const handleNodeDrop = useCallback(
(event: DragEvent) => {
event.preventDefault()
const nodeData = JSON.parse(event.dataTransfer.getData("application/json"))
if (!validateNodeData(nodeData)) {
setErrorData({ title: "Invalid node type" })
return
}
const duplicate = checkDuplicateComponent(nodeData)
if (duplicate) {
handleDuplicate(duplicate, nodeData)
return
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
})
const newNode = buildNodeFromData(nodeData, position)
setNodes((prev) => [...prev, newNode])
},
[validateNodeData, checkDuplicateComponent, screenToFlowPosition],
)
return <div>...</div>
}
```
## Pattern 7: Reduce Boolean Logic Complexity
**Before** (complexity: ~8):
```typescript
const isNodeDisabled = !isAuthenticated
|| flow.is_component
|| isBuilding
|| node.data?.node?.frozen
|| (node.data?.node?.error && node.data.node.error !== "")
|| (!hasConnectedInputs && node.type !== "genericNode")
|| (isLocked && !isSuperUser)
```
**After** (complexity: ~3):
```typescript
// Extract meaningful boolean functions
const hasNodeError = (node: Node) => {
return !!node.data?.node?.error && node.data.node.error !== ""
}
const isNodeAccessible = (node: Node, isLocked: boolean, isSuperUser: boolean) => {
if (isLocked && !isSuperUser) return false
if (node.data?.node?.frozen) return false
return true
}
const canInteractWithNode = (node: Node) => {
if (!isAuthenticated) return false
if (flow.is_component) return false
if (isBuilding) return false
if (hasNodeError(node)) return false
if (!isNodeAccessible(node, isLocked, isSuperUser)) return false
if (!hasConnectedInputs && node.type !== "genericNode") return false
return true
}
const isNodeDisabled = !canInteractWithNode(node)
```
## Pattern 8: Simplify useMemo/useCallback Dependencies
**Before** (complexity: multiple recalculations):
```typescript
const processedNodes = useMemo(() => {
let result = nodes
if (searchTerm) {
result = result.filter(
(n) =>
n.data?.node?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
n.data?.type?.toLowerCase().includes(searchTerm.toLowerCase()),
)
}
if (filterByType) {
result = result.filter((n) => n.data?.type === filterByType)
}
if (sortOrder === "name") {
result = [...result].sort((a, b) =>
(a.data?.node?.display_name ?? "").localeCompare(b.data?.node?.display_name ?? ""),
)
} else if (sortOrder === "type") {
result = [...result].sort((a, b) =>
(a.data?.type ?? "").localeCompare(b.data?.type ?? ""),
)
}
return result
}, [nodes, searchTerm, filterByType, sortOrder])
```
**After** (complexity: separated concerns):
```typescript
// Separate filter and sort utilities
const filterNodes = (nodes: Node[], searchTerm: string, filterByType?: string) => {
let result = nodes
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(
(n) =>
n.data?.node?.display_name?.toLowerCase().includes(term) ||
n.data?.type?.toLowerCase().includes(term),
)
}
if (filterByType) {
result = result.filter((n) => n.data?.type === filterByType)
}
return result
}
const SORT_COMPARATORS: Record<string, (a: Node, b: Node) => number> = {
name: (a, b) =>
(a.data?.node?.display_name ?? "").localeCompare(b.data?.node?.display_name ?? ""),
type: (a, b) =>
(a.data?.type ?? "").localeCompare(b.data?.type ?? ""),
}
const sortNodes = (nodes: Node[], sortOrder: string) => {
const comparator = SORT_COMPARATORS[sortOrder]
if (!comparator) return nodes
return [...nodes].sort(comparator)
}
// Clean component usage
const filteredNodes = useMemo(
() => filterNodes(nodes, searchTerm, filterByType),
[nodes, searchTerm, filterByType],
)
const processedNodes = useMemo(
() => sortNodes(filteredNodes, sortOrder),
[filteredNodes, sortOrder],
)
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | <= 3 levels |
| Conditional Chains | <= 3 conditions |
@@ -0,0 +1,635 @@
# Component Splitting Patterns
This document provides detailed guidance on splitting large components into smaller, focused components in Langflow.
## When to Split Components
Split a component when you identify:
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
1. **Repeated patterns** - Similar UI structures used multiple times
1. **300+ lines** - Component exceeds manageable size
1. **Modal clusters** - Multiple modals rendered in one component
## Splitting Strategies
### Strategy 1: Section-Based Splitting
Identify visual sections and extract each as a component.
```typescript
// Before: Monolithic component (500+ lines)
const FlowPage = () => {
return (
<div className="flex h-full w-full">
{/* Sidebar Section - 100 lines */}
<div className="w-64 border-r">
<input placeholder="Search components..." />
{categories.map((cat) => (
<div key={cat.name}>
<h3>{cat.name}</h3>
{cat.components.map((comp) => (
<div key={comp.name} draggable>
{comp.display_name}
</div>
))}
</div>
))}
</div>
{/* Canvas Section - 200 lines */}
<div className="flex-1">
<ReactFlow nodes={nodes} edges={edges} onConnect={onConnect}>
{/* toolbar, minimap, controls */}
</ReactFlow>
</div>
{/* Inspect Panel Section - 150 lines */}
{showInspectPanel && (
<div className="w-80 border-l">
{selectedNode && <NodeInspector node={selectedNode} />}
</div>
)}
{/* Modals Section - 50 lines */}
{showExportModal && <ExportModal />}
{showShareModal && <ShareModal />}
</div>
)
}
// After: Split into focused components (kebab-case, descriptive names — NEVER index.tsx)
// pages/FlowPage/
// flow-page.tsx (orchestration)
// components/
// flow-sidebar.tsx
// flow-canvas.tsx
// flow-inspect-panel.tsx
// flow-modals.tsx
// FlowSidebar.tsx
interface FlowSidebarProps {
categories: Category[]
searchTerm: string
onSearchChange: (term: string) => void
}
const FlowSidebar: FC<FlowSidebarProps> = ({
categories,
searchTerm,
onSearchChange,
}) => {
return (
<div className="w-64 border-r">
<input
placeholder="Search components..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
{categories.map((cat) => (
<SidebarCategory key={cat.name} category={cat} />
))}
</div>
)
}
// flow-page.tsx (orchestration only — kebab-case, NOT index.tsx)
const FlowPage = () => {
const { nodes, edges, onConnect } = useFlowState()
const { activeModal, openModal, closeModal } = useModalState()
const [searchTerm, setSearchTerm] = useState("")
return (
<div className="flex h-full w-full">
<FlowSidebar
categories={filteredCategories}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<FlowCanvas
nodes={nodes}
edges={edges}
onConnect={onConnect}
/>
{showInspectPanel && (
<FlowInspectPanel selectedNode={selectedNode} />
)}
<FlowModals
activeModal={activeModal}
onClose={closeModal}
/>
</div>
)
}
```
### Strategy 2: Conditional Block Extraction
Extract large conditional rendering blocks.
```typescript
// Before: Large conditional blocks
const NodeField = ({ field }: { field: InputFieldType }) => {
return (
<div>
{field.show ? (
<div className="field-visible">
{field.load_from_db ? (
<div className="global-variable-badge">
<Badge>{field.value}</Badge>
<Button onClick={() => clearGlobalVariable(field.name)}>
Clear
</Button>
</div>
) : field.type === "str" && field.multiline ? (
<TextAreaComponent
value={field.value}
onChange={(val) => handleChange(field.name, val)}
/>
) : field.type === "str" ? (
<InputComponent
value={field.value}
onChange={(val) => handleChange(field.name, val)}
password={field.password}
/>
) : field.type === "code" ? (
<CodeAreaComponent
value={field.value}
onChange={(val) => handleChange(field.name, val)}
/>
) : (
<GenericInput field={field} onChange={handleChange} />
)}
</div>
) : null}
</div>
)
}
// After: Separate rendering components
const GlobalVariableBadge: FC<{ field: InputFieldType; onClear: () => void }> = ({
field,
onClear,
}) => (
<div className="global-variable-badge">
<Badge>{field.value}</Badge>
<Button onClick={onClear}>Clear</Button>
</div>
)
const FieldInput: FC<{ field: InputFieldType; onChange: FieldChangeHandler }> = ({
field,
onChange,
}) => {
if (field.load_from_db) {
return <GlobalVariableBadge field={field} onClear={() => onChange(field.name, "")} />
}
const Component = getFieldComponent(field)
return <Component value={field.value} onChange={(val) => onChange(field.name, val)} />
}
const NodeField = ({ field }: { field: InputFieldType }) => {
if (!field.show) return null
return (
<div className="field-visible">
<FieldInput field={field} onChange={handleChange} />
</div>
)
}
```
### Strategy 3: Modal Extraction
Extract modals with their trigger logic.
```typescript
// Before: Multiple modals in one component
const FlowToolbar = () => {
const [showExport, setShowExport] = useState(false)
const [showShare, setShowShare] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showApi, setShowApi] = useState(false)
const onExport = async (format: string) => { /* 20 lines */ }
const onShare = async (data: ShareData) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main toolbar content */}
{showExport && <ExportModal onConfirm={onExport} onClose={() => setShowExport(false)} />}
{showShare && <ShareModal onConfirm={onShare} onClose={() => setShowShare(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
{showApi && <ApiModal flowId={flowId} onClose={() => setShowApi(false)} />}
</div>
)
}
// After: Modal manager component
// flow-toolbar-modals.tsx
type ToolbarModalType = "export" | "share" | "delete" | "api" | null
interface FlowToolbarModalsProps {
flowId: string
activeModal: ToolbarModalType
onClose: () => void
onSuccess: () => void
}
const FlowToolbarModals: FC<FlowToolbarModalsProps> = ({
flowId,
activeModal,
onClose,
onSuccess,
}) => {
const handleExport = async (format: string) => {
// export logic
onSuccess()
}
const handleShare = async (data: ShareData) => {
// share logic
onSuccess()
}
const handleDelete = async () => {
// delete logic
onSuccess()
}
return (
<>
{activeModal === "export" && (
<ExportModal onConfirm={handleExport} onClose={onClose} />
)}
{activeModal === "share" && (
<ShareModal onConfirm={handleShare} onClose={onClose} />
)}
{activeModal === "delete" && (
<DeleteConfirm onConfirm={handleDelete} onClose={onClose} />
)}
{activeModal === "api" && (
<ApiModal flowId={flowId} onClose={onClose} />
)}
</>
)
}
// Parent component
const FlowToolbar = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main toolbar with openModal triggers */}
<Button onClick={() => openModal("export")}>Export</Button>
<Button onClick={() => openModal("share")}>Share</Button>
<FlowToolbarModals
flowId={flowId}
activeModal={activeModal}
onClose={closeModal}
onSuccess={handleSuccess}
/>
</div>
)
}
```
### Strategy 4: List Item Extraction
Extract repeated item rendering.
```typescript
// Before: Inline item rendering
const ComponentList = () => {
return (
<div>
{components.map((comp) => (
<div key={comp.name} className="component-item">
<div className="flex items-center gap-2">
{comp.icon && <img src={comp.icon} className="h-5 w-5" />}
<span className="font-medium">{comp.display_name}</span>
{comp.beta && <Badge variant="secondary">Beta</Badge>}
{comp.legacy && <Badge variant="destructive">Legacy</Badge>}
</div>
<p className="text-sm text-muted-foreground">{comp.description}</p>
<div className="flex gap-1">
{comp.output_types?.map((type) => (
<Badge key={type} variant="outline">{type}</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleAddToCanvas(comp)}
>
Add
</Button>
</div>
))}
</div>
)
}
// After: Extracted item component
interface ComponentItemProps {
component: APIClassType
onAdd: (component: APIClassType) => void
}
const ComponentItem: FC<ComponentItemProps> = ({ component, onAdd }) => {
return (
<div className="component-item">
<div className="flex items-center gap-2">
{component.icon && <img src={component.icon} className="h-5 w-5" />}
<span className="font-medium">{component.display_name}</span>
{component.beta && <Badge variant="secondary">Beta</Badge>}
{component.legacy && <Badge variant="destructive">Legacy</Badge>}
</div>
<p className="text-sm text-muted-foreground">{component.description}</p>
<div className="flex gap-1">
{component.output_types?.map((type) => (
<Badge key={type} variant="outline">{type}</Badge>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => onAdd(component)}>
Add
</Button>
</div>
)
}
const ComponentList = () => {
return (
<div>
{components.map((comp) => (
<ComponentItem
key={comp.display_name}
component={comp}
onAdd={handleAddToCanvas}
/>
))}
</div>
)
}
```
## Directory Structure Patterns
### Pattern A: Flat Structure (Simple Components)
For components with 2-3 sub-components:
```
my-component/
my-component.tsx # Main component (kebab-case, descriptive — NOT index.tsx)
sub-component-a.tsx
sub-component-b.tsx
my-component-types.ts # Shared types
```
### Pattern B: Nested Structure (Complex Components)
For components with many sub-components:
```
my-component/
my-component.tsx # Main orchestration (NOT index.tsx)
my-component-types.ts # Shared types
hooks/
use-feature-a.ts
use-feature-b.ts
components/
header-section.tsx
content-section.tsx
modals-section.tsx
helpers/
format-data.ts
```
> **IMPORTANT**: Never use `index.tsx` for new files. This is a legacy pattern. Use kebab-case file names that describe the component's purpose.
### Pattern C: Page Structure
Pages follow a standard directory layout with sub-pages, components, hooks, and helpers:
```
pages/SettingsPage/
settings-page.tsx # Main page component
pages/ # Sub-pages
ApiKeysPage/
api-keys-page.tsx
components/
api-key-header.tsx
helpers/
column-defs.ts
get-modal-props.tsx
GlobalVariablesPage/
global-variables-page.tsx
components/ # Shared page components
hooks/ # Page-level hooks
utils/ # Page-level utilities
```
### Pattern D: UI Components (shadcn — kebab-case files)
```
components/ui/
button.tsx
input.tsx
badge.tsx
dialog.tsx
popover.tsx
select.tsx
textarea.tsx
tooltip.tsx
dropdown-menu.tsx
```
### Pattern E: Legacy Codebase (existing — do NOT follow for new code)
The existing codebase has `index.tsx` and mixed naming. When refactoring, migrate toward the new kebab-case standard:
```
// Legacy (existing — DO NOT create new files this way)
components/core/appHeaderComponent/index.tsx
components/common/loadingComponent/index.tsx
CustomNodes/GenericNode/index.tsx
// New standard (use this for all new code and refactors)
components/core/app-header/app-header.tsx
components/common/loading-indicator/loading-indicator.tsx
CustomNodes/GenericNode/generic-node.tsx
NodeName/
NodeOutputField/
NodeStatus/
```
## Props Design
### Minimal Props Principle
Pass only what is needed:
```typescript
// Bad: Passing entire objects when only some fields needed
<NodeHeader nodeData={nodeData} flowData={flowData} />
// Good: Destructure to minimum required
<NodeHeader
displayName={nodeData.node?.display_name ?? ""}
nodeType={nodeData.type}
isFrozen={nodeData.node?.frozen ?? false}
onNameChange={handleNameChange}
/>
```
### Callback Props Pattern
Use callbacks for child-to-parent communication:
```typescript
// Parent
const GenericNode = () => {
const [showDescription, setShowDescription] = useState(false)
return (
<div>
<NodeHeader
displayName={data.node?.display_name ?? ""}
onToggleDescription={() => setShowDescription((prev) => !prev)}
/>
{showDescription && (
<NodeDescription
description={data.node?.description ?? ""}
onChange={handleDescriptionChange}
/>
)}
</div>
)
}
// Child
interface NodeHeaderProps {
displayName: string
onToggleDescription: () => void
}
const NodeHeader: FC<NodeHeaderProps> = ({ displayName, onToggleDescription }) => {
return (
<div className="flex items-center justify-between">
<span>{displayName}</span>
<button onClick={onToggleDescription}>Toggle Description</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface FieldListProps<T> {
fields: T[]
renderField: (field: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function FieldList<T>({ fields, renderField, renderEmpty }: FieldListProps<T>) {
if (fields.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div className="flex flex-col gap-2">
{fields.map((field, index) => renderField(field, index))}
</div>
)
}
// Usage
<FieldList
fields={visibleFields}
renderField={(field, i) => (
<NodeInputField key={field.name ?? i} field={field} onChange={handleChange} />
)}
renderEmpty={() => <span className="text-muted-foreground">No fields</span>}
/>
```
## Langflow-Specific Splitting Guidelines
### Splitting GenericNode Components
The `GenericNode` component is one of the most complex components in Langflow. When splitting:
1. Keep the main `index.tsx` as an orchestrator that composes sub-components.
2. Parameter rendering goes in `components/NodeInputField/`.
3. Handle rendering goes in `components/HandleRenderComponent/`.
4. Status display goes in `components/NodeStatus/`.
5. Keep node-level state in the parent; pass callbacks to children.
### Splitting Flow Page Components
The flow editor page has multiple distinct regions:
1. **Sidebar** - Component library, search, categories
2. **Canvas** - ReactFlow canvas with nodes and edges
3. **Toolbar** - Build, save, export, share actions
4. **Inspect Panel** - Node details when selected
5. **Playground** - Chat/run interface
6. **Modals** - Export, share, API, settings
Each region should be its own component with clearly defined props.
### Splitting Store-Connected Components
When a component reads from multiple Zustand stores:
1. Keep store selectors in the parent orchestrator.
2. Pass data as props to presentational children.
3. This makes children testable without mocking stores.
```typescript
// Parent: reads from stores
const FlowToolbar = () => {
const isBuilding = useFlowStore((state) => state.isBuilding)
const currentFlow = useFlowsManagerStore((state) => state.currentFlow)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return (
<ToolbarActions
isBuilding={isBuilding}
flowName={currentFlow?.name ?? ""}
canBuild={isAuthenticated && !isBuilding}
onBuild={handleBuild}
onSave={handleSave}
/>
)
}
// Child: pure presentational
const ToolbarActions: FC<ToolbarActionsProps> = ({
isBuilding,
flowName,
canBuild,
onBuild,
onSave,
}) => {
return (
<div className="flex items-center gap-2">
<span>{flowName}</span>
<Button onClick={onBuild} disabled={!canBuild}>
{isBuilding ? "Building..." : "Build"}
</Button>
<Button onClick={onSave}>Save</Button>
</div>
)
}
```
@@ -0,0 +1,470 @@
# Hook Extraction Patterns
This document provides detailed guidance on extracting custom hooks from complex components in Langflow.
## When to Extract Hooks
Extract a custom hook when you identify:
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
1. **Business logic** - Data transformations, validations, or calculations
1. **Reusable patterns** - Logic that appears in multiple components
## Extraction Process
### Step 1: Identify State Groups
Look for state variables that are logically related:
```typescript
// These belong together - extract to hook
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 })
// These are canvas-related state that should be in useCanvasState()
```
### Step 2: Identify Related Effects
Find effects that modify the grouped state:
```typescript
// These effects belong with the state above
useEffect(() => {
if (flowData?.nodes) {
setNodes(flowData.nodes)
setEdges(flowData.edges ?? [])
}
}, [flowData])
useEffect(() => {
if (fitViewOnLoad && nodes.length > 0) {
reactFlowInstance?.fitView()
}
}, [nodes.length, fitViewOnLoad, reactFlowInstance])
```
### Step 3: Create the Hook
```typescript
// hooks/use-canvas-state.ts
import type { Edge, Node, Viewport } from "@xyflow/react"
import { useEffect, useState } from "react"
import type { FlowType } from "@/types/flow"
interface UseCanvasStateParams {
flowData: FlowType | undefined
fitViewOnLoad?: boolean
reactFlowInstance?: any
}
interface UseCanvasStateReturn {
nodes: Node[]
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
edges: Edge[]
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
viewport: Viewport
setViewport: React.Dispatch<React.SetStateAction<Viewport>>
}
export const useCanvasState = ({
flowData,
fitViewOnLoad = false,
reactFlowInstance,
}: UseCanvasStateParams): UseCanvasStateReturn => {
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 })
// Sync flow data to canvas state
useEffect(() => {
if (flowData?.nodes) {
setNodes(flowData.nodes)
setEdges(flowData.edges ?? [])
}
}, [flowData])
// Fit view on initial load
useEffect(() => {
if (fitViewOnLoad && nodes.length > 0) {
reactFlowInstance?.fitView()
}
}, [nodes.length, fitViewOnLoad, reactFlowInstance])
return {
nodes,
setNodes,
edges,
setEdges,
viewport,
setViewport,
}
}
```
### Step 4: Update Component
```typescript
// Before: 50+ lines of state management
const FlowPage: FC = () => {
const [nodes, setNodes] = useState<Node[]>([])
// ... lots of related state and effects
}
// After: Clean component
const FlowPage: FC = () => {
const {
nodes,
setNodes,
edges,
setEdges,
viewport,
} = useCanvasState({
flowData,
fitViewOnLoad: true,
reactFlowInstance,
})
// Component now focuses on UI
}
```
## Naming Conventions
### Hook Names
- Use `use` prefix: `useFlowState`, `useNodeDrag`, `useBuildStatus`
- Be specific: `useRefreshModelInputs` not `useRefresh`
- Include domain: `useFlowStore`, `useGlobalVariables`, `useAddComponent`
- Match existing Langflow patterns: `useFlowsManagerStore`, `useFlowStore`
### File Names
- Kebab-case: `use-flow-state.ts`, `use-node-drag.ts`
- Place in `hooks/` directory for globally reusable hooks: `src/frontend/src/hooks/use-debounce.ts`
- Place alongside component for single-use hooks
- Place in component's `hooks/` subdirectory when multiple hooks exist for one feature
### Return Type Names
- Suffix with `Return`: `UseCanvasStateReturn`
- Suffix params with `Params`: `UseCanvasStateParams`
## Common Hook Patterns in Langflow
### 1. Zustand Store Derived State Hook
When you need to compute derived state from a Zustand store, extract a hook rather than computing in the component.
```typescript
// Pattern: Derived state from store
export const useFlowValidation = () => {
const nodes = useFlowStore((state) => state.nodes)
const edges = useFlowStore((state) => state.edges)
const hasErrors = useMemo(
() => nodes.some((n) => n.data?.node?.error),
[nodes],
)
const hasDisconnectedInputs = useMemo(
() =>
nodes.some((n) => {
const requiredInputs = Object.values(n.data?.node?.template ?? {}).filter(
(field) => field.required && field.show,
)
return requiredInputs.some(
(input) =>
!edges.some(
(e) => e.target === n.id && e.targetHandle === input.name,
),
)
}),
[nodes, edges],
)
return {
hasErrors,
hasDisconnectedInputs,
isValid: !hasErrors && !hasDisconnectedInputs,
}
}
```
### 2. API Data Hooks
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
- Use `frontend-query-mutation` for `UseRequestProcessor`, query patterns, cache invalidation, and mutation error handling.
- Do not create thin passthrough `useQuery` hooks; only extract orchestration hooks that combine multiple queries or shared derived state.
- API hooks live in `controllers/API/queries/{domain}/` and follow the `UseRequestProcessor` pattern.
Example of an orchestration hook (acceptable to extract):
```typescript
// hooks/use-flow-with-variables.ts
// This combines multiple API queries with derived state - worth extracting
export const useFlowWithVariables = (flowId: string) => {
const { data: flow } = useGetFlow({ id: flowId })
const { data: globalVariables } = useGetGlobalVariables()
const resolvedVariables = useMemo(() => {
if (!flow || !globalVariables) return {}
return resolveFlowVariables(flow, globalVariables)
}, [flow, globalVariables])
return {
flow,
globalVariables,
resolvedVariables,
isLoading: !flow || !globalVariables,
}
}
```
### 3. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useFlowSettingsForm = (initialValues: FlowSettings) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name?.trim()) newErrors.name = "Name is required"
if (values.endpoint_name && !/^[a-z0-9_-]+$/.test(values.endpoint_name)) {
newErrors.endpoint_name = "Must be lowercase alphanumeric with hyphens or underscores"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues((prev) => ({ ...prev, [field]: value }))
// Clear error on field change
setErrors((prev) => {
const next = { ...prev }
delete next[field]
return next
})
}, [])
const handleSubmit = useCallback(
async (onSubmit: (values: FlowSettings) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
},
[values, validate],
)
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 4. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = "edit" | "delete" | "duplicate" | "export" | null
export const useModalState = <T = any>() => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<T | null>(null)
const openModal = useCallback((type: ModalType, data?: T) => {
setActiveModal(type)
setModalData(data ?? null)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback(
(type: ModalType) => activeModal === type,
[activeModal],
),
}
}
```
### 5. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue((v) => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
### 6. Keyboard Shortcut Hook
Langflow has keyboard shortcut support. Extract shortcut handling to hooks.
```typescript
// Pattern: Keyboard shortcut registration
export const useFlowShortcuts = (handlers: {
onSave?: () => void
onUndo?: () => void
onRedo?: () => void
onDelete?: () => void
}) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isModKey = event.metaKey || event.ctrlKey
if (isModKey && event.key === "s") {
event.preventDefault()
handlers.onSave?.()
} else if (isModKey && event.key === "z" && !event.shiftKey) {
event.preventDefault()
handlers.onUndo?.()
} else if (isModKey && event.key === "z" && event.shiftKey) {
event.preventDefault()
handlers.onRedo?.()
} else if (event.key === "Delete" || event.key === "Backspace") {
handlers.onDelete?.()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [handlers])
}
```
## Testing Extracted Hooks
After extraction, test hooks in isolation using `@testing-library/react`:
```typescript
// use-canvas-state.test.ts
import { act, renderHook } from "@testing-library/react"
import { useCanvasState } from "./use-canvas-state"
describe("useCanvasState", () => {
it("should initialize with empty state", () => {
const { result } = renderHook(() =>
useCanvasState({
flowData: undefined,
fitViewOnLoad: false,
}),
)
expect(result.current.nodes).toEqual([])
expect(result.current.edges).toEqual([])
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
})
it("should sync flow data to canvas state", () => {
const flowData = {
nodes: [{ id: "node-1", type: "genericNode", position: { x: 0, y: 0 }, data: {} }],
edges: [{ id: "edge-1", source: "node-1", target: "node-2" }],
}
const { result } = renderHook(() =>
useCanvasState({
flowData: flowData as any,
fitViewOnLoad: false,
}),
)
expect(result.current.nodes).toEqual(flowData.nodes)
expect(result.current.edges).toEqual(flowData.edges)
})
it("should update nodes via setNodes", () => {
const { result } = renderHook(() =>
useCanvasState({
flowData: undefined,
fitViewOnLoad: false,
}),
)
act(() => {
result.current.setNodes([
{ id: "new-node", type: "genericNode", position: { x: 100, y: 200 }, data: {} } as any,
])
})
expect(result.current.nodes).toHaveLength(1)
expect(result.current.nodes[0].id).toBe("new-node")
})
})
```
## Anti-Patterns to Avoid
### Do Not Wrap Store Selectors
```typescript
// Do not create hooks that just forward store selectors
const useNodes = () => useFlowStore((state) => state.nodes)
const useEdges = () => useFlowStore((state) => state.edges)
// Instead, use selectors directly in the component
const Component = () => {
const nodes = useFlowStore((state) => state.nodes)
const edges = useFlowStore((state) => state.edges)
}
```
### Do Not Wrap Single API Calls
```typescript
// Do not create thin wrappers around UseRequestProcessor queries
const useGetFlow = (flowId: string) => {
const { query } = UseRequestProcessor()
return query(["useGetFlow", flowId], () => api.get(`${getURL("FLOWS")}/${flowId}`))
}
// These already exist in controllers/API/queries/ - use them directly
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow"
```
### Do Extract Orchestration Hooks
```typescript
// Orchestrating multiple queries and derived state is a valid hook extraction
const useFlowBuildState = (flowId: string) => {
const { data: flow } = useGetFlow({ id: flowId })
const { data: builds } = useGetBuilds({ flowId })
const isBuilding = useFlowStore((state) => state.isBuilding)
const lastBuild = useMemo(
() => builds?.sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0],
[builds],
)
const buildProgress = useMemo(() => {
if (!isBuilding) return null
// ... compute progress from build state
}, [isBuilding, builds])
return { flow, lastBuild, isBuilding, buildProgress }
}
```
+354
View File
@@ -0,0 +1,354 @@
---
name: e2e-testing
description: Write and review Playwright E2E tests for Langflow. Trigger when the user asks to write, fix, or review E2E tests, spec files, Playwright tests, or integration tests that exercise the full UI. Also trigger when modifying data-testid attributes, test helpers in tests/utils/, or fixture configuration.
---
# Langflow E2E Testing (Playwright)
## When to Apply
- User asks to **write E2E tests** for a feature or flow
- User asks to **fix a failing E2E test**
- User asks to **review E2E test coverage**
- User modifies `data-testid` attributes in components (may break existing tests)
- User changes test utilities in `src/frontend/tests/utils/`
**Do NOT apply** when:
- User asks about unit tests (use `frontend-testing` skill for Jest)
- User asks about backend tests (use `backend-code-review` skill for pytest)
## Tech Stack
| Tool | Version | Purpose |
|------|---------|---------|
| Playwright | 1.59.1 | E2E test runner + browser automation |
| Chromium | (bundled) | Default browser (Firefox/Safari disabled) |
| Custom fixtures | `tests/fixtures.ts` | Auto-detects API errors and flow execution failures |
## Key Commands
```bash
# Run all E2E tests
npx playwright test
# Run tests filtered by tag
npx playwright test --grep "@release"
npx playwright test --grep "@workspace"
npx playwright test --grep "@starter-projects"
# Run a specific test file
npx playwright test tests/core/features/run-flow.spec.ts
# Debug mode (headed browser + step through)
npx playwright test --debug
# Show HTML report after run
npx playwright show-report
# Update snapshots (if used)
npx playwright test --update-snapshots
```
## Configuration
**File**: `src/frontend/playwright.config.ts`
| Setting | Value | Why |
|---------|-------|-----|
| `fullyParallel` | `true` | Tests run in parallel for speed |
| `timeout` | 5 minutes | Flow builds can be slow; prevents false timeouts |
| `retries` | 3 (local), 2 (CI) | Flaky network/rendering issues; retries catch them |
| `workers` | 2 | Balances speed and resource usage |
| `actionTimeout` | 20s | Individual action timeout (click, fill, etc.) |
| `trace` | `on-first-retry` | Captures trace on failures for debugging |
| `baseURL` | `http://localhost:3000` | Vite dev server |
**WebServer**: Playwright auto-starts backend (uvicorn on 7860) + frontend (npm start on 3000).
## Directory Structure
```
src/frontend/tests/
├── fixtures.ts # Custom test fixture with error detection
├── globalTeardown.ts # Cleanup (removes temp DB after tests)
├── core/
│ ├── features/ # Main feature tests (run-flow, playground, etc.)
│ ├── integrations/ # Starter project / template tests
│ ├── regression/ # Bug regression tests
│ └── unit/ # Component-level Playwright tests
├── extended/
│ ├── features/ # Extended features (MCP, auto-save, etc.)
│ ├── integrations/ # Extended integrations
│ └── regression/ # Extended regressions
└── utils/ # 37+ shared helper functions
```
## File Naming
- **kebab-case** with `.spec.ts` suffix: `run-flow.spec.ts`, `playground.spec.ts`, `flow-lock.spec.ts`
- Template tests may use spaces: `Document QA.spec.ts`, `Social Media Agent.spec.ts`
- Sharded tests for parallelization: `chatInputOutputUser-shard-0.spec.ts`
> **Note**: E2E tests use `.spec.ts` (Playwright convention). Unit tests use `.test.tsx` (Jest convention). Do not mix them.
## Test Anatomy
### Basic Test
```typescript
import { expect, test } from "../../fixtures";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test(
"user should be able to run a flow successfully",
{ tag: ["@release", "@workspace"] },
async ({ page }) => {
await awaitBootstrapTest(page);
// Arrange: Create a flow
await page.getByTestId("blank-flow").click();
// Act: Add components and run
await page.getByTestId("sidebar-search-input").fill("Chat Output");
// ... setup ...
// Assert: Verify result
await expect(page.getByTestId("build-status-success")).toBeVisible({ timeout: 30000 });
},
);
```
### With test.describe
```typescript
test.describe("Flow Lock Feature", () => {
test(
"should lock and unlock a flow",
{ tag: ["@release", "@api"] },
async ({ page }) => {
// ...
},
);
test(
"should prevent editing when locked",
{ tag: ["@release"] },
async ({ page }) => {
// ...
},
);
});
```
### With Serial Mode (tests that depend on order)
```typescript
test.describe.configure({ mode: "serial" });
test("step 1: create flow", async ({ page }) => { /* ... */ });
test("step 2: edit flow", async ({ page }) => { /* ... */ });
test("step 3: delete flow", async ({ page }) => { /* ... */ });
```
### With Event Delivery Modes (streaming/polling/direct)
```typescript
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
withEventDeliveryModes(
"Document Q&A should work",
{ tag: ["@release", "@starter-projects"] },
async ({ page }) => {
// This test runs 3 times: streaming, polling, direct
// Each mode is configured automatically via route interception
},
);
```
## Tags System
Every test MUST have at least one tag. Tags enable filtering and CI pipeline configuration.
| Tag | Purpose | When to Use |
|-----|---------|-------------|
| `@release` | Tests that must pass before release | Critical user flows |
| `@workspace` | Workspace/flow management | Creating, editing, deleting flows |
| `@api` | API-dependent features | Tests that call backend endpoints |
| `@database` | Database operations | Tests involving persistence |
| `@components` | Component-level tests | Individual component behavior |
| `@starter-projects` | Template/starter project tests | Pre-built flow templates |
| `@regression` | Bug regression tests | Tests for specific fixed bugs |
```typescript
// Right: tag your test
test("my feature test", { tag: ["@release", "@workspace"] }, async ({ page }) => { ... });
// Wrong: no tags — test can't be filtered
test("my feature test", async ({ page }) => { ... });
```
## Custom Fixtures: Error Detection
**Always import `test` and `expect` from `../../fixtures`, NOT from `@playwright/test`.**
```typescript
// Right
import { expect, test } from "../../fixtures";
// Wrong — bypasses error detection
import { expect, test } from "@playwright/test";
```
Why: The custom fixture automatically monitors all `/api/` responses and fails the test if:
- HTTP 400, 404, 422, or 500 errors occur
- Flow execution returns `error: true` in event streams
- Python exceptions appear in streamed responses
To opt-in to expected errors (e.g., testing error handling):
```typescript
test("should show error on invalid input", { tag: ["@release"] }, async ({ page }) => {
page.allowFlowErrors(); // Allow flow errors for this test
// ... test that expects errors ...
});
```
## Selector Strategy
### Priority (in order of preference)
1. **`getByTestId`** — Most stable, used 95% of the time in Langflow
2. **`getByRole`** — For buttons, headings, and form elements
3. **`getByText`** — For visible text content
4. **`waitForSelector`** — For CSS selectors and dynamic elements
5. **`locator`** — For complex selectors (CSS, XPath)
### Common data-testid Patterns
**Canvas & Navigation:**
- `blank-flow` — New blank flow button
- `sidebar-search-input` — Component search
- `canvas_controls_dropdown` — Canvas controls menu
- `fit_view`, `zoom_out`, `zoom_in` — Canvas controls
- `react-flow-id` — ReactFlow canvas container
**Component Fields:**
- `popover-anchor-input-{fieldname}` — Input field for a component parameter
- `input-chat-playground` — Playground chat input
- `div-chat-message` — Chat message in playground
**Actions:**
- `add-component-button-{component}` — Add component to canvas
- `button-send` — Send chat message
- `button_run_{component}` — Run specific component
- `publish-button`, `save-flow-button` — Flow actions
- `edit-fields-button` — Toggle inspection panel field editor
**Modals & Panels:**
- `modal-title` — Modal heading
- `icon-Globe` — Global variables
- `icon-Lock` — Flow lock toggle
- `session-selector` — Playground session switcher
### Important: Global Variables and Badges
When a component field has a global variable selected (`load_from_db: true` + `value: "OPENAI_API_KEY"`), the field renders a **badge** instead of an `<input>` element. This means `getByTestId("popover-anchor-input-api_key")` will NOT find the element — it doesn't exist in the DOM.
Templates with global variables pre-selected: Market Research, Price Deal Finder, Research Agent.
Templates without (input IS rendered): Instagram Copywriter.
## Core Helper Functions
Located in `src/frontend/tests/utils/`:
| Function | What it Does | When to Use |
|----------|-------------|-------------|
| `awaitBootstrapTest(page)` | Waits for app to fully load | **Start of every test** |
| `initialGPTsetup(page)` | Full setup: adjustView → updateComponents → selectModel → addKey → adjustView → unselectNodes | Tests that need OpenAI configured |
| `adjustScreenView(page, opts?)` | Fit view + zoom out | After adding components to canvas |
| `zoomOut(page, times)` | Zoom out N times | When components are too small |
| `selectGptModel(page)` | Selects gpt-4o-mini for all Language Model nodes | GPT-dependent tests |
| `addOpenAiInputKey(page)` | Fills OPENAI_API_KEY for all openai_api_key fields | Tests requiring API key |
| `enableInspectPanel(page)` | Toggles inspection panel ON | **MUST call before `edit-fields-button`** |
| `disableInspectPanel(page)` | Toggles inspection panel OFF | Cleanup after inspection |
| `updateOldComponents(page)` | Clicks "Update all" if outdated components exist | After loading saved flows |
| `unselectNodes(page)` | Clicks empty canvas area to deselect all nodes | After node operations |
| `renameFlow(page, { flowName })` | Renames the current flow | Flow management tests |
| `uploadFile(page, filename)` | Uploads a file from test assets | File upload tests |
| `withEventDeliveryModes(...)` | Runs test 3x: streaming, polling, direct | Starter project tests |
### initialGPTsetup Options
```typescript
await initialGPTsetup(page); // All steps
await initialGPTsetup(page, {
skipAdjustScreenView: true,
skipUpdateOldComponents: true,
skipSelectGptModel: true,
});
```
### Inspection Panel Pattern (CRITICAL)
```typescript
// MUST enable inspection panel FIRST
await enableInspectPanel(page);
// Click a node to select it
await page.getByTestId("title-OpenAI").click();
// Open field editor
await page.getByTestId("edit-fields-button").click();
// Toggle field visibility
await page.getByTestId("showmodel_name").click();
// Close field editor
await page.getByTestId("edit-fields-button").click();
```
**If you skip `enableInspectPanel(page)`, the `edit-fields-button` will NOT be visible.**
## Skip Patterns
```typescript
// Skip test if env var missing
test.skip(!process?.env?.OPENAI_API_KEY, "OPENAI_API_KEY required to run this test");
// Skip test unconditionally with reason
test.skip(true, "Feature not yet implemented with new designs");
```
## Writing Good E2E Tests
### Do:
- **Tag every test** with at least one tag
- **Import from `../../fixtures`**, not `@playwright/test`
- **Start with `awaitBootstrapTest(page)`** — always
- **Use `getByTestId`** for stable selectors
- **Set explicit timeouts** on `waitForSelector` and `expect(...).toBeVisible()` for async operations
- **Test the complete user flow**: setup → action → verification
- **Use `withEventDeliveryModes`** for tests that involve flow execution (chat, build)
### Don't:
- **Don't use `page.waitForTimeout()`** unless absolutely necessary — prefer `waitForSelector` or `expect().toBeVisible()`
- **Don't hardcode API keys** — read from `process.env.OPENAI_API_KEY`
- **Don't skip tests without a reason** — always provide the second argument to `test.skip()`
- **Don't import from `@playwright/test`** — use the custom fixtures
- **Don't forget `enableInspectPanel(page)`** before accessing `edit-fields-button`
- **Don't assume input fields exist** when global variables are selected (badge renders instead)
### Challenge Tests (Apply Here Too)
E2E tests should also cover adversarial scenarios:
- **Invalid input**: paste 10K characters, special characters (`<script>alert(1)</script>`), empty submissions
- **Network interruption**: what happens if the user loses connection mid-build?
- **Permission boundaries**: can a user access another user's flow via direct URL?
- **Concurrent actions**: double-click delete, rapid chat messages
- **Error recovery**: does the UI recover gracefully from a 500 error?
## References
- [Selector Patterns](references/selectors.md) — Complete data-testid catalog
- [Helper Functions](references/helpers.md) — Detailed documentation of all 37+ utility functions
- [Test Fixtures](references/fixtures.md) — Custom fixture error detection behavior
@@ -0,0 +1,83 @@
# Custom Test Fixtures
## Overview
Langflow extends Playwright's default `test` and `expect` with a custom fixture in `src/frontend/tests/fixtures.ts`. This fixture adds **automatic error detection** that fails tests when unexpected API errors or flow execution errors occur.
## Why Custom Fixtures Exist
Without the custom fixture, a test could pass even though:
- The backend returned a 500 Internal Server Error (test only checked for UI text)
- A flow build silently failed with a Python exception (test only checked button state)
- An API call returned 404 because the resource was deleted (test didn't check the response)
The custom fixture catches these silent failures by monitoring ALL `/api/` responses during the test.
## Import Rule
**Always import from `../../fixtures`, never from `@playwright/test`:**
```typescript
// Right — includes error detection
import { expect, test } from "../../fixtures";
// Wrong — NO error detection, silent failures possible
import { expect, test } from "@playwright/test";
```
## What the Fixture Detects
### HTTP Error Responses
The fixture intercepts all responses from `/api/` endpoints and fails the test if:
| Status Code | Meaning | Why it Fails |
|-------------|---------|-------------|
| 400 | Bad Request | Client sent invalid data — likely a bug in the frontend |
| 404 | Not Found | Resource doesn't exist — likely a stale ID or missing setup |
| 422 | Validation Error | Pydantic validation failed — likely a schema mismatch |
| 500 | Internal Server Error | Backend crash — always a bug |
### Flow Execution Errors
For streaming responses (build, chat), the fixture parses the event stream and fails if:
- JSON payload contains `"error": true`
- Response body contains Python exception patterns (`Traceback`, `Error`, etc.)
- The stream indicates a component build failure
## Allowing Expected Errors
Some tests intentionally trigger errors (testing error handling, validation feedback, etc.). Use `page.allowFlowErrors()` to prevent the fixture from failing on flow execution errors:
```typescript
test("should show error message on invalid component config", { tag: ["@release"] }, async ({ page }) => {
page.allowFlowErrors(); // Allow flow errors for THIS test only
await awaitBootstrapTest(page);
// ... test that triggers an error ...
await expect(page.getByText(/error/i)).toBeVisible();
});
```
**Note**: `allowFlowErrors()` only suppresses flow execution errors. HTTP 500 errors will still fail the test — those indicate a backend crash, not expected behavior.
## Error Reporting
When the fixture detects an error, it adds it to an internal list. After the test function completes, the fixture checks the list and fails with a descriptive message:
```
Test failed due to unexpected API errors:
- POST /api/v1/build/abc123/flow → 500 Internal Server Error
- Flow execution error: Traceback (most recent call last)...
```
This makes it easy to identify the root cause without adding manual response checks to every test.
## Timeout Behavior
The fixture reads response bodies with a 2-second timeout. If the body can't be read within 2 seconds (e.g., streaming response still in progress), it skips body parsing for that response. This prevents the fixture from blocking indefinitely on long-running streams.
## Global Teardown
`src/frontend/tests/globalTeardown.ts` runs after all tests complete and removes the temporary test database. This ensures a clean state for the next test run.
@@ -0,0 +1,180 @@
# E2E Test Helper Functions
All helpers are located in `src/frontend/tests/utils/`. Import them by name.
## Bootstrap & Setup
### `awaitBootstrapTest(page, options?)`
**Call this at the start of EVERY test.** Waits for the app to fully load and optionally opens the new project modal.
```typescript
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
// Default: waits for load + opens new project modal
await awaitBootstrapTest(page);
// Skip opening modal (for tests that start on an existing page)
await awaitBootstrapTest(page, { skipModal: true });
```
Why: Without this, tests race against the app's initialization. Components may not be rendered, stores may not be hydrated, and API calls may not be complete. Every flaky "element not found" failure is likely a missing `awaitBootstrapTest`.
### `initialGPTsetup(page, options?)`
Full OpenAI setup pipeline. Calls 6 steps in order:
1. `adjustScreenView` — fit view + zoom out
2. `updateOldComponents` — update any outdated components
3. `selectGptModel` — select gpt-4o-mini for all Language Model nodes
4. `addOpenAiInputKey` — fill OPENAI_API_KEY in all openai_api_key fields
5. `adjustScreenView` — fit again (components may have moved)
6. `unselectNodes` — click empty canvas to deselect
```typescript
// All steps
await initialGPTsetup(page);
// Skip specific steps
await initialGPTsetup(page, {
skipAdjustScreenView: true,
skipUpdateOldComponents: true,
skipSelectGptModel: true,
});
```
Why: Many tests need a working OpenAI-connected flow. This helper standardizes the setup so test authors don't reinvent it. The order matters — updating components before selecting models prevents stale model dropdowns.
## Canvas Controls
### `adjustScreenView(page, options?)`
Clicks "fit view" then zooms out. Ensures all nodes are visible and interactable.
```typescript
await adjustScreenView(page); // Default: fit + 1 zoom out
await adjustScreenView(page, { numberOfZoomOut: 3 }); // Fit + 3 zoom outs
```
Why: After adding components, some may be off-screen or overlapping. Fit view centers them; zoom out ensures click targets are large enough for Playwright to hit reliably.
### `zoomOut(page, times)`
Zooms out the canvas a specific number of times.
```typescript
await zoomOut(page, 5);
```
### `unselectNodes(page)`
Clicks the empty canvas area at position (0, 0) to deselect all nodes.
```typescript
await unselectNodes(page);
```
Why: Selected nodes show selection UI (toolbars, handles) that can overlay other elements. Deselecting before interacting with other elements prevents click interception.
## Component Setup
### `selectGptModel(page)`
Finds all Language Model nodes and selects `gpt-4o-mini` in their model dropdown.
Why: Tests that require LLM execution need a specific model. Using `gpt-4o-mini` keeps costs low and responses fast.
### `addOpenAiInputKey(page)`
Finds all fields with `data-testid="popover-anchor-input-openai_api_key"` and fills them with `process.env.OPENAI_API_KEY`.
**Warning**: Only works when the field renders as an `<input>`. If a global variable is selected (badge mode), this helper won't find the field. Check the template's `load_from_db` setting.
### `updateOldComponents(page)`
If the canvas shows an "Update all" button (outdated components), clicks it and waits for update to complete.
Why: Saved flows from older versions may have outdated component definitions. Updating them ensures the test runs against current component behavior, not stale cached versions.
## Inspection Panel
### `enableInspectPanel(page)`
Opens the canvas controls dropdown and toggles the inspection panel ON.
**MUST be called BEFORE any interaction with `edit-fields-button`.** Without it, the inspection panel is hidden and `edit-fields-button` does not exist in the DOM.
```typescript
await enableInspectPanel(page);
await page.getByTestId("title-OpenAI").click(); // Select a node
await page.getByTestId("edit-fields-button").click(); // Now visible
```
### `disableInspectPanel(page)`
Toggles the inspection panel OFF. Use for cleanup or when testing canvas-only behavior.
## Flow Management
### `renameFlow(page, options)`
Renames the current flow in the workspace.
```typescript
await renameFlow(page, { flowName: "My Test Flow" });
```
### `uploadFile(page, filename)`
Uploads a file from the `tests/assets/` directory.
```typescript
await uploadFile(page, "test-document.pdf");
```
## Event Delivery Modes
### `withEventDeliveryModes(title, config, testFn, options?)`
Wraps a test function to run it 3 times: once for each event delivery mode (streaming, polling, direct). Each mode is configured by intercepting the `/api/v1/config` route.
```typescript
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
withEventDeliveryModes(
"Document Q&A should process and respond",
{ tag: ["@release", "@starter-projects"] },
async ({ page }) => {
// Test body — runs 3 times with different delivery modes
await page.getByTestId("input-chat-playground").fill("What is this about?");
await page.keyboard.press("Enter");
await expect(page.getByTestId("div-chat-message")).toBeVisible({ timeout: 60000 });
},
{ timeout: 10000 }, // Optional delay between mode switches
);
```
Why: Langflow supports 3 event delivery modes. A bug that only appears in polling mode would be missed if tests only run in streaming mode. This helper ensures all modes are covered without writing 3x the tests.
## Advanced Patterns
### `openAdvancedOptions(page)` (LEGACY)
Opens the old edit modal for component configuration. **Deprecated** — use `enableInspectPanel` + `edit-fields-button` for new tests.
### `closeAdvancedOptions(page)` (LEGACY)
Closes the old edit modal. **Deprecated**.
## Creating New Helpers
When to create a new helper:
- The same 5+ lines of setup appear in 3+ test files (DRY)
- The pattern involves complex waits or retries that are easy to get wrong
- The helper encapsulates Langflow-specific knowledge (e.g., global variable badge behavior)
When NOT to create a helper:
- For a single test's setup (keep it inline)
- For generic Playwright operations (use Playwright API directly)
- For assertions (assertions should be explicit in the test, not hidden in helpers)
Place new helpers in `src/frontend/tests/utils/` with kebab-case naming: `my-new-helper.ts`.
@@ -0,0 +1,111 @@
# Langflow E2E Selector Catalog
This is the canonical reference for `data-testid` selectors used in Langflow E2E tests. When adding new interactive elements, follow these naming conventions and add the element to this catalog.
## Naming Convention
All `data-testid` values use kebab-case with a prefix that indicates the element type:
| Prefix | Element Type | Example |
|--------|-------------|---------|
| `input-` | Text inputs | `input-chat-playground`, `input-flow-name` |
| `button-` / `button_` | Action buttons | `button-send`, `button_run_chat output` |
| `icon-` | Icon buttons | `icon-Globe`, `icon-Lock`, `icon-ChevronLeft` |
| `popover-anchor-input-` | Component parameter fields | `popover-anchor-input-openai_api_key` |
| `add-component-button-` | Drag-to-add buttons | `add-component-button-chat-output` |
| `card-` | Flow/component cards | `card-my-flow-name` |
| `title-` | Node titles on canvas | `title-OpenAI`, `title-Chat Output` |
| `handle-` | Connection handles | `handle-{component}-{shownode}-{field}-{direction}` |
| `div-chat-message` | Chat messages | `div-chat-message` |
| `show` | Field visibility toggles | `showmodel_name`, `showtemperature` |
## Canvas & Navigation
| Selector | Element | Notes |
|----------|---------|-------|
| `blank-flow` | "New Blank Flow" button | On project creation modal |
| `sidebar-search-input` | Component search input | Sidebar search bar |
| `sidebar-nav-add_note` | Sticky note button | Sidebar navigation |
| `sidebar-add-sticky-note-button` | Add sticky note (new) | Updated button name |
| `react-flow-id` | ReactFlow canvas container | Use for drag targets |
| `canvas_controls_dropdown` | Canvas controls dropdown | Opens zoom/fit/inspector menu |
| `fit_view` | Fit view button | Inside canvas controls |
| `zoom_out` | Zoom out button | Inside canvas controls |
| `zoom_in` | Zoom in button | Inside canvas controls |
| `inspector-toggle` | Inspection panel toggle | Inside canvas controls dropdown |
## Component Fields
| Selector | Element | Notes |
|----------|---------|-------|
| `popover-anchor-input-{name}` | Component input field | `name` matches the field's `name` attribute |
| `popover-anchor-input-openai_api_key` | OpenAI API key field | Only visible when no global variable selected |
| `input_output{component}` | Output connection handle area | e.g., `input_outputChat Output` |
**Important**: When `load_from_db: true` and a global variable is selected, the field renders as a **badge** (not an `<input>`). The `popover-anchor-input-{name}` selector will NOT exist in the DOM for those fields.
## Actions & Buttons
| Selector | Element | Notes |
|----------|---------|-------|
| `button-send` | Send message button | Playground chat |
| `button_run_{component}` | Run component button | e.g., `button_run_chat output` |
| `publish-button` | Publish/deploy flow | Top toolbar |
| `save-flow-button` | Save flow | Top toolbar |
| `edit-fields-button` | Toggle field editor | Inspection panel — requires `enableInspectPanel()` first |
| `playground-btn-flow-io` | Playground button | Use `dispatchEvent("click")` to close (not `.click()`) |
| `manage-model-providers` | Model providers button | Settings |
## Modals & Panels
| Selector | Element | Notes |
|----------|---------|-------|
| `modal-title` | Modal heading | Generic modal title |
| `edit-button-modal` | Edit button (legacy) | Old modal pattern |
| `edit-button-close` | Close edit modal | Old modal pattern |
| `lock-flow-switch` | Flow lock toggle | Flow settings |
| `input-flow-name` | Flow name input | Flow settings modal |
| `input-flow-description` | Flow description input | Flow settings modal |
| `session-selector` | Session selector | Playground session switcher |
## Icons (as buttons)
| Selector | Action |
|----------|--------|
| `icon-Globe` | Open global variables |
| `icon-Lock` | Toggle flow lock |
| `icon-ChevronLeft` | Navigate back |
| `icon-Trash2` | Delete action |
| `icon-Plus` | Add/create action |
## Inspection Panel Field Toggles
These selectors toggle field visibility in the inspection panel. The format is `show{fieldname}` (no separator):
| Selector | Field |
|----------|-------|
| `showmodel_name` | Model name field |
| `showtemperature` | Temperature field |
| `showmax_tokens` | Max tokens field |
| `showopenai_api_key` | OpenAI API key field |
## When to Add New data-testid
Add `data-testid` to an element when:
1. E2E tests need to interact with it (click, fill, assert visibility)
2. The element has no stable `role` or `text` alternative
3. The element is dynamically rendered and needs a stable anchor
Format: `{type}-{descriptive-name}` in kebab-case. Keep it descriptive enough that a test author understands the element without reading the component source.
```tsx
// Right — descriptive, kebab-case
<button data-testid="save-flow-button">Save</button>
<input data-testid="input-flow-name" />
<div data-testid="card-flow-summary" />
// Wrong — too generic
<button data-testid="btn1">Save</button>
<input data-testid="input" />
<div data-testid="wrapper" />
```
@@ -0,0 +1,143 @@
---
name: frontend-code-review
description: "Review frontend code (.tsx, .ts, .js files) for quality, performance, and correctness against Langflow's frontend conventions. Supports pending-change reviews and file-targeted reviews."
---
# Frontend Code Review
## When to use this skill
Use this skill whenever the user asks to **review, analyze, or improve** frontend code (`.tsx`, `.ts`, `.js` files) under the `src/frontend/` directory. Supports the following review modes:
1. **Pending-change review** -- inspect staged or working-tree files slated for commit and flag checklist violations before submission.
2. **File-targeted review** -- review the specific file(s) the user names and report the relevant checklist findings.
Do NOT use this skill when:
- The request is about backend code (`.py` files under `src/backend/`).
- The user is not asking for a review/analysis/improvement of frontend code.
- The scope is outside `src/frontend/` (unless the user explicitly asks to review frontend-related changes elsewhere).
## How to use this skill
Follow these steps when using this skill:
1. **Identify the review mode** (pending-change vs file-targeted) based on the user's input. Keep the scope tight: review only what the user provided or explicitly referenced.
2. Follow the rules defined in the **Checklist** to perform the review. If no checklist rule matches, apply **General Review Rules** as a fallback.
3. Compose the final output strictly following the **Required Output Format**.
Notes when using this skill:
- Always include actionable fixes or suggestions (including possible code snippets).
- Use `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
- The Langflow frontend uses React 19, TypeScript 5.4, Vite 7 with SWC, Zustand for state management, TanStack React Query for server state, @xyflow/react v12 for graph visualization, Radix UI + shadcn-ui components, Tailwind CSS v3, and Biome for linting/formatting.
## Checklist
- **Code quality**: For any reviewed file, follow [references/code-quality.md](references/code-quality.md) to check styling conventions, TypeScript usage, Biome compliance, and component patterns.
- **Performance**: If the review scope involves React components, hooks, Zustand stores, React Query usage, or @xyflow/react node rendering, follow [references/performance.md](references/performance.md) to check for re-render issues, memoization, and data flow patterns.
- **Business logic**: If the review scope involves custom nodes (GenericNode), flow state, API calls, the component system, global variables, or the inspection panel, follow [references/business-logic.md](references/business-logic.md) to check for Langflow-specific correctness.
## General Review Rules
### 1. Security Review
Check for:
- XSS vulnerabilities (dangerouslySetInnerHTML, unescaped user input)
- Sensitive data exposure in client-side code
- Insecure direct object references in API calls
- Hardcoded secrets, tokens, or API keys
### 2. Accessibility Review
Check for:
- Missing aria labels on interactive elements
- Keyboard navigation support
- Proper use of semantic HTML elements
- Color contrast issues in custom styling
### 3. Code Quality Review
Check for:
- Code duplication (DRY violations — extract at 3+ identical usages)
- Functions/components doing too much (SRP violations — if you need "and" to describe it, split it)
- Deep nesting or complex conditionals (prefer early returns and guard clauses)
- Magic numbers/strings without named constants
- Poor naming: generic names (`data`, `result`, `temp`), missing verb prefixes on functions, missing `is`/`has`/`can`/`should` prefixes on booleans
- Missing error handling or error boundaries
- Incomplete TypeScript type coverage (no `any`, no `as any` casts)
- Comments that explain WHAT instead of WHY
- Commented-out code (use version control)
- Boolean parameters that switch component behavior (use two components instead)
- Mutable patterns where `const` or immutable alternatives exist
- Production files exceeding ~500 lines (red flag at 600+)
- `console.log` in production code (Biome flags this)
### 4. Testing Impact Review
Check for:
- Changes to data-testid attributes that may break E2E tests
- Modified component interfaces that require test updates
- New interactive elements missing data-testid attributes
### 5. Pre-Commit Verification
For pending-change reviews, verify:
- `npm run format` (Biome formatter) — zero diffs
- `npm run lint` (Biome linter) — zero errors
- `npm test` (Jest) — zero failures
## Required Output Format
When this skill is invoked, the response must exactly follow one of the two templates:
### Template A (any findings)
```markdown
# Code Review
Found <N> urgent issues that need to be fixed:
## 1. <brief description of issue>
FilePath: <path> line <line>
<relevant code snippet or pointer>
### Suggested fix
<brief description of suggested fix with code example>
---
... (repeat for each urgent issue) ...
Found <M> suggestions for improvement:
## 1. <brief description of suggestion>
FilePath: <path> line <line>
<relevant code snippet or pointer>
### Suggested fix
<brief description of suggested fix with code example>
---
... (repeat for each suggestion) ...
```
- If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
- If the issue count exceeds 10, summarize as "10+ urgent issues" or "10+ suggestions" and output only the first 10 items.
- Do not compress the blank lines between sections; keep them as-is for readability.
- If Template A is used (there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants the suggested fixes applied. For example: "Would you like me to apply the suggested fixes to address these issues?"
### Template B (no issues)
```markdown
## Code Review
No issues found.
```
@@ -0,0 +1,253 @@
# Rule Catalog -- Business Logic
Update this file when adding, editing, or removing Business Logic rules so the catalog remains accurate.
## Custom node components must not import stores directly outside the provider tree
IsUrgent: True
Category: Business Logic
### Description
File path pattern of node components: `src/frontend/src/CustomNodes/GenericNode/` and sub-components.
GenericNode and its child components render inside the @xyflow/react canvas, which has its own React context. These components may also render in contexts where certain store providers are not available (e.g., preview panels, template views). Directly importing and calling Zustand store hooks that depend on a specific provider context can cause blank screens or runtime errors when the component mounts outside that provider.
### Suggested Fix
Use the store hooks that are available globally (like `useFlowStore`, `useTypesStore`) or pass data as props from the parent that has provider access. If imperative store access is needed, use the store's `getState()` method which does not require a provider.
```tsx
// Wrong -- may fail outside provider tree
import { useSpecialContext } from "@/contexts/specialContext";
function NodeField() {
const { config } = useSpecialContext(); // crashes if provider missing
return <div>{config.label}</div>;
}
// Right -- use props or global store
function NodeField({ config }: { config: FieldConfig }) {
return <div>{config.label}</div>;
}
```
## Flow state management: use `flowStore` for current flow state
IsUrgent: True
Category: Business Logic
### Description
The current flow's state (nodes, edges, flow metadata) is managed through `useFlowStore` (located in `src/frontend/src/stores/flowStore.ts`). Always use this store for reading and modifying the active flow. Do not create parallel state tracking for flow data, and do not cache flow state in component-local state unless there is a specific performance reason (document it if so).
### Suggested Fix
```tsx
// Wrong -- local state duplicating flow data
const [nodes, setNodes] = useState(initialNodes);
// Right -- use the flow store
const nodes = useFlowStore((state) => state.nodes);
const setNodes = useFlowStore((state) => state.setNodes);
```
## API interceptor patterns: do not duplicate auth/retry logic
IsUrgent: True
Category: Business Logic
### Description
The Axios HTTP client in `src/frontend/src/controllers/API/` is configured with interceptors that handle authentication headers, token refresh, retry logic, and error normalization. Do not duplicate any of this logic in individual API call sites or components. Use the configured Axios instance (via the API controller helpers) for all backend requests. Do not create new Axios instances or use raw `fetch()` for API calls to the Langflow backend.
### Suggested Fix
```tsx
// Wrong -- raw fetch with manual auth
const response = await fetch("/api/v1/flows", {
headers: { Authorization: `Bearer ${token}` },
});
// Wrong -- new Axios instance bypassing interceptors
const client = axios.create({ baseURL: "/api/v1" });
const response = await client.get("/flows");
// Right -- use the project's API utilities
import { api } from "@/controllers/API/api";
const response = await api.get("/flows");
```
## Component data-testid patterns
IsUrgent: False
Category: Business Logic
### Description
Interactive components must include `data-testid` attributes following the project's established naming conventions. This is critical for E2E test stability. The primary patterns are:
- Input fields: `popover-anchor-input-{name}` (generated via `StrRenderComponent` -> `InputComponent` -> `CustomInputPopover`)
- Buttons: descriptive kebab-case, e.g., `edit-fields-button`, `playground-btn-flow-io`
- Sidebar items: `sidebar-nav-{item}`, `sidebar-add-sticky-note-button`
- Node-related: `title-{display_name}`, `handle-{id}`
When adding new interactive elements, follow the existing patterns. When modifying elements that have `data-testid`, preserve the attribute and its value to avoid breaking E2E tests.
### Suggested Fix
```tsx
// Wrong -- missing data-testid on interactive element
<button onClick={handleSave}>Save</button>
// Right -- includes data-testid
<button data-testid="save-flow-button" onClick={handleSave}>Save</button>
```
## Global variables: understand `load_from_db` and `value` pattern
IsUrgent: True
Category: Business Logic
### Description
Template fields in starter projects use the `load_from_db` + `value` pattern to reference global variables. When `load_from_db: true` and `value` is set to a key like `"OPENAI_API_KEY"`, the field displays a badge (via `InputGlobalComponent`) instead of a text input. The actual input element (`<input>`) does not exist in the DOM when a global variable is selected.
This has implications for:
- **Component rendering**: Do not assume an input element exists when `load_from_db` is true and a global variable is set.
- **E2E tests**: `getByTestId("popover-anchor-input-api_key")` will not find an element when a global variable badge is displayed.
- **Templates affected**: Market Research, Price Deal Finder, Research Agent have `load_from_db: true` with global variable values.
### Suggested Fix
When writing components that interact with template fields, check the `load_from_db` state:
```tsx
// Wrong -- assumes input always exists
const apiKeyInput = data.node.template.api_key;
// Directly rendering an <input> without checking load_from_db
// Right -- handle both states
if (field.load_from_db && field.value) {
// Render global variable badge
return <GlobalVariableBadge name={field.value} />;
}
// Render normal input
return <InputComponent value={field.value} onChange={handleChange} />;
```
## Inspection panel: use `enableInspectPanel` before accessing panel elements
IsUrgent: True
Category: Business Logic
### Description
The inspection panel (added in the panel migration from the old edit modal pattern) must be explicitly enabled before its elements are accessible. In E2E tests and in code that interacts with the panel programmatically:
1. Call `enableInspectPanel(page)` before attempting to access `edit-fields-button` or other panel controls.
2. Click a node to select it, then use `edit-fields-button` to toggle field editing.
3. The old `openAdvancedOptions(page)` / `closeAdvancedOptions(page)` pattern is deprecated.
4. The `editNode` flag is always `false`; `-edit` suffix testids no longer exist.
This rule is especially important when writing or modifying E2E tests that interact with node configuration.
## Node field visibility rules
IsUrgent: False
Category: Business Logic
### Description
Node fields have different visibility rules depending on where they are rendered. Understanding these rules is critical when modifying field display logic:
- **Canvas view**: Shows non-advanced, non-hidden fields. Controlled by `isCanvasVisible()` in `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts`.
- **Inspection panel**: Shows only advanced fields. Controlled by `shouldRenderInspectionPanelField()` in the same file.
- **`showNode` flag**: Defaults to `true`. When set to `false`, fields receive the `hidden` CSS class rather than being removed from the DOM.
When adding new fields or modifying visibility logic, ensure both canvas and inspection panel rendering paths are considered.
### Suggested Fix
```tsx
// Wrong -- only checking one visibility context
if (!field.advanced) {
return <FieldComponent />;
}
// Right -- check the appropriate visibility function for the context
import { isCanvasVisible, shouldRenderInspectionPanelField } from "@/CustomNodes/helpers/parameter-filtering";
// In canvas context
if (isCanvasVisible(field)) {
return <FieldComponent />;
}
// In inspection panel context
if (shouldRenderInspectionPanelField(field)) {
return <FieldComponent />;
}
```
## Customization layer usage
IsUrgent: False
Category: Business Logic
### Description
Langflow has a customization layer in `src/frontend/src/customization/` that allows overriding default behaviors and components. When adding new features that may need to be customized (e.g., branding, feature flags, custom components), use the customization layer rather than hardcoding values. Check `src/frontend/src/customization/` for existing customization points before adding new ones.
## React Hook Form + Zod for form validation
IsUrgent: False
Category: Business Logic
### Description
Forms that require validation should use React Hook Form with Zod schemas. Do not implement manual form state tracking or validation logic when React Hook Form can handle it. Define Zod schemas for form data types and use `zodResolver` to integrate them with React Hook Form.
### Suggested Fix
```tsx
// Wrong -- manual form state and validation
const [name, setName] = useState("");
const [error, setError] = useState("");
const handleSubmit = () => {
if (!name) {
setError("Name is required");
return;
}
// submit
};
// Right -- React Hook Form + Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormData = z.infer<typeof schema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
// submit
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
</form>
);
}
```
@@ -0,0 +1,475 @@
# Rule Catalog -- Code Quality
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
## Use `cn()` for conditional class names
IsUrgent: True
Category: Code Quality
### Description
All conditional CSS class logic must use the `cn()` utility from `@/utils` instead of manual string concatenation, template literals, or ternary expressions that build class strings. The `cn()` function wraps `clsx` and `tailwind-merge`, ensuring proper deduplication and conflict resolution of Tailwind classes.
### Suggested Fix
```tsx
// Wrong -- manual concatenation
<div className={`px-4 ${isActive ? "text-primary" : "text-muted-foreground"}`}>
// Wrong -- ternary without cn()
<div className={isActive ? "px-4 text-primary" : "px-4 text-muted-foreground"}>
// Right
import { cn } from "@/utils/utils";
<div className={cn("px-4", isActive ? "text-primary" : "text-muted-foreground")}>
```
## Tailwind-first styling
IsUrgent: True
Category: Code Quality
### Description
Favor Tailwind CSS utility classes for all styling. Do not introduce new CSS modules or custom CSS files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency, reduces bundle size, and simplifies maintenance. The project uses Tailwind v3 with a custom theme configuration.
Why this matters: Introducing custom CSS files creates a parallel styling system that falls out of sync with Tailwind tokens. When the design team changes a border color, they update the CSS variable in `index.css` — but your CSS module keeps the old hardcoded color. Custom CSS also bypasses Tailwind's purging, increasing bundle size. In Langflow, with 300+ components sharing a design system, inconsistent styling compounds fast.
### Suggested Fix
```tsx
// Wrong -- adding a CSS module for simple styling
import styles from "./Button.module.css";
<div className={styles.container}>
// Right -- Tailwind utilities
<div className="flex items-center gap-2 rounded-md border px-4 py-2">
```
## Use design tokens instead of raw Tailwind colors
IsUrgent: True
Category: Code Quality
### Description
**Never use raw Tailwind color classes** like `text-red-500`, `bg-blue-100`, `border-gray-300`, etc. The project defines a complete design token system via CSS custom properties in `src/frontend/src/style/index.css` and maps them to Tailwind classes in `tailwind.config.mjs`. These tokens support both light and dark mode automatically. Using raw colors bypasses the theme system, breaks dark mode, and creates visual inconsistency.
### Available Design Token Classes
**Core semantic colors** (use these for general UI):
| Purpose | Text | Background | Border |
|---------|------|------------|--------|
| Primary text/bg | `text-foreground` | `bg-background` | `border-border` |
| Primary action | `text-primary` | `bg-primary` | — |
| Primary action text | `text-primary-foreground` | — | — |
| Secondary | `text-secondary-foreground` | `bg-secondary` | — |
| Muted/subtle | `text-muted-foreground` | `bg-muted` | — |
| Destructive/danger | `text-destructive` | `bg-destructive` | — |
| Accent | `text-accent-foreground` | `bg-accent` | — |
| Cards | `text-card-foreground` | `bg-card` | — |
| Popovers | `text-popover-foreground` | `bg-popover` | — |
| Tooltips | `text-tooltip-foreground` | `bg-tooltip` | — |
| Input fields | — | — | `border-input` |
| Focus rings | — | — | `ring-ring` |
| Placeholder | `text-placeholder-foreground` | — | — |
**Status colors** (for build state, node status, alerts):
| Status | Class |
|--------|-------|
| Success | `bg-success-background`, `text-success-foreground` |
| Error | `bg-error-background`, `text-error-foreground`, `bg-error` |
| Info | `bg-info-background`, `text-info-foreground` |
| Warning | `bg-warning`, `text-warning-foreground` |
| Status indicators | `bg-status-green`, `bg-status-red`, `bg-status-yellow`, `bg-status-blue`, `bg-status-gray` |
**Accent colors** (for highlights, badges, categories):
| Accent | Background | Foreground |
|--------|------------|------------|
| Emerald | `bg-accent-emerald` | `text-accent-emerald-foreground` |
| Indigo | `bg-accent-indigo` | `text-accent-indigo-foreground` |
| Blue | `bg-accent-blue` | `text-accent-blue-foreground` |
| Pink | `bg-accent-pink` | `text-accent-pink-foreground` |
| Amber | `bg-accent-amber` | `text-accent-amber-foreground` |
**Data type colors** (for flow node field types):
| Type | Background | Foreground |
|------|------------|------------|
| String | `bg-datatype-yellow` | `text-datatype-yellow-foreground` |
| Number | `bg-datatype-blue` | `text-datatype-blue-foreground` |
| Boolean | `bg-datatype-lime` | `text-datatype-lime-foreground` |
| List | `bg-datatype-purple` | `text-datatype-purple-foreground` |
| Dict | `bg-datatype-indigo` | `text-datatype-indigo-foreground` |
| Object | `bg-datatype-gray` | `text-datatype-gray-foreground` |
| Error | `bg-datatype-red` | `text-datatype-red-foreground` |
| Message | `bg-datatype-emerald` | `text-datatype-emerald-foreground` |
**Sticky note colors**:
`bg-note-amber`, `bg-note-neutral`, `bg-note-rose`, `bg-note-blue`, `bg-note-lime`
**Other tokens**:
`bg-canvas` (flow canvas background), `text-node-ring` (node selection ring), `bg-code-background` / `text-code-foreground` (code blocks), `bg-hover` (hover state), `text-hard-zinc`, `bg-smooth-red`, `text-placeholder`
### Suggested Fix
```tsx
// Wrong -- raw Tailwind colors (breaks dark mode, no theme consistency)
<span className="text-red-500">Error</span>
<div className="bg-gray-100 border-gray-300">
<span className="text-blue-600">Link</span>
<div className="bg-green-50 text-green-800">Success</div>
// Right -- design tokens (automatic dark mode, theme-consistent)
<span className="text-destructive">Error</span>
<div className="bg-muted border-border">
<span className="text-accent-blue-foreground">Link</span>
<div className="bg-success-background text-success-foreground">Success</div>
// Wrong -- hardcoded hex or rgb values
<div style={{ color: "#ef4444" }}>
<div className="text-[#2563eb]">
// Right -- use CSS variable if no Tailwind class exists
<div className="text-status-red">
<div className="text-status-blue">
```
### When Raw Colors Are Acceptable
Raw Tailwind colors are only acceptable when:
1. Building a one-off demo or prototype (tag with `// TODO: replace with design token`)
2. The color is truly static and theme-independent (extremely rare)
3. An exact design spec requires a color with no matching token — in that case, **add a new CSS variable** to `src/frontend/src/style/index.css` (both `:root` and `.dark` variants) and register it in `tailwind.config.mjs` instead of using the raw color inline
## Class name ordering for overridable components
IsUrgent: False
Category: Code Quality
### Description
When a component accepts a `className` prop, always place it last inside `cn()` so downstream consumers can override or extend the component's default styling. The component's own classes come first, then the incoming `className`.
### Suggested Fix
```tsx
import { cn } from "@/utils/utils";
const Card = ({ className }: { className?: string }) => {
return (
<div className={cn("rounded-lg border bg-background p-4 shadow-sm", className)}>
{/* content */}
</div>
);
};
```
Why: If the component's own classes come after `className`, a consumer passing `className='bg-destructive'` gets silently overridden by the component's `bg-background`. The consumer's intent is lost. By placing `className` last in `cn()`, Tailwind Merge resolves conflicts in favor of the consumer.
## Strong TypeScript typing -- no `any`
IsUrgent: True
Category: Code Quality
### Description
Never use `any` as a type annotation. Using `any` disables TypeScript's ability to catch type mismatches at compile time — bugs that would be caught by the compiler instead crash in production. In Langflow, flow data structures are deeply nested (`NodeDataType.node.template[fieldName].value`); an `any` at any level silently allows accessing non-existent properties, causing 'Cannot read properties of undefined' in the canvas. Use `unknown` with type guards for genuinely unknown data, or specific types/generics for everything else.
### Suggested Fix
```ts
// Wrong
function processData(data: any) { ... }
// Right
function processData(data: Record<string, unknown>) { ... }
// Right -- with type guard
function processData(data: unknown) {
if (isFlowData(data)) { ... }
}
```
## Biome formatting conventions
IsUrgent: False
Category: Code Quality
### Description
The project uses Biome (not ESLint/Prettier) for linting and formatting. Follow Biome conventions: double quotes for strings, 2-space indentation, trailing commas in multi-line structures. Run `biome check` or `biome format` to verify compliance. Do not add ESLint or Prettier configuration files.
### Suggested Fix
```ts
// Wrong -- single quotes, 4-space indent
const name = 'flow'
const config = {
key: 'value'
}
// Right -- double quotes, 2-space indent, trailing comma
const name = "flow";
const config = {
key: "value",
};
```
## Prefer Radix UI primitives for accessible components
IsUrgent: False
Category: Code Quality
### Description
When building interactive UI elements (dialogs, dropdowns, tooltips, popovers, tabs, etc.), use Radix UI primitives via the shadcn-ui wrappers in `components/ui/`. Custom implementations of these patterns consistently miss: keyboard navigation (arrow keys in dropdowns), focus trapping (Tab stays inside dialogs), screen reader announcements (ARIA live regions), and Escape key handling. Rebuilding these from scratch introduces accessibility regressions that affect keyboard-only and screen reader users. Langflow is used by enterprise teams that may require WCAG compliance — every custom dropdown without proper focus management is a compliance risk.
### Suggested Fix
```tsx
// Wrong -- custom dropdown implementation
const [open, setOpen] = useState(false);
<div onBlur={() => setOpen(false)}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <div className="absolute">{/* items */}</div>}
</div>
// Right -- use shadcn/Radix DropdownMenu
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
<DropdownMenu>
<DropdownMenuTrigger>Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
## State management: Zustand for client, React Query for server
IsUrgent: True
Category: Code Quality
### Description
Use Zustand stores (in `src/frontend/src/stores/`) for client-side UI state and TanStack React Query (via hooks in `src/frontend/src/controllers/API/`) for server state (data fetching, caching, mutations). Do not store fetched server data in Zustand; let React Query manage it. Do not use React Query for purely client-side state.
### Suggested Fix
```ts
// Wrong -- storing fetched data in Zustand
const useFlowStore = create((set) => ({
flows: [],
fetchFlows: async () => {
const data = await api.getFlows();
set({ flows: data });
},
}));
// Right -- React Query for server state
const useFlows = () => {
return useQueryFunctionType<FlowType[]>(["flows"], api.getFlows);
};
// Right -- Zustand for client UI state
const useFlowStore = create((set) => ({
selectedNodeId: null,
setSelectedNodeId: (id: string | null) => set({ selectedNodeId: id }),
}));
```
## No `console.log` in production code
IsUrgent: True
Category: Code Quality
### Description
Do not leave `console.log`, `console.warn`, or `console.error` statements in production code. Biome enforces this rule. Use proper error handling, error boundaries, or toast notifications for user-facing errors. If logging is truly needed for debugging, remove it before committing.
Why: `console.log` in production code writes to the browser's developer console. In Langflow, component data often includes API keys stored in global variables — a `console.log(data)` accidentally leaks credentials to anyone who opens DevTools. Biome catches this at lint time; if Biome is not catching it, the linting step was skipped.
## Prefer `const` and immutable patterns
IsUrgent: False
Category: Code Quality
### Description
Use `const` by default. Only use `let` when reassignment is genuinely needed. Never use `var`. Prefer immutable update patterns (spread operator, `map`, `filter`) over mutation when working with arrays and objects, especially in state updates.
### Suggested Fix
```ts
// Wrong
let items = getItems();
items.push(newItem);
setState({ items });
// Right
const items = getItems();
setState({ items: [...items, newItem] });
```
Why: Mutation creates invisible dependencies between code paths. If function A mutates an array that function B reads, changing A's behavior silently breaks B with no compile error. React also relies on reference equality for re-renders — mutating an object in state doesn't trigger a re-render because `oldObj === newObj` is still `true`. Spread/map/filter create new references that React can detect.
## Functional components with hooks only
IsUrgent: True
Category: Code Quality
### Description
All React components must be functional components using hooks. Do not use class components, `React.Component`, or lifecycle methods (`componentDidMount`, etc.). Use `useEffect`, `useState`, `useMemo`, `useCallback`, and custom hooks instead.
Why: Class components have a larger API surface (lifecycle methods, `this` binding, constructor), making them harder to test and reason about. Hooks compose better — a custom hook can combine state, effects, and context without nesting HOCs. The entire Langflow codebase uses functional components; introducing a class component breaks consistency and forces other developers to context-switch between paradigms.
## Early returns for guard clauses
IsUrgent: False
Category: Code Quality
### Description
Use early returns to handle edge cases and guard conditions at the top of functions and components. This reduces nesting, improves readability, and makes the main logic path clear.
Deep nesting increases cognitive load: each additional level of indentation requires the reader to hold one more condition in working memory. Studies show that code with 3+ nesting levels has significantly higher defect rates. Guard clauses at the top make preconditions explicit and keep the main logic at the lowest indentation — the 'happy path' is visually clear.
### Suggested Fix
```tsx
// Wrong -- deeply nested
function NodeComponent({ data }: NodeProps) {
if (data) {
if (data.node) {
if (data.node.template) {
return <div>{/* main content */}</div>;
}
}
}
return null;
}
// Right -- early returns
function NodeComponent({ data }: NodeProps) {
if (!data?.node?.template) return null;
return <div>{/* main content */}</div>;
}
```
## Use Lucide React for icons
IsUrgent: False
Category: Code Quality
### Description
The project standardizes on Lucide React for icons. Do not import icons from other libraries (heroicons, react-icons, font-awesome, etc.) unless Lucide does not have an equivalent. Check the Lucide icon set first at https://lucide.dev before introducing a new icon dependency.
Why: Multiple icon libraries bloat the bundle (each library ships its own SVG set). Lucide is tree-shakeable — unused icons are removed at build time. Mixing libraries also creates visual inconsistency (different stroke widths, sizing conventions, visual weight). Langflow standardized on Lucide; check https://lucide.dev before adding a new dependency.
### Suggested Fix
```tsx
// Wrong
import { AiOutlineCheck } from "react-icons/ai";
// Right
import { Check } from "lucide-react";
```
## Follow the project's naming and folder conventions
IsUrgent: True
Category: Code Quality
### Description
All **new** frontend code must use **kebab-case** file names with descriptive names. The legacy codebase has mixed conventions (`index.tsx`, camelCase, PascalCase) — do NOT follow those patterns for new code.
**The standard for NEW code:**
- **All files**: kebab-case with a name that describes what the file does. **Never name a file `index.tsx`** — this is a legacy pattern that makes navigation and search harder.
- **Folders**: kebab-case, named after the feature or component.
- **Hooks**: kebab-case with `use-` prefix (`use-add-flow.ts`, `use-debounce.ts`).
- **Stores**: camelCase with "Store" suffix (`flowStore.ts`, `alertStore.ts`) — existing convention, keep it.
- **Types**: kebab-case folders with descriptive file names.
| What | Convention | Example |
|------|-----------|---------|
| Component file | kebab-case, descriptive | `flow-settings-panel.tsx`, `node-input-field.tsx` |
| Component folder | kebab-case | `flow-settings/`, `node-toolbar/` |
| Hook file | kebab-case, `use-` prefix | `use-save-flow.ts`, `use-debounce.ts` |
| Helper file | kebab-case, descriptive | `column-defs.ts`, `format-data.ts` |
| Type file | kebab-case | `flow-types.ts`, `api-types.ts` |
| Store file | camelCase + "Store" | `flowStore.ts` (existing convention) |
| UI component (shadcn) | kebab-case | `dropdown-menu.tsx` (shadcn standard) |
**Never `index.tsx`:**
```
// Wrong (legacy pattern — do NOT use for new code)
my-component/
├── index.tsx ← ambiguous, hard to find in search/tabs
// Right (new standard)
my-component/
├── my-component.tsx ← clear, searchable, self-documenting
├── components/
│ ├── sub-part.tsx
│ └── another-part.tsx
├── helpers/
│ ├── column-defs.ts
│ └── format-data.ts
├── hooks/
│ └── use-local-state.ts
└── types/
└── my-component-types.ts
```
**Key rule: helpers are LOCAL, not centralized.** Each component owns its own `helpers/` folder. Do not create shared helper files in unrelated directories.
### Suggested Fix
```tsx
// Wrong -- index.tsx (legacy pattern)
// src/frontend/src/components/core/myComponent/index.tsx
// Wrong -- centralized helper
// src/frontend/src/helpers/flowHelpers.ts
// Right -- kebab-case, descriptive name, local helpers
// src/frontend/src/components/core/my-component/my-component.tsx
// src/frontend/src/pages/FlowPage/components/flow-sidebar/helpers/format-flow.ts
```
## Use available shadcn components from `components/ui/`
IsUrgent: False
Category: Code Quality
### Description
Before building a custom interactive UI element, check if a shadcn/Radix component already exists in `src/frontend/src/components/ui/`. The project has 39+ base components available:
**Layout & Containers:** `accordion`, `card`, `dialog`, `dialog-with-no-close`, `disclosure`, `popover`, `separator`, `sidebar`, `simple-sidebar`, `tabs`, `tabs-button`
**Forms & Inputs:** `button`, `checkbox`, `input`, `label`, `radio-group`, `select`, `select-custom`, `switch`, `textarea`
**Feedback & Overlay:** `alert`, `badge`, `command`, `context-menu`, `dropdown-menu`, `loading`, `skeleton`, `skeletonGroup`, `tooltip`
**Visual:** `animated-close`, `background-gradient`, `checkmark`, `dot-background`, `refreshButton`, `table`, `text-loop`, `textAnimation`, `TextShimmer`, `xmark`
Do not recreate these patterns manually. Import from `@/components/ui/` instead.
@@ -0,0 +1,265 @@
# Rule Catalog -- Performance
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
## @xyflow/react data usage
IsUrgent: True
Category: Performance
### Description
When working with the flow graph powered by @xyflow/react v12, use `useNodes()` and `useEdges()` hooks for rendering UI that displays node/edge data. For callbacks that mutate or read node/edge state imperatively (e.g., inside event handlers), use `useReactFlow()` or `useStoreApi()` to access the store directly. Avoid pulling the full node/edge arrays in callbacks, as this creates unnecessary subscriptions and re-renders.
### Suggested Fix
```tsx
// Wrong -- subscribes to all node changes in a callback-only context
function MyPanel() {
const nodes = useNodes();
const handleExport = () => {
const data = nodes.map((n) => n.data);
exportFlow(data);
};
return <button onClick={handleExport}>Export</button>;
}
// Right -- use store API for imperative access
function MyPanel() {
const { getNodes } = useReactFlow();
const handleExport = () => {
const data = getNodes().map((n) => n.data);
exportFlow(data);
};
return <button onClick={handleExport}>Export</button>;
}
```
## Complex prop memoization with `useMemo`
IsUrgent: True
Category: Performance
### Description
Wrap complex prop values (objects, arrays, computed structures) in `useMemo` before passing them to child components. Without memoization, a new reference is created on every render, causing children wrapped in `React.memo` to re-render unnecessarily. This is especially critical in the flow canvas where hundreds of nodes may be rendered simultaneously.
### Suggested Fix
```tsx
// Wrong -- new object on every render
<GenericNode
config={{
template: data.node.template,
display_name: data.node.display_name,
}}
/>
// Right -- stable reference
const config = useMemo(
() => ({
template: data.node.template,
display_name: data.node.display_name,
}),
[data.node.template, data.node.display_name],
);
<GenericNode config={config} />
```
## Avoid re-renders with `React.memo` and `useCallback`
IsUrgent: True
Category: Performance
### Description
Use `React.memo` for components that receive stable props and should not re-render when their parent re-renders. Use `useCallback` for callback functions passed as props to memoized children. This is critical for node components on the canvas: GenericNode and its sub-components should be memoized to avoid cascading re-renders when the flow state changes.
### Suggested Fix
```tsx
// Wrong -- inline callback causes child re-render
function ParentNode({ id }: { id: string }) {
return (
<ChildComponent
onUpdate={(value: string) => updateNodeField(id, value)}
/>
);
}
// Right -- stable callback reference
function ParentNode({ id }: { id: string }) {
const handleUpdate = useCallback(
(value: string) => updateNodeField(id, value),
[id],
);
return <ChildComponent onUpdate={handleUpdate} />;
}
const ChildComponent = React.memo(({ onUpdate }: Props) => {
// ...
});
```
## Lazy loading for heavy pages
IsUrgent: False
Category: Performance
### Description
Use `React.lazy()` with `<Suspense>` for route-level code splitting of heavy pages (flow editor, settings, store, etc.). This reduces the initial bundle size and improves first-load performance. Route definitions should use lazy imports rather than static imports for page components.
### Suggested Fix
```tsx
// Wrong -- static import of heavy page
import FlowEditor from "@/pages/FlowEditor";
// Right -- lazy loaded
const FlowEditor = React.lazy(() => import("@/pages/FlowEditor"));
<Suspense fallback={<LoadingSpinner />}>
<FlowEditor />
</Suspense>
```
## No expensive operations in render functions
IsUrgent: True
Category: Performance
### Description
Do not perform expensive computations (deep object traversals, array sorting/filtering of large datasets, JSON parsing, regex on large strings) directly in the render body of a component. Move these into `useMemo` with appropriate dependencies, or into event handlers / effects if they produce side effects. The flow canvas may trigger frequent re-renders; keeping the render path lightweight is critical.
### Suggested Fix
```tsx
// Wrong -- sorts on every render
function NodeList({ nodes }: { nodes: NodeType[] }) {
const sorted = nodes
.filter((n) => n.data.showNode)
.sort((a, b) => a.data.node.display_name.localeCompare(b.data.node.display_name));
return <>{sorted.map((n) => <NodeCard key={n.id} node={n} />)}</>;
}
// Right -- memoized computation
function NodeList({ nodes }: { nodes: NodeType[] }) {
const sorted = useMemo(
() =>
nodes
.filter((n) => n.data.showNode)
.sort((a, b) =>
a.data.node.display_name.localeCompare(b.data.node.display_name),
),
[nodes],
);
return <>{sorted.map((n) => <NodeCard key={n.id} node={n} />)}</>;
}
```
## Zustand selector optimization
IsUrgent: True
Category: Performance
### Description
When reading from Zustand stores, always use selectors to subscribe to only the specific slices of state the component needs. Selecting the entire store object causes the component to re-render on every store update, even for unrelated state changes. Langflow has 15+ Zustand stores; careless subscriptions can create cascading performance problems.
### Suggested Fix
```ts
// Wrong -- subscribes to entire store, re-renders on any change
const store = useFlowStore();
const nodes = store.nodes;
// Wrong -- inline object selector creates new reference each render
const { nodes, edges } = useFlowStore((state) => ({
nodes: state.nodes,
edges: state.edges,
}));
// Right -- individual selectors for each value
const nodes = useFlowStore((state) => state.nodes);
const edges = useFlowStore((state) => state.edges);
// Right -- use useShallow for multiple values if needed
import { useShallow } from "zustand/react/shallow";
const { nodes, edges } = useFlowStore(
useShallow((state) => ({ nodes: state.nodes, edges: state.edges })),
);
```
## React Query: proper staleTime and gcTime configuration
IsUrgent: False
Category: Performance
### Description
Configure `staleTime` and `gcTime` (formerly `cacheTime`) appropriately for each query based on how frequently the data changes. Avoid leaving defaults (0ms staleTime) for data that rarely changes (e.g., component type definitions, user settings), as this causes unnecessary refetches. Conversely, do not set excessively long stale times for data that changes frequently (e.g., flow execution status).
### Suggested Fix
```ts
// Wrong -- default staleTime of 0, refetches on every mount
const { data } = useQuery({
queryKey: ["componentTypes"],
queryFn: getComponentTypes,
});
// Right -- component types rarely change, cache for 5 minutes
const { data } = useQuery({
queryKey: ["componentTypes"],
queryFn: getComponentTypes,
staleTime: 5 * 60 * 1000,
});
```
## Avoid creating new objects/arrays inline in JSX
IsUrgent: False
Category: Performance
### Description
Do not create new object or array literals directly inside JSX props, especially for configuration objects, data arrays, or callback payloads. Each render creates a new reference, which defeats `React.memo` and `useMemo` optimizations in child components. Extract these to constants outside the component, or memoize them inside the component.
**Note**: Since this project uses Tailwind CSS, inline `style={}` props should be extremely rare. Use Tailwind classes with design tokens instead (see code-quality.md). This rule applies primarily to non-style object props like `config`, `data`, `options`, `columns`, etc.
### Suggested Fix
```tsx
// Wrong -- new config object every render, child re-renders even if data hasn't changed
function FlowSettings({ flowId }: { flowId: string }) {
return (
<SettingsPanel
config={{ flowId, autoSave: true, showMinimap: false }}
/>
);
}
// Right -- constant outside component for static values
const DEFAULT_SETTINGS = { autoSave: true, showMinimap: false } as const;
function FlowSettings({ flowId }: { flowId: string }) {
const config = useMemo(
() => ({ flowId, ...DEFAULT_SETTINGS }),
[flowId],
);
return <SettingsPanel config={config} />;
}
// Wrong -- new array every render
<SelectComponent options={["option1", "option2", "option3"]} />
// Right -- constant outside component
const OPTIONS = ["option1", "option2", "option3"] as const;
<SelectComponent options={OPTIONS} />
```
@@ -0,0 +1,72 @@
---
name: frontend-query-mutation
description: Guide for implementing Langflow frontend query and mutation patterns with Axios and TanStack React Query v5. Trigger when creating or updating API hooks in controllers/API/queries, consuming UseRequestProcessor in components, deciding whether to use useQuery or useMutation, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy API calls to the query hook pattern.
---
# Frontend Query & Mutation
## Intent
- Keep API hooks in `controllers/API/queries/{domain}/` as the organized source of truth.
- Use `UseRequestProcessor` for consistent retry, invalidation, and error handling.
- Use Axios `api` instance for all HTTP calls (never raw `fetch` for REST).
- Use `performStreamingRequest()` for streaming operations (build, chat).
- Keep cache invalidation in mutation `onSettled` callbacks.
## Architecture Overview
Langflow uses **Axios + TanStack React Query v5** for data fetching. There is no oRPC or contract layer. The architecture is:
```
Component
-> API hook (controllers/API/queries/{domain}/use-{verb}-{resource}.ts)
-> UseRequestProcessor (controllers/API/services/request-processor.ts)
-> useQuery / useMutation (TanStack React Query)
-> queryFn / mutationFn
-> api.get/post/patch/delete (Axios instance from controllers/API/api.tsx)
```
### Key Files
| File | Purpose |
|------|---------|
| `controllers/API/api.tsx` | Axios instance, `ApiInterceptor` component, `performStreamingRequest()` |
| `controllers/API/services/request-processor.ts` | `UseRequestProcessor` hook wrapping `useQuery`/`useMutation` with retry defaults |
| `controllers/API/helpers/constants.ts` | URL constants (`URLs`) and `getURL()` helper |
| `controllers/API/queries/{domain}/` | Domain-organized query and mutation hooks |
| `types/api/index.ts` | `useQueryFunctionType`, `useMutationFunctionType` type helpers |
| `stores/` | Zustand stores for client-side state |
## Workflow
1. Identify the change surface.
- Read `references/query-patterns.md` for hook structure, naming, URL patterns, and query/mutation call-site shape.
- Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and streaming.
- Read both references when a task spans hook creation and runtime behavior.
2. Implement the hook following existing patterns.
- Use `UseRequestProcessor` for `query()` and `mutate()` wrappers.
- Use the `api` Axios instance for all HTTP calls.
- Use `getURL()` for constructing API paths.
- Type the hook with `useQueryFunctionType` or `useMutationFunctionType`.
3. Preserve Langflow conventions.
- Query keys are arrays with the hook name: `["useGetFlows"]`.
- Mutations invalidate related queries in `onSettled`.
- Zustand store updates happen in the query/mutation function, not in components.
- `UseRequestProcessor` provides default retry (5 for queries, 3 for mutations) with exponential backoff.
## Files Commonly Touched
- `controllers/API/queries/{domain}/use-get-*.ts` (query hooks)
- `controllers/API/queries/{domain}/use-post-*.ts` (create mutations)
- `controllers/API/queries/{domain}/use-patch-*.ts` (update mutations)
- `controllers/API/queries/{domain}/use-delete-*.ts` (delete mutations)
- `controllers/API/helpers/constants.ts` (URL constants)
- `types/api/index.ts` (API type definitions)
- `stores/` (Zustand store updates triggered by queries)
## References
- Use `references/query-patterns.md` for hook structure, naming conventions, directory layout, and anti-patterns.
- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, error handling, and streaming requests.
Treat this skill as the single query and mutation entry point for Langflow frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs.
@@ -0,0 +1,472 @@
# Query and Mutation Patterns
## Table of Contents
- Intent
- Directory structure
- Query hook structure
- Mutation hook structure
- URL constants
- Query keys
- Type signatures
- Anti-patterns
## Intent
- Keep API hooks in `controllers/API/queries/{domain}/` organized by domain.
- Use `UseRequestProcessor` for consistent behavior across all hooks.
- Use the shared `api` Axios instance for all REST calls.
- Type hooks with `useQueryFunctionType` or `useMutationFunctionType` from `types/api/`.
## Directory Structure
```text
controllers/API/
api.tsx # Axios instance, interceptors, streaming
helpers/
constants.ts # URL constants and getURL()
services/
request-processor.ts # UseRequestProcessor hook
queries/
flows/
use-get-flow.ts
use-get-refresh-flows-query.ts
use-post-add-flow.ts
use-delete-delete-flows.ts
use-get-download-flows.ts
index.ts
folders/
use-get-folder.ts
use-get-folders.ts
use-post-folders.ts
use-patch-folders.ts
use-delete-folders.ts
index.ts
variables/
use-get-global-variables.ts
use-post-global-variables.ts
use-patch-global-variables.ts
index.ts
auth/
use-get-autologin.ts
use-post-login-user.ts
use-post-logout.ts
use-post-refresh-access.ts
index.ts
messages/
use-get-messages.ts
use-get-messages-polling.ts
use-delete-messages.ts
index.ts
```
### Naming Convention
- **Queries**: `use-get-{resource}.ts` (e.g., `use-get-flow.ts`, `use-get-global-variables.ts`)
- **Create mutations**: `use-post-{resource}.ts` (e.g., `use-post-add-flow.ts`)
- **Update mutations**: `use-patch-{resource}.ts` (e.g., `use-patch-global-variables.ts`)
- **Delete mutations**: `use-delete-{resource}.ts` (e.g., `use-delete-messages.ts`)
- **Other mutations**: `use-{action}-{resource}.ts` (e.g., `use-rename-session.ts`)
## Query Hook Structure
Every query hook follows this structure:
```typescript
import type { UseQueryResult } from "@tanstack/react-query"
import type { useQueryFunctionType } from "@/types/api"
import { api } from "../../api"
import { getURL } from "../../helpers/constants"
import { UseRequestProcessor } from "../../services/request-processor"
// 1. Define the response type (or import from types/)
interface GlobalVariable {
id: string
name: string
value: string
type: string
}
// 2. Export the hook typed with useQueryFunctionType
export const useGetGlobalVariables: useQueryFunctionType<
undefined,
GlobalVariable[]
> = (options?) => {
// 3. Get query helper from UseRequestProcessor
const { query } = UseRequestProcessor()
// 4. Define the query function using the api Axios instance
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
const res = await api.get(`${getURL("VARIABLES")}/`)
return res.data
}
// 5. Return the query result
const queryResult: UseQueryResult<GlobalVariable[], Error> = query(
["useGetGlobalVariables"],
getGlobalVariablesFn,
{
refetchOnWindowFocus: false,
...options,
},
)
return queryResult
}
```
### Query Hook with Parameters
When a query takes parameters, use the first type argument:
```typescript
export const useGetFolder: useQueryFunctionType<
{ id: string },
FolderResponse
> = (params, options?) => {
const { query } = UseRequestProcessor()
const getFolderFn = async (): Promise<FolderResponse> => {
const res = await api.get(`${getURL("FOLDERS")}/${params.id}`)
return res.data
}
return query(["useGetFolder", params.id], getFolderFn, {
...options,
})
}
```
### Important: Some "get" Hooks Use Mutation
In the Langflow codebase, some hooks named `use-get-*` (e.g., `useGetFlow`) are actually **mutation hooks** typed with `useMutationFunctionType`. This happens when the "get" operation is triggered imperatively (on demand) rather than declaratively (on mount/re-render). Check the actual type signature before following the query pattern — if a hook uses `mutate` from `UseRequestProcessor`, follow the Mutation Hook Structure below instead.
### Query Hook with Store Updates
Many Langflow queries update Zustand stores as a side effect within the query function:
```typescript
export const useGetGlobalVariables: useQueryFunctionType<
undefined,
GlobalVariable[]
> = (options?) => {
const { query } = UseRequestProcessor()
// Access store setters
const setGlobalVariablesEntries = useGlobalVariablesStore(
(state) => state.setGlobalVariablesEntries,
)
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
const res = await api.get(`${getURL("VARIABLES")}/`)
// Update store as side effect of fetching
setGlobalVariablesEntries(res.data.map((entry) => entry.name))
return res.data
}
return query(["useGetGlobalVariables"], getGlobalVariablesFn, {
refetchOnWindowFocus: false,
...options,
})
}
```
## Mutation Hook Structure
Every mutation hook follows this structure:
```typescript
import type { UseMutationResult } from "@tanstack/react-query"
import type { useMutationFunctionType } from "@/types/api"
import { api } from "../../api"
import { getURL } from "../../helpers/constants"
import { UseRequestProcessor } from "../../services/request-processor"
// 1. Define the payload type
interface PostAddFlowPayload {
name: string
data: ReactFlowJsonObject
description: string
folder_id: string
}
// 2. Export the hook typed with useMutationFunctionType
export const usePostAddFlow: useMutationFunctionType<
undefined, // Params (undefined if none)
PostAddFlowPayload // Variables (mutation payload)
> = (options?) => {
// 3. Get mutate helper and queryClient from UseRequestProcessor
const { mutate, queryClient } = UseRequestProcessor()
// 4. Define the mutation function using the api Axios instance
const postAddFlowFn = async (payload: PostAddFlowPayload): Promise<any> => {
const response = await api.post(`${getURL("FLOWS")}/`, {
name: payload.name,
data: payload.data,
description: payload.description,
folder_id: payload.folder_id || null,
})
return response.data
}
// 5. Return the mutation with cache invalidation in onSettled
// NOTE: Place hook-specific options (onSettled, retry) BEFORE ...options
// so consumers can override them if needed. This matches the codebase convention.
const mutation: UseMutationResult<any, any, PostAddFlowPayload> = mutate(
["usePostAddFlow"],
postAddFlowFn,
{
onSettled: (response) => {
if (response) {
// Invalidate related queries so they refetch
queryClient.refetchQueries({
queryKey: ["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }],
})
queryClient.refetchQueries({
queryKey: ["useGetFolder", response.folder_id],
})
}
},
...options, // Consumer options come LAST (can override onSettled, retry, etc.)
},
)
return mutation
}
```
### Delete Mutation Example
```typescript
export const useDeleteMessages: useMutationFunctionType<undefined, string[]> = (
options?,
) => {
const { mutate, queryClient } = UseRequestProcessor()
const deleteMessagesFn = async (messageIds: string[]): Promise<void> => {
await api.delete(`${getURL("MESSAGES")}/`, {
data: messageIds,
})
}
const mutation = mutate(["useDeleteMessages"], deleteMessagesFn, {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["useGetMessages"] })
},
...options,
})
return mutation
}
```
### Patch Mutation Example
```typescript
export const usePatchGlobalVariables: useMutationFunctionType<
undefined,
{ id: string; name: string; value: string; type: string }
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor()
const patchGlobalVariablesFn = async (payload: {
id: string
name: string
value: string
type: string
}): Promise<GlobalVariable> => {
const response = await api.patch(
`${getURL("VARIABLES")}/${payload.id}`,
payload,
)
return response.data
}
const mutation = mutate(["usePatchGlobalVariables"], patchGlobalVariablesFn, {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["useGetGlobalVariables"] })
},
retry: false,
...options,
})
return mutation
}
```
## URL Constants
All API paths are defined in `controllers/API/helpers/constants.ts`:
```typescript
export const URLs = {
FLOWS: "flows",
FOLDERS: "projects",
VARIABLES: "variables",
MESSAGES: "monitor/messages",
BUILDS: "monitor/builds",
API_KEY: "api_key",
FILES: "files",
// ...
} as const
```
Use `getURL()` to construct full API paths:
```typescript
import { getURL } from "../../helpers/constants"
// Basic: /api/v1/flows
const url = getURL("FLOWS")
// With params: /api/v1/flows/{flowId}
const url = `${getURL("FLOWS")}/${flowId}`
// With v2 flag: /api/v2/flows
const url = getURL("FLOWS", {}, true)
```
When adding new endpoints, add the constant to `URLs` first, then use `getURL()` in the hook.
## Query Keys
Query keys are arrays that uniquely identify cached data. Langflow conventions:
```typescript
// Hook name as key (no params)
["useGetGlobalVariables"]
// Hook name + params for cache isolation
["useGetFlow", flowId]
["useGetFolder", folderId]
// Hook name + object params for complex queries
["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }]
["useGetMessages", { flowId, sessionId, page }]
```
Rules:
- The first element is always the hook name as a string.
- Additional elements are parameters that differentiate cache entries.
- Use objects for multi-parameter queries.
- Keep keys consistent so invalidation works correctly.
## Type Signatures
### useQueryFunctionType
Used for query hooks. Defined in `types/api/index.ts`:
```typescript
// Without params: useQueryFunctionType<undefined, ResponseType>
// The hook signature becomes: (options?) => UseQueryResult<ResponseType>
// With params: useQueryFunctionType<ParamsType, ResponseType>
// The hook signature becomes: (params, options?) => UseQueryResult<ResponseType>
```
### useMutationFunctionType
Used for mutation hooks:
```typescript
// Without params: useMutationFunctionType<undefined, VariablesType>
// The hook signature becomes: (options?) => UseMutationResult<Data, Error, Variables>
// With params: useMutationFunctionType<ParamsType, VariablesType>
// The hook signature becomes: (params, options?) => UseMutationResult<Data, Error, Variables>
```
## Anti-Patterns
### Do Not Bypass UseRequestProcessor
```typescript
// Do not call useQuery/useMutation directly
const result = useQuery({
queryKey: ["flows"],
queryFn: () => api.get("/api/v1/flows"),
})
// Use UseRequestProcessor for consistent retry and error handling
const { query } = UseRequestProcessor()
const result = query(["useGetFlows"], () => api.get(getURL("FLOWS")))
```
### Do Not Bypass the api Instance
```typescript
// Do not use raw axios or fetch for REST calls
const result = await axios.get("/api/v1/flows")
const result = await fetch("/api/v1/flows")
// Use the shared api instance (has interceptors for auth, headers, error handling)
const result = await api.get(`${getURL("FLOWS")}/`)
```
### Do Not Hardcode URLs
```typescript
// Do not hardcode API paths
const result = await api.get("/api/v1/variables/")
// Use getURL() helper
const result = await api.get(`${getURL("VARIABLES")}/`)
```
### Do Not Duplicate Query Keys
```typescript
// Do not use different key strings for the same query
query(["getFlows"], fetchFn) // in hook A
query(["useGetFlows"], fetchFn) // in hook B
query(["flows-list"], fetchFn) // in hook C
// Use one canonical key matching the hook name
query(["useGetFlows"], fetchFn)
```
### Do Not Invalidate from Components
```typescript
// Do not put invalidation logic in components
const Component = () => {
const queryClient = useQueryClient()
const { mutate } = useDeleteFlow()
const handleDelete = () => {
mutate(flowId, {
onSuccess: () => {
// Avoid: invalidation knowledge leaks into component
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
queryClient.invalidateQueries({ queryKey: ["useGetFolder"] })
},
})
}
}
// Put invalidation in the mutation hook's onSettled
export const useDeleteFlow = (options?) => {
const { mutate, queryClient } = UseRequestProcessor()
return mutate(["useDeleteFlow"], deleteFlowFn, {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
queryClient.invalidateQueries({ queryKey: ["useGetFolder"] })
},
...options, // Consumer options come LAST
})
}
```
### Do Not Create Thin Wrapper Hooks
```typescript
// Do not create hooks that just re-export an existing query hook
const useFlows = () => {
return useGetRefreshFlowsQuery({ get_all: true })
}
// Import the existing hook directly where needed
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows"
```
@@ -0,0 +1,415 @@
# Runtime Rules
## Table of Contents
- Conditional queries
- Cache invalidation
- Query key conventions
- `mutate` vs `mutateAsync`
- Error handling
- Streaming requests
- UseRequestProcessor defaults
## Conditional Queries
Use the `enabled` option to conditionally run queries. Langflow hooks pass through `options` to `UseRequestProcessor`, which passes them to `useQuery`.
```typescript
// Pattern: Disable query when not authenticated
export const useGetGlobalVariables: useQueryFunctionType<
undefined,
GlobalVariable[]
> = (options?) => {
const { query } = UseRequestProcessor()
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
if (!isAuthenticated) return []
const res = await api.get(`${getURL("VARIABLES")}/`)
return res.data
}
return query(["useGetGlobalVariables"], getGlobalVariablesFn, {
refetchOnWindowFocus: false,
enabled: isAuthenticated && (options?.enabled ?? true),
...options,
})
}
```
```typescript
// Pattern: Disable query when required param is missing
export const useGetFlow: useQueryFunctionType<{ id: string }, FlowResponse> = (
params,
options?,
) => {
const { query } = UseRequestProcessor()
const getFlowFn = async (): Promise<FlowResponse> => {
const res = await api.get(`${getURL("FLOWS")}/${params.id}`)
return res.data
}
return query(["useGetFlow", params.id], getFlowFn, {
enabled: !!params.id && (options?.enabled ?? true),
...options,
})
}
```
```typescript
// Pattern: Consumer disables query via options
const { data: flow } = useGetFlow(
{ id: flowId },
{ enabled: showFlowDetails },
)
```
Rules:
- Always check `options?.enabled ?? true` when combining with other conditions, so consumers can also disable the query.
- Guard early in the query function when auth or required data might be missing.
- Do not use non-null assertions on params to work around missing data; use `enabled` instead.
## Cache Invalidation
Bind invalidation in the mutation hook definition. Components should only add UI feedback (toasts, navigation) in call-site callbacks, not decide which queries to invalidate.
### Invalidation via onSettled
The `UseRequestProcessor.mutate()` wrapper already calls `queryClient.invalidateQueries({ queryKey: mutationKey })` in its default `onSettled`. For additional invalidation, extend `onSettled`:
```typescript
export const usePostAddFlow: useMutationFunctionType<
undefined,
PostAddFlowPayload
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor()
const myCollectionId = useFolderStore((state) => state.myCollectionId)
const postAddFlowFn = async (payload: PostAddFlowPayload): Promise<any> => {
const response = await api.post(`${getURL("FLOWS")}/`, payload)
return response.data
}
return mutate(["usePostAddFlow"], postAddFlowFn, {
onSettled: (response) => {
if (response) {
queryClient.refetchQueries({
queryKey: ["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }],
})
queryClient.refetchQueries({
queryKey: ["useGetFolder", response.folder_id ?? myCollectionId],
})
}
},
...options, // Consumer options come LAST
})
}
```
### Invalidation Patterns
```typescript
// Broad invalidation: all queries for a domain
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
// Specific invalidation: single cache entry
queryClient.invalidateQueries({ queryKey: ["useGetFlow", flowId] })
// Refetch instead of invalidate when you need data immediately
queryClient.refetchQueries({ queryKey: ["useGetFolder", folderId] })
```
### Component-side callbacks are for UI only
```typescript
// Component only adds UI behavior
const { mutate: addFlow } = usePostAddFlow()
const handleCreate = () => {
addFlow(flowData, {
onSuccess: (response) => {
// UI-only: navigate, show toast
navigate(`/flow/${response.id}`)
setSuccessData({ title: "Flow created successfully" })
},
onError: (error) => {
setErrorData({
title: "Failed to create flow",
list: [error.message],
})
},
})
}
```
## Query Key Conventions
```typescript
// Base key: hook name
["useGetGlobalVariables"]
// Parameterized key: hook name + params
["useGetFlow", flowId]
["useGetFolder", folderId]
// Complex key: hook name + param object
["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }]
["useGetMessages", { flowId, sessionId }]
["useGetBuilds", { flowId }]
// Mutation key: hook name (used for automatic invalidation by UseRequestProcessor)
["usePostAddFlow"]
["useDeleteMessages"]
```
Rules:
- First element is always the hook name as a string.
- Keep mutation keys matching the hook name.
- `UseRequestProcessor.mutate()` auto-invalidates the `mutationKey` on settled.
- Additional invalidation targets must be explicitly added in `onSettled`.
## `mutate` vs `mutateAsync`
Prefer `mutate` by default. Use `mutateAsync` only when Promise semantics are truly required.
Rules:
- Event handlers should call `mutate(...)` with `onSuccess` or `onError`.
- Every `await mutateAsync(...)` must be wrapped in `try/catch`.
- Do not use `mutateAsync` when callbacks already express the flow clearly.
```typescript
// Default: use mutate with callbacks
const { mutate: deleteFlow } = useDeleteFlow()
const handleDelete = () => {
deleteFlow(flowId, {
onSuccess: () => {
navigate("/flows")
setSuccessData({ title: "Flow deleted" })
},
onError: (error) => {
setErrorData({ title: "Delete failed", list: [error.message] })
},
})
}
// Exception: Promise semantics needed for sequential operations
const handleDuplicateAndOpen = async () => {
try {
const newFlow = await duplicateFlow.mutateAsync(flowData)
await renameFlow.mutateAsync({ id: newFlow.id, name: `${flowData.name} (copy)` })
navigate(`/flow/${newFlow.id}`)
} catch (error) {
setErrorData({
title: "Failed to duplicate flow",
list: [error instanceof Error ? error.message : "Unknown error"],
})
}
}
```
## Error Handling
### API Interceptor Handles Auth Errors
The `ApiInterceptor` in `controllers/API/api.tsx` automatically handles:
- **401 Unauthorized**: Attempts token refresh via `useRefreshAccessToken`, then retries the request.
- **403 Forbidden**: Same auth error flow as 401.
- **After 3+ auth errors**: Logs the user out automatically.
- **500 errors**: Clears build vertex state in the flow store.
You do not need to handle 401/403 errors in individual hooks or components.
### Mutation Error Handling
Handle errors at the call site for user-facing feedback:
```typescript
const { mutate: saveFlow } = useSaveFlow()
const handleSave = () => {
saveFlow(flowData, {
onError: (error) => {
setErrorData({
title: "Failed to save flow",
list: [error.response?.data?.detail ?? error.message],
})
},
})
}
```
### Query Error Handling
For queries, React Query's retry logic (5 retries with exponential backoff via `UseRequestProcessor`) handles transient failures. For permanent errors, use `onError` in options or error boundaries:
```typescript
const { data, error, isError } = useGetFlow(
{ id: flowId },
{
retry: false, // Override default retry for known-missing resources
onError: (error) => {
if (error.response?.status === 404) {
navigate("/flows")
}
},
},
)
```
## Streaming Requests
Langflow uses `performStreamingRequest()` from `controllers/API/api.tsx` for build and chat streaming. This uses the browser `fetch` API (not Axios) with Server-Sent Events (SSE) parsing.
### Streaming Architecture
```typescript
import { performStreamingRequest } from "@/controllers/API/api"
const buildController = new AbortController()
await performStreamingRequest({
method: "POST",
url: `${baseURL}/api/v1/build/${flowId}/flow`,
body: { inputs, files },
buildController,
onData: async (event) => {
// Process individual SSE events
// Return true to continue, false to abort
return true
},
onDataBatch: async (events) => {
// Process batch of events from a single chunk (more efficient)
// Return true to continue, false to abort
return true
},
onError: (statusCode) => {
// Handle HTTP error status
},
onNetworkError: (error) => {
// Handle network-level errors
},
})
```
### Streaming vs REST Decision
| Operation | Method |
|-----------|--------|
| Build flow | `performStreamingRequest()` with SSE |
| Chat interaction | `performStreamingRequest()` with SSE |
| CRUD operations (flows, folders, variables) | Axios `api` instance via query/mutation hooks |
| File upload/download | Axios `api` instance |
| Auth operations | Axios `api` instance |
### Aborting Streams
Use `AbortController` to cancel streaming operations:
```typescript
const buildController = useRef(new AbortController())
const handleStopBuild = () => {
buildController.current.abort()
buildController.current = new AbortController()
}
```
## UseRequestProcessor Defaults
The `UseRequestProcessor` hook in `controllers/API/services/request-processor.ts` provides:
### Query Defaults
```typescript
{
retry: 5,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// 1s, 2s, 4s, 8s, 16s (capped at 30s)
}
```
### Mutation Defaults
The actual `mutate()` implementation in `request-processor.ts`:
```typescript
function mutate(mutationKey, mutationFn, options = {}) {
return useMutation({
mutationKey,
mutationFn,
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey: mutationKey });
options.onSettled && options.onSettled(data, error, variables, context);
},
...options, // Spreads AFTER the wrapper onSettled
retry: options.retry ?? 3, // Comes AFTER the spread
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
```
**How onSettled actually works (important subtlety):**
1. `UseRequestProcessor.mutate()` defines a wrapper `onSettled` that (a) auto-invalidates the `mutationKey`, then (b) calls the hook's `options.onSettled`.
2. **However**, the `...options` spread comes AFTER the wrapper, so if the hook's `options` object contains its own `onSettled`, it **overrides** the wrapper. This means the auto-invalidation is effectively **skipped** when hooks provide their own `onSettled`.
3. In practice, most hooks DO provide their own `onSettled` with specific refetch/invalidation logic, so the auto-invalidation of the mutation key rarely runs.
4. This is fine because invalidating a mutation key is usually unnecessary — mutations don't have cached entries like queries do.
**Hook convention: place custom `onSettled` BEFORE `...options`:**
```typescript
mutate(["usePostAddFlow"], fn, {
onSettled: () => { queryClient.refetchQueries(...) }, // Hook-specific invalidation
retry: false, // Override default retry if needed
...options, // Consumer options come LAST (can override onSettled, retry, etc.)
})
```
### When to Use `retry: false`
Override the default `retry: 3` with `retry: false` for mutations where retrying would cause problems:
- **Create operations** that are not idempotent (duplicate creation risk)
- **Update operations** on global variables or settings (stale data risk)
- **Delete operations** where the resource may already be gone
```typescript
// Example: POST that creates a resource (not safe to retry)
const mutation = mutate(["usePostGlobalVariables"], postFn, {
onSettled: () => { queryClient.refetchQueries({ queryKey: ["useGetGlobalVariables"] }) },
retry: false,
...options,
})
```
## Polling Patterns
For queries that need periodic refreshing (build status, messages):
```typescript
export const useGetMessagesPolling: useQueryFunctionType<
{ flowId: string; sessionId: string },
Message[]
> = (params, options?) => {
const { query } = UseRequestProcessor()
const getMessagesFn = async (): Promise<Message[]> => {
const res = await api.get(
`${getURL("MESSAGES")}/?flow_id=${params.flowId}&session_id=${params.sessionId}`,
)
return res.data
}
return query(
["useGetMessagesPolling", params.flowId, params.sessionId],
getMessagesFn,
{
refetchInterval: 3000, // Poll every 3 seconds
refetchIntervalInBackground: false,
...options,
},
)
}
```
+296
View File
@@ -0,0 +1,296 @@
# Frontend Testing Skill - Langflow
## When to Apply
Activate this skill when:
- Writing new unit or integration tests for React components, hooks, utilities, or Zustand stores
- Reviewing existing tests for correctness, coverage, or best practices
- Improving test coverage for under-tested modules
- Debugging flaky or failing tests
- Refactoring test code for maintainability
## Tech Stack
| Technology | Version | Purpose |
|---|---|---|
| Jest | 30.x | Test runner and assertion framework |
| ts-jest | 29.x | TypeScript transform for Jest |
| React Testing Library | 16.x | Component rendering and DOM queries |
| @testing-library/user-event | 14.x | Realistic user interaction simulation |
| @testing-library/jest-dom | 6.x | Extended DOM matchers |
| jsdom | (via jest-environment-jsdom 30.x) | Browser environment simulation |
| React | 19.x | UI framework |
| TypeScript | 5.4 | Type safety |
| Zustand | 4.x | State management |
| React Router DOM | 6.x | Client-side routing |
| @tanstack/react-query | 5.x | Server state management |
| Axios | 1.x | HTTP client |
## Project Configuration
- **Jest config**: `src/frontend/jest.config.js`
- **Setup files**: `src/frontend/jest.setup.js` (globals/mocks) and `src/frontend/src/setupTests.ts` (DOM matchers, ResizeObserver, IntersectionObserver, matchMedia)
- **Path alias**: `@/` maps to `<rootDir>/src/`
- **Test match patterns**: `src/**/__tests__/**/*.{test,spec}.{ts,tsx}` and `src/**/*.{test,spec}.{ts,tsx}`
- **Transform**: Custom `transform-import-meta.js` handles `import.meta` for Jest compatibility
- **Global mocks** (in `jest.setup.js`): `@radix-ui/react-form`, `react-markdown`, `lucide-react/dynamicIconImports`, `@/components/common/genericIconComponent`, `@/icons/BotMessageSquare`, `@/stores/darkStore`, `localStorage`, `sessionStorage`, `crypto`
## Key Commands
```bash
# Run all tests
npm test
# Run a specific test file
npm test -- path/to/file.test.tsx
# Run tests matching a pattern
npm test -- --testPathPattern="alertStore"
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
# Run a single test file with coverage
npm test -- --coverage --collectCoverageFrom='src/path/to/source.ts' path/to/__tests__/source.test.ts
```
## File Naming and Location
Test files follow one of two patterns:
1. **Dedicated `__tests__` directory** (preferred for components and modules):
```
src/components/core/my-component/
├── my-component.tsx
└── __tests__/
└── my-component.test.tsx
```
2. **Co-located test file** (acceptable for utilities and simple modules):
```
src/utils/
├── myUtil.ts
└── myUtil.test.ts
```
Naming convention: `ComponentName.test.tsx` for components, `hook-name.test.ts` for hooks, `util-name.test.ts` for utilities.
**Do NOT use `.spec.tsx`** -- while technically matched, the project convention is `.test.tsx`.
## Test Structure Template
```tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MyComponent from "../MyComponent";
// Mock dependencies (use jest.mock, NOT vi.mock)
jest.mock("@/controllers/API/api", () => ({
get: jest.fn(),
post: jest.fn(),
}));
describe("MyComponent", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("rendering", () => {
it("should render the component with default props", () => {
// Arrange
render(<MyComponent />);
// Act - (none for render test)
// Assert
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
});
});
describe("user interactions", () => {
it("should call onSubmit when the form is submitted", async () => {
// Arrange
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<MyComponent onSubmit={onSubmit} />);
// Act
await user.click(screen.getByRole("button", { name: /submit/i }));
// Assert
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
});
```
## Incremental Testing Workflow
When testing a directory with multiple files, follow this order:
1. **Identify all source files** in the target directory
2. **Order by complexity** (simplest first):
- Pure utility functions (no React, no side effects)
- Constants and configuration objects
- Custom hooks (no UI rendering)
- Simple presentational components (no state, no side effects)
- Stateful components with local state
- Components using Zustand stores
- Components with API calls or complex async behavior
- Integration-level components that compose many children
3. **For each file**:
a. Read the source file completely
b. Identify all exported functions, components, and types
c. Write tests covering all branches and edge cases
d. Run the tests and fix any failures
e. Check coverage and add tests for uncovered lines
f. Move to the next file
4. **Run full directory coverage** at the end to verify
## Complexity-Based Test Ordering
Within a single test file, order test cases from simplest to most complex:
1. Default rendering / initial state
2. Props variations and conditional rendering
3. User interactions (clicks, typing, form submission)
4. Async operations (API calls, timers)
5. Error states and edge cases
6. Integration with stores or context
7. Cleanup and unmount behavior
## Core Principles
### Arrange-Act-Assert (AAA)
Every test should have a clear three-phase structure. Use blank lines to separate each phase for readability.
### Black-Box Testing
Test the component from the user's perspective. Query by role, label, text, or `data-testid` -- never by CSS class, internal state variable, or implementation detail.
### Single Behavior Per Test
Each `it()` block should verify exactly one behavior. If you need to write "and" in the test name, split it into two tests.
### Semantic Test Names
Use descriptive names that explain the expected behavior:
- Good: `"should disable the submit button when the form is invalid"`
- Bad: `"button test"` or `"test 1"`
Format: `"should [expected behavior] when [condition]"`
## Required Test Scenarios
For every component, cover at minimum:
### Rendering
- Default render with no optional props
- Render with all optional props provided
- Conditional rendering branches (if/else in JSX)
### Props and State
- Each prop variation that changes rendered output
- Default prop values
- State transitions triggered by user actions
### User Interactions
- Click handlers
- Form input and submission
- Keyboard navigation (if applicable)
- Hover/focus states (if applicable)
### Challenge Tests (MANDATORY — not optional)
**Happy path tests alone are NOT enough.** They only confirm the code works when everything is perfect. Real bugs hide in the cracks. You MUST write tests that actively TRY TO BREAK the code:
**Unexpected inputs:**
- `null`, `undefined`, `""`, `[]`, `{}`, `0`, `-1`, `NaN`, `Infinity`
- What happens when a required prop is missing?
- What happens when data from the API comes back with missing fields?
**Boundary values:**
- Max length strings (paste 10,000 chars in an input)
- Exactly at the limit, one past the limit
- Zero items, one item, maximum items
- First page, last page, out-of-range page
**Malformed data:**
- API returns `{ data: null }` instead of `{ data: [] }`
- JSON with extra unexpected fields
- Dates in wrong format, numbers as strings
**Error states:**
- Network failure (API rejects with 500)
- Authentication expired mid-action (401)
- Resource not found (404)
- Permission denied (403)
- Timeout
**What should NOT happen:**
- Verify that deleting a flow does NOT delete flows from other users
- Verify that a read-only user CANNOT trigger write mutations
- Verify that XSS payloads in user input are sanitized
**Rapid/concurrent actions:**
- Double-click on submit button
- Rapid repeated API calls
- Unmount component while async operation is in flight
**Write tests based on REQUIREMENTS, not on what the source code does.** This is how you catch bugs where the code diverges from expected behavior.
**When a test fails:** first ask if the CODE is wrong, not the test. Do NOT silently change a failing assertion to match the current code without understanding WHY.
### Async Behavior
- Loading states
- Success states
- Error states
- Timeout/retry behavior
## Coverage Goals
Per source file:
- **Function coverage**: 100%
- **Branch coverage**: > 95%
- **Line coverage**: > 95%
- **Statement coverage**: > 95%
Run coverage for a specific file:
```bash
npm test -- --coverage --collectCoverageFrom='src/path/to/file.ts' src/path/to/__tests__/file.test.ts
```
## Important Rules
1. **Never use Vitest APIs**: Use `jest.fn()`, `jest.mock()`, `jest.spyOn()`, `jest.mocked()` -- never `vi.*` equivalents.
2. **Never mock base UI components** from `@/components/ui/` -- render them as-is.
3. **Check `jest.setup.js` before mocking**: Many modules are already globally mocked (darkStore, genericIconComponent, react-markdown, radix-form, etc.). Do not re-mock them.
4. **Use `@testing-library/user-event`** over `fireEvent` for user interactions.
5. **Wrap state updates in `act()`** when testing Zustand stores or React state changes.
6. **Clean up after each test**: Use `beforeEach(() => jest.clearAllMocks())` and `afterEach` for timers.
7. **Always write both happy path AND adversarial tests** (null, undefined, empty values, boundary conditions, error states).
8. **Minimum coverage: 75%** (target 80%). Below 75% the task is not complete.
## Forbidden Test Anti-Patterns
| Pattern | Problem | How to Detect |
|---------|---------|---------------|
| **The Liar** | Test passes but doesn't verify the behavior it claims to test | Assertions don't match the test name |
| **The Mirror** | Test reads source code and asserts exactly what the code does — finds zero bugs | Test would never fail even if logic changes |
| **The Giant** | 50+ lines of setup, multiple acts, dozens of assertions | Should be 5+ separate tests |
| **The Mockery** | So many mocks that the test only tests the mock setup | Count mocks — if > 3 deep, rethink |
| **The Inspector** | Coupled to implementation details, breaks on any refactor | Tests internal state instead of behavior |
| **The Chain Gang** | Tests depend on execution order or share mutable state | Tests fail when run in isolation |
| **The Flaky** | Sometimes passes, sometimes fails with no code changes | Non-deterministic assertions or timing issues |
## References
- [Incremental Testing Workflow](references/workflow.md) - Step-by-step process for testing directories
- [Mocking Guide](references/mocking.md) - Patterns for mocking APIs, stores, routers, and context
- [Async Testing](references/async-testing.md) - Patterns for async operations, timers, and waitFor
- [Common Patterns](references/common-patterns.md) - Query priority, events, forms, modals, data-driven tests
- [Test Checklist](references/checklist.md) - Pre-submission verification checklist
- [Domain Components](references/domain-components.md) - Langflow-specific component testing patterns
- [Component Template](assets/component-test.template.tsx) - Starter template for component tests
- [Hook Template](assets/hook-test.template.ts) - Starter template for hook tests
- [Utility Template](assets/utility-test.template.ts) - Starter template for utility tests
@@ -0,0 +1,203 @@
/**
* Test template for React components in Langflow.
*
* Usage:
* 1. Copy this file to `__tests__/ComponentName.test.tsx`
* 2. Replace all TEMPLATE_* placeholders with actual values
* 3. Remove unused sections
* 4. Run: npm test -- path/to/__tests__/ComponentName.test.tsx
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// Replace with actual component import path
import TEMPLATE_COMPONENT from "../TEMPLATE_COMPONENT";
// ============================================================
// Mocks
// ============================================================
// Mock API calls (if component fetches data)
// jest.mock("@/controllers/API/api", () => ({
// __esModule: true,
// default: {
// get: jest.fn(),
// post: jest.fn(),
// },
// }));
// Mock Zustand stores (if component reads from stores)
// jest.mock("@/stores/flowStore", () => ({
// __esModule: true,
// default: (selector?: (state: any) => any) =>
// selector ? selector({ nodes: [], edges: [] }) : {},
// }));
// Mock child components (only if they have complex dependencies)
// jest.mock("../ChildComponent", () => ({
// __esModule: true,
// default: (props: any) => <div data-testid="mock-child">{props.label}</div>,
// }));
// ============================================================
// Test Data
// ============================================================
const defaultProps = {
// Replace with actual default props for the component
// title: "Test Title",
// onClick: jest.fn(),
};
// ============================================================
// Helper Functions
// ============================================================
function renderComponent(overrides = {}) {
const props = { ...defaultProps, ...overrides };
return render(<TEMPLATE_COMPONENT {...props} />);
}
// Use this if the component requires providers (router, query client, context)
// function renderWithProviders(overrides = {}) {
// const props = { ...defaultProps, ...overrides };
// const queryClient = new QueryClient({
// defaultOptions: { queries: { retry: false } },
// });
// return render(
// <QueryClientProvider client={queryClient}>
// <MemoryRouter>
// <TEMPLATE_COMPONENT {...props} />
// </MemoryRouter>
// </QueryClientProvider>,
// );
// }
// ============================================================
// Tests
// ============================================================
describe("TEMPLATE_COMPONENT", () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ----------------------------------------------------------
// Rendering
// ----------------------------------------------------------
describe("rendering", () => {
it("should render with default props", () => {
renderComponent();
// Replace with actual assertions
// expect(screen.getByRole("button")).toBeInTheDocument();
// expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render with all optional props", () => {
renderComponent({
// Provide all optional props
});
// Assert all optional elements are rendered
});
it("should apply conditional rendering based on props", () => {
// Test each conditional branch in the JSX
});
});
// ----------------------------------------------------------
// User Interactions
// ----------------------------------------------------------
describe("user interactions", () => {
it("should handle click events", async () => {
const user = userEvent.setup();
const onClick = jest.fn();
renderComponent({ onClick });
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should handle text input", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
renderComponent({ onChange });
await user.type(screen.getByRole("textbox"), "test input");
expect(onChange).toHaveBeenCalled();
});
});
// ----------------------------------------------------------
// Async Behavior
// ----------------------------------------------------------
// describe("async behavior", () => {
// it("should show loading state initially", () => {
// renderComponent();
// expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
// });
//
// it("should display data after loading", async () => {
// jest.mocked(api.get).mockResolvedValueOnce({
// data: { items: [{ id: "1", name: "Test" }] },
// });
//
// renderComponent();
//
// await waitFor(() => {
// expect(screen.getByText("Test")).toBeInTheDocument();
// });
// });
//
// it("should handle API errors gracefully", async () => {
// jest.mocked(api.get).mockRejectedValueOnce(new Error("Network error"));
//
// renderComponent();
//
// await waitFor(() => {
// expect(screen.getByText(/error/i)).toBeInTheDocument();
// });
// });
// });
// ----------------------------------------------------------
// Edge Cases
// ----------------------------------------------------------
describe("edge cases", () => {
it("should handle empty data", () => {
renderComponent({
// Pass empty arrays, empty strings, etc.
});
// Assert empty state rendering
});
it("should handle undefined optional props", () => {
renderComponent({
// Explicitly pass undefined for optional props
});
// Assert fallback behavior
});
});
// ----------------------------------------------------------
// Cleanup
// ----------------------------------------------------------
// describe("cleanup", () => {
// it("should clean up on unmount", () => {
// const { unmount } = renderComponent();
// unmount();
// // Assert cleanup (e.g., clearInterval called, event listeners removed)
// });
// });
});
@@ -0,0 +1,240 @@
/**
* Test template for custom React hooks in Langflow.
*
* Usage:
* 1. Copy this file to `__tests__/use-hook-name.test.ts`
* 2. Replace all TEMPLATE_* placeholders with actual values
* 3. Remove unused sections
* 4. Run: npm test -- path/to/__tests__/use-hook-name.test.ts
*/
import { act, renderHook, waitFor } from "@testing-library/react";
// Replace with actual hook import path
import { TEMPLATE_HOOK } from "../TEMPLATE_HOOK";
// ============================================================
// Mocks
// ============================================================
// Mock dependencies (if the hook calls APIs, reads stores, etc.)
// jest.mock("@/controllers/API/api", () => ({
// __esModule: true,
// default: {
// get: jest.fn(),
// post: jest.fn(),
// },
// }));
// Mock Zustand store (if the hook reads from a store)
// import useMyStore from "@/stores/myStore";
// beforeEach(() => {
// useMyStore.setState({ items: [], loading: false });
// });
// ============================================================
// Tests
// ============================================================
describe("TEMPLATE_HOOK", () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ----------------------------------------------------------
// Initial State
// ----------------------------------------------------------
describe("initial state", () => {
it("should return the correct initial value", () => {
const { result } = renderHook(() => TEMPLATE_HOOK());
// Replace with actual initial state assertions
// expect(result.current.value).toBe(initialValue);
// expect(result.current.loading).toBe(false);
// expect(result.current.error).toBeNull();
});
});
// ----------------------------------------------------------
// State Updates
// ----------------------------------------------------------
describe("state updates", () => {
it("should update state when action is called", () => {
const { result } = renderHook(() => TEMPLATE_HOOK());
act(() => {
// Call the hook's update function
// result.current.setValue("new value");
});
// Assert the new state
// expect(result.current.value).toBe("new value");
});
it("should handle multiple sequential updates", () => {
const { result } = renderHook(() => TEMPLATE_HOOK());
act(() => {
// result.current.increment();
// result.current.increment();
// result.current.increment();
});
// expect(result.current.count).toBe(3);
});
});
// ----------------------------------------------------------
// Parameters
// ----------------------------------------------------------
describe("parameters", () => {
it("should accept and use initial parameters", () => {
const { result } = renderHook(() =>
TEMPLATE_HOOK(/* initial params */),
);
// Assert hook uses the provided parameters
});
it("should react to parameter changes on rerender", () => {
const { result, rerender } = renderHook(
({ param }) => TEMPLATE_HOOK(param),
{ initialProps: { param: "initial" } },
);
// Assert initial state
// expect(result.current.value).toBe("initial");
// Rerender with new parameter
rerender({ param: "updated" });
// Assert updated state
// expect(result.current.value).toBe("updated");
});
});
// ----------------------------------------------------------
// Async Behavior
// ----------------------------------------------------------
// describe("async behavior", () => {
// it("should fetch data on mount", async () => {
// jest.mocked(api.get).mockResolvedValueOnce({
// data: { items: [{ id: "1" }] },
// });
//
// const { result } = renderHook(() => TEMPLATE_HOOK());
//
// // Initially loading
// expect(result.current.loading).toBe(true);
//
// // Wait for data
// await waitFor(() => {
// expect(result.current.loading).toBe(false);
// });
//
// expect(result.current.data).toEqual([{ id: "1" }]);
// });
//
// it("should handle fetch errors", async () => {
// jest.mocked(api.get).mockRejectedValueOnce(new Error("Failed"));
//
// const { result } = renderHook(() => TEMPLATE_HOOK());
//
// await waitFor(() => {
// expect(result.current.error).toBeTruthy();
// });
// });
// });
// ----------------------------------------------------------
// Debounced/Throttled Behavior
// ----------------------------------------------------------
// describe("debounced behavior", () => {
// beforeEach(() => {
// jest.useFakeTimers();
// });
//
// afterEach(() => {
// jest.runOnlyPendingTimers();
// jest.useRealTimers();
// });
//
// it("should debounce the callback", () => {
// const callback = jest.fn();
// const { result } = renderHook(() => TEMPLATE_HOOK(callback, 500));
//
// result.current("arg1");
// result.current("arg2");
// result.current("arg3");
//
// expect(callback).not.toHaveBeenCalled();
//
// jest.advanceTimersByTime(500);
//
// expect(callback).toHaveBeenCalledTimes(1);
// expect(callback).toHaveBeenCalledWith("arg3");
// });
// });
// ----------------------------------------------------------
// Memoization
// ----------------------------------------------------------
describe("memoization", () => {
it("should return stable reference when dependencies do not change", () => {
const { result, rerender } = renderHook(() => TEMPLATE_HOOK());
const firstResult = result.current;
rerender();
const secondResult = result.current;
// For functions/objects that should be memoized:
// expect(firstResult.handler).toBe(secondResult.handler);
});
});
// ----------------------------------------------------------
// Edge Cases
// ----------------------------------------------------------
describe("edge cases", () => {
it("should handle empty input", () => {
const { result } = renderHook(() => TEMPLATE_HOOK(/* empty/null */));
// Assert graceful handling
});
it("should handle rapid calls", () => {
const { result } = renderHook(() => TEMPLATE_HOOK());
act(() => {
for (let i = 0; i < 100; i++) {
// result.current.action();
}
});
// Assert correct final state
});
});
// ----------------------------------------------------------
// Cleanup
// ----------------------------------------------------------
// describe("cleanup", () => {
// it("should clean up resources on unmount", () => {
// const removeListenerSpy = jest.fn();
// jest.spyOn(window, "addEventListener").mockImplementation(() => {});
// jest.spyOn(window, "removeEventListener").mockImplementation(removeListenerSpy);
//
// const { unmount } = renderHook(() => TEMPLATE_HOOK());
// unmount();
//
// expect(removeListenerSpy).toHaveBeenCalled();
// });
// });
});
@@ -0,0 +1,185 @@
/**
* Test template for utility/helper functions in Langflow.
*
* Usage:
* 1. Copy this file to `__tests__/util-name.test.ts`
* 2. Replace all TEMPLATE_* placeholders with actual values
* 3. Remove unused sections
* 4. Run: npm test -- path/to/__tests__/util-name.test.ts
*/
// Replace with actual utility import path
import {
TEMPLATE_FUNCTION_A,
TEMPLATE_FUNCTION_B,
} from "../TEMPLATE_UTIL_FILE";
// ============================================================
// Mocks (only if the utility has side effects or external deps)
// ============================================================
// jest.mock("@/controllers/API/api", () => ({
// __esModule: true,
// default: { get: jest.fn(), post: jest.fn() },
// }));
// ============================================================
// Test Data
// ============================================================
// Define reusable test fixtures
// const VALID_INPUT = { id: "1", name: "Test" };
// const EMPTY_INPUT = {};
// const INVALID_INPUT = null;
// ============================================================
// Tests
// ============================================================
describe("TEMPLATE_FUNCTION_A", () => {
// ----------------------------------------------------------
// Normal Cases
// ----------------------------------------------------------
describe("normal cases", () => {
it("should return expected output for standard input", () => {
// const result = TEMPLATE_FUNCTION_A(VALID_INPUT);
// expect(result).toEqual(expectedOutput);
});
it("should handle multiple valid inputs", () => {
// Test with different valid inputs
});
});
// ----------------------------------------------------------
// Data-Driven Tests
// ----------------------------------------------------------
// Use it.each for functions with many input/output combinations
// it.each([
// { input: "hello", expected: "HELLO", description: "lowercase string" },
// { input: "Hello", expected: "HELLO", description: "mixed case string" },
// { input: "", expected: "", description: "empty string" },
// { input: "HELLO", expected: "HELLO", description: "already uppercase" },
// ])("should return $expected for $description", ({ input, expected }) => {
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expected);
// });
// Alternatively, use array format for simpler cases:
// it.each([
// [0, "0s"],
// [500, "0.5s"],
// [1000, "1.0s"],
// [60000, "1m 0s"],
// ])("should format %i ms as %s", (input, expected) => {
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expected);
// });
// ----------------------------------------------------------
// Edge Cases
// ----------------------------------------------------------
describe("edge cases", () => {
it("should handle empty input", () => {
// const result = TEMPLATE_FUNCTION_A("");
// expect(result).toBe(expectedDefault);
});
it("should handle null or undefined", () => {
// expect(TEMPLATE_FUNCTION_A(null)).toBe(fallbackValue);
// expect(TEMPLATE_FUNCTION_A(undefined)).toBe(fallbackValue);
});
it("should handle boundary values", () => {
// Test with minimum/maximum values
// expect(TEMPLATE_FUNCTION_A(0)).toBe(expectedForZero);
// expect(TEMPLATE_FUNCTION_A(Number.MAX_SAFE_INTEGER)).toBe(expectedForMax);
});
it("should handle special characters", () => {
// Test with unicode, emojis, special chars if applicable
});
});
// ----------------------------------------------------------
// Error Cases
// ----------------------------------------------------------
describe("error cases", () => {
it("should throw for invalid input", () => {
// expect(() => TEMPLATE_FUNCTION_A(invalidInput)).toThrow("Expected error message");
});
it("should return fallback for malformed data", () => {
// const result = TEMPLATE_FUNCTION_A(malformedData);
// expect(result).toBe(fallbackValue);
});
});
// ----------------------------------------------------------
// Type-Specific Tests
// ----------------------------------------------------------
// describe("with arrays", () => {
// it("should handle empty array", () => {
// expect(TEMPLATE_FUNCTION_A([])).toEqual([]);
// });
//
// it("should handle single element", () => {
// expect(TEMPLATE_FUNCTION_A([1])).toEqual([expectedSingle]);
// });
//
// it("should preserve order", () => {
// const input = [3, 1, 2];
// const result = TEMPLATE_FUNCTION_A(input);
// expect(result).toEqual([expectedOrdered]);
// });
//
// it("should not mutate the original array", () => {
// const input = [1, 2, 3];
// const inputCopy = [...input];
// TEMPLATE_FUNCTION_A(input);
// expect(input).toEqual(inputCopy);
// });
// });
// describe("with objects", () => {
// it("should handle nested objects", () => {
// const input = { a: { b: { c: "deep" } } };
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expectedDeep);
// });
//
// it("should not mutate the original object", () => {
// const input = { key: "value" };
// const inputCopy = { ...input };
// TEMPLATE_FUNCTION_A(input);
// expect(input).toEqual(inputCopy);
// });
// });
});
// ============================================================
// Second Function (if the file exports multiple functions)
// ============================================================
describe("TEMPLATE_FUNCTION_B", () => {
it("should return expected output for standard input", () => {
// const result = TEMPLATE_FUNCTION_B(input);
// expect(result).toBe(expected);
});
// Add more tests following the same pattern as above
});
// ============================================================
// Integration Tests (if functions work together)
// ============================================================
// describe("integration", () => {
// it("should compose TEMPLATE_FUNCTION_A and TEMPLATE_FUNCTION_B correctly", () => {
// const intermediate = TEMPLATE_FUNCTION_A(rawInput);
// const result = TEMPLATE_FUNCTION_B(intermediate);
// expect(result).toBe(expectedFinalOutput);
// });
// });
@@ -0,0 +1,347 @@
# Async Testing Patterns
Patterns for testing asynchronous behavior in Langflow with Jest and React Testing Library.
## waitFor
Use `waitFor` when you need to wait for an asynchronous operation to complete before making assertions.
```typescript
import { render, screen, waitFor } from "@testing-library/react";
it("should load and display data", async () => {
jest.mocked(api.get).mockResolvedValueOnce({
data: { items: [{ id: "1", name: "Test Item" }] },
});
render(<ItemList />);
// Wait for the async data to appear
await waitFor(() => {
expect(screen.getByText("Test Item")).toBeInTheDocument();
});
});
```
### waitFor Options
```typescript
await waitFor(
() => {
expect(screen.getByText("loaded")).toBeInTheDocument();
},
{
timeout: 3000, // Max time to wait (default: 1000ms)
interval: 50, // Polling interval (default: 50ms)
},
);
```
### waitFor Best Practices
- Put only ONE assertion inside `waitFor`. Multiple assertions can cause misleading failures.
- Use `waitFor` for appearance, not disappearance. Use `waitForElementToBeRemoved` for that.
- Do not use `waitFor` for synchronous operations -- it adds unnecessary delay.
```typescript
// GOOD: Single assertion in waitFor
await waitFor(() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});
// BAD: Multiple assertions -- if second fails, first may have been true
await waitFor(() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
expect(screen.getByText("5 items")).toBeInTheDocument(); // unreliable
});
// GOOD: Chain waitFor calls
await waitFor(() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});
expect(screen.getByText("5 items")).toBeInTheDocument();
```
## waitForElementToBeRemoved
```typescript
it("should hide loading spinner after data loads", async () => {
jest.mocked(api.get).mockResolvedValueOnce({ data: [] });
render(<ItemList />);
// Spinner appears immediately
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
// Wait for it to disappear
await waitForElementToBeRemoved(() =>
screen.queryByTestId("loading-spinner"),
);
// Now assert the loaded state
expect(screen.getByText("No items found")).toBeInTheDocument();
});
```
## findBy Queries
`findBy*` queries return a promise that resolves when the element appears. They are shorthand for `waitFor` + `getBy*`.
```typescript
it("should display async content", async () => {
jest.mocked(api.get).mockResolvedValueOnce({ data: { name: "Test" } });
render(<AsyncComponent />);
// findByText = waitFor(() => getByText(...))
const element = await screen.findByText("Test");
expect(element).toBeInTheDocument();
});
```
### findBy with Custom Timeout
```typescript
const element = await screen.findByText("Slow content", {}, { timeout: 5000 });
```
## Fake Timers
Use fake timers for components that use `setTimeout`, `setInterval`, or `Date.now`.
```typescript
describe("TimerComponent", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it("should update after interval", () => {
render(<TimerComponent interval={1000} />);
expect(screen.getByText("0 seconds")).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText("3 seconds")).toBeInTheDocument();
});
it("should clean up interval on unmount", () => {
const clearIntervalSpy = jest.spyOn(global, "clearInterval");
const { unmount } = render(<TimerComponent interval={1000} />);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
});
```
### Combining Fake Timers with Async Operations
When a component uses both timers and async operations (API calls), you need special handling:
```typescript
it("should poll for updates", async () => {
jest.useFakeTimers();
const mockGet = jest.mocked(api.get);
mockGet
.mockResolvedValueOnce({ data: { status: "pending" } })
.mockResolvedValueOnce({ data: { status: "complete" } });
render(<PollingComponent />);
// First fetch happens immediately
await waitFor(() => {
expect(screen.getByText("pending")).toBeInTheDocument();
});
// Advance past the polling interval
act(() => {
jest.advanceTimersByTime(5000);
});
// Second fetch resolves
await waitFor(() => {
expect(screen.getByText("complete")).toBeInTheDocument();
});
jest.useRealTimers();
});
```
### Mocking Date.now
For components that use `Date.now()` for elapsed time:
```typescript
it("should track elapsed time", async () => {
const realDateNow = Date.now;
let mockTime = 1000000;
Date.now = jest.fn(() => mockTime);
jest.useFakeTimers();
render(<ElapsedTimer />);
// Advance mock time by 2 seconds
mockTime += 2000;
act(() => {
jest.advanceTimersByTime(100); // Trigger interval callback
});
await waitFor(() => {
expect(screen.getByText("2.0s")).toBeInTheDocument();
});
Date.now = realDateNow;
jest.useRealTimers();
});
```
## Testing Promise Rejection
```typescript
it("should display error message on API failure", async () => {
jest.mocked(api.get).mockRejectedValueOnce(new Error("Server error"));
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText(/server error/i)).toBeInTheDocument();
});
});
```
## Testing Debounced Functions
```typescript
it("should debounce search input", async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchInput onSearch={mockSearch} debounceMs={300} />);
const input = screen.getByRole("textbox");
await user.type(input, "hello");
// Should not have called search yet
expect(mockSearch).not.toHaveBeenCalled();
// Advance past debounce delay
act(() => {
jest.advanceTimersByTime(300);
});
expect(mockSearch).toHaveBeenCalledWith("hello");
jest.useRealTimers();
});
```
**Important**: When using `userEvent` with fake timers, pass `advanceTimers: jest.advanceTimersByTime` to the setup options. This allows userEvent to advance fake timers during its internal delays.
## Testing Event Streams / WebSockets
For components that consume SSE or WebSocket events:
```typescript
it("should handle streaming messages", async () => {
const mockEventSource = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
close: jest.fn(),
};
jest.spyOn(global, "EventSource" as any).mockImplementation(
() => mockEventSource,
);
render(<StreamingChat />);
// Simulate incoming message
const onMessage = mockEventSource.addEventListener.mock.calls.find(
([event]: [string]) => event === "message",
)?.[1];
act(() => {
onMessage?.({ data: JSON.stringify({ text: "Hello from stream" }) });
});
expect(screen.getByText("Hello from stream")).toBeInTheDocument();
});
```
## Testing React Query Mutations
```typescript
it("should save and show success", async () => {
const user = userEvent.setup();
jest.mocked(api.post).mockResolvedValueOnce({ data: { id: "new-1" } });
render(
<QueryClientProvider client={createTestQueryClient()}>
<CreateForm />
</QueryClientProvider>,
);
await user.type(screen.getByLabelText("Name"), "New Item");
await user.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
});
expect(api.post).toHaveBeenCalledWith("/api/v1/items", {
name: "New Item",
});
});
```
## Testing Loading States
```typescript
it("should show loading then content", async () => {
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
jest.mocked(api.get).mockReturnValueOnce(promise as any);
render(<DataDisplay />);
// Loading state
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByText("Data content")).not.toBeInTheDocument();
// Resolve the promise
await act(async () => {
resolvePromise!({ data: { content: "Data content" } });
});
// Loaded state
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(screen.getByText("Data content")).toBeInTheDocument();
});
```
## Common Pitfalls
1. **Forgetting `act()` with timers**: Always wrap `jest.advanceTimersByTime()` in `act()` when it triggers React state updates.
2. **Not cleaning up timers**: Always call `jest.runOnlyPendingTimers()` then `jest.useRealTimers()` in `afterEach` to prevent timer leaks between tests.
3. **Using `waitFor` for sync assertions**: If the element is already in the DOM after render, use `getBy*` directly. `waitFor` adds unnecessary polling.
4. **Missing `await` on async operations**: Forgetting `await` on `waitFor`, `findBy*`, or `userEvent` methods causes tests to pass vacuously.
5. **Fake timers breaking promises**: If promises are not resolving with fake timers, try `jest.advanceTimersByTime()` inside `act(async () => { ... })`.
@@ -0,0 +1,98 @@
# Test Generation Checklist
Verify each item before submitting test code for Langflow frontend.
## Pre-Writing
- [ ] Read the source file completely before writing any tests
- [ ] Identify all exported functions, components, hooks, and types
- [ ] Identify all conditional branches (if/else, ternary, switch, early returns, `&&`/`||` short-circuits)
- [ ] Identify all dependencies that need mocking
- [ ] Check `jest.setup.js` for pre-existing global mocks to avoid duplication
- [ ] Check if there is an existing test file to extend (rather than creating a new one)
## File Structure
- [ ] File is in `__tests__/` directory or co-located with source
- [ ] File name follows `ComponentName.test.tsx` or `util-name.test.ts` convention
- [ ] Imports use `@/` path alias consistently
- [ ] `jest.mock()` calls are at the top of the file, after imports
- [ ] `beforeEach` includes `jest.clearAllMocks()`
- [ ] `beforeEach` resets Zustand store state (if using stores)
- [ ] `afterEach` cleans up fake timers (if used)
## Test Quality
- [ ] Each `it()` block tests exactly ONE behavior
- [ ] Test names follow `"should [behavior] when [condition]"` format
- [ ] Tests are ordered from simplest to most complex
- [ ] Tests use Arrange-Act-Assert (AAA) pattern with blank line separation
- [ ] No implementation details tested (no internal state, no CSS classes, no private functions)
- [ ] No test depends on another test's side effects (test isolation)
## Queries and Assertions
- [ ] Queries follow RTL priority: `getByRole` > `getByLabelText` > `getByText` > `getByTestId`
- [ ] `queryBy*` used for asserting absence (`expect(queryByText("x")).not.toBeInTheDocument()`)
- [ ] `findBy*` or `waitFor` used for async elements
- [ ] `screen` object used for all queries (not destructured from `render`)
- [ ] Assertions use `jest-dom` matchers: `toBeInTheDocument()`, `toHaveValue()`, `toBeDisabled()`, etc.
- [ ] No use of `.innerHTML`, `.className`, or `.style` for assertions
## User Interactions
- [ ] `userEvent.setup()` used (not `fireEvent`)
- [ ] All `userEvent` calls are `await`ed
- [ ] `userEvent.setup({ advanceTimers: jest.advanceTimersByTime })` used when combining with fake timers
## Mocking
- [ ] Uses `jest.fn()`, `jest.mock()`, `jest.spyOn()` (NEVER `vi.*`)
- [ ] Only mocks what is necessary (prefer real implementations)
- [ ] Does NOT mock base UI components from `@/components/ui/`
- [ ] Does NOT re-mock modules already mocked in `jest.setup.js`
- [ ] Mock return values match the actual API shape (type-safe)
- [ ] `jest.mocked()` used for type-safe mock assertions
## Async
- [ ] All `waitFor` calls contain a single assertion
- [ ] All promises are properly `await`ed
- [ ] `act()` wraps state-updating operations (store updates, timer advances)
- [ ] Fake timers cleaned up in `afterEach`: `jest.runOnlyPendingTimers()` then `jest.useRealTimers()`
- [ ] No unhandled promise rejections
## Challenge Tests (MANDATORY — not just happy paths)
- [ ] Happy path tests exist (baseline — code works under normal conditions)
- [ ] **Unexpected inputs tested**: `null`, `undefined`, `""`, `[]`, `{}`, `0`, `-1`
- [ ] **Boundary values tested**: max length, exactly at limit, one past limit, zero items, max items
- [ ] **Malformed data tested**: missing fields, extra fields, wrong types
- [ ] **Error states tested**: API 500, 404, 401, network failure, timeout
- [ ] **What should NOT happen tested**: forbidden actions are rejected, wrong user can't access resources
- [ ] **Error messages verified**: not just that it fails, but HOW it fails (correct message, correct type)
- [ ] Tests written based on **requirements/spec**, not copied from source code logic
## Coverage
- [ ] All exported functions/components are tested
- [ ] All conditional branches are covered (both sides of if/else, all catch blocks)
- [ ] Function coverage: 100%
- [ ] Branch coverage: > 95%
- [ ] Line coverage: > 95%
## Execution
- [ ] Test file runs without errors: `npm test -- path/to/file.test.tsx`
- [ ] No console warnings or errors in test output (or they are intentionally suppressed)
- [ ] Tests pass in isolation (can run the file alone)
- [ ] Tests pass with the full suite (no cross-file contamination)
- [ ] Coverage verified: `npm test -- --coverage --collectCoverageFrom='src/path/to/source.ts' path/to/test.test.ts`
## Final Review
- [ ] No `console.log` left in test code
- [ ] No `.only` or `.skip` left on tests
- [ ] No hardcoded timeouts longer than 5000ms
- [ ] No `any` type assertions that could be properly typed
- [ ] Test file is not in `testPathIgnorePatterns` (not named `test-utils.tsx`)
@@ -0,0 +1,411 @@
# Common Testing Patterns
Frequently used patterns for testing Langflow React components with Jest and React Testing Library.
## Query Priority
Use queries in this priority order (most to least preferred):
| Priority | Query | When to Use |
|---|---|---|
| 1 | `getByRole` | Buttons, inputs, headings, links, checkboxes |
| 2 | `getByLabelText` | Form inputs associated with labels |
| 3 | `getByPlaceholderText` | Inputs with placeholder text |
| 4 | `getByText` | Non-interactive content, paragraphs, spans |
| 5 | `getByDisplayValue` | Filled input/textarea/select values |
| 6 | `getByAltText` | Images |
| 7 | `getByTitle` | Elements with title attribute |
| 8 | `getByTestId` | Last resort -- when no semantic query works |
### Query Variants
| Variant | Throws if not found | Returns | Use When |
|---|---|---|---|
| `getBy*` | Yes | Element | Element should exist |
| `queryBy*` | No | Element or null | Asserting element does NOT exist |
| `findBy*` | Yes (after timeout) | Promise<Element> | Element appears asynchronously |
| `getAllBy*` | Yes | Element[] | Multiple elements expected |
| `queryAllBy*` | No | Element[] (may be empty) | Counting or absence of multiple |
| `findAllBy*` | Yes (after timeout) | Promise<Element[]> | Multiple elements appear async |
### Examples
```typescript
// Preferred: query by role
screen.getByRole("button", { name: /save/i });
screen.getByRole("textbox", { name: /search/i });
screen.getByRole("heading", { level: 2 });
screen.getByRole("checkbox", { name: /agree/i });
screen.getByRole("combobox");
// Assert absence
expect(screen.queryByText("Error")).not.toBeInTheDocument();
// Async appearance
const element = await screen.findByText("Loaded");
```
## User Events
Always use `@testing-library/user-event` over `fireEvent`:
```typescript
import userEvent from "@testing-library/user-event";
describe("UserInteractions", () => {
it("should handle click", async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<button onClick={onClick}>Click me</button>);
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should handle typing", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<input onChange={onChange} />);
await user.type(screen.getByRole("textbox"), "hello");
expect(onChange).toHaveBeenCalledTimes(5); // One per character
});
it("should handle clearing and typing", async () => {
const user = userEvent.setup();
render(<input defaultValue="old value" />);
const input = screen.getByRole("textbox");
await user.clear(input);
await user.type(input, "new value");
expect(input).toHaveValue("new value");
});
it("should handle keyboard navigation", async () => {
const user = userEvent.setup();
render(
<div>
<input data-testid="input-1" />
<input data-testid="input-2" />
</div>,
);
await user.tab();
expect(screen.getByTestId("input-1")).toHaveFocus();
await user.tab();
expect(screen.getByTestId("input-2")).toHaveFocus();
});
it("should handle select/dropdown", async () => {
const user = userEvent.setup();
render(
<select>
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>,
);
await user.selectOptions(screen.getByRole("combobox"), "b");
expect(screen.getByRole("combobox")).toHaveValue("b");
});
it("should handle hover", async () => {
const user = userEvent.setup();
const onMouseEnter = jest.fn();
render(<div onMouseEnter={onMouseEnter}>Hover me</div>);
await user.hover(screen.getByText("Hover me"));
expect(onMouseEnter).toHaveBeenCalledTimes(1);
});
});
```
## Form Testing
```typescript
describe("LoginForm", () => {
it("should submit form with valid data", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/username/i), "testuser");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
username: "testuser",
password: "password123",
});
});
it("should show validation errors for empty fields", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
it("should disable submit button while submitting", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn(() => new Promise(() => {})); // Never resolves
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/username/i), "testuser");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
});
});
```
## Modal/Dialog Testing
Langflow uses Radix UI dialogs:
```typescript
describe("ConfirmDialog", () => {
it("should open and close the dialog", async () => {
const user = userEvent.setup();
render(<ConfirmDialog trigger={<button>Open</button>} />);
// Dialog should not be visible initially
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// Open dialog
await user.click(screen.getByRole("button", { name: /open/i }));
// Dialog should be visible
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
// Close dialog by clicking cancel
await user.click(screen.getByRole("button", { name: /cancel/i }));
// Dialog should be hidden
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
});
it("should call onConfirm when confirmed", async () => {
const user = userEvent.setup();
const onConfirm = jest.fn();
render(
<ConfirmDialog trigger={<button>Open</button>} onConfirm={onConfirm} />,
);
await user.click(screen.getByRole("button", { name: /open/i }));
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
```
## Data-Driven Tests (Parameterized)
Use `it.each` for testing multiple inputs with the same logic:
```typescript
describe("formatDuration", () => {
it.each([
[0, "0s"],
[500, "0.5s"],
[1000, "1.0s"],
[1500, "1.5s"],
[60000, "1m 0s"],
[90000, "1m 30s"],
[3600000, "1h 0m"],
])("should format %i ms as %s", (input, expected) => {
expect(formatDuration(input)).toBe(expected);
});
});
```
### Named Parameters with Objects
```typescript
it.each([
{ input: "", expected: false, description: "empty string" },
{ input: "valid@email.com", expected: true, description: "valid email" },
{ input: "no-at-sign", expected: false, description: "missing @" },
{ input: "@no-local", expected: false, description: "missing local part" },
])("should return $expected for $description", ({ input, expected }) => {
expect(isValidEmail(input)).toBe(expected);
});
```
## Snapshot Testing
Use sparingly -- only for stable, presentational components:
```typescript
it("should match snapshot", () => {
const { container } = render(<Badge variant="success" label="Active" />);
expect(container.firstChild).toMatchSnapshot();
});
```
Prefer explicit assertions over snapshots. Snapshots are fragile and do not communicate test intent.
## Testing Conditional Rendering
```typescript
describe("StatusBadge", () => {
it("should render success variant", () => {
render(<StatusBadge status="success" />);
expect(screen.getByText("Success")).toBeInTheDocument();
});
it("should render error variant", () => {
render(<StatusBadge status="error" />);
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should render nothing for unknown status", () => {
const { container } = render(<StatusBadge status="unknown" />);
expect(container).toBeEmptyDOMElement();
});
});
```
## Testing Lists and Tables
```typescript
describe("ItemList", () => {
it("should render all items", () => {
const items = [
{ id: "1", name: "Item 1" },
{ id: "2", name: "Item 2" },
{ id: "3", name: "Item 3" },
];
render(<ItemList items={items} />);
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(3);
expect(listItems[0]).toHaveTextContent("Item 1");
expect(listItems[1]).toHaveTextContent("Item 2");
expect(listItems[2]).toHaveTextContent("Item 3");
});
it("should show empty state when no items", () => {
render(<ItemList items={[]} />);
expect(screen.getByText(/no items/i)).toBeInTheDocument();
expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
});
});
```
## Testing Tooltips
Langflow uses Radix tooltips which require hover:
```typescript
it("should show tooltip on hover", async () => {
const user = userEvent.setup();
render(<TooltipButton label="Delete" tooltip="Delete this item" />);
await user.hover(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Delete this item");
});
});
```
## Testing Error Boundaries
```typescript
describe("ErrorBoundary", () => {
// Suppress console.error for expected errors
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
it("should catch errors and show fallback UI", () => {
const ThrowError = () => {
throw new Error("Test error");
};
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowError />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});
```
## Testing Custom data-testid Attributes
Langflow components frequently use `data-testid` for testing. Common patterns:
```typescript
// Input components
screen.getByTestId("popover-anchor-input-api_key");
// Buttons
screen.getByTestId("sidebar-nav-add_note");
// Modal elements
screen.getByTestId("modal-title");
// Flow elements
screen.getByTestId("handle-source-bottom");
```
## Testing with Zustand Store Updates
```typescript
it("should react to store changes", async () => {
render(<NotificationBanner />);
// Initially no notification
expect(screen.queryByText("Error occurred")).not.toBeInTheDocument();
// Update store
act(() => {
useAlertStore.setState({
errorData: { title: "Error occurred", list: [] },
});
});
// Notification should appear
expect(screen.getByText("Error occurred")).toBeInTheDocument();
});
```
## Cleanup
React Testing Library automatically cleans up after each test (unmounts rendered components). You do not need to call `cleanup()` manually.
However, you DO need to clean up:
- Fake timers: `jest.useRealTimers()` in `afterEach`
- Spies: `spy.mockRestore()` or `jest.restoreAllMocks()` in `afterEach`
- Store state: Reset via `store.setState()` in `beforeEach`
- Global overrides: Restore original values in `afterEach`
@@ -0,0 +1,598 @@
# Domain-Specific Component Testing for Langflow
Patterns for testing Langflow-specific components that have unique architectural concerns.
## Flow/Graph Components
Langflow's flow editor is built on `@xyflow/react` (React Flow). These components require specific mocking strategies.
### GenericNode
`GenericNode` is the main node component rendered in the flow canvas. Located at `src/frontend/src/CustomNodes/GenericNode/`.
```typescript
import { render, screen } from "@testing-library/react";
import GenericNode from "@/CustomNodes/GenericNode";
// Mock @xyflow/react
jest.mock("@xyflow/react", () => ({
Handle: ({ type, position, id }: any) => (
<div data-testid={`handle-${type}-${id}`} data-position={position} />
),
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
useUpdateNodeInternals: () => jest.fn(),
useReactFlow: () => ({
getNodes: jest.fn().mockReturnValue([]),
getEdges: jest.fn().mockReturnValue([]),
setNodes: jest.fn(),
setEdges: jest.fn(),
}),
}));
// Mock the flow store
jest.mock("@/stores/flowStore", () => ({
__esModule: true,
default: (selector?: (state: any) => any) =>
selector
? selector({
nodes: [],
edges: [],
setNodes: jest.fn(),
onNodesChange: jest.fn(),
})
: {},
}));
const mockNodeData = {
id: "node-1",
type: "genericNode",
data: {
node: {
display_name: "OpenAI",
description: "OpenAI language model",
template: {
api_key: {
type: "str",
required: true,
show: true,
name: "api_key",
display_name: "API Key",
password: true,
value: "",
advanced: false,
},
model_name: {
type: "str",
required: false,
show: true,
name: "model_name",
display_name: "Model Name",
options: ["gpt-4", "gpt-3.5-turbo"],
value: "gpt-4",
advanced: false,
},
},
output_types: ["Message"],
input_types: ["Text"],
},
showNode: true,
},
position: { x: 100, y: 200 },
};
describe("GenericNode", () => {
it("should render the node with display name", () => {
render(<GenericNode data={mockNodeData.data} id="node-1" />);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
it("should render input handles for each visible input field", () => {
render(<GenericNode data={mockNodeData.data} id="node-1" />);
// Verify handles are rendered based on template fields
expect(screen.getByTestId("handle-target-api_key")).toBeInTheDocument();
});
});
```
### Edge Components
Test edge rendering and connection validation:
```typescript
describe("CustomEdge", () => {
it("should render a connection between compatible types", () => {
render(
<CustomEdge
id="edge-1"
source="node-1"
target="node-2"
sourceHandle="output-Message"
targetHandle="input-Text"
/>,
);
expect(screen.getByTestId("edge-edge-1")).toBeInTheDocument();
});
});
```
### Connection Validation
Test type compatibility logic (typically a pure function):
```typescript
import { isValidConnection } from "@/CustomNodes/helpers/connection-validation";
describe("isValidConnection", () => {
it("should allow connection between compatible types", () => {
expect(
isValidConnection({
sourceType: "Message",
targetType: "Text",
compatibleTypes: { Text: ["Message", "Text"] },
}),
).toBe(true);
});
it("should reject connection between incompatible types", () => {
expect(
isValidConnection({
sourceType: "DataFrame",
targetType: "Text",
compatibleTypes: { Text: ["Message", "Text"] },
}),
).toBe(false);
});
});
```
### Handle Rendering
Test the handle display logic for minimized/expanded nodes:
```typescript
import { computeDisplayHandle } from "@/CustomNodes/GenericNode/components/RenderInputParameters/helpers";
describe("computeDisplayHandle", () => {
it("should show handles for non-advanced visible fields", () => {
const result = computeDisplayHandle({
showNode: true,
isAdvanced: false,
isHidden: false,
});
expect(result).toBe(true);
});
it("should hide handles when node is minimized", () => {
const result = computeDisplayHandle({
showNode: false,
isAdvanced: false,
isHidden: false,
});
expect(result).toBe(false);
});
});
```
## Component Configuration Panels
Components for editing node parameters in the inspection panel or modal.
### Parameter Render Components
Located at `src/frontend/src/components/core/parameterRenderComponent/`:
```typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import InputComponent from "@/components/core/parameterRenderComponent/components/inputComponent";
describe("InputComponent", () => {
const defaultProps = {
value: "",
onChange: jest.fn(),
name: "api_key",
id: "input-api_key",
password: false,
placeholder: "Enter API key",
};
it("should render input with placeholder", () => {
render(<InputComponent {...defaultProps} />);
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
});
it("should call onChange when user types", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<InputComponent {...defaultProps} onChange={onChange} />);
await user.type(screen.getByRole("textbox"), "sk-123");
expect(onChange).toHaveBeenCalled();
});
it("should mask input when password is true", () => {
render(<InputComponent {...defaultProps} password={true} />);
expect(screen.getByTestId("popover-anchor-input-api_key")).toHaveAttribute(
"type",
"password",
);
});
});
```
### Dropdown/Select Components
```typescript
describe("DropdownComponent", () => {
it("should render options and allow selection", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DropdownComponent
value="gpt-4"
options={["gpt-4", "gpt-3.5-turbo", "gpt-4-turbo"]}
onChange={onChange}
/>,
);
// Open dropdown
await user.click(screen.getByRole("combobox"));
// Select an option
await user.click(screen.getByText("gpt-3.5-turbo"));
expect(onChange).toHaveBeenCalledWith("gpt-3.5-turbo");
});
});
```
## Chat Components
Located at `src/frontend/src/components/core/chatComponents/` and `src/frontend/src/modals/IOModal/components/chatView/`.
### Message Display
```typescript
import { render, screen } from "@testing-library/react";
import ChatMessage from "@/components/core/chatComponents/ChatMessage";
describe("ChatMessage", () => {
it("should render user message", () => {
render(
<ChatMessage
message={{ text: "Hello!", sender: "User", sender_name: "User" }}
/>,
);
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
it("should render bot message with different styling", () => {
render(
<ChatMessage
message={{ text: "Hi there!", sender: "Machine", sender_name: "AI" }}
/>,
);
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("should render markdown content", () => {
render(
<ChatMessage
message={{
text: "Here is **bold** text",
sender: "Machine",
sender_name: "AI",
}}
/>,
);
// react-markdown is mocked globally, so test the text content
expect(screen.getByText(/bold/)).toBeInTheDocument();
});
});
```
### Message Sorting
Test the message sorting utility (pure function):
```typescript
import { sortMessages } from "@/modals/IOModal/components/chatView/helpers";
describe("sortMessages", () => {
it("should sort messages by timestamp ascending", () => {
const messages = [
{ id: "3", timestamp: "2024-01-03T00:00:00Z" },
{ id: "1", timestamp: "2024-01-01T00:00:00Z" },
{ id: "2", timestamp: "2024-01-02T00:00:00Z" },
];
const sorted = sortMessages(messages);
expect(sorted.map((m) => m.id)).toEqual(["1", "2", "3"]);
});
it("should handle empty array", () => {
expect(sortMessages([])).toEqual([]);
});
});
```
### Chat Input
```typescript
describe("ChatInput", () => {
it("should send message on Enter key", async () => {
const user = userEvent.setup();
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello{Enter}");
expect(onSend).toHaveBeenCalledWith("Hello");
});
it("should not send empty message", async () => {
const user = userEvent.setup();
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "{Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("should disable input while message is being sent", () => {
render(<ChatInput onSend={jest.fn()} isLoading={true} />);
expect(screen.getByRole("textbox")).toBeDisabled();
});
});
```
## Global Variable Components
Located at `src/frontend/src/components/core/GlobalVariableModal/` and `src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/`.
### Global Variable Modal
```typescript
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GlobalVariableModal from "@/components/core/GlobalVariableModal";
// Mock the API
jest.mock("@/controllers/API/api", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
},
}));
describe("GlobalVariableModal", () => {
it("should render the form fields", () => {
render(
<GlobalVariableModal
open={true}
onClose={jest.fn()}
/>,
);
expect(screen.getByLabelText(/variable name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("should validate required fields", async () => {
const user = userEvent.setup();
render(
<GlobalVariableModal
open={true}
onClose={jest.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
});
});
```
### Global Variable in Node Fields
When a global variable is selected for a node field, the input renders as a badge instead of a text input. This is important for testing:
```typescript
describe("InputGlobalComponent", () => {
it("should show badge when global variable is selected", () => {
render(
<InputGlobalComponent
name="api_key"
value="OPENAI_API_KEY"
load_from_db={true}
/>,
);
// Badge is rendered instead of input
expect(screen.queryByTestId("popover-anchor-input-api_key")).not.toBeInTheDocument();
// Look for the global variable badge instead
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
it("should show input when no global variable is selected", () => {
render(
<InputGlobalComponent
name="api_key"
value=""
load_from_db={false}
/>,
);
expect(screen.getByTestId("popover-anchor-input-api_key")).toBeInTheDocument();
});
});
```
## Playground Components
Located at `src/frontend/src/components/core/playgroundComponent/`.
### Playground View
```typescript
// Mock stores needed by playground
jest.mock("@/stores/flowStore", () => ({
__esModule: true,
default: (selector?: (state: any) => any) =>
selector
? selector({
nodes: [],
edges: [],
inputs: [{ id: "input-1", type: "ChatInput" }],
outputs: [{ id: "output-1", type: "ChatOutput" }],
})
: {},
}));
jest.mock("@/stores/playgroundStore", () => ({
__esModule: true,
default: (selector?: (state: any) => any) =>
selector
? selector({
isPlaygroundOpen: true,
setIsPlaygroundOpen: jest.fn(),
})
: {},
}));
describe("PlaygroundComponent", () => {
it("should render chat interface when flow has chat I/O", () => {
render(<PlaygroundComponent />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});
```
### Duration Display
The `DurationDisplay` component shows elapsed time during message processing. It uses `Date.now()` and `setInterval`:
```typescript
// See DurationDisplay.test.tsx in the codebase for a comprehensive example
// Key patterns:
// 1. Mock Date.now for deterministic time
// 2. Use jest.useFakeTimers() for interval control
// 3. Use act() when advancing timers
// 4. Test remount behavior (playground open/close)
```
## Sidebar Components
Located at `src/frontend/src/pages/FlowPage/components/flowSidebarComponent/`.
### Sidebar Search and Filtering
```typescript
describe("SidebarSearch", () => {
it("should filter components by search term", async () => {
const user = userEvent.setup();
render(
<FlowSidebar
components={[
{ name: "OpenAI", category: "LLMs" },
{ name: "Pinecone", category: "Vector Stores" },
{ name: "ChatInput", category: "Inputs" },
]}
/>,
);
await user.type(screen.getByPlaceholderText(/search/i), "open");
expect(screen.getByText("OpenAI")).toBeInTheDocument();
expect(screen.queryByText("Pinecone")).not.toBeInTheDocument();
expect(screen.queryByText("ChatInput")).not.toBeInTheDocument();
});
});
```
### Draggable Components
```typescript
describe("SidebarDraggableComponent", () => {
it("should have draggable attributes", () => {
render(
<SidebarDraggableComponent
name="OpenAI"
type="llm"
draggable={true}
/>,
);
const draggable = screen.getByTestId("sidebar-draggable-openai");
expect(draggable).toHaveAttribute("draggable", "true");
});
});
```
## API Code Generation (Modals)
Located at `src/frontend/src/modals/apiModal/utils/`.
These are pure functions that generate code snippets -- ideal for data-driven tests:
```typescript
import { getPythonApiCode } from "@/modals/apiModal/utils/get-python-api-code";
describe("getPythonApiCode", () => {
it("should generate valid Python code for a flow", () => {
const code = getPythonApiCode({
flowId: "flow-123",
tweaks: { "node-1": { model_name: "gpt-4" } },
isAuth: true,
});
expect(code).toContain("flow-123");
expect(code).toContain("gpt-4");
expect(code).toContain("Authorization");
});
it("should omit auth header when isAuth is false", () => {
const code = getPythonApiCode({
flowId: "flow-123",
tweaks: {},
isAuth: false,
});
expect(code).not.toContain("Authorization");
});
});
```
## Testing Tips for Langflow Components
1. **Node template data**: Create reusable mock node data objects. The template structure is deeply nested and used across many components.
2. **Store interdependencies**: Many components read from multiple stores (flowStore, typesStore, alertStore). Mock or initialize all relevant stores.
3. **data-testid conventions**: Langflow uses `data-testid` extensively. Common patterns:
- `popover-anchor-input-{name}` for input fields
- `handle-{type}-{position}` for flow handles
- `sidebar-nav-{action}` for sidebar buttons
- `edit-fields-button` for inspection panel
4. **Global variable awareness**: When testing components that render node fields, be aware that fields with `load_from_db: true` and a global variable value render as badges, not inputs. The input element will not exist in the DOM.
5. **Inspection panel vs. canvas**: Fields render differently depending on context. Canvas shows non-advanced fields; the inspection panel shows advanced fields. Use `shouldRenderInspectionPanelField()` and `isCanvasVisible()` from `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts`.
@@ -0,0 +1,458 @@
# Mocking Guide for Langflow Tests
All mocking in Langflow uses Jest APIs. Never use Vitest (`vi.*`) APIs.
## Quick Reference
| Vitest (DO NOT USE) | Jest (USE THIS) |
|---|---|
| `vi.fn()` | `jest.fn()` |
| `vi.mock()` | `jest.mock()` |
| `vi.spyOn()` | `jest.spyOn()` |
| `vi.mocked()` | `jest.mocked()` |
| `vi.clearAllMocks()` | `jest.clearAllMocks()` |
| `vi.useFakeTimers()` | `jest.useFakeTimers()` |
| `vi.useRealTimers()` | `jest.useRealTimers()` |
| `vi.advanceTimersByTime()` | `jest.advanceTimersByTime()` |
## Pre-Existing Global Mocks
These modules are already mocked in `jest.setup.js`. Do NOT re-mock them unless you need different behavior:
- `@radix-ui/react-form` (all exports render children)
- `react-markdown` (renders null)
- `lucide-react/dynamicIconImports` (empty object)
- `@/components/common/genericIconComponent` (renders null)
- `@/icons/BotMessageSquare` (renders null)
- `@/stores/darkStore` (returns default state)
- `localStorage` and `sessionStorage` (jest.fn() stubs)
To use the real implementation instead:
```typescript
jest.unmock("@/stores/darkStore");
```
## API Mocking
### Mocking Axios Calls
Langflow uses Axios via a configured API instance. Mock the API module:
```typescript
import api from "@/controllers/API/api";
jest.mock("@/controllers/API/api", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
},
}));
describe("MyComponent", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should fetch data on mount", async () => {
jest.mocked(api.get).mockResolvedValueOnce({
data: { items: [{ id: "1", name: "Test" }] },
});
render(<MyComponent />);
await waitFor(() => {
expect(screen.getByText("Test")).toBeInTheDocument();
});
expect(api.get).toHaveBeenCalledWith("/api/v1/items");
});
it("should handle API errors", async () => {
jest.mocked(api.get).mockRejectedValueOnce(new Error("Network error"));
render(<MyComponent />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
```
### Mocking Specific API Query Hooks
For components using React Query hooks from `@/controllers/API/queries/`:
```typescript
jest.mock("@/controllers/API/queries/flows", () => ({
useGetFlowsQuery: jest.fn().mockReturnValue({
data: [{ id: "flow-1", name: "My Flow" }],
isLoading: false,
error: null,
refetch: jest.fn(),
}),
}));
```
### Mocking Individual API Functions
```typescript
jest.mock("@/controllers/API", () => ({
getFlows: jest.fn().mockResolvedValue([]),
saveFlow: jest.fn().mockResolvedValue({ id: "new-flow" }),
deleteFlow: jest.fn().mockResolvedValue(undefined),
}));
```
## Zustand Store Mocking
Langflow does NOT have a global Zustand auto-mock. You have two options:
### Option 1: Use Real Stores with `setState()` (Preferred)
```typescript
import useAlertStore from "@/stores/alertStore";
describe("Alert-dependent component", () => {
beforeEach(() => {
// Reset store to known state before each test
useAlertStore.setState({
errorData: { title: "", list: [] },
noticeData: { title: "", link: "" },
successData: { title: "" },
notificationCenter: false,
notificationList: [],
tempNotificationList: [],
});
});
it("should display error notification", () => {
// Pre-set store state
useAlertStore.setState({
errorData: { title: "Something went wrong", list: ["Detail"] },
});
render(<NotificationBanner />);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});
```
### Option 2: Mock the Store Module
Use when the real store has complex initialization or `import.meta` dependencies:
```typescript
const mockFlowStore = {
nodes: [],
edges: [],
setNodes: jest.fn(),
setEdges: jest.fn(),
onNodesChange: jest.fn(),
onEdgesChange: jest.fn(),
onConnect: jest.fn(),
};
jest.mock("@/stores/flowStore", () => ({
__esModule: true,
default: (selector?: (state: any) => any) =>
selector ? selector(mockFlowStore) : mockFlowStore,
}));
describe("FlowCanvas", () => {
beforeEach(() => {
jest.clearAllMocks();
mockFlowStore.nodes = [];
mockFlowStore.edges = [];
});
it("should render nodes from the store", () => {
mockFlowStore.nodes = [
{ id: "node-1", type: "genericNode", data: { node: { display_name: "OpenAI" } }, position: { x: 0, y: 0 } },
];
render(<FlowCanvas />);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
});
```
### Option 3: Use `renderHook` for Store Tests
For testing the store itself:
```typescript
import { act, renderHook } from "@testing-library/react";
import useMyStore from "../myStore";
describe("useMyStore", () => {
beforeEach(() => {
useMyStore.setState({ count: 0 });
});
it("should increment count", () => {
const { result } = renderHook(() => useMyStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
```
## React Router Mocking
Langflow uses `react-router-dom` v6 (NOT Next.js routing).
### Wrapping with MemoryRouter
```typescript
import { MemoryRouter } from "react-router-dom";
it("should render the page", () => {
render(
<MemoryRouter initialEntries={["/flows/flow-123"]}>
<FlowPage />
</MemoryRouter>,
);
});
```
### Mocking useNavigate
```typescript
const mockNavigate = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useNavigate: () => mockNavigate,
}));
it("should navigate to flow page on click", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<FlowCard flow={mockFlow} />
</MemoryRouter>,
);
await user.click(screen.getByText("Open Flow"));
expect(mockNavigate).toHaveBeenCalledWith("/flow/flow-123");
});
```
### Mocking useParams
```typescript
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useParams: () => ({ flowId: "flow-123" }),
}));
```
### Mocking useSearchParams
```typescript
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useSearchParams: () => [new URLSearchParams("tab=settings"), jest.fn()],
}));
```
## React Query Mocking
### Creating a Test QueryClient
```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
}
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
```
### Mocking useMutation
```typescript
jest.mock("@tanstack/react-query", () => ({
...jest.requireActual("@tanstack/react-query"),
useMutation: jest.fn().mockReturnValue({
mutate: jest.fn(),
mutateAsync: jest.fn(),
isPending: false,
isError: false,
error: null,
data: undefined,
}),
}));
```
## Context Provider Mocking
### Wrapping with Providers
For components that depend on React context:
```typescript
import { AuthContext } from "@/contexts/authContext";
const mockAuthContext = {
isAuthenticated: true,
userData: { id: "user-1", username: "testuser" },
login: jest.fn(),
logout: jest.fn(),
getAuthentication: jest.fn(),
autoLogin: false,
};
it("should show user name when authenticated", () => {
render(
<AuthContext.Provider value={mockAuthContext}>
<UserMenu />
</AuthContext.Provider>,
);
expect(screen.getByText("testuser")).toBeInTheDocument();
});
```
### Mocking useContext Directly
```typescript
jest.mock("react", () => ({
...jest.requireActual("react"),
useContext: jest.fn().mockReturnValue({
isAuthenticated: true,
userData: { username: "testuser" },
}),
}));
```
## Component Mocking
### Mocking Child Components
When a child component is complex or has side effects:
```typescript
jest.mock("@/components/core/chatView/ChatView", () => ({
__esModule: true,
default: ({ onSend }: any) => (
<div data-testid="mock-chat-view">
<button onClick={() => onSend("test message")}>Send</button>
</div>
),
}));
```
### DO NOT Mock Base UI Components
Never mock components from `@/components/ui/`:
- `Button`, `Input`, `Select`, `Dialog`, `Popover`, etc.
- These are simple Radix/shadcn wrappers and should be rendered as-is
- Mocking them hides real rendering issues
### Mocking Third-Party Components
```typescript
// Mock @xyflow/react for flow-related tests
jest.mock("@xyflow/react", () => ({
ReactFlow: ({ children }: any) => <div data-testid="react-flow">{children}</div>,
useReactFlow: () => ({
getNodes: jest.fn().mockReturnValue([]),
getEdges: jest.fn().mockReturnValue([]),
setNodes: jest.fn(),
setEdges: jest.fn(),
fitView: jest.fn(),
zoomIn: jest.fn(),
zoomOut: jest.fn(),
}),
useNodesState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]),
useEdgesState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]),
Background: () => null,
Controls: () => null,
MiniMap: () => null,
Handle: ({ type, position }: any) => (
<div data-testid={`handle-${type}-${position}`} />
),
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
MarkerType: { ArrowClosed: "arrowclosed" },
}));
```
## Window and DOM Mocking
### Already Mocked in Setup Files
These are globally mocked in `setupTests.ts` -- do not re-mock:
- `ResizeObserver`
- `IntersectionObserver`
- `window.matchMedia`
### Mocking window.location
```typescript
const originalLocation = window.location;
beforeEach(() => {
Object.defineProperty(window, "location", {
value: { ...originalLocation, href: "http://localhost:3000", assign: jest.fn() },
writable: true,
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});
```
### Mocking Clipboard
```typescript
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
readText: jest.fn().mockResolvedValue("clipboard content"),
},
});
```
## Common Pitfalls
1. **Mocking before imports**: `jest.mock()` calls are hoisted automatically by Jest. You do not need to place them before import statements (but it is conventional to do so for readability).
2. **Forgetting `__esModule: true`**: When mocking a module with default exports, include `__esModule: true` in the mock factory.
3. **Over-mocking**: Only mock what is necessary. If the real module works in jsdom, prefer using it.
4. **Not resetting mocks**: Always use `jest.clearAllMocks()` in `beforeEach` to prevent test contamination.
5. **Mocking globally-mocked modules**: Check `jest.setup.js` first. Duplicating a mock may cause unexpected behavior.
@@ -0,0 +1,197 @@
# Incremental Testing Workflow
Step-by-step process for systematically testing a directory of source files in Langflow.
## Phase 1: Discovery
### 1.1 Identify Source Files
List all `.ts` and `.tsx` files in the target directory (excluding existing test files):
```bash
find src/frontend/src/path/to/directory -name '*.ts' -o -name '*.tsx' | grep -v '__tests__' | grep -v '.test.' | grep -v '.spec.' | sort
```
### 1.2 Categorize by Complexity
Sort files into tiers:
| Tier | Description | Examples |
|---|---|---|
| 1 | Pure functions, constants, types | `utils.ts`, `constants.ts`, `types.ts` |
| 2 | Custom hooks (no UI) | `useDebounce.ts`, `useLocalStorage.ts` |
| 3 | Simple presentational components | `Badge.tsx`, `EmptyState.tsx` |
| 4 | Stateful components | `SearchInput.tsx`, `FilterPanel.tsx` |
| 5 | Store-connected components | `NodeToolbar.tsx`, `SidebarHeader.tsx` |
| 6 | Async/API components | `FlowList.tsx`, `GlobalVariablesPage.tsx` |
| 7 | Integration components | `FlowPage.tsx`, `ChatView.tsx` |
### 1.3 Check Existing Coverage
```bash
npm test -- --coverage --collectCoverageFrom='src/path/to/directory/**/*.{ts,tsx}' src/path/to/directory/__tests__/
```
## Phase 2: Write Tests (Per File)
For each source file, starting from Tier 1:
### 2.1 Read the Source
Read the entire source file. Identify:
- All exported functions, components, hooks, and types
- All conditional branches (if/else, ternary, switch, early returns)
- All side effects (API calls, store mutations, DOM manipulation)
- All dependencies that need mocking
### 2.2 Create the Test File
Create `__tests__/SourceFileName.test.tsx` (or `.test.ts` for non-JSX files).
### 2.3 Write Tests in Order
1. **Imports and mocks** at the top
2. **describe block** named after the exported entity
3. **beforeEach** with `jest.clearAllMocks()` and any store resets
4. **Rendering tests** first
5. **Props/state variation tests** next
6. **Interaction tests** next
7. **Async/error tests** last
### 2.4 Run and Verify
```bash
# Run the single test file
npm test -- src/path/to/__tests__/SourceFileName.test.tsx
# Check coverage for the source file
npm test -- --coverage --collectCoverageFrom='src/path/to/SourceFileName.tsx' src/path/to/__tests__/SourceFileName.test.tsx
```
### 2.5 Fix Failures
Common issues:
- **Missing mock**: Add `jest.mock()` for the dependency
- **Act warning**: Wrap state updates in `act()` or use `await waitFor()`
- **Element not found**: Check query strategy -- use `screen.debug()` to inspect DOM
- **Globally mocked module**: Check `jest.setup.js` -- the module may already be mocked
### 2.6 Reach Coverage Targets
If coverage is below targets (100% function, >95% branch/line):
1. Identify uncovered lines in the coverage report
2. Write additional test cases targeting those branches
3. Re-run coverage to confirm improvement
## Phase 3: Directory-Level Verification
After all files in the directory have tests:
### 3.1 Run All Tests Together
```bash
npm test -- --testPathPattern="src/path/to/directory"
```
### 3.2 Check Combined Coverage
```bash
npm test -- --coverage --collectCoverageFrom='src/path/to/directory/**/*.{ts,tsx}' --testPathPattern="src/path/to/directory"
```
### 3.3 Review Test Quality
For each test file, verify:
- [ ] No implementation details tested (no internal state inspection)
- [ ] No redundant tests (each test adds unique coverage)
- [ ] All async operations properly awaited
- [ ] Cleanup performed (timers, spies, store resets)
- [ ] Test names are descriptive and follow the `"should ... when ..."` pattern
## Phase 4: Cleanup
### 4.1 Remove Unnecessary Mocks
If a mock is not actually needed (the real implementation works in jsdom), remove it. Fewer mocks means more realistic tests.
### 4.2 Extract Shared Test Utilities
If multiple test files use the same setup patterns, extract them into a `test-utils.tsx` file in the `__tests__` directory:
```tsx
// __tests__/test-utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions & { route?: string },
) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[options?.route ?? "/"]}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
options,
);
}
```
Note: The Jest config ignores files named `test-utils.tsx` via `testPathIgnorePatterns`.
### 4.3 Final Verification
```bash
# Run full test suite to ensure nothing is broken
npm test
# Run with coverage for a summary report
npm run test:coverage
```
## Troubleshooting
### "Cannot find module" Errors
The `@/` alias maps to `src/frontend/src/`. If a module is not resolved, check `jest.config.js` `moduleNameMapper`.
### "import.meta" Errors
The project uses `transform-import-meta.js` to handle Vite's `import.meta.env`. If you encounter issues, check that the transform is applied in `jest.config.js`.
### Global Mocks Conflicting
Modules mocked in `jest.setup.js` are mocked globally. If you need the real implementation in a specific test:
```typescript
// At the top of your test file, before other imports
jest.unmock("@/stores/darkStore");
```
### Zustand Store State Leaking Between Tests
Always reset store state in `beforeEach`:
```typescript
import useMyStore from "@/stores/myStore";
beforeEach(() => {
useMyStore.setState({
// Reset to initial state
items: [],
loading: false,
});
});
```
+2 -1
View File
@@ -36,7 +36,8 @@ reviews:
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
base_branches:
- "release-.*"
finishing_touches:
docstrings:
enabled: true
-268
View File
@@ -1,268 +0,0 @@
---
description: "Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing."
globs:
- "src/backend/**/*.py"
- "tests/**/*.py"
- "Makefile"
- "pyproject.toml"
- "uv.lock"
alwaysApply: false
---
# Backend Development Guidelines
## Purpose
Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing.
---
## 1. Backend Environment Setup
### Prerequisites
- **Python Package Manager:** `uv` (>=0.4) for dependency management
- **Database:** SQLite for development, PostgreSQL for production
- **Development Tools:** `make` for build coordination
### Backend Service
```bash
make backend # Start FastAPI backend on port 7860
```
- Auto-reloads on file changes
- Health check: http://localhost:7860/health
- Backend components: `src/backend/base/langflow/`
---
## 2. Component Development
### Component Structure
```
src/backend/base/langflow/components/
├── agents/ # Agent components
├── data/ # Data processing components
├── embeddings/ # Embedding components
├── input_output/ # Input/output components
├── models/ # Language model components
├── processing/ # Text processing components
├── prompts/ # Prompt components
├── tools/ # Tool components
└── vectorstores/ # Vector store components
```
### Adding New Components
1. **Location:** Add to appropriate subdirectory under `src/backend/base/langflow/components/`
2. **Import:** Update `__init__.py` with alphabetical imports:
```python
from .my_component import MyComponent
__all__ = [
"ExistingComponent",
"MyComponent", # Add alphabetically
]
```
3. **Auto-restart:** Backend auto-restarts on save
4. **Browser refresh:** Refresh browser to see component changes
### Component Testing
- **Unit Tests:** `src/backend/tests/unit/components/`
- **Test Structure:** Mirror component directory structure
- **Test Base Classes:** Use `ComponentTestBaseWithClient` or `ComponentTestBaseWithoutClient`
- **Version Testing:** Provide `file_names_mapping` for backward compatibility
### Development Tips
- **Fast iteration:** Edit component in UI first, then save to source
- **Component updates:** Old components show "Updates Available" after backend restart
- **Testing:** Create comprehensive unit tests for all new components
---
## 3. Backend Code Quality
### Formatting (CRITICAL)
```bash
make format_backend # Format Python code
```
**Important:** Run `make format_backend` _early and often_ (ideally before running linting or committing changes). It auto-corrects the majority of style issues, preventing lengthy manual fixes when lint errors surface later.
### Linting
```bash
make lint # Run linting checks
```
### Testing
```bash
make unit_tests # Run backend unit tests
```
### Pre-commit Workflow
1. **Run `make format_backend`** (FIRST - saves time on lint fixes)
2. Run `make lint`
3. Run `make unit_tests`
4. Commit changes
---
## 4. FastAPI Development
### API Structure
```
src/backend/base/langflow/api/
├── v1/ # API version 1
│ ├── chat.py # Chat endpoints
│ ├── flows.py # Flow management
│ ├── users.py # User management
│ └── ...
└── v2/ # API version 2 (future)
```
### Testing APIs
- Use `client` fixture from `conftest.py`
- Test with `logged_in_headers` for authenticated endpoints
- Example:
```python
async def test_flows_endpoint(client, logged_in_headers):
response = await client.post(
"api/v1/flows/",
json=flow_data,
headers=logged_in_headers
)
assert response.status_code == 201
```
---
## 5. Database Development
### Models Location
```
src/backend/base/langflow/services/database/models/
├── api_key/ # API key models
├── flow/ # Flow models
├── folder/ # Folder models
├── user/ # User models
└── ...
```
### Database Testing
- Use in-memory SQLite for tests
- Database tests may fail in batch runs - run individually if needed:
```bash
uv run pytest src/backend/tests/unit/test_database.py
```
---
## 6. Async Development Patterns
### Component Async Methods
```python
async def run(self) -> MessageType:
"""Main component execution method."""
# Use await for async operations
result = await self.async_operation()
return result
async def message_response(self) -> Message:
"""Return a Message object for chat components."""
return Message(
text=self.input_value,
sender=self.sender,
session_id=self.session_id,
)
```
### Background Tasks
```python
import asyncio
async def process_in_background(self):
"""Process items without blocking."""
# Use asyncio.create_task for background work
task = asyncio.create_task(self.heavy_operation())
# Ensure proper cleanup
try:
result = await task
return result
except asyncio.CancelledError:
# Handle cancellation gracefully
await self.cleanup()
raise
```
### Queue Operations
```python
async def queue_processing(self):
"""Non-blocking queue operations."""
queue = asyncio.Queue()
# Non-blocking put
queue.put_nowait(data)
# Timeout-controlled get
try:
result = await asyncio.wait_for(queue.get(), timeout=5.0)
return result
except asyncio.TimeoutError:
# Handle timeout appropriately
raise ComponentError("Processing timeout")
```
---
## 7. Component Integration Testing
### Flow Testing
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events
async def test_component_in_flow(client, json_flow, logged_in_headers):
"""Test component within a complete flow."""
flow_id = await create_flow(client, json_flow, logged_in_headers)
build_response = await build_flow(client, flow_id, logged_in_headers)
# Validate flow execution
job_id = build_response["job_id"]
events_response = await get_build_events(client, job_id, logged_in_headers)
assert events_response.status_code == 200
```
### External API Testing
```python
@pytest.mark.api_key_required
@pytest.mark.no_blockbuster
async def test_with_real_api(self):
"""Test component with external service."""
api_key = os.getenv("OPENAI_API_KEY")
component = MyComponent(api_key=api_key, model="gpt-4o")
response = await component.run()
assert response is not None
```
---
## 8. Known Backend Issues
### Testing Quirks
- `test_database.py` may fail in batch runs but pass individually
- Use `@pytest.mark.no_blockbuster` to skip blockbuster plugin when needed
- Context variables may not propagate correctly in `asyncio.to_thread` - test both patterns
### File Changes
- Starter project files auto-format after `langflow run`
- These formatting changes can be committed or ignored
---
## Backend Development Checklist
- [ ] Component added to appropriate subdirectory
- [ ] `__init__.py` updated with alphabetical imports
- [ ] Code formatted with `make format_backend` (FIRST)
- [ ] Linting passed with `make lint`
- [ ] Unit tests created and passing with `make unit_tests`
- [ ] Component tested in UI with backend restart + browser refresh
- [ ] Version mapping provided for backward compatibility
- [ ] Async patterns implemented correctly with proper cleanup
- [ ] External API calls use appropriate pytest markers
@@ -1,82 +0,0 @@
---
description: "Guide for creating a basic Langflow component, including requirements gathering, component structure, and implementation best practices."
globs:
- "src/backend/**/components/**/*.py"
- "src/backend/base/langflow/components/**/*.py"
alwaysApply: false
---
# Rule: How to Create a Basic Langflow Component
## Purpose
Guide for Creating a Langflow Component
---
### 1. Gather Requirements
Ask the user for:
- **Component Name:** What should the component be called?
- **Description:** What does the component do?
- **Inputs:** What are the required inputs? (e.g., text, dropdown, boolean, etc.)
- **Outputs:** What should the component output? (e.g., a message, a value, etc.)
- **Category:** Which component category should this component be stored under in `langflow/src/backend/base/langflow/components`
### 2. Define the Component
- Inherit from `Component`.
- Set `display_name`, `description`, `icon`.
- Define the `inputs` and `outputs` as lists of input/output field objects (e.g., `DropdownInput`, `MessageTextInput`, `Output`).
- Implement the main logic as a method (e.g., `get_current_date`, `true_response`, etc.).
### 3. Example: Conditional If-Else Component
```python
class ConditionalRouterComponent(Component):
display_name = "If-Else"
description = "Routes an input message to a corresponding output based on text comparison."
icon = "split"
name = "ConditionalRouter"
inputs = [
# Define your inputs here
]
outputs = [
# Define your outputs here
]
# Implement your logic methods here
```
### 4. Example: Current Date Component
```python
class CurrentDateComponent(Component):
display_name = "Current Date"
description = "Returns the current date and time in the selected timezone."
icon = "clock"
name = "CurrentDate"
inputs = [
# Define your inputs here
]
outputs = [
# Define your outputs here
]
# Implement your logic methods here
```
### 5. Best Practices
- Use clear and descriptive names for inputs and outputs.
- Provide helpful `info` for each input to guide users.
- Handle errors gracefully and provide meaningful error messages.
- Use appropriate icons to visually represent the component's function.
- Use a Lucide icon or if you want a custom icon follow the icon rules (`./cursor/rules/icons.mdc`)
---
## Checklist for Creating a Component
- [ ] Ask the user for component name, description, inputs, and outputs.
- [ ] Define the component class with the required fields.
- [ ] Implement the main logic.
- [ ] Add helpful info and error handling.
- [ ] Test the component.
-443
View File
@@ -1,443 +0,0 @@
---
description: "Guidelines for developing and maintaining Langflow documentation using Docusaurus, including content structure, style, and deployment processes."
globs:
- "docs/**/*.{md,mdx}"
- "docs/**/*.{js,jsx,ts,tsx}"
- "docs/package*.json"
- "docs/docusaurus.config.js"
- "docs/sidebars.js"
- "docs/static/**/*"
alwaysApply: false
---
# Documentation Development Guidelines
## Purpose
Guidelines for developing and maintaining Langflow documentation using Docusaurus, including content structure, style, and deployment processes.
---
## 1. Documentation Environment Setup
### Prerequisites
- **Node.js:** v22.12 LTS for runtime
- **Package Manager:** npm for dependency management
- **Documentation Framework:** Docusaurus v3
### Documentation Service
```bash
cd docs
npm install # Install dependencies
npm run start # Start dev server (usually port 3001)
```
- Auto-reloads on documentation changes
- Access at: http://localhost:3001/
- Documentation source: `docs/`
---
## 2. Documentation Structure
### Directory Layout
```
docs/
├── docs/ # Main documentation content
│ ├── agents/ # Agent and MCP guides
│ ├── get-started/ # Getting started guides
│ ├── tutorials/ # Langflow tutorials
│ ├── components/ # Component documentation
│ ├── flows/ # Guides to build, run, and test flows
│ ├── deployment/ # Guides for deploying and hosting a Langflow server
│ ├── develop/ # Guides for developing apps with Langflow
│ ├── support/ # Help and release notes
│ ├── contributing/ # Contribution guidelines
│ └── api-reference/ # API documentation
├── src/ # Custom React components
├── static/ # Static assets (images, etc.)
├── sidebars.js # Sidebar configuration
├── docusaurus.config.js # Main configuration
└── package.json # Dependencies
```
### Content Types
- **Guides:** Step-by-step tutorials (`docs/getting-started/`)
- **Reference:** API and component reference (`docs/api-reference/`)
- **How-to:** Problem-solving articles (`docs/components/`)
- **Concepts:** Explanatory articles about Langflow concepts
- **Blog:** Release notes, announcements (`blog/`)
---
## 3. Writing Documentation
### Markdown Conventions
```markdown
---
title: Page Title
description: Brief description for SEO
sidebar_position: 1
---
# Page Title
Brief introduction paragraph.
## Section Header
Content with proper formatting.
### Subsection
More detailed content.
:::tip
Use admonitions for important information.
:::
:::warning
Use warnings for potential issues.
:::
:::danger
Use danger for critical warnings.
:::
```
### Code Blocks
````markdown
```python title="component_example.py"
from langflow.components.base import Component
class MyComponent(Component):
display_name = "My Component"
description = "Example component"
def run(self):
return "Hello, World!"
```
````
### Images and Assets
```markdown
<!-- Images go in static/img/ -->
![Component Overview](/img/components/overview.png)
<!-- Use descriptive alt text -->
![Langflow interface showing the flow editor with nodes and connections](/img/flow-editor.png)
```
---
## 4. Component Documentation
### Component Page Template
```markdown
---
title: Component Name
description: Brief description of what the component does
sidebar_position: 1
---
# Component Name
Brief overview of the component's purpose.
## Overview
What this component does and when to use it.
## Configuration
### Inputs
| Input | Type | Required | Description |
|-------|------|----------|-------------|
| `input_text` | String | Yes | The text to process |
| `model_name` | String | No | Model to use (default: gpt-3.5-turbo) |
### Outputs
| Output | Type | Description |
|--------|------|-------------|
| `result` | Message | Processed result |
## Usage Example
```python
# Example of using the component
component = MyComponent(
input_text="Hello, world!",
model_name="gpt-4"
)
result = component.run()
```
## Common Issues
### Issue: Component not loading
**Solution:** Check that all required inputs are provided.
### Issue: API key errors
**Solution:** Ensure your API key is properly configured.
```
### API Documentation
```markdown
---
title: API Endpoint
description: REST API endpoint documentation
---
# API Endpoint Name
## Endpoint
`POST /api/v1/endpoint`
## Request
### Headers
```json
{
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
```
### Body
```json
{
"parameter": "value",
"optional_param": "optional_value"
}
```
## Response
### Success (200)
```json
{
"success": true,
"data": {
"result": "success"
}
}
```
### Error (400)
```json
{
"success": false,
"error": "Error message"
}
```
## Example
```bash
curl -X POST http://localhost:7860/api/v1/endpoint \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '{"parameter": "value"}'
```
```
---
## 5. Blog Posts and Announcements
### Blog Post Template
```markdown
---
title: "Release: Langflow v1.1.0"
description: "New features and improvements in Langflow v1.1.0"
authors: [author-name]
date: 2024-01-15
tags: [release, features]
---
# Release: Langflow v1.1.0
Brief introduction to the release.
## New Features
### Feature 1
Description of the feature and how to use it.
### Feature 2
Another feature description.
## Improvements
- List of improvements
- Bug fixes
- Performance enhancements
## Breaking Changes
:::warning
List any breaking changes that require user action.
:::
## Migration Guide
Steps to migrate from previous versions.
```
### Announcement Posts
```markdown
---
title: "Announcement: New Integration"
description: "Langflow now supports integration with XYZ service"
authors: [author-name]
date: 2024-01-15
tags: [announcement, integration]
---
Brief announcement content with clear call-to-action.
```
---
## 6. Configuration
### Sidebar Configuration (`sidebars.js`)
```javascript
module.exports = {
docs: [
'introduction',
{
type: 'category',
label: 'Getting Started',
items: [
'getting-started/installation',
'getting-started/quickstart',
'getting-started/first-flow',
],
},
{
type: 'category',
label: 'Components',
items: [
'components/overview',
'components/inputs',
'components/outputs',
'components/processing',
],
},
],
};
```
### Site Configuration (`docusaurus.config.js`)
```javascript
module.exports = {
title: 'Langflow Documentation',
tagline: 'Build AI flows visually',
url: 'https://docs.langflow.org',
baseUrl: '/',
themeConfig: {
navbar: {
title: 'Langflow',
logo: {
alt: 'Langflow Logo',
src: 'img/logo.svg',
},
items: [
{
type: 'doc',
docId: 'introduction',
position: 'left',
label: 'Docs',
},
{
to: '/blog',
label: 'Blog',
position: 'left'
},
],
},
},
};
```
---
## 7. Documentation Testing
### Link Checking
```bash
# Check for broken internal links
npm run build
npm run serve
# Manual testing or use link checker tools
```
### Content Review
- **Accuracy:** Verify all code examples work
- **Completeness:** Ensure all features are documented
- **Clarity:** Review for clear, concise language
- **Navigation:** Test sidebar and cross-references
### Screenshots
- Keep screenshots up-to-date with current UI
- Use consistent browser/OS for screenshots
- Highlight relevant UI elements
- Use descriptive file names
---
## 8. Style Guide
### Writing Style
- **Tone:** Professional but approachable
- **Voice:** Second person ("you") for instructions
- **Tense:** Present tense for current features
- **Length:** Keep paragraphs short and scannable
### Formatting
- **Headers:** Use sentence case
- **Code:** Inline code with `backticks`
- **Emphasis:** Use **bold** for UI elements, *italic* for emphasis
- **Lists:** Use parallel structure
### Terminology
- **Langflow:** Always capitalize
- **Component:** Capitalize when referring to Langflow components
- **Flow:** Capitalize when referring to Langflow flows
- **API:** Always uppercase
- **JSON:** Always uppercase
---
## 9. Deployment
### Local Testing
```bash
npm run build # Build static site
npm run serve # Serve built site locally
```
### Production Deployment
- Documentation is automatically deployed on commit to main branch
- Build artifacts go to `build/` directory
- Static site is served via CDN
---
## Documentation Development Checklist
- [ ] Documentation service running with `npm run start`
- [ ] Content follows markdown conventions
- [ ] Code examples are tested and working
- [ ] Images have descriptive alt text
- [ ] Internal links are functional
- [ ] Sidebar navigation is updated
- [ ] Content follows style guide
- [ ] Screenshots are current and properly formatted
- [ ] Cross-references between related topics
- [ ] Build succeeds with `npm run build`
-374
View File
@@ -1,374 +0,0 @@
---
description: "Guidelines for frontend development in Langflow, focusing on React/TypeScript UI components, build processes, and frontend testing."
globs:
- "src/frontend/**/*.{ts,tsx,js,jsx}"
- "src/frontend/**/*.{css,scss,json}"
- "src/frontend/package*.json"
- "src/frontend/vite.config.*"
- "src/frontend/tailwind.config.*"
- "src/frontend/tsconfig.json"
alwaysApply: false
---
## 1. Frontend Environment Setup
### Prerequisites
- **Node.js:** v22.12 LTS for JavaScript runtime
- **Package Manager:** npm (v10.9) for dependency management
- **Development Tools:** Vite for build tooling
### Frontend Service
```bash
make frontend # Start Vite dev server on port 3000
```
- Hot-reload enabled for UI changes
- Access at: http://localhost:3000/
- Frontend source: `src/frontend/`
---
## 2. Frontend Structure
### Directory Layout
```
src/frontend/src/
├── components/ # Reusable UI components
├── pages/ # Page-level components
├── icons/ # Component icons and lazy loading
├── stores/ # State management (Zustand)
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── hooks/ # Custom React hooks
├── services/ # API service functions
└── assets/ # Static assets
```
### Key Technologies
- **React 18** with TypeScript
- **Vite** for build tooling and dev server
- **Tailwind CSS** for styling
- **Zustand** for state management
- **React Flow** for flow graph visualization
- **Lucide React** for icons
---
## 3. Frontend Code Quality
### Formatting
```bash
make format_frontend # Format TypeScript/JavaScript code
```
### Linting
```bash
make lint # Run ESLint and TypeScript checks
```
### Testing
```bash
make tests_frontend # Run frontend tests (requires additional setup)
```
### Pre-commit Workflow
1. Run `make format_frontend`
2. Run `make lint`
3. Test changes in browser
4. Commit changes
---
## 4. State Management
### Zustand Stores
```typescript
// stores/myStore.ts
import { create } from 'zustand';
interface MyState {
value: string;
setValue: (value: string) => void;
}
export const useMyStore = create<MyState>((set) => ({
value: '',
setValue: (value) => set({ value }),
}));
```
### Using Stores in Components
```typescript
// components/MyComponent.tsx
import { useMyStore } from '@/stores/myStore';
export function MyComponent() {
const { value, setValue } = useMyStore();
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
```
---
## 5. API Integration
### Service Functions
```typescript
// services/api.ts
import { api } from '@/controllers/API';
export async function createFlow(flowData: FlowData) {
const response = await api.post('/flows/', flowData);
return response.data;
}
export async function getFlows() {
const response = await api.get('/flows/');
return response.data;
}
```
### Error Handling
```typescript
// hooks/useApi.ts
import { useState, useCallback } from 'react';
export function useApi<T>(apiFunction: (...args: any[]) => Promise<T>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const execute = useCallback(async (...args: any[]) => {
try {
setLoading(true);
setError(null);
const result = await apiFunction(...args);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
throw err;
} finally {
setLoading(false);
}
}, [apiFunction]);
return { execute, loading, error };
}
```
---
## 6. React Flow Integration
### Flow Graph Components
```typescript
// components/FlowGraph.tsx
import ReactFlow, {
Node,
Edge,
Controls,
Background
} from 'reactflow';
interface FlowGraphProps {
nodes: Node[];
edges: Edge[];
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
}
export function FlowGraph({
nodes,
edges,
onNodesChange,
onEdgesChange
}: FlowGraphProps) {
return (
<div className="w-full h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
>
<Controls />
<Background />
</ReactFlow>
</div>
);
}
```
### Custom Node Types
```typescript
// components/nodes/ComponentNode.tsx
import { memo } from 'react';
import { Handle, Position } from 'reactflow';
interface ComponentNodeProps {
data: {
label: string;
icon?: string;
};
}
export const ComponentNode = memo(({ data }: ComponentNodeProps) => {
return (
<div className="px-4 py-2 shadow-md rounded-md bg-white border">
<Handle type="target" position={Position.Top} />
<div className="flex items-center">
{data.icon && (
<img src={data.icon} alt="" className="w-4 h-4 mr-2" />
)}
<span className="text-sm">{data.label}</span>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
});
```
---
## 7. Styling with Tailwind
### Component Styling
```typescript
// components/Button.tsx
import { cn } from '@/utils/cn';
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick
}: ButtonProps) {
return (
<button
className={cn(
'rounded-md font-medium transition-colors',
{
'bg-blue-600 hover:bg-blue-700 text-white': variant === 'primary',
'bg-gray-200 hover:bg-gray-300 text-gray-900': variant === 'secondary',
'px-2 py-1 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
}
)}
onClick={onClick}
>
{children}
</button>
);
}
```
### Dark Mode Support
```typescript
// hooks/useDarkMode.ts
import { useDarkStore } from '@/stores/darkStore';
export function useDarkMode() {
const { dark, setDark } = useDarkStore();
const toggle = () => setDark(!dark);
return { isDark: dark, toggle };
}
```
---
## 8. Frontend Testing
### Component Testing
```typescript
// __tests__/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '@/components/Button';
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
```
### Integration Testing
```typescript
// __tests__/FlowEditor.test.tsx
import { render, screen } from '@testing-library/react';
import { FlowEditor } from '@/pages/FlowEditor';
describe('FlowEditor', () => {
it('loads flow data correctly', async () => {
render(<FlowEditor flowId="test-flow-id" />);
// Wait for flow to load
await screen.findByText('Flow loaded');
expect(screen.getByTestId('flow-canvas')).toBeInTheDocument();
});
});
```
---
## 9. Build and Deployment
### Development Build
```bash
make build_frontend # Build frontend static files
```
### Production Build
```bash
cd src/frontend
npm run build # Creates dist/ directory
```
### Build Integration
- Frontend builds to `src/frontend/dist/`
- Backend serves static files from this directory in production
- `make init` includes frontend build step
---
## Frontend Development Checklist
- [ ] Frontend service running with `make frontend`
- [ ] Changes hot-reload correctly in browser
- [ ] State management uses Zustand stores
- [ ] API calls use proper error handling
- [ ] Components styled with Tailwind CSS
- [ ] Dark mode support implemented where needed
- [ ] Code formatted with `make format_frontend`
- [ ] Linting passed with `make lint`
- [ ] Changes tested in both light and dark mode
- [ ] Components styled with Tailwind CSS
- [ ] Dark mode support implemented where needed
- [ ] Code formatted with `make format_frontend`
- [ ] Changes tested in both light and dark mode
-137
View File
@@ -1,137 +0,0 @@
---
description: "Rules for creating and implementing component icons in Langflow, covering both backend Python component icon attributes and frontend React icon implementation."
globs:
- "src/frontend/src/icons/**/*"
- "src/frontend/src/icons/lazyIconImports.ts"
- "src/backend/**/*component*.py"
- "src/backend/**/components/**/*.py"
alwaysApply: false
---
# Component Icon Rules
## Purpose
To ensure consistent, clear, and functional icon usage for components, covering both backend (Python) and frontend (React/TypeScript) steps.
---
## 1. Backend (Python) — Setting the Icon Name
- **Where:** In your component class (e.g., in `src/backend/base/langflow/components/vectorstores/astradb.py`)
- **How:**
Set the `icon` attribute to a string matching the icon you want to use.
```python
icon = "AstraDB"
```
- **Tip:**
The string must match the frontend icon mapping exactly (case-sensitive).
---
## 2. Frontend (React/TypeScript) — Adding the Icon
### a. Create the Icon Component
- **Where:**
In a new directory for your icon, e.g., `src/frontend/src/icons/AstraDB/`.
- **How:**
- Add your SVG as a React component, e.g., `AstraSVG` in `AstraDB.jsx`.
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path
// ...
/>
</svg>
);
```
- Create an `index.tsx` that exports your icon using `forwardRef`:
```tsx
import React, { forwardRef } from "react";
import AstraSVG from "./AstraDB";
export const AstraDBIcon = forwardRef<
SVGSVGElement,
React.PropsWithChildren<{}>
>((props, ref) => {
return <AstraSVG ref={ref} isDark={isDark} {...props} />;
});
```
#### Supporting Light and Dark Mode Icons
- **How:**
- In your SVG component (e.g., `AstraDB.jsx`), use the `isDark` prop to switch colors:
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path
fill={props.isDark ? "#ffffff" : "#0A0A0A"}
// ...
/>
</svg>
);
```
- The `isDark` prop is passed from the icon wrapper (see above) and should be used to toggle between light and dark color schemes.
- You can use a utility like `stringToBool` to ensure the prop is interpreted correctly.
### b. Add to Lazy Icon Imports
- **Where:**
In `src/frontend/src/icons/lazyIconImports.ts`
- **How:**
Add an entry to the `lazyIconsMapping` object:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
- **Tip:**
The key (`AstraDB`) must match the string used in the backend.
---
## 3. Best Practices
- **Naming:**
Use clear, recognizable names (e.g., `"AstraDB"`, `"Postgres"`, `"OpenAI"`).
- **Consistency:**
Always use the same icon name for the same service across backend and frontend.
- **Missing Icon:**
If no icon exists, use a [lucide icon](https://lucide.dev/icons)
- **Light/Dark Mode:**
Always support both light and dark mode for custom icons by using the `isDark` prop in your SVG.
---
## 4. Checklist for Adding a New Icon
- [ ] Decide on a clear, descriptive icon name (e.g., `AstraDB`).
- [ ] In your Python component, set `icon = "YourIconName"`.
- [ ] Create a new icon directory in `src/frontend/src/icons/YourIconName/`.
- [ ] Add your SVG as a React component (e.g., `YourIconNameIcon.jsx`).
- [ ] Create an `index.tsx` that exports your icon using `forwardRef` and passes the `isDark` prop.
- [ ] Add your icon to `lazyIconsMapping` in `src/frontend/src/icons/lazyIconImports.ts` with the exact same name.
- [ ] Verify the icon appears correctly in the UI in both light and dark mode.
- [ ] If no suitable icon exists, use a generic icon and request a new one if needed.
---
**Example for AstraDB:**
- Backend:
```python
icon = "AstraDB"
```
- Frontend:
- `src/icons/AstraDB/AstraDB.jsx` (SVG as React component, uses `isDark` prop)
- `src/icons/AstraDB/index.tsx` (exports `AstraDBIcon` and passes `isDark`)
- Add to `lazyIconImports.ts`:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
---
-561
View File
@@ -1,561 +0,0 @@
---
description: "Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices."
globs:
- "src/backend/tests/**/*.py"
- "src/frontend/**/*.test.{ts,tsx,js,jsx}"
- "src/frontend/**/*.spec.{ts,tsx,js,jsx}"
- "tests/**/*.py"
- "conftest.py"
- "pytest.ini"
alwaysApply: false
---
# Testing Guidelines for Langflow
## Purpose
Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices.
---
## 1. Testing Structure
### Backend Tests Location
- **Unit Tests:** `src/backend/tests/`
- **Component Tests:** `src/backend/tests/unit/components/` (organized by component subdirectory)
- **Integration Tests:** Available via `make integration_tests` (requires additional setup)
### Test File Naming
- Use same filename as component with appropriate test prefix/suffix
- Example: `my_component.py` → `test_my_component.py`
---
## 2. Built-in Fixtures & Base Classes
### `client` Fixture (FastAPI Test Client)
- Defined in `src/backend/tests/conftest.py`
- Provides an **async** `httpx.AsyncClient` connected to the full application via `ASGITransport` + `LifespanManager`.
- Use it for API tests:
```python
async def test_login_endpoint(client):
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
assert response.status_code == 200
```
- Automatically configured with an **in-memory SQLite database** and mocked environment variables. No additional setup needed in individual tests.
- Skip client creation by marking the test with `@pytest.mark.noclient`.
### `ComponentTestBase` Family
Located in `src/backend/tests/base.py`.
| Base Class | Creates `client`? | Typical Use | Notes |
|------------|------------------|-------------|-------|
| `ComponentTestBase` | No | Core logic for component version testing | Requires you to provide fixtures described below. |
| `ComponentTestBaseWithClient` | Yes (`@pytest.mark.usefixtures("client")`) | Components that need API access during `run()` | Inherit when the component interacts with backend services. |
| `ComponentTestBaseWithoutClient` | No | Pure-logic components with no API calls | Lightweight alternative. |
Required fixtures for subclasses:
1. `component_class` → the component **class** under test.
2. `default_kwargs` → dict of kwargs to instantiate the component (can be empty).
3. `file_names_mapping` → list of `VersionComponentMapping` dicts mapping historical **Langflow versions** to module/file names.
Example skeleton:
```python
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
from langflow.components.my_namespace import MyComponent
class TestMyComponent(ComponentTestBaseWithClient):
@pytest.fixture
def component_class(self):
return MyComponent
@pytest.fixture
def default_kwargs(self):
return {"foo": "bar"}
@pytest.fixture
def file_names_mapping(self):
return [
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
# Older versions can be mapped or DID_NOT_EXIST
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
]
```
`ComponentTestBase` automatically provides:
- `test_latest_version` → Instantiates component via `component_class` and asserts `run()` doesn't return `None`.
- `test_all_versions_have_a_file_name_defined` → Ensures mapping completeness vs `SUPPORTED_VERSIONS` constant (`src/backend/tests/constants.py`).
- `test_component_versions` (parametrised) → Builds component from source for each supported version and asserts successful execution.
When adding a new component test, **inherit from the correct base class and provide the three fixtures**. This greatly reduces boilerplate and enforces version compatibility.
---
## 3. Component Testing Requirements
### Minimum Testing Requirements
- **Unit Tests:** Create comprehensive unit tests for all new components
- **Manual Test Documentation:** If unit tests are incomplete, create a Markdown file with manual testing steps
- Location: Same directory as unit tests
- Filename: Same as component but with `.md` extension
- Content: Detailed manual testing steps and expected outcomes
### Testing Best Practices
- Test both sync and async code paths
- Mock external dependencies appropriately
- Test error handling and edge cases
- Validate input/output behavior
- Test component initialization and configuration
---
## 4. Async Testing Patterns
### Async Component Testing
```python
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_component():
# Test async component methods
result = await component.async_method()
assert result is not None
```
### Testing Background Tasks
```python
@pytest.mark.asyncio
async def test_background_task_completion():
# Ensure background tasks complete properly
task = asyncio.create_task(component.background_operation())
result = await asyncio.wait_for(task, timeout=5.0)
assert result.success
```
### Testing Queue Operations
```python
@pytest.mark.asyncio
async def test_queue_operations():
# Test queue put/get operations without blocking
queue = asyncio.Queue()
# Non-blocking put
queue.put_nowait(test_data)
# Verify queue processing
result = await asyncio.wait_for(queue.get(), timeout=1.0)
assert result == test_data
```
---
## 5. Special Testing Considerations
### Blockbuster Plugin
- Use `no_blockbuster` pytest marker to skip blockbuster plugin in tests
- Example: `@pytest.mark.no_blockbuster`
### Database Tests
- `test_database.py` may fail in batch runs but pass individually
- Run problematic tests sequentially: `uv run pytest src/backend/tests/unit/test_database.py`
- Consider this behavior when writing database-related tests
### Context Variables in Async Tests
- Be aware of ContextVar propagation in async tests
- Test both direct event loop execution and `asyncio.to_thread` scenarios
- Ensure proper context isolation between test cases
---
## 6. Test Execution
### Running Tests
```bash
make unit_tests # Run all backend unit tests
make integration_tests # Run integration tests (requires additional setup)
make coverage # Run tests with coverage (requires additional setup)
make tests_frontend # Run frontend tests (requires additional setup)
```
### Individual Test Execution
```bash
# Run specific test file
uv run pytest src/backend/tests/unit/test_specific_component.py
# Run specific test method
uv run pytest src/backend/tests/unit/test_component.py::test_specific_method
# Run with verbose output
uv run pytest -v src/backend/tests/unit/
```
---
## 7. Robust Testing Patterns
### Error Handling Tests
```python
def test_component_error_handling():
"""Test that component handles errors gracefully."""
with pytest.raises(ExpectedError):
component.method_that_should_fail(invalid_input)
# Test error message quality
try:
component.risky_operation()
except ComponentError as e:
assert "helpful error message" in str(e)
```
### Resource Cleanup Tests
```python
@pytest.fixture
async def component_with_cleanup():
"""Fixture ensuring proper resource cleanup."""
component = MyComponent()
await component.initialize()
try:
yield component
finally:
await component.cleanup()
```
### Timeout and Performance Tests
```python
@pytest.mark.asyncio
async def test_operation_timeout():
"""Test that operations respect timeout constraints."""
start_time = time.time()
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
component.long_running_operation(),
timeout=1.0
)
elapsed = time.time() - start_time
assert elapsed < 1.5 # Allow some tolerance
```
---
## 8. Documentation in Tests
### Test Documentation Standards
- Each test should have a clear docstring explaining its purpose
- Complex test setups should be commented
- Mock usage should be documented
- Expected behaviors should be explicitly stated
### Example Well-Documented Test
```python
async def test_component_background_processing():
"""
Test that component processes items in background without blocking.
This test verifies:
1. Items are added to processing queue immediately
2. Background processing completes successfully
3. No deadlocks occur during shutdown
4. All tasks are properly cleaned up
"""
# Setup component with background processing
component = BackgroundComponent()
await component.start()
try:
# Add items for processing
for i in range(10):
await component.add_item(f"item_{i}")
# Verify items are queued (non-blocking)
assert component.queue_size() == 10
# Wait for processing to complete
await component.wait_for_completion(timeout=5.0)
# Verify all items processed
assert component.processed_count() == 10
finally:
# Ensure clean shutdown
await component.stop()
assert component.is_stopped()
```
---
## Testing Checklist
- [ ] Unit tests created for all new components
- [ ] Async patterns tested appropriately
- [ ] Error handling and edge cases covered
- [ ] Manual testing documentation created (if unit tests incomplete)
- [ ] Background tasks and queues tested for proper cleanup
- [ ] Database tests considered for sequential execution if needed
- [ ] Appropriate pytest markers used (`no_blockbuster`, etc.)
- [ ] Tests are well-documented with clear docstrings
- [ ] Resource cleanup properly handled in fixtures
- [ ] Timeout and performance constraints validated
---
## 9. Langflow-Specific Testing Patterns
### Message Testing
Test Langflow's `Message` objects and chat functionality:
```python
from langflow.schema.message import Message
from langflow.utils.constants import MESSAGE_SENDER_AI, MESSAGE_SENDER_USER
async def test_message_response(self, component_class, default_kwargs):
"""Test Message object creation and properties."""
component = component_class(**default_kwargs)
message = await component.message_response()
assert isinstance(message, Message)
assert message.text == default_kwargs["input_value"]
assert message.sender == default_kwargs["sender"]
assert message.sender_name == default_kwargs["sender_name"]
assert message.session_id == default_kwargs["session_id"]
assert message.files == default_kwargs["files"]
# Test message properties structure
assert message.properties.model_dump() == {
"background_color": default_kwargs["background_color"],
"text_color": default_kwargs["text_color"],
"icon": default_kwargs["chat_icon"],
# ... other expected properties
}
```
### Flow Testing with JSON Data
Use predefined JSON flows and utility functions:
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events, consume_and_assert_stream
async def test_flow_execution(client, json_memory_chatbot_no_llm, logged_in_headers):
"""Test flow creation, building, and execution."""
# Create flow from JSON data
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
# Start the build process
build_response = await build_flow(client, flow_id, logged_in_headers)
job_id = build_response["job_id"]
# Get and validate event stream
events_response = await get_build_events(client, job_id, logged_in_headers)
assert events_response.status_code == codes.OK
# Process events and validate structure
await consume_and_assert_stream(events_response, job_id)
```
### Component Testing with Real Services
Test components that need external APIs with proper markers:
```python
@pytest.mark.api_key_required
@pytest.mark.no_blockbuster
async def test_component_with_external_api(self):
"""Test component with external API integration."""
api_key = os.getenv("OPENAI_API_KEY")
component = MyAPIComponent(
api_key=api_key,
model_name="gpt-4o",
input_value="test input",
session_id=str(uuid4()),
)
response = await component.message_response()
assert response.data.get("text") is not None
```
### Mocking Language Models
Use `MockLanguageModel` for testing without external APIs:
```python
from tests.unit.mock_language_model import MockLanguageModel
@pytest.fixture
def default_kwargs(self):
return {
"agent_llm": MockLanguageModel(),
"input_value": "test message",
"session_id": str(uuid4()),
# ... other component-specific kwargs
}
```
### File Handling in Tests
Use `anyio` and `aiofiles` for async file operations:
```python
import aiofiles
import anyio
async def test_file_operations(self, component_class, tmp_path):
"""Test component file handling."""
# Create temporary test file
test_file = anyio.Path(tmp_path) / "test.txt"
await test_file.write_text("Test content", encoding="utf-8")
# Test with file input
component = component_class(files=[str(test_file)])
result = await component.run()
# Verify file was processed
assert len(result.files) == 1
assert await test_file.exists()
```
### API Endpoint Testing
Test Langflow's REST API endpoints:
```python
async def test_flows_endpoint(client, logged_in_headers):
"""Test flow creation via API."""
flow_data = {
"name": "Test Flow",
"description": "Test description",
"data": {"nodes": [], "edges": []}
}
response = await client.post(
"api/v1/flows/",
json=flow_data,
headers=logged_in_headers
)
assert response.status_code == codes.CREATED
flow = response.json()
assert flow["name"] == "Test Flow"
assert "id" in flow
```
### Build Config Testing
Test component configuration updates:
```python
async def test_build_config_updates(self, component_class, default_kwargs):
"""Test dynamic build configuration updates."""
component = await self.component_setup(component_class, default_kwargs)
frontend_node = component.to_frontend_node()
build_config = frontend_node["data"]["node"]["template"]
# Test configuration update
component.set(llm_provider="OpenAI")
updated_config = await component.update_build_config(
build_config, "OpenAI", "llm_provider"
)
assert updated_config["llm_provider"]["value"] == "OpenAI"
assert "gpt-4o" in updated_config["model_name"]["options"]
```
### Testing Event Streams
Test real-time event streaming endpoints:
```python
async def consume_and_validate_events(response, expected_job_id):
"""Consume NDJSON event stream and validate structure."""
count = 0
first_event_seen = False
end_event_seen = False
async for line in response.aiter_lines():
if not line:
continue
parsed = json.loads(line)
# Validate job_id in events
if "job_id" in parsed:
assert parsed["job_id"] == expected_job_id
# First event should be vertices_sorted
if not first_event_seen:
assert parsed["event"] == "vertices_sorted"
first_event_seen = True
# Track end event
elif parsed["event"] == "end":
end_event_seen = True
count += 1
assert first_event_seen and end_event_seen
return count
```
### Testing Component Versioning
Test backward compatibility across Langflow versions:
```python
@pytest.fixture
def file_names_mapping(self):
"""Map component files across Langflow versions."""
return [
VersionComponentMapping(
version="1.1.1",
module="inputs",
file_name="chat"
),
VersionComponentMapping(
version="1.1.0",
module="inputs",
file_name="chat"
),
VersionComponentMapping(
version="1.0.19",
module="inputs",
file_name="ChatInput"
),
]
```
### Webhook Testing
Test webhook endpoints with proper payload handling:
```python
async def test_webhook_endpoint(client, added_webhook_test):
"""Test webhook flow execution."""
endpoint_name = added_webhook_test["endpoint_name"]
endpoint = f"api/v1/webhook/{endpoint_name}"
payload = {"path": "/tmp/test_file.txt"}
response = await client.post(endpoint, json=payload)
assert response.status_code == 202
# Verify webhook processed the payload
# ... additional validation logic
```
### Monkeypatch for Testing Errors
Test error handling by mocking internal functions:
```python
async def test_cancellation_error(client, flow_id, logged_in_headers, monkeypatch):
"""Test error handling during flow cancellation."""
import langflow.api.v1.chat
async def mock_cancel_with_error(*args, **kwargs):
raise RuntimeError("Cancellation failed")
monkeypatch.setattr(
langflow.api.v1.chat,
"cancel_flow_build",
mock_cancel_with_error
)
response = await client.post(
f"api/v1/build/{job_id}/cancel",
headers=logged_in_headers
)
assert response.status_code == codes.INTERNAL_SERVER_ERROR
assert "Cancellation failed" in response.json()["detail"]
```
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
# Set timezone
ENV TZ=UTC
+14
View File
@@ -135,6 +135,20 @@ LANGFLOW_API_KEY=
# Values: true, false
HANZOFLOW_STORE_ENVIRONMENT_VARIABLES=
# SSRF (Server-Side Request Forgery) Protection
# When enabled, blocks requests to private IP ranges, localhost, and cloud metadata endpoints
# Values: true, false
# Default: false (for backward compatibility)
# Set to true to enable protection against SSRF attacks
LANGFLOW_SSRF_PROTECTION_ENABLED=
# SSRF Allowed Hosts
# Comma-separated list of hosts/IPs/CIDR ranges to allow despite SSRF protection
# Supports: exact hostnames, wildcard domains (*.example.com), exact IPs, CIDR ranges
# Example: LANGFLOW_SSRF_ALLOWED_HOSTS=internal-api.company.local,192.168.1.0/24,*.dev.internal
# Note: Only takes effect when LANGFLOW_SSRF_PROTECTION_ENABLED=true
LANGFLOW_SSRF_ALLOWED_HOSTS=
# Should enable the MCP composer feature in MCP projects
# Values: true, false
# Default: true
+4 -1
View File
@@ -6,7 +6,6 @@ python:
- "pyproject.toml"
- "uv.lock"
- "src/backend/base/pyproject.toml"
- "src/backend/base/uv.lock"
- "src/lfx/pyproject.toml"
- "**/python_test.yml"
- ".github/workflows/ci.yml"
@@ -110,3 +109,7 @@ database:
mainpage:
- "src/frontend/src/pages/MainPage/**"
design-md:
- "DESIGN.md"
- ".github/workflows/ci.yml"
+27
View File
@@ -0,0 +1,27 @@
name: CI Scripts Tests
on:
pull_request:
paths:
- "scripts/ci/**"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test CI release scripts
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: pip install pytest requests packaging
- name: Run CI script tests
run: python -m pytest scripts/ci/ -v
+125 -17
View File
@@ -11,7 +11,7 @@ on:
description: "Python Versions"
required: false
type: string
default: "['3.10']"
default: "['3.10', '3.14']"
frontend-tests-folder:
description: "Frontend Tests Folder"
required: false
@@ -50,7 +50,7 @@ on:
description: "Python Versions"
required: false
type: string
default: "['3.10']"
default: "['3.10', '3.14']"
runs-on:
description: "Runner to use for the tests"
required: false
@@ -88,6 +88,22 @@ jobs:
echo " run-all-tests: ${{ inputs.run-all-tests }}"
echo " runs-on: ${{ inputs.runs-on }}"
validate-pr-title:
name: Validate PR Title
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
env:
PR_TITLE: ${{ github.event.pull_request.title }}
steps:
- name: Reject titles ending in ellipsis
run: |
echo "PR title: $PR_TITLE"
if [[ "$PR_TITLE" == *"..." || "$PR_TITLE" == *"…" ]]; then
echo "❌ PR title ends with ellipsis. Please write a complete, descriptive title."
exit 1
fi
echo "✅ PR title is complete."
check-nightly-status:
name: Check PyPI Version Update
runs-on: ubuntu-latest
@@ -186,6 +202,7 @@ jobs:
api: ${{ steps.filter.outputs.api }}
database: ${{ steps.filter.outputs.database }}
docker: ${{ steps.filter.outputs.docker }}
design-md: ${{ steps.filter.outputs.design-md }}
docs-only: ${{
steps.filter.outputs.docs == 'true' &&
steps.filter.outputs.python != 'true' &&
@@ -239,7 +256,7 @@ jobs:
needs.set-ci-condition.outputs.should-run-tests == 'true'
uses: ./.github/workflows/python_test.yml
with:
python-versions: ${{ inputs.python-versions || '["3.10"]' }}
python-versions: ${{ inputs.python-versions || '["3.10", "3.14"]' }}
runs-on: ${{ inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
ref: ${{ inputs.ref || github.ref }}
secrets:
@@ -247,6 +264,20 @@ jobs:
ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}"
CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"
lint-frontend:
needs: [path-filter, set-ci-condition]
name: Lint Frontend
if: |
always() &&
!cancelled() &&
needs.set-ci-condition.outputs.should-run-ci == 'true' &&
(
inputs.run-all-tests ||
(needs.path-filter.result != 'skipped' &&
needs.path-filter.outputs.frontend == 'true')
)
uses: ./.github/workflows/lint-js.yml
test-frontend-unit:
needs: [path-filter, set-ci-condition]
name: Run Frontend Unit Tests
@@ -279,15 +310,6 @@ jobs:
ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}"
TAVILY_API_KEY: "${{ secrets.TAVILY_API_KEY }}"
lint-backend:
needs: path-filter
if: |
always() &&
!cancelled() &&
(inputs.run-all-tests || needs.path-filter.outputs.python == 'true')
name: Lint Backend
uses: ./.github/workflows/lint-py.yml
test-docs-build:
needs: path-filter
if: |
@@ -339,21 +361,105 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
lint-design-md:
needs: path-filter
name: Lint DESIGN.md
if: ${{ inputs.run-all-tests || needs.path-filter.outputs.design-md == 'true' }}
runs-on: ubuntu-latest
env:
DESIGN_MD_CLI_VERSION: "0.1.1"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Lint DESIGN.md
run: npx --yes @google/design.md@${{ env.DESIGN_MD_CLI_VERSION }} lint DESIGN.md
merge-frontend-coverage:
name: Merge Frontend Jest + Playwright Coverage Reports
needs: [path-filter, set-ci-condition, test-frontend-unit, test-frontend]
if: |
always() &&
!cancelled() &&
needs.set-ci-condition.outputs.should-run-tests == 'true' &&
(
inputs.run-all-tests ||
(needs.path-filter.result != 'skipped' &&
needs.path-filter.outputs.docs-only != 'true')
)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download Jest Coverage
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: jest-coverage
path: coverage/jest
- name: Download Playwright Coverage
uses: actions/download-artifact@v4
continue-on-error: true
with:
pattern: playwright-coverage-*
path: coverage/playwright
merge-multiple: true
- name: Merge Coverage
run: |
mkdir -p coverage/combined
npx nyc merge coverage/playwright/individual-test coverage/combined/playwright.json || echo "Failed to merge Playwright coverage"
npx nyc merge coverage/jest coverage/combined/jest.json || echo "Failed to merge Jest coverage"
npx nyc merge coverage/combined coverage/combined/total-frontend-coverage.json || echo "Failed to combine Playwright + Jest coverage"
- name: Generate Combined Report
run: |
npx nyc report \
--reporter=html \
--reporter=text \
--temp-dir coverage/combined \
--report-dir coverage/final
- name: Upload Merged Frontend Coverage to Codecov
if: env.CODECOV_TOKEN != ''
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
uses: codecov/codecov-action@v5
with:
token: ${{ env.CODECOV_TOKEN }}
files: coverage/combined/total-frontend-coverage.json
flags: frontend
fail_ci_if_error: false
# https://github.com/langchain-ai/langchain/blob/master/.github/workflows/check_diffs.yml
ci_success:
name: "CI Success"
# Note: `lint-frontend` is intentionally NOT in this list — the Biome
# check runs on every PR and shows up in the checks list, but its failure
# does NOT block merge (informational only).
needs:
[
validate-pr-title,
test-backend,
test-frontend-unit,
test-frontend,
lint-backend,
test-docs-build,
test-templates,
test-docker,
lint-design-md,
set-ci-condition,
path-filter,
check-nightly-status,
merge-frontend-coverage,
]
if: ${{ always() }}
@@ -365,7 +471,7 @@ jobs:
DOCS_ONLY: ${{ needs.path-filter.outputs.docs-only }}
EXIT_CODE: ${{ (contains(needs.*.result, 'failure') ||
contains(needs.*.result, 'cancelled') ||
(needs.check-nightly-status.outputs.should-proceed != 'true' && github.event_name != 'workflow_dispatch' && needs.path-filter.outputs.docs-only != 'true'))
(needs.check-nightly-status.outputs.should-proceed != 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'merge_group' && needs.path-filter.outputs.docs-only != 'true' && !contains(github.event.pull_request.labels.*.name, 'skip-nightly-check')))
&& '1' || '0' }}
steps:
- name: "CI Success"
@@ -392,20 +498,22 @@ jobs:
to_entries[]
| select(.value.result=="failure")
| .key as $job
| if $job == "test-backend" then
| if $job == "validate-pr-title" then
" - PR Title Validation: Title ends with ellipsis (...). Use a complete, descriptive title."
elif $job == "test-backend" then
" - Backend Tests: Check Python code, tests, and dependencies"
elif $job == "test-frontend-unit" then
" - Frontend Unit Tests: Check React components and unit test logic"
elif $job == "test-frontend" then
" - Frontend E2E Tests: Check integration tests and UI functionality"
elif $job == "lint-backend" then
" - Backend Linting: Run '\''make format_backend'\'' then '\''make lint'\'' to fix code style issues"
elif $job == "test-docs-build" then
" - Documentation Build: Check documentation syntax and build process"
elif $job == "test-templates" then
" - Template Tests: Check starter project templates"
elif $job == "test-docker" then
" - Docker Tests: Check Docker image builds and version verification"
elif $job == "lint-design-md" then
" - DESIGN.md Lint: Check token references, WCAG contrast, and spec compliance"
elif $job == "path-filter" then
" - Path Filter: File path filtering failed"
elif $job == "set-ci-condition" then
+211 -156
View File
@@ -31,6 +31,9 @@ jobs:
outputs:
base-artifact-name: ${{ steps.set-names.outputs.base-artifact-name }}
main-artifact-name: ${{ steps.set-names.outputs.main-artifact-name }}
lfx-artifact-name: ${{ steps.set-names.outputs.lfx-artifact-name }}
sdk-artifact-name: ${{ steps.set-names.outputs.sdk-artifact-name }}
bundles-artifact-name: ${{ steps.set-names.outputs.bundles-artifact-name }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -53,8 +56,45 @@ jobs:
# Base package builds to dist/ but should be in src/backend/base/dist/
mkdir -p src/backend/base/dist
mv dist/langflow_base*.whl src/backend/base/dist/
- name: Build LFX package
run: |
cd src/lfx
uv build --wheel --out-dir dist
- name: Build SDK package
run: |
cd src/sdk
uv build --wheel --out-dir dist
- name: Build main package
run: make build_langflow args="--wheel"
- name: Build bundle wheels
id: build-bundles
run: |
shopt -s nullglob
bundles=(src/bundles/*/pyproject.toml)
if [ ${#bundles[@]} -eq 0 ]; then
echo "No bundles found under src/bundles/*/"
echo "bundles-found=0" >> $GITHUB_OUTPUT
exit 0
fi
mkdir -p bundles-dist
BUNDLES_DIST="$PWD/bundles-dist"
for bundle_pyproject in "${bundles[@]}"; do
bundle_dir=$(dirname "$bundle_pyproject")
echo "Building wheel for $bundle_dir"
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
done
echo "bundles-found=1" >> $GITHUB_OUTPUT
ls -la bundles-dist/
- name: Upload lfx artifact
uses: actions/upload-artifact@v6
with:
name: adhoc-dist-lfx
path: src/lfx/dist
- name: Upload SDK artifact
uses: actions/upload-artifact@v6
with:
name: adhoc-dist-sdk
path: src/sdk/dist
- name: Upload base artifact
uses: actions/upload-artifact@v6
with:
@@ -65,11 +105,24 @@ jobs:
with:
name: adhoc-dist-main
path: dist
- name: Upload bundles artifact
if: steps.build-bundles.outputs.bundles-found == '1'
uses: actions/upload-artifact@v6
with:
name: adhoc-dist-bundles
path: bundles-dist
- name: Set artifact names
id: set-names
run: |
echo "base-artifact-name=adhoc-dist-base" >> $GITHUB_OUTPUT
echo "main-artifact-name=adhoc-dist-main" >> $GITHUB_OUTPUT
echo "lfx-artifact-name=adhoc-dist-lfx" >> $GITHUB_OUTPUT
echo "sdk-artifact-name=adhoc-dist-sdk" >> $GITHUB_OUTPUT
if [ "${{ steps.build-bundles.outputs.bundles-found }}" = "1" ]; then
echo "bundles-artifact-name=adhoc-dist-bundles" >> $GITHUB_OUTPUT
else
echo "bundles-artifact-name=" >> $GITHUB_OUTPUT
fi
test-installation-stable:
name: Install & Run - ${{ matrix.os }} ${{ matrix.arch }} ${{ matrix.python-version }}
@@ -156,11 +209,18 @@ jobs:
shell: bash
# Download artifacts for wheel installation
- name: Download LFX package artifact
if: steps.install-method.outputs.method == 'wheel' && inputs.lfx-artifact-name != ''
- name: Download langflow-sdk package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.lfx-artifact-name }}
name: ${{ inputs.sdk-artifact-name || needs.build-if-needed.outputs.sdk-artifact-name || 'adhoc-dist-sdk' }}
path: ./sdk-dist
- name: Download LFX package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name || 'adhoc-dist-lfx' }}
path: ./lfx-dist
- name: Download base package artifact
@@ -177,94 +237,81 @@ jobs:
name: ${{ inputs.main-artifact-name || needs.build-if-needed.outputs.main-artifact-name || 'adhoc-dist-main' }}
path: ./main-dist
- name: Download bundles package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.bundles-artifact-name != '' || needs.build-if-needed.outputs.bundles-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.bundles-artifact-name || needs.build-if-needed.outputs.bundles-artifact-name }}
path: ./bundles-dist
- name: Create fresh virtual environment
run: |
uv venv test-env --seed
shell: bash
# Wheel installation steps
- name: Install LFX package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && inputs.lfx-artifact-name != ''
- name: Install packages from wheel
if: steps.install-method.outputs.method == 'wheel'
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
else
echo "No wheel file found in ./lfx-dist/"
exit 1
fi
shell: bash
set -eo pipefail
shopt -s nullglob
- name: Install base package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
run: |
ls -la ./base-dist/
find ./base-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
if [ "${{ matrix.os }}" = "windows" ]; then
PYTHON=./test-env/Scripts/python.exe
else
echo "No wheel file found in ./base-dist/"
exit 1
PYTHON=./test-env/bin/python
fi
shell: bash
- name: Install main package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
run: |
ls -la ./main-dist/
find ./main-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
else
echo "No wheel file found in ./main-dist/"
exit 1
fi
shell: bash
refresh_find_links() {
FIND_LINKS=()
for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do
if [ -d "$wheel_dir" ]; then
FIND_LINKS+=(--find-links "$wheel_dir")
fi
done
}
- name: Install LFX package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && inputs.lfx-artifact-name != ''
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./lfx-dist/"
exit 1
fi
shell: bash
install_wheels() {
label="$1"
shift
if [ "$#" -eq 0 ]; then
echo "No $label wheel found"
exit 1
fi
printf 'Installing %s wheel(s):\n' "$label"
printf '%s\n' "$@"
refresh_find_links
# Use `if-necessary-or-explicit` (not `allow`): nightly langflow* wheels
# are .dev pre-releases so we need to allow them explicitly, but `allow`
# leaks to transitive deps and resolves pre-release pydantic alphas
# (e.g. 2.14.0a1) which break langchain-core's module-level
# `RunnablePassthrough()` call at import time.
uv pip install --prerelease=if-necessary-or-explicit --python "$PYTHON" "${FIND_LINKS[@]}" "$@"
}
- name: Install base package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
run: |
ls -la ./base-dist/
find ./base-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./base-dist/"
exit 1
SDK_WHEELS=(./sdk-dist/*.whl)
LFX_WHEELS=(./lfx-dist/*.whl)
if [ ${#SDK_WHEELS[@]} -gt 0 ] || [ ${#LFX_WHEELS[@]} -gt 0 ]; then
install_wheels "SDK/LFX" "${SDK_WHEELS[@]}" "${LFX_WHEELS[@]}"
fi
shell: bash
- name: Install main package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
run: |
ls -la ./main-dist/
find ./main-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./main-dist/"
BASE_WHEELS=(./base-dist/*.whl)
install_wheels "base" "${BASE_WHEELS[@]}"
if [ -d ./bundles-dist ]; then
BUNDLE_WHEELS=(./bundles-dist/*.whl)
install_wheels "bundle" "${BUNDLE_WHEELS[@]}"
fi
MAIN_WHEELS=()
while IFS= read -r wheel; do
MAIN_WHEELS+=("$wheel")
done < <(find ./main-dist -name "langflow*.whl" ! -name "langflow_base*.whl" -type f | sort)
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
echo "Expected exactly one langflow wheel in ./main-dist, found ${#MAIN_WHEELS[@]}"
find ./main-dist -name "*.whl" -type f | sort
exit 1
fi
install_wheels "main" "${MAIN_WHEELS[@]}"
shell: bash
# PyPI installation steps
@@ -455,11 +502,18 @@ jobs:
shell: bash
# Download artifacts for wheel installation
- name: Download LFX package artifact
if: steps.install-method.outputs.method == 'wheel' && inputs.lfx-artifact-name != ''
- name: Download langflow-sdk package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.lfx-artifact-name }}
name: ${{ inputs.sdk-artifact-name || needs.build-if-needed.outputs.sdk-artifact-name || 'adhoc-dist-sdk' }}
path: ./sdk-dist
- name: Download LFX package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name || 'adhoc-dist-lfx' }}
path: ./lfx-dist
- name: Download base package artifact
@@ -476,109 +530,101 @@ jobs:
name: ${{ inputs.main-artifact-name || needs.build-if-needed.outputs.main-artifact-name || 'adhoc-dist-main' }}
path: ./main-dist
- name: Download bundles package artifact
if: steps.install-method.outputs.method == 'wheel' && (inputs.bundles-artifact-name != '' || needs.build-if-needed.outputs.bundles-artifact-name != '')
uses: actions/download-artifact@v7
with:
name: ${{ inputs.bundles-artifact-name || needs.build-if-needed.outputs.bundles-artifact-name }}
path: ./bundles-dist
- name: Create fresh virtual environment
run: |
uv venv test-env --seed
shell: bash
# Wheel installation steps
- name: Install LFX package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && inputs.lfx-artifact-name != ''
- name: Install packages from wheel
if: steps.install-method.outputs.method == 'wheel'
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
else
echo "No wheel file found in ./lfx-dist/"
exit 1
fi
shell: bash
set -eo pipefail
shopt -s nullglob
- name: Install base package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
run: |
ls -la ./base-dist/
find ./base-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
if [ "${{ matrix.os }}" = "windows" ]; then
PYTHON=./test-env/Scripts/python.exe
else
echo "No wheel file found in ./base-dist/"
exit 1
PYTHON=./test-env/bin/python
fi
shell: bash
- name: Install main package from wheel (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
run: |
ls -la ./main-dist/
find ./main-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
else
echo "No wheel file found in ./main-dist/"
exit 1
fi
shell: bash
refresh_find_links() {
FIND_LINKS=()
for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do
if [ -d "$wheel_dir" ]; then
FIND_LINKS+=(--find-links "$wheel_dir")
fi
done
}
- name: Install LFX package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && inputs.lfx-artifact-name != ''
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./lfx-dist/"
exit 1
fi
shell: bash
install_wheels() {
label="$1"
shift
if [ "$#" -eq 0 ]; then
echo "No $label wheel found"
exit 1
fi
printf 'Installing %s wheel(s):\n' "$label"
printf '%s\n' "$@"
refresh_find_links
# See stable job above for rationale on --prerelease=if-necessary-or-explicit.
uv pip install --prerelease=if-necessary-or-explicit --python "$PYTHON" "${FIND_LINKS[@]}" "$@"
}
- name: Install base package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
run: |
ls -la ./base-dist/
find ./base-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./base-dist/"
exit 1
SDK_WHEELS=(./sdk-dist/*.whl)
LFX_WHEELS=(./lfx-dist/*.whl)
if [ ${#SDK_WHEELS[@]} -gt 0 ] || [ ${#LFX_WHEELS[@]} -gt 0 ]; then
install_wheels "SDK/LFX" "${SDK_WHEELS[@]}" "${LFX_WHEELS[@]}"
fi
shell: bash
- name: Install main package from wheel (Unix)
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
run: |
ls -la ./main-dist/
find ./main-dist -name "*.whl" -type f
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
else
echo "No wheel file found in ./main-dist/"
BASE_WHEELS=(./base-dist/*.whl)
install_wheels "base" "${BASE_WHEELS[@]}"
if [ -d ./bundles-dist ]; then
BUNDLE_WHEELS=(./bundles-dist/*.whl)
install_wheels "bundle" "${BUNDLE_WHEELS[@]}"
fi
MAIN_WHEELS=()
while IFS= read -r wheel; do
MAIN_WHEELS+=("$wheel")
done < <(find ./main-dist -name "langflow*.whl" ! -name "langflow_base*.whl" -type f | sort)
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
echo "Expected exactly one langflow wheel in ./main-dist, found ${#MAIN_WHEELS[@]}"
find ./main-dist -name "*.whl" -type f | sort
exit 1
fi
install_wheels "main" "${MAIN_WHEELS[@]}"
shell: bash
- name: Force reinstall local wheels to prevent downgrades (Windows)
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
run: |
# Reinstall LFX if it was provided
if [ -n "${{ inputs.lfx-artifact-name }}" ]; then
if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$LFX_WHEEL" ]; then
echo "Force reinstalling LFX: $LFX_WHEEL"
INSTALL_WHEELS=("$LFX_WHEEL")
SDK_WHEEL=""
if [ -d ./sdk-dist ]; then
SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
fi
if [ -n "$SDK_WHEEL" ]; then
echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
fi
NO_DEPS="--no-deps"
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "$LFX_WHEEL"
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "${INSTALL_WHEELS[@]}"
fi
fi
@@ -590,7 +636,7 @@ jobs:
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "$BASE_WHEEL"
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "$BASE_WHEEL"
fi
shell: bash
@@ -598,15 +644,24 @@ jobs:
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
run: |
# Reinstall LFX if it was provided
if [ -n "${{ inputs.lfx-artifact-name }}" ]; then
if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$LFX_WHEEL" ]; then
echo "Force reinstalling LFX: $LFX_WHEEL"
INSTALL_WHEELS=("$LFX_WHEEL")
SDK_WHEEL=""
if [ -d ./sdk-dist ]; then
SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
fi
if [ -n "$SDK_WHEEL" ]; then
echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
fi
NO_DEPS="--no-deps"
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "$LFX_WHEEL"
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "${INSTALL_WHEELS[@]}"
fi
fi
@@ -618,7 +673,7 @@ jobs:
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "$BASE_WHEEL"
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "$BASE_WHEEL"
fi
shell: bash
@@ -0,0 +1,491 @@
name: DB Migration Validation
on:
workflow_call:
inputs:
nightly_tag:
description: "Nightly tag to test migration to"
required: true
type: string
workflow_dispatch:
inputs:
nightly_tag:
description: "Nightly tag to test migration to (e.g., langflowai/langflow-nightly:latest)"
required: false
type: string
default: "langflowai/langflow-nightly:latest"
# Note: This workflow is called by nightly_build.yml after Docker images are built
env:
PYTHON_VERSION: "3.13"
POSTGRES_DB: langflow_test
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow_test_pass # pragma: allowlist secret
jobs:
migration-pip-venv:
name: "Migration Test: pip/venv (stable → nightly)"
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: ${{ env.POSTGRES_DB }}
POSTGRES_USER: ${{ env.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Create test directory
run: |
mkdir -p migration-test
cd migration-test
- name: Install latest stable Langflow with PostgreSQL
working-directory: migration-test
run: |
echo "Installing latest stable langflow[postgresql]..."
uv venv
source .venv/bin/activate
uv pip install "langflow[postgresql]"
# Verify installation
python -c 'from importlib.metadata import version; print("Installed Langflow version:", version("langflow"))'
- name: Initialize database with stable version
working-directory: migration-test
env:
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} # pragma: allowlist secret
run: |
source .venv/bin/activate
echo "Starting Langflow to initialize database..."
# shellcheck disable=SC2016
timeout 120 bash -c '
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
LANGFLOW_PID=$!
until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do
sleep 2
done
kill $LANGFLOW_PID
wait $LANGFLOW_PID 2>/dev/null || true
' || {
echo "Failed to start Langflow stable version"
exit 1
}
echo "Database initialized successfully with stable version"
- name: Create witness flow
working-directory: migration-test
env:
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}
run: |
source .venv/bin/activate
# Start Langflow briefly to create test data
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
LANGFLOW_PID=$!
# Wait for startup
timeout 60 bash -c 'until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do sleep 2; done'
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
TOKEN=$(curl -fsS http://127.0.0.1:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Failed to get authentication token"
kill $LANGFLOW_PID
exit 1
fi
echo "✅ Authentication token obtained"
# Create a witness flow (proves row persistence)
curl -fsSL -X POST http://127.0.0.1:7860/api/v1/flows/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Migration Witness Flow",
"description": "Test flow to verify data persistence across migration",
"data": {"nodes": [], "edges": []}
}' > flow_response.json
if ! grep -q '"id"' flow_response.json; then
echo "❌ Flow creation failed - no id in response"
cat flow_response.json
exit 1
fi
echo "✅ Witness flow created successfully"
# Create a witness credential (type=Credential stores encrypted value, exercises encrypted-column migrations)
curl -fsSL -X POST http://127.0.0.1:7860/api/v1/variables/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "migration_witness_secret", "value": "witness_value_12345", "type": "Credential", "default_fields": []}' > variable_response.json # pragma: allowlist secret
if ! grep -q '"id"' variable_response.json; then
echo "❌ Credential creation failed - no id in response"
cat variable_response.json
exit 1
fi
echo "✅ Witness credential created successfully"
# Stop Langflow
kill $LANGFLOW_PID
wait $LANGFLOW_PID 2>/dev/null || true
echo "Witness data created (flow + credential)"
- name: Upgrade to nightly version
working-directory: migration-test
run: |
source .venv/bin/activate
NIGHTLY_TAG="${{ inputs.nightly_tag || 'langflowai/langflow-nightly:latest' }}"
echo "Upgrading to nightly version: $NIGHTLY_TAG"
# Remove stable distributions before installing nightly.
# langflow and langflow-nightly share langflow-base which writes to the
# same site-packages/langflow/ namespace. If stable stays installed,
# python -m langflow still boots the stable version and no real
# nightly migration is exercised (false-positive test).
echo "Removing stable langflow to avoid namespace collision with nightly..."
uv pip uninstall -y langflow langflow-base || true
# Extract version from Docker tag (format: langflowai/langflow-nightly:v1.10.0.dev20260522)
if [[ "$NIGHTLY_TAG" == *":"* ]]; then
VERSION="${NIGHTLY_TAG##*:}"
echo "Extracted version from tag: $VERSION"
# Strip 'v' prefix if present (PyPI doesn't use 'v' prefix)
VERSION="${VERSION#v}"
echo "Version for PyPI: $VERSION"
if [[ "$VERSION" == "latest" ]]; then
# Install latest nightly from PyPI
uv pip install --upgrade 'langflow-nightly[postgresql]'
else
# Install specific version
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
fi
else
# Direct version string (strip 'v' prefix if present)
VERSION="${NIGHTLY_TAG#v}"
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
fi
# Verify upgrade
python -c 'from importlib.metadata import version; print("Upgraded to Langflow Nightly version:", version("langflow-nightly"))'
- name: Run migration and verify startup
working-directory: migration-test
env:
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} # pragma: allowlist secret
run: |
source .venv/bin/activate
echo "Starting Langflow nightly to run migrations..."
# shellcheck disable=SC2016
timeout 180 bash -c '
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only > langflow_nightly.log 2>&1 &
LANGFLOW_PID=$!
until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do
if ! kill -0 $LANGFLOW_PID 2>/dev/null; then
echo "Langflow process died during startup"
cat langflow_nightly.log
exit 1
fi
sleep 2
done
echo "Langflow nightly started successfully"
kill $LANGFLOW_PID
wait $LANGFLOW_PID 2>/dev/null || true
' || {
echo "Failed to start Langflow nightly version"
cat langflow_nightly.log || true
exit 1
}
- name: Verify witness data persisted
working-directory: migration-test
env:
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}
run: |
source .venv/bin/activate
# Start Langflow to query data
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
LANGFLOW_PID=$!
timeout 60 bash -c 'until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do sleep 2; done'
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
TOKEN=$(curl -fsS http://127.0.0.1:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Failed to get authentication token"
kill $LANGFLOW_PID
exit 1
fi
echo "✅ Authentication token obtained"
# Verify witness flow persisted
curl -fsSL --compressed http://127.0.0.1:7860/api/v1/flows/ \
-H "Authorization: Bearer $TOKEN" > flows_after_migration.json
if grep -q "Migration Witness Flow" flows_after_migration.json; then
echo "✅ Witness flow found after migration"
else
echo "❌ Witness flow NOT found after migration"
cat flows_after_migration.json
kill $LANGFLOW_PID
exit 1
fi
# Verify witness credential persisted (confirms encrypted-column migration ran without data loss)
curl -fsSL --compressed http://127.0.0.1:7860/api/v1/variables/ \
-H "Authorization: Bearer $TOKEN" > variables_after_migration.json
if grep -q "migration_witness_secret" variables_after_migration.json; then
echo "✅ Witness credential found after migration"
else
echo "❌ Witness credential NOT found after migration"
cat variables_after_migration.json
kill $LANGFLOW_PID
exit 1
fi
kill $LANGFLOW_PID
wait $LANGFLOW_PID 2>/dev/null || true
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: migration-pip-venv-logs
path: |
migration-test/*.log
migration-test/*.json
retention-days: 7
migration-docker-compose:
name: "Migration Test: Docker Compose (stable → nightly)"
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Create Docker Compose test directory
run: |
mkdir -p docker-migration-test
cd docker-migration-test
- name: Create Docker Compose file for stable
working-directory: docker-migration-test
run: | # pragma: allowlist secret
cat > docker-compose.yml <<'EOF'
services:
langflow:
image: langflowai/langflow:latest
ports:
- "7860:7860"
environment: # pragma: allowlist secret
- LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@postgres:5432/langflow # pragma: allowlist secret
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7860/health_check"]
interval: 10s
timeout: 5s
retries: 10
postgres:
image: postgres:16
environment: # pragma: allowlist secret
- POSTGRES_USER=langflow
- POSTGRES_PASSWORD=langflow # pragma: allowlist secret
- POSTGRES_DB=langflow
volumes:
- langflow_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langflow"]
interval: 10s
timeout: 5s
retries: 5
volumes:
langflow_postgres_data:
EOF
- name: Start stable Langflow with Docker Compose
working-directory: docker-migration-test
run: |
echo "Starting Langflow stable version..."
docker compose up -d
echo "Waiting for Langflow to be healthy..."
timeout 180 bash -c 'until docker compose exec -T langflow curl -f http://localhost:7860/health_check 2>/dev/null; do sleep 5; done' || {
echo "Langflow stable failed to start"
docker compose logs
exit 1
}
echo "Langflow stable is running"
- name: Create witness flow via API
working-directory: docker-migration-test
run: |
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
TOKEN=$(curl -fsS http://localhost:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Failed to get authentication token"
exit 1
fi
echo "✅ Authentication token obtained"
echo "Creating witness flow..."
curl -fsSL -X POST http://localhost:7860/api/v1/flows/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Docker Migration Witness Flow",
"description": "Test flow for Docker Compose migration",
"data": {"nodes": [], "edges": []}
}' > flow_response.json
if ! grep -q '"id"' flow_response.json; then
echo "❌ Flow creation failed - no id in response"
cat flow_response.json
exit 1
fi
echo "✅ Witness flow created successfully"
echo "Creating witness credential (exercises encrypted-column migrations)..."
curl -fsSL -X POST http://localhost:7860/api/v1/variables/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "migration_witness_secret", "value": "witness_value_12345", "type": "Credential", "default_fields": []}' > variable_response.json # pragma: allowlist secret
if ! grep -q '"id"' variable_response.json; then
echo "❌ Credential creation failed - no id in response"
cat variable_response.json
exit 1
fi
echo "✅ Witness credential created successfully"
- name: Stop stable Langflow (keep PostgreSQL volume)
working-directory: docker-migration-test
run: |
echo "Stopping Langflow stable..."
docker compose stop langflow
docker compose rm -f langflow
- name: Update to nightly image
working-directory: docker-migration-test
run: |
NIGHTLY_TAG="${{ inputs.nightly_tag || 'langflowai/langflow-nightly:latest' }}"
# Strip 'v' prefix from version part — Docker images are published without it
# e.g. langflowai/langflow-nightly:v1.10.0.dev20260522 → langflowai/langflow-nightly:1.10.0.dev20260522
VERSION_PART="${NIGHTLY_TAG##*:}"
IMAGE_NAME="${NIGHTLY_TAG%%:*}"
DOCKER_TAG="${IMAGE_NAME}:${VERSION_PART#v}"
echo "Updating to nightly: $DOCKER_TAG"
# Update docker-compose.yml to use nightly image
sed -i "s|image: langflowai/langflow:latest|image: $DOCKER_TAG|" docker-compose.yml
cat docker-compose.yml
- name: Start nightly Langflow with same PostgreSQL volume
working-directory: docker-migration-test
run: |
echo "Starting Langflow nightly version..."
docker compose up -d langflow
echo "Waiting for Langflow nightly to be healthy..."
timeout 180 bash -c 'until docker compose exec -T langflow curl -f http://localhost:7860/health_check 2>/dev/null; do sleep 5; done' || {
echo "Langflow nightly failed to start"
docker compose logs langflow
exit 1
}
echo "Langflow nightly started successfully"
- name: Verify witness data persisted
working-directory: docker-migration-test
run: |
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
TOKEN=$(curl -fsS http://localhost:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Failed to get authentication token"
exit 1
fi
echo "✅ Authentication token obtained"
echo "Verifying witness flow persisted..."
curl -fsSL --compressed http://localhost:7860/api/v1/flows/ \
-H "Authorization: Bearer $TOKEN" > flows_after_migration.json
if grep -q "Docker Migration Witness Flow" flows_after_migration.json; then
echo "✅ Witness flow found after Docker migration"
else
echo "❌ Witness flow NOT found after Docker migration"
cat flows_after_migration.json
exit 1
fi
echo "Verifying witness credential persisted (confirms encrypted-column migration ran without data loss)..."
curl -fsSL --compressed http://localhost:7860/api/v1/variables/ \
-H "Authorization: Bearer $TOKEN" > variables_after_migration.json
if grep -q "migration_witness_secret" variables_after_migration.json; then
echo "✅ Witness credential found after Docker migration"
else
echo "❌ Witness credential NOT found after Docker migration"
cat variables_after_migration.json
exit 1
fi
- name: Collect logs on failure
if: failure()
working-directory: docker-migration-test
run: |
docker compose logs > docker-compose-logs.txt
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: migration-docker-compose-logs
path: |
docker-migration-test/*.log
docker-migration-test/*.json
docker-migration-test/*.txt
retention-days: 7
- name: Cleanup
if: always()
working-directory: docker-migration-test
run: |
docker compose down -v
+6 -2
View File
@@ -28,6 +28,8 @@ jobs:
cache-dependency-path: ./docs/package-lock.json
- name: Validate Branch Names
env:
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
run: |
# Check if branch names contain invalid characters. Only alphanumeric, _, -, ., and / are allowed.
validate_branch_name() {
@@ -37,10 +39,12 @@ jobs:
exit 1
fi
}
validate_branch_name "${{ github.event.pull_request.head.ref }}"
validate_branch_name "$BRANCH_REF"
- name: Extract Branch Names
id: extract_branch
env:
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
run: |
# Extract and transform branch names
extract_branch() {
@@ -56,7 +60,7 @@ jobs:
}
# Transform branch names in form of `refs/heads/main` to `main`
draft_branch=$(extract_branch "${{ github.event.pull_request.head.ref }}")
draft_branch=$(extract_branch "$BRANCH_REF")
# Replace / with - in the draft branch name to use as a directory name
draft_directory=$(echo "$draft_branch" | tr / -)
+4 -3
View File
@@ -124,7 +124,7 @@ jobs:
- name: Determine version
id: version
run: |
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
echo "Main version from pyproject.toml: $version"
if [ ${{inputs.pre_release}} == "true" ]; then
@@ -137,7 +137,8 @@ jobs:
echo "Latest main release version: $last_released_version"
fi
if [ "$version" = "$last_released_version" ]; then
# Skip check only applies to main image, not backend/frontend which are separate images
if [ "$version" = "$last_released_version" ] && [[ "${{ inputs.release_type }}" != "main-backend" ]] && [[ "${{ inputs.release_type }}" != "main-frontend" ]]; then
echo "Main docker version $version is already released. Skipping release."
echo skipped=true >> $GITHUB_OUTPUT
exit 0
@@ -603,7 +604,7 @@ jobs:
if [[ "${{ inputs.release_type }}" == "base" ]]; then
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
else
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
fi
echo "Using version: $version"
echo version=$version >> $GITHUB_OUTPUT
+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: ${{ inputs.main_version == '' && (inputs.release_type == 'main' || inputs.release_type == 'main-ep' || inputs.release_type == 'nightly-main' || inputs.release_type == 'main-all' || inputs.release_type == 'nightly-main-all') }}
id: get-version-main
run: |
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}' | sed 's/^v//')
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}' | sed 's/^v//')
echo version=$version
echo version=$version >> $GITHUB_OUTPUT
setup:
+9 -3
View File
@@ -117,6 +117,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push_base.Dockerfile
tags: ${{ steps.tags.outputs.docker_tags }}
platforms: linux/${{ matrix.arch }}
@@ -129,6 +130,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push_base.Dockerfile
tags: ${{ steps.tags.outputs.ghcr_tags }}
platforms: linux/${{ matrix.arch }}
@@ -166,7 +168,7 @@ jobs:
id: version
run: |
echo "Extracting main version from pyproject.toml"
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
# Verify nightly tag format
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]]; then
@@ -210,6 +212,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push.Dockerfile
tags: ${{ steps.tags.outputs.docker_tags }}
platforms: linux/${{ matrix.arch }}
@@ -222,6 +225,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push.Dockerfile
tags: ${{ steps.tags.outputs.ghcr_tags }}
platforms: linux/${{ matrix.arch }}
@@ -259,7 +263,7 @@ jobs:
id: version
run: |
echo "Extracting main version from pyproject.toml"
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
# Verify nightly tag format
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]]; then
@@ -303,6 +307,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push_with_extras.Dockerfile
tags: ${{ steps.tags.outputs.docker_tags }}
platforms: linux/${{ matrix.arch }}
@@ -315,6 +320,7 @@ jobs:
with:
context: .
push: ${{ inputs.push_to_registry }}
pull: true
file: ./docker/build_and_push_with_extras.Dockerfile
tags: ${{ steps.tags.outputs.ghcr_tags }}
platforms: linux/${{ matrix.arch }}
@@ -347,7 +353,7 @@ jobs:
if [[ "${{ inputs.release_type }}" == "nightly-base" ]]; then
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
else
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
fi
echo "Using version: $version"
echo version=$version >> $GITHUB_OUTPUT
-124
View File
@@ -1,124 +0,0 @@
name: Update OpenAPI Spec
on:
schedule:
- cron: "0 20 * * 1" # Monday 4pm EST
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
check-openapi-updates:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install jq
run: sudo apt-get install -y jq
- name: Check if there is already an open pull request
id: check_pull_request
run: |
if [[ -n $(gh pr list --state open --repo ${{ github.repository }} | grep "docs: OpenAPI spec") ]]; then
echo "There is already an open PR with updates to the OpenAPI spec. Merge or close that PR first. Skipping."
echo "pr_exists=true" >> $GITHUB_OUTPUT
else
echo "pr_exists=false" >> $GITHUB_OUTPUT
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run Langflow container and get OpenAPI spec
if: steps.check_pull_request.outputs.pr_exists != 'true'
run: |
# Start the container in the background
docker run -d --name langflow -p 7860:7860 ghcr.io/hanzoai/flow:latest
# Wait for the service to be ready (adjust timeout as needed)
echo "Waiting for Langflow to start..."
timeout=60
elapsed=0
while ! curl -s http://localhost:7860/health > /dev/null; do
sleep 2
elapsed=$((elapsed+2))
if [ "$elapsed" -ge "$timeout" ]; then
echo "Timed out waiting for Langflow to start"
exit 1
fi
echo "Still waiting... ($elapsed seconds)"
done
# Get the OpenAPI spec and save to file
echo "Fetching OpenAPI spec..."
curl -s http://localhost:7860/openapi.json > docs/openapi/new_openapi.json
# Verify file was created
ls -la docs/openapi/new_openapi.json
# stop the container when done
docker stop langflow
- name: Compare OpenAPI files
if: steps.check_pull_request.outputs.pr_exists != 'true'
id: compare
run: |
# Extract versions
NEW_VERSION=$(jq -r '.info.version' docs/openapi/new_openapi.json)
CURRENT_VERSION=$(jq -r '.info.version' docs/openapi/openapi.json)
echo "Current version: $CURRENT_VERSION"
echo "New version: $NEW_VERSION"
# Compare file content (normalize by sorting keys)
jq --sort-keys . docs/openapi/new_openapi.json > docs/openapi/sorted_new.json
jq --sort-keys . docs/openapi/openapi.json > docs/openapi/sorted_current.json
if ! cmp -s docs/openapi/sorted_new.json docs/openapi/sorted_current.json; then
echo "OpenAPI spec content has changed."
# Clean the new spec for ReDoc formatting
echo "Cleaning OpenAPI spec formatting..."
python3 docs/scripts/clean_openapi_formatting.py docs/openapi/new_openapi.json
# Compare versions (assuming semantic versioning)
if [ "$(printf '%s\n' "$CURRENT_VERSION" "$NEW_VERSION" | sort -V | tail -n1)" == "$NEW_VERSION" ] && [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
echo "New version detected. Creating PR."
echo "NEEDS_UPDATE=true" >> $GITHUB_ENV
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
echo "UPDATE_REASON=version upgraded from $CURRENT_VERSION to $NEW_VERSION" >> $GITHUB_ENV
else
echo "File content changed but version remains the same. Creating PR."
echo "NEEDS_UPDATE=true" >> $GITHUB_ENV
echo "NEW_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV
echo "UPDATE_REASON=content updated without version change" >> $GITHUB_ENV
fi
cat docs/openapi/new_openapi.json | jq > docs/openapi/openapi.json
else
echo "No changes detected in OpenAPI spec content."
echo "NEEDS_UPDATE=false" >> $GITHUB_ENV
fi
# Clean up
rm docs/openapi/new_openapi.json docs/openapi/sorted_new.json docs/openapi/sorted_current.json
- name: Create Pull Request
if: env.NEEDS_UPDATE == 'true' && steps.check_pull_request.outputs.pr_exists != 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "docs: OpenAPI spec ${{ env.UPDATE_REASON }}"
title: "docs: OpenAPI spec ${{ env.UPDATE_REASON }}"
body: |
This PR updates the OpenAPI spec.
Update reason: ${{ env.UPDATE_REASON }}
Version: ${{ env.NEW_VERSION }}
branch: update-openapi-spec
branch-suffix: timestamp
delete-branch: true
reviewers: mendonk
+107
View File
@@ -0,0 +1,107 @@
name: GP Download Translations
on:
schedule:
- cron: "0 23 * * *" # 11 PM UTC — 1h before nightly build at midnight
workflow_dispatch:
jobs:
download:
runs-on: ubuntu-latest
name: Download translations from Globalization Pipeline
environment: GP-test
permissions:
contents: write
pull-requests: write
steps:
- name: Resolve latest release branch
id: resolve-branch
run: |
BRANCH=$(git ls-remote --heads https://github.com/${{ github.repository }} 'refs/heads/release-*' \
| awk '{print $2}' \
| sed 's|refs/heads/||' \
| grep -E '^release-[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -n 1)
if [ -z "$BRANCH" ]; then
echo "No release-* branch found in ${{ github.repository }}"
exit 1
fi
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "Using release branch: $BRANCH"
- name: Checkout release branch
uses: actions/checkout@v4
with:
ref: ${{ steps.resolve-branch.outputs.branch }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r scripts/gp/requirements.txt
- name: Download translations from GP
working-directory: scripts/gp
env:
GP_ADMIN_USER_ID: ${{ secrets.GP_ADMIN_USER_ID }}
GP_ADMIN_PASSWORD: ${{ secrets.GP_ADMIN_PASSWORD }}
GP_INSTANCE: ${{ vars.GP_INSTANCE }}
GP_BUNDLE: ${{ vars.GP_BUNDLE }}
run: python download_translations.py --output ../../src/frontend/src/locales/
- name: Close stale translation PRs
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
for (const pull of pulls) {
if (pull.title === "chore: update translations from Globalization Pipeline") {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
state: 'closed'
});
}
}
- name: Create Pull Request
id: create-pr
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch-token: ${{ secrets.GITHUB_TOKEN }}
base: ${{ steps.resolve-branch.outputs.branch }}
commit-message: "chore: update translations from Globalization Pipeline [skip ci]"
title: "chore: update translations from Globalization Pipeline"
body: |
Automated PR to update locale files from IBM Globalization Pipeline.
This PR was automatically created by the `gp-download` workflow.
If no files changed, this PR will be empty and can be closed.
branch: translations/update-gp
branch-suffix: timestamp
delete-branch: true
maintainer-can-modify: true
add-paths: src/frontend/src/locales/*.json
- name: Enable auto-merge
if: steps.create-pr.outputs.pull-request-number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash \
--repo ${{ github.repository }}
- name: Report GP download failure
if: failure()
run: echo "::warning::GP translation download failed — translations may be out of date. Check the 'Download translations from GP' step above for details."
+27
View File
@@ -0,0 +1,27 @@
name: GP Scripts Tests
on:
pull_request:
paths:
- "scripts/gp/**"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test Globalization Pipeline scripts
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r scripts/gp/requirements.txt pytest
- name: Run GP script tests
run: python -m pytest scripts/gp/tests/ -v
+56
View File
@@ -0,0 +1,56 @@
name: GP Upload Strings
on:
push:
branches:
- "release-*"
paths:
- "src/frontend/src/locales/en.json"
workflow_dispatch:
jobs:
upload:
runs-on: ubuntu-latest
name: Upload English strings to Globalization Pipeline
environment: GP-test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check if this is the latest release branch
id: check-branch
run: |
LATEST=$(git ls-remote --heads https://github.com/${{ github.repository }} 'refs/heads/release-*' \
| awk '{print $2}' \
| sed 's|refs/heads/||' \
| grep -E '^release-[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -n 1)
CURRENT="${{ github.ref_name }}"
if [ "$CURRENT" != "$LATEST" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping upload — $CURRENT is not the latest release branch ($LATEST)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python
if: steps.check-branch.outputs.skip != 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
if: steps.check-branch.outputs.skip != 'true'
run: pip install -r scripts/gp/requirements.txt
- name: Upload strings to GP
if: steps.check-branch.outputs.skip != 'true'
working-directory: scripts/gp
env:
GP_ADMIN_USER_ID: ${{ secrets.GP_ADMIN_USER_ID }}
GP_ADMIN_PASSWORD: ${{ secrets.GP_ADMIN_PASSWORD }}
GP_INSTANCE: ${{ vars.GP_INSTANCE }}
GP_BUNDLE: ${{ vars.GP_BUNDLE }}
run: python upload_strings.py --source ../../src/frontend/src/locales/en.json
+1 -1
View File
@@ -13,7 +13,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
default: "['3.10', '3.11', '3.12', '3.13']"
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
ref:
description: "(Optional) ref to checkout"
required: false
+10 -20
View File
@@ -45,20 +45,20 @@ jobs:
- name: Run Frontend Unit Tests
run: make test_frontend_ci
- name: Publish Test Results
- name: Publish Jest Test Results
uses: mikepenz/action-junit-report@v5
if: always()
with:
report_paths: 'src/frontend/test-results/junit.xml'
check_name: 'Frontend Unit Test Results'
report_paths: "src/frontend/test-results/junit.xml"
check_name: "Frontend Unit Test Results"
fail_on_failure: true
require_tests: true
- name: Add Coverage PR Comment
- name: Add Jest Coverage PR Comment
uses: MishaKav/jest-coverage-comment@main
if: github.event_name == 'pull_request'
with:
coverage-summary-path: src/frontend/coverage/coverage-summary.json
coverage-summary-path: src/frontend/coverage/jest/coverage-summary.json
title: Frontend Unit Test Coverage Report
summary-title: Coverage Summary
badge-title: Coverage
@@ -68,20 +68,10 @@ jobs:
junitxml-title: Unit Test Results
junitxml-path: src/frontend/test-results/junit.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: src/frontend/coverage/lcov.info
flags: frontend
name: frontend-coverage
fail_ci_if_error: false
directory: src/frontend/coverage/
- name: Upload Coverage Reports
uses: actions/upload-artifact@v6
- name: Upload Jest Coverage Artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: frontend-coverage-report
path: src/frontend/coverage/
retention-days: 30
name: jest-coverage
path: src/frontend/coverage/jest
retention-days: 30
+38 -2
View File
@@ -25,6 +25,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.branch || github.ref }}
# Need full history so biome can diff against the PR base
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -47,7 +49,41 @@ jobs:
npm install
if: ${{ steps.setup-node.outputs.cache-hit != 'true' }}
- name: Run Biome
- name: Run Biome on PR-changed files
run: |
# Determine the branch this PR targets (falls back to `main` for
# workflow_dispatch / non-PR contexts).
BASE_REF="${{ github.base_ref || 'main' }}"
echo "=== Debug ==="
echo "HEAD: $(git rev-parse HEAD)"
echo "BASE_REF: $BASE_REF"
echo "origin/$BASE_REF: $(git rev-parse "origin/$BASE_REF" 2>/dev/null || echo NOT_FOUND)"
# Merge-base = the commit where this branch forked off its target.
# Diffing against it yields ONLY the files this PR introduced,
# ignoring unrelated upstream changes merged into the base since.
MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF")
echo "MERGE_BASE: $MERGE_BASE"
# Collect frontend files modified in this PR. Filter to file types
# biome can process (ts/tsx/js/jsx/json/jsonc and the like).
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR \
"$MERGE_BASE"...HEAD -- 'src/frontend/' \
| grep -E '\.(tsx?|jsx?|c(js|ts)|m(js|ts)|jsonc?)$' \
|| true)
if [ -z "$CHANGED_FILES" ]; then
echo "No frontend files changed in this PR — skipping biome."
exit 0
fi
echo "=== Files to lint ==="
echo "$CHANGED_FILES"
# Biome runs from src/frontend; strip that prefix from paths.
RELATIVE_FILES=$(echo "$CHANGED_FILES" | sed 's|^src/frontend/||')
cd src/frontend
npx @biomejs/biome check --changed
echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \
--files-ignore-unknown=true \
--diagnostic-level=error
+1
View File
@@ -19,6 +19,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.14"
- "3.13"
- "3.12"
- "3.11"
+56
View File
@@ -0,0 +1,56 @@
name: "Mend"
on:
push:
branches: ["release-*", "main"]
pull_request:
branches: ["release-*", "main"]
concurrency:
group: mend-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: "22"
jobs:
mend-scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup Node.js Environment
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: ./src/frontend/package-lock.json
- name: Install Frontend Dependencies
working-directory: ./src/frontend
run: npm ci
- name: Set up Java (Semeru)
uses: actions/setup-java@v5
with:
distribution: "semeru"
java-version: "21"
- name: Download Mend Unified Agent
run: |
curl --fail --show-error --retry 3 --location -o wss-unified-agent.jar https://unified-agent.s3.amazonaws.com/wss-unified-agent.jar
- name: Run Mend Scan
#prevent forks from running mend scans
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
WS_APIKEY: ${{ secrets.WS_APIKEY }}
WS_WSS_URL: ${{ secrets.WS_WSS_URL }}
WS_USERKEY: ${{ secrets.WS_USERKEY }}
WS_PRODUCTNAME: ${{ secrets.WS_PRODUCTNAME }}
WS_PROJECTNAME: ${{ secrets.WS_PROJECTNAME }}
run: |
java -jar wss-unified-agent.jar -d src -logLevel info
+95 -48
View File
@@ -3,11 +3,59 @@ name: Database Migration Validation
on:
pull_request:
paths:
- 'src/backend/base/langflow/alembic/versions/*.py'
- 'alembic/versions/*.py'
- 'src/backend/base/langflow/alembic/**'
- 'src/backend/base/langflow/services/database/models/**'
- 'src/backend/base/langflow/services/database/service.py'
- 'src/backend/tests/unit/alembic/**'
- '.github/workflows/migration-validation.yml'
jobs:
model-migration-consistency:
name: Model/Migration Consistency
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
POSTGRES_DB: langflow
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langflow"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install dependencies
run: |
uv sync --extra postgresql
- name: Check model/migration consistency
env:
MIGRATION_VALIDATION_CI: "true"
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow"
run: |
uv run pytest src/backend/tests/unit/alembic/test_migration_execution.py -x -v
validate-migration:
name: Migration Pattern Validation
runs-on: ubuntu-latest
steps:
@@ -19,47 +67,41 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install sqlalchemy alembic
python-version: '3.12'
- name: Get changed migration files
id: changed-files
run: |
# Get all changed Python files in alembic/versions directories
# CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '(alembic|migrations)/versions/.*\.py$' || echo "")
# Exclude test migrations, as they are not part of the main codebase
CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '(alembic|migrations)/versions/.*\.py$' | grep -v 'test_migrations/' || echo "")
set -euo pipefail
CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E 'src/backend/base/langflow/alembic/versions/.*\.py$' | grep -v 'test_migrations/' || echo "")
if [ -z "$CHANGED_FILES" ]; then
echo "No migration files changed"
echo "files=" >> $GITHUB_OUTPUT
echo "files=" >> "$GITHUB_OUTPUT"
else
echo "Changed migration files:"
echo "$CHANGED_FILES"
# Convert newlines to spaces for passing as arguments
echo "files=$(echo $CHANGED_FILES | tr '\n' ' ')" >> $GITHUB_OUTPUT
echo "files=$(printf '%s' "$CHANGED_FILES" | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
fi
- name: Validate migrations
- name: Validate migration patterns
if: steps.changed-files.outputs.files != ''
env:
MIGRATION_FILES: ${{ steps.changed-files.outputs.files }}
run: |
python src/backend/base/langflow/alembic/migration_validator.py ${{ steps.changed-files.outputs.files }}
# - name: Check migration phase sequence
# if: steps.changed-files.outputs.files != ''
# run: |
# python scripts/check_phase_sequence.py ${{ steps.changed-files.outputs.files }}
python src/backend/base/langflow/alembic/migration_validator.py $MIGRATION_FILES
- name: Generate validation report
if: always() && steps.changed-files.outputs.files != ''
env:
MIGRATION_FILES: ${{ steps.changed-files.outputs.files }}
run: |
python src/backend/base/langflow/alembic/migration_validator.py \
--json ${{ steps.changed-files.outputs.files }} > validation-report.json || true
--json $MIGRATION_FILES > validation-report.json 2> validation-stderr.txt || true
if [ ! -s validation-report.json ]; then
echo "::error::Validator produced no output. Stderr:"
cat validation-stderr.txt
fi
- name: Post PR comment with results
if: always() && steps.changed-files.outputs.files != ''
@@ -110,7 +152,7 @@ jobs:
}
}
message += `### 📚 Resources\n`;
message += `### Resources\n`;
message += `- Review the [DB Migration Guide](./src/backend/base/langflow/alembic/DB-MIGRATION-GUIDE.MD)\n`;
message += `- Use \`python scripts/generate_migration.py --help\` to generate compliant migrations\n\n`;
@@ -123,37 +165,42 @@ jobs:
} catch (error) {
message = `⚠️ **Migration validation check failed to run properly**\n`;
message += `Error: ${error.message}\n`;
validationPassed = false;
}
// Post or update comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Migration Validation')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: message
});
} else {
await github.rest.issues.createComment({
// Post or update comment (non-critical — don't let API errors mask validation results)
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Migration Validation')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: message
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
} catch (apiError) {
core.warning(`Failed to post PR comment: ${apiError.message}`);
}
// Fail the workflow if validation didn't pass
if (!validationPassed) {
core.setFailed('Migration validation failed');
}
}
+38 -14
View File
@@ -68,6 +68,7 @@ jobs:
main_tag: ${{ steps.generate_main_tag.outputs.main_tag }}
base_tag: ${{ steps.set_base_tag.outputs.base_tag }}
lfx_tag: ${{ steps.generate_lfx_tag.outputs.lfx_tag }}
sdk_tag: ${{ steps.generate_sdk_tag.outputs.sdk_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -86,6 +87,9 @@ jobs:
- name: Generate main nightly tag
id: generate_main_tag
run: |
# langflow-nightly pins langflow-base-nightly[complete]==<exact dev>, so both tags
# MUST be identical. Compute them in ONE invocation (single PyPI snapshot) so they
# cannot drift across separate calls.
# NOTE: This outputs the tag with the `v` prefix.
MAIN_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py main)"
echo "main_tag=$MAIN_TAG" >> $GITHUB_OUTPUT
@@ -99,14 +103,6 @@ jobs:
git push --delete origin ${{ steps.generate_main_tag.outputs.main_tag }} || true
echo "main_tag_exists=false" >> $GITHUB_OUTPUT
- name: Generate base nightly tag
id: generate_base_tag
run: |
# NOTE: This outputs the tag with the `v` prefix.
BASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py base)"
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
echo "base_tag=$BASE_TAG"
- name: Generate LFX nightly tag
id: generate_lfx_tag
run: |
@@ -115,6 +111,14 @@ jobs:
echo "lfx_tag=$LFX_TAG" >> $GITHUB_OUTPUT
echo "lfx_tag=$LFX_TAG"
- name: Generate SDK nightly tag
id: generate_sdk_tag
run: |
# NOTE: This outputs the tag with the `v` prefix.
SDK_TAG="$(uv run ./scripts/ci/sdk_nightly_tag.py)"
echo "sdk_tag=$SDK_TAG" >> $GITHUB_OUTPUT
echo "sdk_tag=$SDK_TAG"
- name: Commit tag
id: commit_tag
run: |
@@ -126,16 +130,24 @@ jobs:
MAIN_TAG="${{ steps.generate_main_tag.outputs.main_tag }}"
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
LFX_TAG="${{ steps.generate_lfx_tag.outputs.lfx_tag }}"
SDK_TAG="${{ steps.generate_sdk_tag.outputs.sdk_tag }}"
echo "Updating SDK project version to $SDK_TAG"
uv run --no-sync ./scripts/ci/update_sdk_version.py $SDK_TAG
echo "Updating LFX project version to $LFX_TAG"
uv run ./scripts/ci/update_lfx_version.py $LFX_TAG
echo "Updating base project version to $BASE_TAG and updating main project version to $MAIN_TAG"
uv run --no-sync ./scripts/ci/update_pyproject_combined.py main $MAIN_TAG $BASE_TAG $LFX_TAG
uv lock
cd src/backend/base && uv lock && cd ../../..
cd src/lfx && uv lock && cd ../..
git add pyproject.toml src/backend/base/pyproject.toml src/lfx/pyproject.toml uv.lock src/backend/base/uv.lock
git add pyproject.toml src/backend/base/pyproject.toml src/lfx/pyproject.toml src/sdk/pyproject.toml uv.lock
# update_lfx_version.py re-pins each bundle's `lfx` dep to
# lfx-nightly==<dev>, so any modified bundle pyprojects need to
# ride the same commit/tag as the rest of the version bumps.
if compgen -G "src/bundles/*/pyproject.toml" > /dev/null; then
git add src/bundles/*/pyproject.toml
fi
git commit -m "Update version and project name"
echo "Tagging main with $MAIN_TAG"
@@ -172,8 +184,8 @@ jobs:
BASE_TAG="${{ steps.retrieve_base_tag.outputs.base_tag }}"
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
echo "base_tag=$BASE_TAG"
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_base_tag.outputs.base_tag }}" ]; then
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_shared_tag.outputs.base_tag }}" ]; then
BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}"
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
echo "base_tag=$BASE_TAG"
else
@@ -286,7 +298,7 @@ jobs:
needs: create-nightly-tag
uses: ./.github/workflows/python_test.yml
with:
python-versions: '["3.10", "3.11", "3.12", "3.13"]'
python-versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
runs-on: ${{ inputs['runs_on'] || github.event.inputs['runs_on'] || 'ubuntu-latest' }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -316,10 +328,22 @@ jobs:
nightly_tag_main: ${{ needs.create-nightly-tag.outputs.main_tag }}
nightly_tag_base: ${{ needs.create-nightly-tag.outputs.base_tag }}
nightly_tag_lfx: ${{ needs.create-nightly-tag.outputs.lfx_tag }}
nightly_tag_sdk: ${{ needs.create-nightly-tag.outputs.sdk_tag }}
# When triggered by schedule, inputs.push_to_registry is not set, so default to true
# When triggered manually, use the provided value (default is also true)
push_to_registry: ${{ github.event_name == 'schedule' || inputs.push_to_registry != false }}
secrets: inherit
db-migration-validation:
name: Run DB Migration Validation
needs: [create-nightly-tag, release-nightly-build]
if: ${{ github.repository == 'langflow-ai/langflow' && always() && needs.release-nightly-build.result == 'success' }}
uses: ./.github/workflows/db-migration-validation.yml
with:
# Pass Docker image reference for docker-compose and version without 'v' prefix for pip
# release_tag format: v1.10.0.dev20260522
# We need: langflowai/langflow-nightly:1.10.0.dev20260522 for Docker
nightly_tag: langflowai/langflow-nightly:${{ needs.create-nightly-tag.outputs.release_tag }}
secrets: inherit
slack-notification:
name: Send Slack Notification
@@ -529,7 +553,7 @@ jobs:
fi
- name: Send success notification to Slack
if: ${{ !inputs.skip_slack && needs.release-nightly-build.result == 'success' }}
if: ${{ !inputs.skip_slack && needs.release-nightly-build.result == 'success' && needs.db-migration-validation.result != 'failure' }}
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
+30 -6
View File
@@ -5,6 +5,7 @@ on:
- "**/*.py"
- "src/lfx/src/lfx/components/**"
- "scripts/build_component_index.py"
types: [opened, synchronize, reopened, labeled]
env:
PYTHON_VERSION: "3.13"
@@ -12,7 +13,7 @@ env:
jobs:
lint:
name: Run Ruff Check and Format
if: ${{ github.actor != 'github-actions[bot]' }}
if: github.event.action != 'labeled' && github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -30,7 +31,7 @@ jobs:
update-starter-projects:
name: Update Starter Projects
if: ${{ github.actor != 'github-actions[bot]' }}
if: github.event.action != 'labeled' && github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -55,10 +56,36 @@ jobs:
update-component-index:
name: Update Component Index
if: ${{ github.actor != 'github-actions[bot]' }}
if: >-
github.actor != 'github-actions[bot]'
&& (github.event.action != 'labeled' || github.event.label.name == 'fix-index')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Merge base branch
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git merge origin/${{ github.base_ref }} --no-edit || {
# Check which files have conflicts
CONFLICTED=$(git diff --name-only --diff-filter=U)
# If any non-generated files conflict, bail out — needs manual resolution
if echo "$CONFLICTED" | grep -v component_index.json | grep -q .; then
echo "::error::Merge conflicts in non-generated files require manual resolution"
git merge --abort
exit 1
fi
# Only component_index.json conflicts — resolve and finish merge
git checkout --theirs src/lfx/src/lfx/_assets/component_index.json
git add src/lfx/src/lfx/_assets/component_index.json
GIT_EDITOR=true git merge --continue
}
- name: "Setup Environment"
uses: astral-sh/setup-uv@v6
with:
@@ -76,6 +103,3 @@ jobs:
run: make build_component_index
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
+8 -8
View File
@@ -14,7 +14,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
default: "['3.10', '3.11', '3.12', '3.13']"
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
ref:
description: "(Optional) ref to checkout"
required: false
@@ -35,7 +35,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
default: "['3.10', '3.11', '3.12', '3.13']"
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
runs-on:
description: "Runner to use for the tests"
required: false
@@ -58,7 +58,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
splitCount: [5]
group: [1, 2, 3, 4, 5]
steps:
@@ -93,7 +93,7 @@ jobs:
- name: Run unit tests
uses: nick-fields/retry@v3
with:
timeout_minutes: 12
timeout_minutes: 40
max_attempts: 2
command: make unit_tests args="-x -vv --splits ${{ matrix.splitCount }} --group ${{ matrix.group }} --reruns 5 --cov --cov-config=src/backend/.coveragerc --cov-report=xml --cov-report=html"
@@ -123,7 +123,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- uses: actions/checkout@v6
with:
@@ -148,7 +148,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- uses: actions/checkout@v6
with:
@@ -161,7 +161,7 @@ jobs:
python-version: ${{ matrix.python-version }}
prune-cache: false
- name: Run lfx tests
run: uv run make lfx_tests
run: make lfx_tests
env:
DO_NOT_TRACK: true # disable telemetry reporting
@@ -191,7 +191,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]') }}
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- name: Check out the code at a specific ref
uses: actions/checkout@v6
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout code
uses: actions/checkout@v6
+422 -19
View File
@@ -23,6 +23,16 @@ on:
required: false
type: boolean
default: false
release_sdk:
description: "Release langflow-sdk package (required when releasing LFX)"
required: false
type: boolean
default: false
release_bundles:
description: "Release every src/bundles/* package (required when releasing main, since main pins exact bundle versions)"
required: false
type: boolean
default: false
build_docker_base:
description: "Build Docker Image for Langflow Base"
required: true
@@ -48,6 +58,11 @@ on:
required: false
type: boolean
default: true
run_pip_check:
description: "Whether to run pip check - ONLY SET TO FALSE ONCE YOU HAVE ALREADY ATTEMPTED AND FAILED A RUN THEN MADE SURE DEPS DO NOT CAUSE FAILURES"
required: false
type: boolean
default: true
jobs:
echo-inputs:
@@ -60,6 +75,8 @@ jobs:
echo "release_package_base: ${{ inputs.release_package_base }}"
echo "release_package_main: ${{ inputs.release_package_main }}"
echo "release_lfx: ${{ inputs.release_lfx }}"
echo "release_sdk: ${{ inputs.release_sdk }}"
echo "release_bundles: ${{ inputs.release_bundles }}"
echo "build_docker_base: ${{ inputs.build_docker_base }}"
echo "build_docker_main: ${{ inputs.build_docker_main }}"
echo "pre_release: ${{ inputs.pre_release }}"
@@ -92,10 +109,50 @@ jobs:
echo "Validated: '${{ inputs.release_tag }}' is a valid tag."
validate-tag-format:
name: Validate Tag Format and Check for Duplicates
runs-on: ubuntu-latest
needs: [validate-tag]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate Tag Has v Prefix
run: |
TAG="${{ inputs.release_tag }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Error: Tag must start with 'v' and follow semver format (v1.2.3)"
echo " Got: $TAG"
echo ""
echo "This is required to ensure GitHub's generateReleaseNotes uses the correct base comparison."
exit 1
fi
echo "✅ Tag format is valid: $TAG"
- name: Check for Duplicate Tag Without v Prefix
run: |
TAG="${{ inputs.release_tag }}"
TAG_WITHOUT_V="${TAG#v}"
if git rev-parse "$TAG_WITHOUT_V" >/dev/null 2>&1; then
echo "❌ Error: Duplicate tag without 'v' prefix exists: $TAG_WITHOUT_V"
echo " This will cause release notes to use the wrong base comparison."
echo ""
echo " The tag '$TAG_WITHOUT_V' points to commit: $(git rev-parse --short $TAG_WITHOUT_V)"
echo " The tag '$TAG' points to commit: $(git rev-parse --short $TAG)"
echo ""
echo " To fix this, delete the duplicate tag:"
echo " git push origin :refs/tags/$TAG_WITHOUT_V"
exit 1
fi
echo "✅ No duplicate tag found"
validate-dependencies:
name: Validate Release Dependencies
runs-on: ubuntu-latest
if: ${{ inputs.release_package_base || inputs.release_package_main || inputs.release_lfx || inputs.build_docker_base || inputs.build_docker_main }}
if: ${{ inputs.release_package_base || inputs.release_package_main || inputs.release_lfx || inputs.release_sdk || inputs.build_docker_base || inputs.build_docker_main }}
needs: [validate-tag]
steps:
- name: Validate that build-base is enabled if build-main is enabled
@@ -106,6 +163,18 @@ jobs:
exit 1
fi
if [ "${{ inputs.release_lfx }}" = "true" ] && [ "${{ inputs.release_sdk }}" = "false" ]; then
echo "Error: Cannot release LFX without releasing langflow-sdk."
echo "LFX depends on langflow-sdk. Please enable 'release_sdk' or disable 'release_lfx'."
exit 1
fi
if [ "${{ inputs.release_package_main }}" = "true" ] && [ "${{ inputs.release_bundles }}" = "false" ]; then
echo "Error: Cannot release Langflow Main without releasing bundles."
echo "Langflow Main pins exact bundle versions (lfx-arxiv, lfx-duckduckgo, ...). Please enable 'release_bundles' or disable 'release_package_main'."
exit 1
fi
echo "✅ Release dependencies validated successfully."
determine-base-version:
@@ -188,7 +257,7 @@ jobs:
- name: Determine version
id: version
run: |
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}' | sed 's/^v//')
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}' | sed 's/^v//')
echo "Main version from pyproject.toml: $version"
if [ ${{inputs.pre_release}} == "true" ]; then
@@ -259,23 +328,84 @@ jobs:
echo skipped=false >> $GITHUB_OUTPUT
fi
determine-sdk-version:
name: Determine SDK Version
needs: [validate-tag, validate-dependencies]
if: ${{ inputs.release_sdk || inputs.release_lfx }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
skipped: ${{ steps.version.outputs.skipped }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag }}
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: "3.13"
prune-cache: false
- name: Install SDK dependencies
run: uv sync --dev --package langflow-sdk
- name: Determine version
id: version
run: |
version=$(python3 -c "
import tomllib, pathlib
data = tomllib.loads(pathlib.Path('src/sdk/pyproject.toml').read_text())
print(data['project']['version'])
")
echo "SDK version from pyproject.toml: $version"
# Check if langflow-sdk exists on PyPI (may be first release)
pypi_response=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/langflow-sdk/json")
if [ "$pypi_response" = "404" ]; then
echo "langflow-sdk has never been published to PyPI. This will be the first release."
last_released_version=""
else
if [ ${{inputs.pre_release}} == "true" ]; then
last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1)
version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")"
echo "Latest SDK pre-release version: $last_released_version"
echo "SDK pre-release version to be released: $version"
else
last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1)
echo "Latest SDK release version: $last_released_version"
fi
fi
if [ "$version" = "$last_released_version" ]; then
echo "SDK pypi version $version is already released. Skipping release."
echo skipped=true >> $GITHUB_OUTPUT
exit 1
else
echo version=$version >> $GITHUB_OUTPUT
echo skipped=false >> $GITHUB_OUTPUT
fi
ci:
name: CI
needs: [validate-tag, validate-dependencies]
uses: ./.github/workflows/ci.yml
with:
ref: ${{ inputs.release_tag }}
python-versions: "['3.10', '3.11', '3.12', '3.13']"
python-versions: "['3.10', '3.11', '3.12', '3.13', '3.14']"
frontend-tests-folder: "tests"
release: true
run-all-tests: true
runs-on: ubuntu-latest
secrets: inherit
build-lfx:
name: Build LFX
needs: [determine-lfx-version]
if: ${{ needs.determine-lfx-version.outputs.skipped == 'false' }}
build-sdk:
name: Build langflow-sdk
needs: [determine-sdk-version]
if: ${{ needs.determine-sdk-version.outputs.skipped == 'false' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -289,6 +419,81 @@ jobs:
cache-dependency-glob: "uv.lock"
python-version: "3.13"
prune-cache: false
- name: Set version for pre-release
if: ${{ inputs.pre_release }}
run: |
VERSION="${{ needs.determine-sdk-version.outputs.version }}"
echo "Setting SDK pre-release version to: $VERSION"
cd src/sdk
# Update version in SDK pyproject.toml
sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
# Verify the change
echo "Updated pyproject.toml version:"
grep "^version" pyproject.toml
- name: Build project for distribution
run: make sdk_build args="--wheel"
- name: Verify built version
run: |
EXPECTED_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
WHEEL_FILE=$(ls src/sdk/dist/*.whl)
echo "Built wheel: $WHEEL_FILE"
NORMALIZED_VERSION=$(echo "$EXPECTED_VERSION" | sed 's/\.rc/rc/g; s/\.a/a/g; s/\.b/b/g; s/\.dev/dev/g')
echo "Expected version: $EXPECTED_VERSION"
echo "Normalized for wheel: $NORMALIZED_VERSION"
if [[ ! "$WHEEL_FILE" =~ $NORMALIZED_VERSION ]]; then
echo "❌ Error: Wheel version doesn't match expected version"
echo "Expected: $EXPECTED_VERSION (normalized: $NORMALIZED_VERSION)"
echo "Wheel file: $WHEEL_FILE"
exit 1
fi
echo "✅ Version verified: $EXPECTED_VERSION"
- name: Test SDK import
run: |
cd src/sdk
uv venv test-env --seed
uv pip install --python ./test-env/bin/python dist/*.whl
EXPECTED_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
# importlib.metadata returns the PEP 440 canonical form, so normalize
# the expected version the same way the wheel filename check does.
NORMALIZED_VERSION=$(echo "$EXPECTED_VERSION" | sed 's/\.rc/rc/g; s/\.a/a/g; s/\.b/b/g; s/\.dev/dev/g')
./test-env/bin/python -c "from importlib.metadata import version; import langflow_sdk; installed_version = version('langflow-sdk'); print(f'langflow-sdk {installed_version} imported successfully'); assert installed_version == '$NORMALIZED_VERSION', f'Installed version {installed_version} does not match expected version $NORMALIZED_VERSION'"
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
name: dist-sdk
path: src/sdk/dist
build-lfx:
name: Build LFX
needs: [determine-lfx-version, determine-sdk-version, build-sdk]
if: |
always() &&
!cancelled() &&
needs.determine-lfx-version.result == 'success' &&
needs.determine-lfx-version.outputs.skipped == 'false'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag }}
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: "3.13"
prune-cache: false
- name: Download SDK artifact
if: needs.build-sdk.result == 'success'
uses: actions/download-artifact@v7
with:
name: dist-sdk
path: ./sdk-dist
- name: Install LFX dependencies
run: uv sync --dev --package lfx
- name: Set version for pre-release
@@ -304,6 +509,31 @@ jobs:
# Verify the change
echo "Updated pyproject.toml version:"
grep "^version" pyproject.toml
- name: Update langflow-sdk dependency for pre-release
if: ${{ inputs.pre_release && needs.build-sdk.result == 'success' }}
run: |
SDK_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
echo "Updating langflow-sdk dependency to allow pre-release version: $SDK_VERSION"
cd src/lfx
# Extract current langflow-sdk constraint from pyproject.toml
CURRENT_CONSTRAINT=$(grep -E '^\s*"langflow-sdk' pyproject.toml | head -1)
echo "Current constraint: $CURRENT_CONSTRAINT"
# Create new constraint using the next major derived from SDK_VERSION
SDK_BASE_VERSION=$(echo "$SDK_VERSION" | sed 's/[^0-9.].*$//')
SDK_MAJOR=$(echo "$SDK_BASE_VERSION" | cut -d. -f1)
NEXT_MAJOR=$((SDK_MAJOR + 1))
NEW_CONSTRAINT="\"langflow-sdk>=$SDK_VERSION,<$NEXT_MAJOR.dev0\""
echo "New constraint: $NEW_CONSTRAINT"
# Replace the constraint
sed -i.bak "s|\"langflow-sdk[^\"]*\"|$NEW_CONSTRAINT|" pyproject.toml
# Verify the change
echo "Updated langflow-sdk dependency:"
grep "langflow-sdk" pyproject.toml
- name: Build project for distribution
run: |
cd src/lfx
@@ -329,8 +559,16 @@ jobs:
- name: Test CLI
run: |
cd src/lfx
uv pip install dist/*.whl --force-reinstall
uv run lfx --help
# Use a clean venv and install both SDK and LFX wheels together
# so uv can resolve the local SDK dependency without falling back to PyPI.
uv venv test-env --seed
if [ -d "../../sdk-dist" ]; then
SDK_WHEEL=$(find ../../sdk-dist -name "*.whl" -type f | head -1)
uv pip install --python ./test-env/bin/python --force-reinstall "$SDK_WHEEL" dist/*.whl
else
uv pip install --python ./test-env/bin/python --force-reinstall dist/*.whl
fi
./test-env/bin/lfx --help
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
@@ -339,8 +577,14 @@ jobs:
build-base:
name: Build Langflow Base
needs: [build-lfx, determine-base-version, determine-lfx-version]
if: ${{ inputs.release_package_base && needs.determine-base-version.outputs.skipped == 'false' }}
needs: [build-lfx, build-sdk, determine-base-version, determine-lfx-version]
if: |
always() &&
!cancelled() &&
inputs.release_package_base &&
needs.determine-base-version.result == 'success' &&
needs.determine-base-version.outputs.skipped == 'false' &&
needs.build-lfx.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -359,13 +603,24 @@ jobs:
with:
name: dist-lfx
path: ./lfx-dist
- name: Install dependencies with local LFX wheel
- name: Download SDK artifact
if: needs.build-sdk.result == 'success'
uses: actions/download-artifact@v7
with:
name: dist-sdk
path: ./sdk-dist
- name: Install dependencies with local LFX and SDK wheels
run: |
# Create virtual environment
uv venv
# Install using pip with local wheel directory as find-links
uv pip install --find-links ./lfx-dist --prerelease=allow -e src/backend/base
# Install using pip with local wheel directories as find-links
FIND_LINKS="--find-links ./lfx-dist"
if [ -d "./sdk-dist" ]; then
FIND_LINKS="$FIND_LINKS --find-links ./sdk-dist"
fi
uv pip install $FIND_LINKS --prerelease=allow -e src/backend/base
- name: Check for dependency incompatibility
if: ${{ inputs.run_pip_check }}
run: uv pip check
- name: Set version for pre-release
if: ${{ inputs.pre_release }}
@@ -460,11 +715,19 @@ jobs:
[
build-base,
build-lfx,
build-sdk,
determine-base-version,
determine-main-version,
determine-lfx-version,
]
if: ${{ inputs.release_package_main && needs.determine-main-version.outputs.skipped == 'false' }}
if: |
always() &&
!cancelled() &&
inputs.release_package_main &&
needs.determine-main-version.result == 'success' &&
needs.determine-main-version.outputs.skipped == 'false' &&
needs.build-base.result == 'success' &&
needs.build-lfx.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -483,6 +746,12 @@ jobs:
with:
name: dist-lfx
path: ./lfx-dist
- name: Download SDK artifact
if: needs.build-sdk.result == 'success'
uses: actions/download-artifact@v7
with:
name: dist-sdk
path: ./sdk-dist
- name: Download base artifact
uses: actions/download-artifact@v7
with:
@@ -493,8 +762,13 @@ jobs:
# Create virtual environment
uv venv
# Install using pip with local wheel directories as find-links
uv pip install --find-links ./lfx-dist --find-links ./base-dist --prerelease=allow -e .
FIND_LINKS="--find-links ./lfx-dist --find-links ./base-dist"
if [ -d "./sdk-dist" ]; then
FIND_LINKS="$FIND_LINKS --find-links ./sdk-dist"
fi
uv pip install $FIND_LINKS --prerelease=allow -e .
- name: Check for dependency incompatibility
if: ${{ inputs.run_pip_check }}
run: uv pip check
- name: Set version for pre-release
if: ${{ inputs.pre_release }}
@@ -582,9 +856,63 @@ jobs:
name: dist-main
path: dist
build-bundles:
name: Build Langflow Bundles
needs: [build-lfx, determine-lfx-version]
if: |
always() &&
!cancelled() &&
inputs.release_bundles &&
needs.build-lfx.result == 'success'
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
bundles-found: ${{ steps.build.outputs.bundles-found }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag }}
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: "3.13"
prune-cache: false
- name: Install the project
run: uv sync
- name: Build bundle wheels
id: build
run: |
shopt -s nullglob
bundles=(src/bundles/*/pyproject.toml)
if [ ${#bundles[@]} -eq 0 ]; then
echo "No bundles found under src/bundles/*/"
echo "bundles-found=0" >> $GITHUB_OUTPUT
exit 0
fi
mkdir -p bundles-dist
BUNDLES_DIST="$PWD/bundles-dist"
for bundle_pyproject in "${bundles[@]}"; do
bundle_dir=$(dirname "$bundle_pyproject")
echo "Building wheel for $bundle_dir"
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
done
echo "bundles-found=1" >> $GITHUB_OUTPUT
ls -la bundles-dist/
- name: Upload bundles artifact
if: steps.build.outputs.bundles-found == '1'
uses: actions/upload-artifact@v6
with:
name: dist-bundles
path: bundles-dist
test-cross-platform:
name: Test Cross-Platform Installation
needs: [build-base, build-main, build-lfx]
needs: [build-base, build-main, build-lfx, build-sdk, build-bundles]
if: |
always() &&
!cancelled() &&
@@ -618,6 +946,46 @@ jobs:
run: |
cd src/backend/base && uv publish dist/*.whl
publish-bundles:
name: Publish Bundles to PyPI
needs: [build-bundles, test-cross-platform, ci, publish-lfx]
if: |
always() &&
!cancelled() &&
inputs.release_bundles &&
needs.build-bundles.result == 'success' &&
needs.build-bundles.outputs.bundles-found == '1' &&
needs.test-cross-platform.result == 'success' &&
needs.ci.result == 'success' &&
(needs.publish-lfx.result == 'success' || needs.publish-lfx.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Download bundles artifact
uses: actions/download-artifact@v7
with:
name: dist-bundles
path: bundles-dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: "3.13"
- name: Publish bundles to PyPI
if: ${{ !inputs.dry_run }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
shopt -s nullglob
wheels=(bundles-dist/*.whl)
if [ ${#wheels[@]} -eq 0 ]; then
echo "No bundle wheels to publish."
exit 0
fi
for wheel in "${wheels[@]}"; do
echo "Publishing $wheel"
uv publish "$wheel"
done
publish-main:
name: Publish Langflow Main to PyPI
if: ${{ inputs.release_package_main }}
@@ -641,6 +1009,39 @@ jobs:
run: |
uv publish dist/*.whl
publish-sdk:
name: Publish langflow-sdk to PyPI
needs: [build-sdk, test-cross-platform, ci]
if: |
always() &&
!cancelled() &&
inputs.release_sdk &&
needs.build-sdk.result == 'success' &&
needs.ci.result == 'success' &&
(needs.test-cross-platform.result == 'success' || needs.test-cross-platform.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag }}
- name: Download SDK artifact
uses: actions/download-artifact@v7
with:
name: dist-sdk
path: src/sdk/dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: "3.13"
- name: Publish SDK to PyPI
if: ${{ !inputs.dry_run }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
make sdk_publish
publish-lfx:
name: Publish LFX to PyPI
if: ${{ inputs.release_lfx }}
@@ -739,14 +1140,16 @@ jobs:
create_release:
name: Create Release
runs-on: ubuntu-latest
needs: [determine-main-version, build-main, publish-main]
needs:
[determine-main-version, build-main, publish-main, validate-tag-format]
if: |
always() &&
!cancelled() &&
!inputs.dry_run &&
inputs.create_release &&
needs.build-main.result == 'success' &&
needs.publish-main.result == 'success'
needs.publish-main.result == 'success' &&
needs.validate-tag-format.result == 'success'
steps:
- uses: actions/download-artifact@v4
with:
+223
View File
@@ -0,0 +1,223 @@
name: Release Bundles
# Manually-triggered workflow for publishing stable `lfx-<name>` bundle wheels
# from src/bundles/* to PyPI. Bundles change infrequently, so we release them
# on demand rather than on a schedule. Re-running the workflow without a
# version bump is a no-op (PyPI rejects the duplicate upload and we tolerate
# the conflict).
on:
workflow_dispatch:
inputs:
release_tag:
description: "Git ref to build from (branch, tag, or SHA). Defaults to the workflow's checked-out ref."
required: false
type: string
default: ""
dry_run:
description: "Build and test only; skip the PyPI publish step."
required: false
type: boolean
default: false
env:
PYTHON_VERSION: "3.13"
jobs:
build-bundles:
name: Build Langflow Bundles
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
bundles-found: ${{ steps.build.outputs.bundles-found }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: ${{ env.PYTHON_VERSION }}
prune-cache: false
- name: Install the project
run: uv sync
- name: Build bundle wheels
id: build
run: |
shopt -s nullglob
bundles=(src/bundles/*/pyproject.toml)
if [ ${#bundles[@]} -eq 0 ]; then
echo "No bundles found under src/bundles/*/"
echo "bundles-found=0" >> $GITHUB_OUTPUT
exit 0
fi
mkdir -p bundles-dist
BUNDLES_DIST="$PWD/bundles-dist"
for bundle_pyproject in "${bundles[@]}"; do
bundle_dir=$(dirname "$bundle_pyproject")
echo "Building wheel for $bundle_dir"
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
done
echo "bundles-found=1" >> $GITHUB_OUTPUT
ls -la bundles-dist/
# Bundles pin lfx (e.g. lfx>=0.5.0,<0.6.0). The smoke test below
# installs the bundles in a fresh venv, so it needs an lfx wheel that
# satisfies the pin available locally — PyPI may not yet have a
# matching lfx published when bundles are released ahead of a main
# release. We build the lfx wheel from the current ref so the smoke
# test resolves against it; this artifact is not published.
- name: Build lfx wheel for smoke test
if: steps.build.outputs.bundles-found == '1'
run: |
mkdir -p lfx-dist
LFX_DIST="$PWD/lfx-dist"
(cd src/lfx && uv build --wheel --out-dir "$LFX_DIST")
ls -la lfx-dist/
- name: Upload bundles artifact
if: steps.build.outputs.bundles-found == '1'
uses: actions/upload-artifact@v6
with:
name: dist-bundles
path: bundles-dist
- name: Upload lfx artifact (smoke-test only; not published)
if: steps.build.outputs.bundles-found == '1'
uses: actions/upload-artifact@v6
with:
name: dist-lfx-smoketest
path: lfx-dist
test-install:
name: Install Smoke Test - ${{ matrix.os }} ${{ matrix.python-version }}
needs: [build-bundles]
if: needs.build-bundles.outputs.bundles-found == '1'
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
runner: ubuntu-latest
python-version: "3.10"
- os: linux
runner: ubuntu-latest
python-version: "3.12"
- os: macos
runner: macos-latest
python-version: "3.12"
- os: windows
runner: windows-latest
python-version: "3.12"
steps:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Setup UV
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
ignore-empty-workdir: true
- name: Download bundles artifact
uses: actions/download-artifact@v7
with:
name: dist-bundles
path: ./bundles-dist
- name: Download lfx artifact
uses: actions/download-artifact@v7
with:
name: dist-lfx-smoketest
path: ./lfx-dist
- name: Create fresh virtual environment
run: uv venv test-env --seed
shell: bash
- name: Install lfx + bundle wheels (Unix)
if: matrix.os != 'windows'
run: |
shopt -s nullglob
BUNDLE_WHEELS=(./bundles-dist/*.whl)
LFX_WHEELS=(./lfx-dist/*.whl)
if [ ${#BUNDLE_WHEELS[@]} -eq 0 ]; then
echo "No bundle wheels found in ./bundles-dist/"
exit 1
fi
if [ ${#LFX_WHEELS[@]} -eq 0 ]; then
echo "No lfx wheel found in ./lfx-dist/"
exit 1
fi
printf 'Installing: %s\n' "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
uv pip install --prerelease=allow --python ./test-env/bin/python "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
shell: bash
- name: Install lfx + bundle wheels (Windows)
if: matrix.os == 'windows'
run: |
shopt -s nullglob
BUNDLE_WHEELS=(./bundles-dist/*.whl)
LFX_WHEELS=(./lfx-dist/*.whl)
if [ ${#BUNDLE_WHEELS[@]} -eq 0 ]; then
echo "No bundle wheels found in ./bundles-dist/"
exit 1
fi
if [ ${#LFX_WHEELS[@]} -eq 0 ]; then
echo "No lfx wheel found in ./lfx-dist/"
exit 1
fi
printf 'Installing: %s\n' "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
shell: bash
publish-bundles:
name: Publish Bundles to PyPI
needs: [build-bundles, test-install]
if: |
always() &&
!cancelled() &&
!inputs.dry_run &&
needs.build-bundles.result == 'success' &&
needs.build-bundles.outputs.bundles-found == '1' &&
needs.test-install.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Download bundles artifact
uses: actions/download-artifact@v7
with:
name: dist-bundles
path: bundles-dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: ${{ env.PYTHON_VERSION }}
- name: Publish bundles to PyPI
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
shopt -s nullglob
wheels=(bundles-dist/*.whl)
if [ ${#wheels[@]} -eq 0 ]; then
echo "No bundle wheels to publish."
exit 0
fi
failed=0
for wheel in "${wheels[@]}"; do
echo "Publishing $wheel"
# Capture stderr so we can tolerate "already exists" without failing
# the whole run — re-running this workflow without bumping a bundle
# version should be a no-op for that bundle.
if ! err=$(uv publish "$wheel" 2>&1); then
echo "$err"
if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
echo "Skipping $wheel: already published."
else
echo "Publish failed for $wheel"
failed=1
fi
fi
done
if [ "$failed" = "1" ]; then
exit 1
fi
+159 -7
View File
@@ -36,6 +36,10 @@ on:
description: "Tag for the nightly LFX build"
required: false
type: string
nightly_tag_sdk:
description: "Tag for the nightly SDK build"
required: false
type: string
push_to_registry:
description: "Whether to push images to registries. Set to false for testing builds without publishing."
required: true
@@ -72,6 +76,20 @@ jobs:
- name: Install LFX dependencies
run: cd src/lfx && uv sync
- name: Verify SDK Nightly Name and Version
if: ${{ inputs.build_lfx }}
run: |
name=$(grep '^name = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
version=$(grep '^version = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
if [ "$name" != "langflow-sdk-nightly" ]; then
echo "Name $name does not match langflow-sdk-nightly. Exiting the workflow."
exit 1
fi
if [ "v$version" != "${{ inputs.nightly_tag_sdk }}" ]; then
echo "Version v$version does not match nightly tag ${{ inputs.nightly_tag_sdk }}. Exiting the workflow."
exit 1
fi
- name: Verify Nightly Name and Version
id: verify
run: |
@@ -90,6 +108,10 @@ jobs:
version=$(echo $version | sed 's/^v//')
echo "version=$version" >> $GITHUB_OUTPUT
- name: Build langflow-sdk for distribution
run: |
make sdk_build args="--wheel"
- name: Build LFX for distribution
run: |
cd src/lfx
@@ -99,12 +121,21 @@ jobs:
- name: Test LFX CLI
run: |
cd src/lfx
uv pip install dist/*.whl --force-reinstall
uv run lfx --help
# Use a clean venv and install both wheels together so uv can resolve
# the local SDK dependency without falling back to PyPI.
uv venv test-env --seed
uv pip install --python ./test-env/bin/python --force-reinstall ../sdk/dist/*.whl dist/*.whl
./test-env/bin/lfx --help
echo "LFX CLI test completed successfully"
# PyPI publishing moved to after cross-platform testing
- name: Upload langflow-sdk Artifact
uses: actions/upload-artifact@v6
with:
name: dist-nightly-sdk
path: src/sdk/dist
- name: Upload LFX Artifact
uses: actions/upload-artifact@v6
with:
@@ -163,15 +194,18 @@ jobs:
name_without_extras=$(echo $name | sed 's/\[.*\]//')
if [ "$name_without_extras" != "langflow-base-nightly" ]; then
echo "Name $name_without_extras does not match langflow-base-nightly. Exiting the workflow."
echo "skipped=true" >> $GITHUB_OUTPUT
exit 1
fi
if [ "$version" != "${{ inputs.nightly_tag_base }}" ]; then
echo "Version $version does not match nightly tag ${{ inputs.nightly_tag_base }}. Exiting the workflow."
echo "skipped=true" >> $GITHUB_OUTPUT
exit 1
fi
# Strip the leading `v` from the version
version=$(echo $version | sed 's/^v//')
echo "version=$version" >> $GITHUB_OUTPUT
echo "skipped=false" >> $GITHUB_OUTPUT
- name: Build Langflow Base for distribution
run: |
@@ -263,8 +297,8 @@ jobs:
- name: Verify Nightly Name and Version
id: verify
run: |
name=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $1}')
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}')
name=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $1}')
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}')
if [ "$name" != "langflow-nightly" ]; then
echo "Name $name does not match langflow-nightly. Exiting the workflow."
exit 1
@@ -300,13 +334,50 @@ jobs:
echo "Server terminated successfully"
fi
- name: Prepare Langflow Main artifact
run: |
shopt -s nullglob
MAIN_WHEELS=(dist/langflow_nightly-*.whl)
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
echo "Expected exactly one langflow-nightly wheel in dist/, found ${#MAIN_WHEELS[@]}"
ls -la dist/
exit 1
fi
rm -rf main-dist
mkdir -p main-dist
cp "${MAIN_WHEELS[0]}" main-dist/
- name: Build bundle wheels for cross-platform test
run: |
shopt -s nullglob
bundles=(src/bundles/*/pyproject.toml)
if [ ${#bundles[@]} -eq 0 ]; then
echo "No bundles found under src/bundles/*/"
exit 1
fi
rm -rf bundles-dist
mkdir -p bundles-dist
BUNDLES_DIST="$PWD/bundles-dist"
for bundle_pyproject in "${bundles[@]}"; do
bundle_dir=$(dirname "$bundle_pyproject")
echo "Building wheel for $bundle_dir"
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
done
ls -la bundles-dist/
# PyPI publishing moved to after cross-platform testing
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
name: dist-nightly-main
path: dist
path: main-dist
- name: Upload bundles artifact
uses: actions/upload-artifact@v6
with:
name: dist-nightly-bundles
path: bundles-dist
test-cross-platform:
name: Test Cross-Platform Installation
@@ -316,10 +387,12 @@ jobs:
base-artifact-name: "dist-nightly-base"
main-artifact-name: "dist-nightly-main"
lfx-artifact-name: "dist-nightly-lfx"
sdk-artifact-name: "dist-nightly-sdk"
bundles-artifact-name: "dist-nightly-bundles"
publish-nightly-lfx:
name: Publish LFX Nightly to PyPI
needs: [build-nightly-lfx, test-cross-platform]
needs: [build-nightly-lfx, test-cross-platform, publish-nightly-sdk]
if: ${{ inputs.build_lfx }}
runs-on: ubuntu-latest
steps:
@@ -345,6 +418,34 @@ jobs:
run: |
make lfx_publish
publish-nightly-sdk:
name: Publish SDK Nightly to PyPI
needs: [build-nightly-lfx, test-cross-platform]
if: ${{ inputs.build_lfx }}
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v6
with:
ref: ${{ inputs.nightly_tag_release }}
persist-credentials: true
- name: Download langflow-sdk artifact
uses: actions/download-artifact@v7
with:
name: dist-nightly-sdk
path: src/sdk/dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: "3.13"
- name: Publish SDK to PyPI
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
make sdk_publish
publish-nightly-base:
name: Publish Langflow Base Nightly to PyPI
needs: [build-nightly-base, test-cross-platform, publish-nightly-lfx]
@@ -374,9 +475,60 @@ jobs:
run: |
make publish base=true
publish-nightly-bundles:
name: Publish Langflow Bundles Nightly to PyPI
# langflow-nightly pins each `lfx-<name>-nightly==<dev>` exactly, so the
# bundles must be on PyPI before main is published (publish-nightly-main
# gates on this job). They depend on `lfx-nightly`, so gate on the lfx
# publish too (or skip it when lfx isn't being rebuilt).
needs: [build-nightly-main, test-cross-platform, publish-nightly-lfx]
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && (needs.publish-nightly-lfx.result == 'success' || inputs.build_lfx == false) }}
runs-on: ubuntu-latest
steps:
- name: Download bundles artifact
uses: actions/download-artifact@v7
with:
name: dist-nightly-bundles
path: bundles-dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: "3.13"
- name: Publish bundles to PyPI
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
shopt -s nullglob
wheels=(bundles-dist/*.whl)
if [ ${#wheels[@]} -eq 0 ]; then
echo "No bundle wheels to publish."
exit 1
fi
failed=0
for wheel in "${wheels[@]}"; do
echo "Publishing $wheel"
# Capture stderr so a re-run without a version bump is a no-op for
# an already-published bundle instead of failing the whole job.
if ! err=$(uv publish "$wheel" 2>&1); then
echo "$err"
if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
echo "Skipping $wheel: already published."
else
echo "Publish failed for $wheel"
failed=1
fi
fi
done
if [ "$failed" = "1" ]; then
exit 1
fi
publish-nightly-main:
name: Publish Langflow Main Nightly to PyPI
needs: [build-nightly-main, test-cross-platform, publish-nightly-base]
needs: [build-nightly-main, test-cross-platform, publish-nightly-base, publish-nightly-bundles]
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && needs.publish-nightly-base.result == 'success' && needs.publish-nightly-bundles.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
+175
View File
@@ -0,0 +1,175 @@
# Advisory-only check: flags PRs that change source code (.py / .ts / .tsx)
# without touching any test file. It NEVER fails and is NOT a required check,
# so it cannot block other jobs or the merge — it only posts a sticky PR
# comment + job summary so the author knows tests appear to be missing.
name: Test Coverage Advisor
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# Dedicated concurrency group so this never cancels or queues behind CI.
concurrency:
group: test-coverage-advisor-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
advise:
name: Test Coverage Advisor
# Skip drafts to avoid noise; they re-run on "ready_for_review".
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
# Full history so merge-base against the PR target works.
fetch-depth: 0
- name: Detect missing tests
id: detect
shell: bash
run: |
set -euo pipefail
BASE_REF="${{ github.event.pull_request.base.ref }}"
git fetch --no-tags --depth=50 origin "$BASE_REF" || true
MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF" || git rev-parse HEAD~1)
echo "BASE_REF=$BASE_REF"
echo "MERGE_BASE=$MERGE_BASE"
CHANGED=$(git diff --name-only --diff-filter=ACMR "$MERGE_BASE"...HEAD || true)
echo "=== Changed files ==="
echo "$CHANGED"
# --- Backend Python ---------------------------------------------
# Source: .py under src/backend or src/lfx, excluding tests,
# migrations and dunder/init plumbing.
PY_SRC=$(echo "$CHANGED" \
| grep -E '^(src/backend|src/lfx)/.*\.py$' \
| grep -Ev '(^|/)tests?/' \
| grep -Ev '(^|/)(test_[^/]+|conftest)\.py$' \
| grep -Ev '/alembic/versions/' \
| grep -Ev '/__init__\.py$' \
|| true)
PY_TEST=$(echo "$CHANGED" \
| grep -E '\.py$' \
| grep -E '((^|/)tests?/|(^|/)test_[^/]+\.py$|(^|/)conftest\.py$)' \
|| true)
# --- Frontend TS/TSX --------------------------------------------
# Source: .ts/.tsx under src/frontend/src, excluding *.test.* /
# *.spec.* / __tests__ and type declaration files.
FE_SRC=$(echo "$CHANGED" \
| grep -E '^src/frontend/src/.*\.(ts|tsx)$' \
| grep -Ev '\.(test|spec)\.(ts|tsx)$' \
| grep -Ev '(^|/)__tests__/' \
| grep -Ev '\.d\.ts$' \
|| true)
FE_TEST=$(echo "$CHANGED" \
| grep -E '\.(ts|tsx)$' \
| grep -E '(^src/frontend/tests/|\.(test|spec)\.(ts|tsx)$|(^|/)__tests__/)' \
|| true)
need_be=false
need_fe=false
[ -n "$PY_SRC" ] && [ -z "$PY_TEST" ] && need_be=true
[ -n "$FE_SRC" ] && [ -z "$FE_TEST" ] && need_fe=true
{
echo "need_be=$need_be"
echo "need_fe=$need_fe"
} >> "$GITHUB_OUTPUT"
# Multiline outputs for the comment body.
{
echo "py_src<<EOF"
echo "$PY_SRC"
echo "EOF"
echo "fe_src<<EOF"
echo "$FE_SRC"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build advisory message
id: msg
shell: bash
run: |
set -euo pipefail
NEED_BE="${{ steps.detect.outputs.need_be }}"
NEED_FE="${{ steps.detect.outputs.need_fe }}"
BODY_FILE="$(mktemp)"
echo '<!-- test-coverage-advisor -->' > "$BODY_FILE"
if [ "$NEED_BE" != "true" ] && [ "$NEED_FE" != "true" ]; then
{
echo '### ✅ Test Coverage Advisor'
echo
echo 'No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉'
echo
echo '> _Advisory check only — never blocks merge._'
} >> "$BODY_FILE"
else
{
echo '### ⚠️ Test Coverage Advisor — tests appear to be missing'
echo
echo 'This PR changes source code but does **not** add or modify any test files.'
echo 'This is an **advisory** check: it will **not** block this PR or any other job —'
echo 'please add tests if the change is testable.'
echo
} >> "$BODY_FILE"
if [ "$NEED_BE" = "true" ]; then
{
echo '#### Backend (Python) — no `test_*.py` / `tests/` change found'
echo '```'
echo '${{ steps.detect.outputs.py_src }}'
echo '```'
} >> "$BODY_FILE"
fi
if [ "$NEED_FE" = "true" ]; then
{
echo '#### Frontend (TS/TSX) — no `*.test.*` / `*.spec.*` / `tests/` change found'
echo '```'
echo '${{ steps.detect.outputs.fe_src }}'
echo '```'
} >> "$BODY_FILE"
fi
{
echo
echo '> _Advisory check only — it always passes and is never a required status._'
} >> "$BODY_FILE"
fi
# Mirror into the run's Job Summary (works even on fork PRs).
cat "$BODY_FILE" >> "$GITHUB_STEP_SUMMARY"
echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
- name: Find existing advisory comment
id: find
uses: peter-evans/find-comment@v3
continue-on-error: true
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: "<!-- test-coverage-advisor -->"
- name: Upsert advisory comment
uses: peter-evans/create-or-update-comment@v4
# Fork PRs get a read-only token — don't fail the run if so.
continue-on-error: true
with:
comment-id: ${{ steps.find.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body-path: ${{ steps.msg.outputs.body_file }}
edit-mode: replace
- name: Always succeed
# Explicit: this job never blocks anything.
run: 'echo "Advisory check complete — this status is informational only."'
+54 -6
View File
@@ -69,7 +69,8 @@ env:
# Define the directory where Playwright browsers will be installed.
# This path is used for caching across workflows
PLAYWRIGHT_BROWSERS_PATH: "ms-playwright"
PLAYWRIGHT_VERSION: "1.57.0"
PLAYWRIGHT_VERSION: "1.59.1"
LANGFLOW_FEATURE_WXO_DEPLOYMENTS: "true"
jobs:
determine-test-suite:
@@ -224,8 +225,22 @@ jobs:
id: setup-node
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: ./src/frontend/package-lock.json
# npm caching is decoupled from setup-node so a transient cache-service
# hiccup (frequent on Windows runners) never fails the job. See #25 / #1.
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
uses: actions/cache@v5
continue-on-error: true
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-npm-${{ env.NODE_VERSION }}-${{ hashFiles('src/frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-${{ env.NODE_VERSION }}-
- name: Install Frontend Dependencies
run: npm ci
@@ -294,8 +309,23 @@ jobs:
id: setup-node
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: ./src/frontend/package-lock.json
# npm caching is decoupled from setup-node so a transient cache-service
# hiccup (frequent on Windows runners) never fails the job. See #25 / #1.
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
uses: actions/cache@v5
continue-on-error: true
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-npm-${{ env.NODE_VERSION }}-${{ hashFiles('src/frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-${{ env.NODE_VERSION }}-
- name: Install Frontend Dependencies
run: npm ci
working-directory: ./src/frontend
@@ -304,6 +334,7 @@ jobs:
- name: Cache Playwright Browsers
id: cache-playwright
uses: actions/cache@v5
continue-on-error: true
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ env.PLAYWRIGHT_VERSION }}-chromium-${{ runner.os }}
@@ -341,7 +372,12 @@ jobs:
- name: Execute Playwright Tests
uses: nick-fields/retry@v3
with:
timeout_minutes: 12
# Bumped from 12 → 18 minutes. Shard 38 carries all four
# starter-projects-shardN tests + the sentiment streaming test;
# iterating 8 templates per test under Linux nightly load
# consistently runs past 12 minutes once a single iteration
# triggers a per-test Playwright retry.
timeout_minutes: 18
max_attempts: 2
command: |
cd src/frontend
@@ -354,11 +390,23 @@ jobs:
- name: Upload Test Results
if: always()
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: blob-report-${{ matrix.shardIndex }}
path: src/frontend/blob-report
retention-days: 1
overwrite: true
- name: Upload Playwright Coverage Artifact
if: always() && hashFiles('src/frontend/coverage/playwright/**') != ''
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-coverage-${{ matrix.shardIndex }}
path: src/frontend/coverage/playwright
retention-days: 7
overwrite: true
- name: Cleanup UV Cache
run: uv cache prune --ci
+3 -1
View File
@@ -19,12 +19,14 @@ repos:
language: system
types_or: [python, pyi]
args: [--fix]
exclude: ^docs/.*/API-Reference/python-examples/
- id: ruff-format
name: ruff format
entry: uv run ruff format
language: system
types_or: [python, pyi]
args: [--config, pyproject.toml]
exclude: ^docs/.*/API-Reference/python-examples/
- id: validate-migrations
name: Validate Alembic Migrations (Expand-Contract)
entry: python src/backend/base/langflow/alembic/migration_validator.py
@@ -45,7 +47,7 @@ repos:
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
exclude: '(^docs/|^SECURITY\.md$|src/lfx/src/lfx/_assets/component_index\.json$)'
exclude: '(^docs/|^SECURITY\.md$|src/lfx/src/lfx/_assets/component_index\.json$|src/lfx/README\.md$)'
- repo: local
hooks:
- id: local-biome-check
+3390 -792
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -24,7 +24,9 @@
"--reload-include",
"./src/lfx/*",
"--reload-exclude",
"*.db*"
"*.db*",
"--reload-exclude",
"./tmp_toolguard/*"
],
"jinja": true,
"justMyCode": false,
+12
View File
@@ -0,0 +1,12 @@
{
"settingsInheritedFrom": "whitesource-config/whitesource-config@master",
"scanSettings": {
"dependencyScopes": [
"prod"
],
"baseBranches": [
"main",
"release-*"
]
}
}
+147
View File
@@ -0,0 +1,147 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
> **Before you write code, read [docs/agents/PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md).** Most off-narrative work comes from skipping it.
## What Langflow is
Langflow is a **visual flow builder first**. Users drag components onto a canvas, wire them together, and either run the resulting flow in the Playground, deploy it as an API, or expose it as an MCP server. Saved flows are persistent JSON artifacts running in production — backwards compatibility for components is non-negotiable.
The repo is three Python packages and one frontend:
- **`lfx`** (`src/lfx/`): the executor core. Component base classes, the graph engine, and built-in components live here. Should not depend on `langflow` or `langflow-base` — see [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md) for the ~14 known violations to fix-not-extend.
- **`langflow-base`** (`src/backend/base/langflow/`): the platform. FastAPI routes, auth, persistence, alembic, services. May import `lfx`.
- **`langflow`**: the integration distribution that ships everything together.
- **`frontend`** (`src/frontend/`): React 19 + TypeScript + Vite + Zustand + `@xyflow/react`. Talks to the backend over HTTP/WebSocket only.
Dependencies flow one way: `frontend → langflow → langflow-base → lfx`. See [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md).
## Non-negotiable tenets
These are extracted from [PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md). The full file has the rest; these are the ones every change must respect.
1. **Flows are user artifacts.** Component class names, `name` attributes, input/output names, and input types are frozen once shipped. Mark old components `legacy=True` instead of editing.
2. **Every backend feature must land on the canvas.** If it can't be a component or a property of one, it's an SDK feature and belongs in `lfx`, not `langflow-base`.
3. **Components are the unit of work.** Don't add a route, store, or service unless a component or UI page consumes it.
4. **Visible data flow beats clever magic.** Pass data through inputs and outputs. No hidden globals or side-channel state.
5. **Composition over capability.** One job per component. Split before you add a tenth input.
6. **It is not a fix without evidence.** Adding error handling, retries, type widening, or skipping a flaky test does not constitute a fix.
## Documentation map
Read the file that matches your task before you write code.
| Topic | File | When to read |
|---|---|---|
| Project story, design tenets | [docs/agents/PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md) | Before any non-trivial change |
| Package boundaries, where code goes, API versioning | [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md) | Before adding a file or endpoint |
| Component dev: scope, breaking changes, conventions, icons | [docs/agents/COMPONENTS.md](./docs/agents/COMPONENTS.md) | Before adding or editing a component |
| User-facing contracts: flow JSON, REST API, MCP, env vars | [docs/agents/CONTRACTS.md](./docs/agents/CONTRACTS.md) | Before changing anything user-visible |
| Test patterns, fixtures, mocking policy | [docs/agents/TESTING.md](./docs/agents/TESTING.md) | Before writing or changing tests |
| Don't/do rules, "fixes that aren't," before-claiming-done checklist | [docs/agents/ANTI-PATTERNS.md](./docs/agents/ANTI-PATTERNS.md) | Before claiming work is done |
## Prerequisites
- **Python:** 3.10–3.13
- **uv:** ≥0.4 (always use `uv run` for Python commands)
- **Node.js:** ≥20.19.0 (v22.12 LTS recommended)
- **npm:** v10.9+
- **make:** for build coordination
## Common commands
### Development setup
```bash
make init # Install all dependencies + pre-commit hooks
make run_cli # Build and run Langflow (http://localhost:7860)
make run_clic # Clean build and run (use when frontend issues occur)
```
### Development mode (hot reload)
```bash
make backend # FastAPI on port 7860 (terminal 1)
make frontend # Vite dev server on port 3000 (terminal 2)
```
For component development with dynamic loading:
```bash
LFX_DEV=1 make backend # Load all components dynamically
LFX_DEV=mistral,openai make backend # Load only specific modules
```
### Code quality
```bash
make format_backend # Format Python (ruff) — run FIRST before lint
make format_frontend # Format TypeScript (biome)
make format # Both
make lint # mypy type checking
```
### Testing
```bash
make unit_tests # Backend unit tests (pytest, parallel)
make unit_tests async=false # Sequential
uv run pytest path/to/test.py # Single test file
uv run pytest path/to/test.py::test_name # Single test
make test_frontend # Jest unit tests
make tests_frontend # Playwright e2e tests
# lfx tests specifically — run uv sync inside src/lfx (not src/lfx/src/lfx)
cd src/lfx && uv sync && uv run pytest
# Sub-package tests (langflow-base, lfx) — sync that package's dev group first,
# otherwise dev-only deps like fakeredis stay uninstalled.
uv sync --group dev --package langflow-base
```
See [docs/agents/TESTING.md](./docs/agents/TESTING.md) for fixtures, base classes, and the graph testing pattern.
### Database migrations
```bash
make alembic-revision message="Description" # Create migration
make alembic-upgrade # Apply migrations
make alembic-downgrade # Rollback one version
```
Never edit a past migration. Run `test_database.py` sequentially after any DB change.
### Version management
```bash
make patch v=1.5.0 # Update version across all packages
```
## Pre-commit workflow
Pre-commit hooks run ruff and biome automatically on `git commit`, so manual formatting isn't required. To avoid an extra commit cycle:
1. Run `make format_backend` once before staging — fixes most ruff issues up front.
2. Run `uv run git commit` (the `uv run` ensures pre-commit finds the right Python).
3. If you touched backend code, run `make unit_tests` locally for faster feedback than CI.
## Pull request guidelines
- Follow [semantic commit conventions](https://www.conventionalcommits.org/).
- Reference issues fixed (`Fixes #1234`).
- Target the active `release-X.Y.Z` branch, not `main`. See [CONTRIBUTING.md](./CONTRIBUTING.md).
- Don't push or open PRs without explicit user direction.
- No "Generated with Claude Code" / `Co-Authored-By: Claude` trailers.
- No test-plan checklists or Jira links in PR descriptions.
## Documentation
Documentation uses Docusaurus and lives in `docs/`:
```bash
cd docs
yarn install
yarn start # Dev server on port 3000 (3001 if 3000 is in use)
```
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
@AGENTS.md
This project uses [AGENTS.md](https://agents.md/) as the standard for providing context to AI coding agents. The `@AGENTS.md` import above tells Claude Code to load `AGENTS.md` automatically; other tools that natively support `AGENTS.md` will pick it up directly.
+752
View File
@@ -0,0 +1,752 @@
---
version: alpha
name: Langflow
description: Visual AI workflow builder for composing LLM-powered applications
colors:
# Core UI palette
primary: "#000000"
primary-foreground: "#FFFFFF"
primary-hover: "#27272A"
background: "#FFFFFF"
foreground: "#000000"
muted: "#F4F4F5"
muted-foreground: "#71717A"
border: "#E4E4E7"
input: "#E4E4E7"
ring: "#000000"
card: "#FFFFFF"
card-foreground: "#000000"
popover: "#FFFFFF"
popover-foreground: "#000000"
secondary: "#FFFFFF"
secondary-foreground: "#27272A"
secondary-hover: "#E4E4E7"
accent: "#F4F4F5"
accent-foreground: "#000000"
placeholder: "#A1A1AA"
placeholder-foreground: "#A1A1AA"
tooltip: "#000000"
tooltip-foreground: "#FFFFFF"
# Semantic status
destructive: "#DC2626"
destructive-foreground: "#FFFFFF"
error: "#991B1B"
error-background: "#FEF2F2"
error-foreground: "#991B1B"
success-background: "#F0FDF4"
success-foreground: "#14532D"
info-background: "#F0F4FD"
info-foreground: "#141653"
warning: "#FCE68A"
warning-foreground: "#18181B"
warning-text: "#FFFFFF"
# Status indicators
status-red: "#EF4444"
status-green: "#4ADE80"
status-yellow: "#EAB308"
status-blue: "#2563EB"
status-gray: "#6B7280"
# Accent families (background / foreground pairs)
accent-emerald: "#D1F9E4"
accent-emerald-foreground: "#047857"
accent-emerald-hover: "#A7F3D0"
accent-indigo: "#E0E7FF"
accent-indigo-foreground: "#4F46E5"
accent-pink: "#FCE8F3"
accent-pink-foreground: "#BE185D"
accent-amber: "#FCE68A"
accent-amber-foreground: "#B45309"
# Standalone accent references (not bg/fg pairs -- used for text, icons, links)
accent-blue: "#3B82F6"
accent-blue-muted: "#D5E4FF"
accent-blue-muted-foreground: "#51A2FF"
accent-purple-foreground: "#9333EA"
accent-purple-muted: "#EDD5FF"
accent-purple-muted-foreground: "#C27AFF"
accent-red-foreground: "#DC2626"
# Indigo scale
high-indigo: "#4338CA"
medium-indigo: "#6366F1"
low-indigo: "#E0E7FF"
# Canvas and node system
canvas: "#F4F4F5"
canvas-dot: "#A1A1AA"
node-selected: "#4F46E5"
node-ring: "#E4E4E7"
connection: "#555555"
hover: "#F2F4F5"
selected: "#2196F3"
# Code blocks
code-background: "#18181B"
code-foreground: "#E4E4E7"
# Utility
hard-zinc: "#51515A"
smooth-red: "#FDE1E1"
# Note colors (sticky notes on canvas)
note-amber: "#FCE68A"
note-neutral: "#E4E4E7"
note-rose: "#FECDD3"
note-blue: "#BFDBFE"
note-lime: "#D9F99D"
# Beta feature badge
beta-background: "#DBEAFE"
beta-foreground: "#2563EB"
# Data type colors (node port type indicators -- light mode)
# In light mode: base = saturated, foreground = light tint
datatype-pink: "#DB2777"
datatype-pink-foreground: "#FBE6F2"
datatype-rose: "#E11D48"
datatype-rose-foreground: "#FFE4E6"
datatype-yellow: "#CA8A04"
datatype-yellow-foreground: "#FEF9C3"
datatype-blue: "#2563EB"
datatype-blue-foreground: "#DBEAFE"
datatype-gray: "#4B5563"
datatype-gray-foreground: "#F3F4F6"
datatype-lime: "#65A30D"
datatype-lime-foreground: "#ECFCCB"
datatype-red: "#DC2626"
datatype-red-foreground: "#FEE2E2"
datatype-violet: "#7C3AED"
datatype-violet-foreground: "#EDE9FE"
datatype-emerald: "#059669"
datatype-emerald-foreground: "#D1FAE5"
datatype-fuchsia: "#C026D3"
datatype-fuchsia-foreground: "#FAE8FF"
datatype-purple: "#9333EA"
datatype-purple-foreground: "#F3E8FF"
datatype-cyan: "#0891B2"
datatype-cyan-foreground: "#CFFAFE"
datatype-indigo: "#4F46E5"
datatype-indigo-foreground: "#E0E7FF"
datatype-orange: "#EA580C"
datatype-orange-foreground: "#FFEDD5"
# Gradient / neon accents (flow gradients, tool-mode effects)
neon-fuchsia: "#FF3276"
digital-orchid: "#F480FF"
plasma-purple: "#7C3AED"
electric-blue: "#3B10FD"
holo-frost: "#9AF3FD"
terminal-green: "#9AFDA9"
cosmic-void: "#1A0250"
# Brand-specific UI
component-icon: "#D8598A"
flow-icon: "#2F67D0"
chat-bot-icon: "#AFE6EF"
chat-user-icon: "#AFACE9"
build-trigger: "#DC735B"
chat-trigger: "#5C8BE1"
chat-send: "#000000"
ice: "#31A3CC"
discord: "#5765F2"
# Sidebar (dark mode only -- light mode inherits core tokens)
sidebar-background: "#18181B"
sidebar-foreground: "#F4F4F5"
sidebar-primary: "#1D4ED8"
sidebar-primary-foreground: "#FFFFFF"
sidebar-accent: "#27272A"
sidebar-accent-foreground: "#F4F4F5"
sidebar-border: "#27272A"
sidebar-ring: "#3B82F6"
typography:
headline-lg:
fontFamily: Inter
fontSize: 30px
fontWeight: 700
lineHeight: 1.2
letterSpacing: -0.02em
headline-md:
fontFamily: Inter
fontSize: 24px
fontWeight: 600
lineHeight: 1.3
headline-sm:
fontFamily: Inter
fontSize: 20px
fontWeight: 600
lineHeight: 1.4
body-lg:
fontFamily: Inter
fontSize: 16px
fontWeight: 400
lineHeight: 1.6
body-md:
fontFamily: Inter
fontSize: 14px
fontWeight: 400
lineHeight: 1.5
body-sm:
fontFamily: Inter
fontSize: 13px
fontWeight: 400
lineHeight: 1.5
body-xs:
fontFamily: Inter
fontSize: 12px
fontWeight: 400
lineHeight: 1.5
body-xxs:
fontFamily: Inter
fontSize: 11px
fontWeight: 400
lineHeight: 1.4
label-lg:
fontFamily: Inter
fontSize: 14px
fontWeight: 500
lineHeight: 1.4
label-md:
fontFamily: Inter
fontSize: 13px
fontWeight: 500
lineHeight: 1.4
label-sm:
fontFamily: Inter
fontSize: 12px
fontWeight: 500
lineHeight: 1.3
label-xs:
fontFamily: Inter
fontSize: 11px
fontWeight: 500
lineHeight: 1.3
code-md:
fontFamily: JetBrains Mono
fontSize: 14px
fontWeight: 400
lineHeight: 1.6
code-sm:
fontFamily: JetBrains Mono
fontSize: 12px
fontWeight: 400
lineHeight: 1.5
display:
fontFamily: Chivo
fontSize: 36px
fontWeight: 700
lineHeight: 1.1
rounded:
none: 0px
sm: 4px
md: 6px
lg: 8px
xl: 12px
full: 9999px
spacing:
unit: 4px
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
3xl: 64px
components:
# Buttons
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-default-hover:
backgroundColor: "{colors.primary-hover}"
button-secondary:
backgroundColor: "{colors.muted}"
textColor: "{colors.secondary-foreground}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-secondary-hover:
backgroundColor: "{colors.secondary-hover}"
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.destructive-foreground}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-outline:
backgroundColor: transparent
textColor: "{colors.foreground}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-outline-hover:
backgroundColor: "{colors.input}"
button-ghost:
backgroundColor: transparent
textColor: "{colors.foreground}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-ghost-hover:
backgroundColor: "{colors.accent}"
button-warning:
backgroundColor: "{colors.warning-foreground}"
textColor: "{colors.warning-text}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 8px 16px
button-link:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.label-md}"
button-icon-md:
backgroundColor: transparent
rounded: "{rounded.md}"
padding: 6px
button-icon-sm:
backgroundColor: transparent
rounded: "{rounded.md}"
padding: 2px
# Cards
card-default:
backgroundColor: "{colors.muted}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
card-title:
textColor: "{colors.foreground}"
typography: "{typography.label-lg}"
card-description:
textColor: "{colors.muted-foreground}"
typography: "{typography.body-md}"
# Inputs
input-field:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
typography: "{typography.body-md}"
rounded: "{rounded.lg}"
height: 40px
padding: 0 12px
input-field-hover:
backgroundColor: "{colors.background}"
input-node:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
typography: "{typography.body-md}"
rounded: "{rounded.lg}"
padding: 2px 12px
# Badges
badge-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-secondary:
backgroundColor: "{colors.muted}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-emerald:
backgroundColor: "{colors.accent-emerald}"
textColor: "{colors.accent-emerald-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.destructive-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-pink:
backgroundColor: "{colors.accent-pink}"
textColor: "{colors.accent-pink-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-purple:
backgroundColor: "{colors.background}"
textColor: "{colors.accent-purple-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
badge-error:
backgroundColor: "{colors.error-background}"
textColor: "{colors.error-foreground}"
rounded: "{rounded.full}"
padding: 0 10px
# Tooltips
tooltip-default:
backgroundColor: "{colors.popover}"
textColor: "{colors.popover-foreground}"
typography: "{typography.body-md}"
rounded: "{rounded.md}"
padding: 6px 12px
# Dialogs
dialog-content:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.xl}"
padding: 24px
dialog-title:
textColor: "{colors.foreground}"
typography: "{typography.headline-sm}"
dialog-description:
textColor: "{colors.muted-foreground}"
typography: "{typography.body-md}"
# Select / Dropdown
select-trigger:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.body-md}"
rounded: "{rounded.lg}"
height: 32px
padding: 8px 16px
select-content:
backgroundColor: "{colors.popover}"
textColor: "{colors.popover-foreground}"
rounded: "{rounded.lg}"
padding: 4px
select-item:
backgroundColor: transparent
textColor: "{colors.foreground}"
typography: "{typography.body-md}"
rounded: "{rounded.sm}"
padding: 6px 32px
select-item-hover:
backgroundColor: "{colors.accent}"
# Tabs
tab-trigger:
backgroundColor: transparent
textColor: "{colors.muted-foreground}"
typography: "{typography.label-md}"
padding: 12px 6px
tab-trigger-active:
textColor: "{colors.primary}"
# Form controls
checkbox:
backgroundColor: transparent
textColor: "{colors.primary}"
rounded: "{rounded.sm}"
size: 16px
checkbox-checked:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
switch-track:
backgroundColor: "{colors.input}"
rounded: "{rounded.full}"
width: 44px
height: 24px
switch-track-checked:
backgroundColor: "{colors.primary}"
switch-thumb:
backgroundColor: "{colors.background}"
rounded: "{rounded.full}"
size: 16px
# Alerts
alert-default:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 16px
alert-destructive:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
padding: 16px
# Canvas nodes
node-card:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
node-card-selected:
backgroundColor: "{colors.background}"
node-toolbar:
backgroundColor: "{colors.background}"
rounded: "{rounded.xl}"
padding: 4px
node-toolbar-button:
backgroundColor: transparent
rounded: "{rounded.md}"
padding: 6px
# Sidebar
sidebar-menu-button:
backgroundColor: transparent
textColor: "{colors.secondary-foreground}"
typography: "{typography.body-md}"
rounded: "{rounded.lg}"
height: 32px
padding: 8px
sidebar-menu-button-active:
backgroundColor: "{colors.accent}"
textColor: "{colors.accent-foreground}"
sidebar-menu-button-hover:
backgroundColor: "{colors.accent}"
---
## Overview
Langflow is a visual AI workflow builder where users compose LLM-powered applications by dragging, connecting, and configuring modular components on an infinite canvas. The design language is that of a professional developer tool -- clean, information-dense, and deliberately restrained. Color is earned, not decorative; every hue in the system carries semantic meaning.
The aesthetic sits between a code editor and a node-based creative tool. The core UI is strictly monochromatic -- black primary on white, with a warm zinc-gray scale for layering. This neutral foundation lets the **data type color system** (14 distinct hues encoding connection compatibility) and **accent families** (emerald for success, indigo for selection, pink for components) communicate meaning without competing for attention. The result is an interface that feels focused and engineered: complexity emerges from the user's composition, not from the UI itself.
Light and dark themes are fully supported via class-based toggling (`.dark` on root). The dark theme uses a deep zinc-black (#18181B) as its foundation, not a tinted dark blue, keeping the neutral character consistent across both modes.
## Colors
The palette is built in concentric layers: a monochromatic core, semantic accent families, status colors, and the domain-specific data type system.
### Core Palette
- **Primary (#000000):** Solid black for primary buttons, text, focus rings, and tooltips. Inverts to white (#FFFFFF) in dark mode. This is the only "strong" color in structural UI.
- **Background (#FFFFFF):** Pure white for cards, popovers, dialogs, and content areas. Dark mode uses #18181B (zinc-900).
- **Muted (#F4F4F5):** Light zinc-gray for secondary surfaces -- canvas background, card fills, inactive states. Dark mode: #27272A.
- **Muted Foreground (#71717A):** Medium gray for captions, descriptions, metadata, and secondary text.
- **Border (#E4E4E7):** Subtle zinc border shared by inputs, cards, dividers, and node rings. Dark mode: #3F3F46.
- **Placeholder (#A1A1AA):** Input placeholder text and canvas grid dots.
### Accent Families
Accents that serve as **background / foreground pairs** for badges, tags, and contextual surfaces. Both colors in each pair must meet WCAG AA contrast (4.5:1):
- **Emerald** (#D1F9E4 / #047857): Success, completion, enabled states, "built" indicators. Hover: #A7F3D0.
- **Indigo** (#E0E7FF / #4F46E5): Node selection, active filters, focus indicators. Also used for the indigo scale (high: #4338CA, medium: #6366F1, low: #E0E7FF).
- **Pink** (#FCE8F3 / #BE185D): Component icons, feminine-coded accents, static badges.
- **Amber** (#FCE68A / #B45309): Warnings, caution badges. The background is a pale yellow; the foreground is burnt orange.
**Standalone accent references** (used for text color, icons, and links -- not as bg/fg pairs):
- **Blue** (#3B82F6): Links, informational highlights, chat triggers, sidebar primary. Always used on a white/dark background, never as a surface fill. Muted variant (#D5E4FF) available for subtle backgrounds.
- **Purple** (#9333EA): AI/agent-related highlights. Muted variant (#EDD5FF) for subtle backgrounds.
- **Red** (#DC2626): Error-related text and icons on neutral surfaces.
### Status Colors
Used for build status indicators, connection health, and real-time feedback dots:
- **Red** (#EF4444): Error, failed, disconnected.
- **Green** (#4ADE80): Success, connected, running.
- **Yellow** (#EAB308): Warning, building, pending.
- **Blue** (#2563EB): Info, selected, active.
- **Gray** (#6B7280): Inactive, unknown, disabled.
### Data Type Color System
Langflow uses **14 distinct hues** to encode the type of data flowing through node connections. Each type has a saturated foreground and a light-tint background. In **light mode**, the base token is the saturated color (used for port dots and connection lines) and the `-foreground` token is the light tint (used for backgrounds). In **dark mode, these roles swap** -- the base becomes the light tint and the foreground becomes the saturated color. This ensures readability against both light and dark surfaces.
| Data Type | Color Name | Hex (saturated) | Used For |
|:----------|:-----------|:-----------------|:---------|
| str / Text / Message | indigo | #4F46E5 | String data, text, messages |
| Document | lime | #65A30D | Document objects |
| Data / JSON | red | #DC2626 | Structured data, JSON |
| Embeddings | emerald | #059669 | Vector embeddings |
| LanguageModel | fuchsia | #C026D3 | LLM model objects |
| Prompt | violet | #7C3AED | Prompt templates |
| Tool | cyan | #0891B2 | Tool definitions |
| Agent | purple | #9333EA | Agent objects |
| number | purple | #9333EA | Numeric values |
| DataFrame / Table | pink | #DB2777 | Tabular data |
| chains | orange | #EA580C | Chain compositions |
| memories | yellow | #CA8A04 | Memory objects |
| unknown | gray | #4B5563 | Untyped or unknown |
| inputs | emerald | #059669 | Input components |
### Note Colors
Sticky notes on the canvas use soft pastel backgrounds: amber (#FCE68A), neutral (#E4E4E7), rose (#FECDD3), blue (#BFDBFE), lime (#D9F99D).
### Neon / Gradient Accents
Used for flow icon gradients, tool-mode indicators, and decorative color swatches. These are high-saturation, high-energy colors intentionally outside the normal UI palette:
- **Neon Fuchsia** (#FF3276) to **Digital Orchid** (#F480FF): Tool-mode gradient.
- **Plasma Purple** (#7C3AED), **Electric Blue** (#3B10FD): Deep saturated anchors.
- **Holo Frost** (#9AF3FD), **Terminal Green** (#9AFDA9): Light luminous accents.
- **Cosmic Void** (#1A0250): Ultra-dark purple for contrast backgrounds.
## Typography
Three font families serve distinct roles in the interface.
- **Inter** (sans-serif, variable, 14-32 optical size): The primary UI typeface. Used for all headings, body text, labels, navigation, buttons, and form elements. Its large optical-size range and variable weight (100-900) provide precise control without font-switching. The neutral, geometric character keeps the UI professional and out of the way.
- **JetBrains Mono** (monospace, variable): Used exclusively for code -- code blocks, JSON editors, API keys, component IDs, and any machine-readable content. Its programming ligatures and distinct character shapes (especially `0` vs `O`, `1` vs `l`) aid readability in dense technical contexts. Never used for UI labels or headings.
- **Chivo** (display, variable): Reserved for marketing-facing or splash contexts (landing pages, onboarding screens). Its slightly condensed, geometric character adds personality at large sizes. Never used within the application workspace.
The type scale is compact. The application uses 11px (`xxs`) through 16px (`base`) for the vast majority of UI. Headlines rarely exceed 24px inside the workspace. Density is preferred over visual hierarchy through size alone -- hierarchy is communicated through weight, color, and spatial grouping.
## Layout
The application follows a **sidebar + infinite canvas** model. A collapsible left sidebar (19rem when expanded, 4rem when collapsed to icons) holds navigation, component search, and category panels. The main area is a pannable, zoomable ReactFlow canvas where nodes live.
Spacing follows a strict **4px base grid**:
- **4px** (`xs`): Micro-adjustments -- icon padding within buttons, badge margins, tight inline spacing.
- **8px** (`sm`): Standard internal padding for compact components, gaps between inline elements.
- **16px** (`md`): Card content padding, standard gaps between sibling elements, default component padding.
- **24px** (`lg`): Section margins, dialog padding, sidebar header/footer padding.
- **32px** (`xl`): Major layout divisions, generous whitespace between sections.
- **48px - 64px** (`2xl` - `3xl`): Page-level margins, hero spacing.
**Breakpoints:**
- `mdd: 45rem` (720px) -- medium-density layout shift
- `xl: 1200px` -- wide layout
- `2xl: 1400px` -- container max-width
- `3xl: 1500px` -- extra-wide
The canvas itself is unbounded -- nodes can be placed anywhere. The sidebar is the only fixed-width structural element.
## Elevation & Depth
Elevation is minimal and functional. The design avoids heavy shadows, instead using **border-based containment** and **tonal shifts** to communicate layering. Depth is communicated through three levels:
### Level 0: Canvas
The infinite workspace background. A dotted grid pattern provides spatial orientation without visual noise.
- **Light mode:** Zinc-gray (#F4F4F5) background with medium-gray dots (#A1A1AA).
- **Dark mode:** Pure black (#000000) background with dark zinc dots (#3F3F46).
### Level 1: Nodes & Cards
Content containers that sit on the canvas. White background with a soft ambient shadow.
- **Shadow:** `0 0px 15px -3px rgba(0,0,0,0.1), 0 0px 6px -4px rgba(0,0,0,0.1)` -- barely perceptible, just enough to lift nodes off the grid.
- **Selected state:** An indigo ring (#4F46E5) replaces the default border, immediately communicating focus.
- **Frozen state:** A special icy glow effect -- `0 0 10px 2px rgba(128,190,230,0.5)` shadow with a 2px `rgba(128,190,219,0.86)` border and frosted overlay. Signals the node is locked from editing.
### Level 2: Popovers & Modals
Overlaid surfaces that demand attention. White background with `shadow-lg` and a border.
- **Entry animation:** Scale from 0.95 with a clip-path reveal, 400ms duration, `cubic-bezier(0.16, 1, 0.3, 1)` easing (spring-like overshoot).
- **Exit animation:** Reverse at 500ms for a slightly more deliberate dismissal.
- **Overlay:** Semi-transparent background to dim the canvas.
### Active States
Interactive feedback is communicated through micro-animations rather than shadow changes:
- **Button press:** `active:scale-[0.97]` -- a subtle inward squeeze.
- **Hover:** Background color shift to accent or muted, never shadow addition.
- **Focus ring:** 1px ring using the `ring` token (black in light, white in dark).
## Shapes
The shape language is subtly rounded -- not pill-shaped, not sharp. The base radius is **8px** (`--radius: 0.5rem`), which gives cards, buttons, and containers a modern but not toy-like feel.
- **lg (8px):** The default. Cards, modals, buttons, inputs, dropdown content, node containers.
- **md (6px):** Nested or secondary elements -- tooltip content, compact controls, dialog close buttons.
- **sm (4px):** Tight elements -- badges, checkbox corners, compact inline inputs, select items.
- **xl (12px):** Prominent containers -- dialog content, node toolbars, sidebar inset panels.
- **full (9999px):** Circular elements -- avatars, status dots, switch tracks, scrollbar thumbs, badge pills.
Borders are thin. Standard weight is 1px. Occasional 1.5px or 1.75px for emphasis on selected or focus states. All borders use the `border` token color -- never a hard black or white line.
## Components
### Buttons
The button system uses 7 semantic variants:
- **Default (primary):** Solid black background, white text. The highest-emphasis action on any screen. Hover darkens to #27272A.
- **Secondary:** Muted background with border, dark text. For secondary actions alongside a primary button.
- **Destructive:** Red background, white text. Reserved for delete, remove, and irreversible actions.
- **Outline:** Transparent with border. For medium-emphasis actions. Hover fills with the input color.
- **Ghost:** Transparent, no border. For toolbar actions and icon buttons. Hover fills with accent background.
- **Warning:** Dark warning-foreground background. For caution-related actions.
- **Link:** Underlined text with no background. For inline text links.
All buttons share: 40px default height, `rounded-lg` (8px), `font-medium` (500 weight), `text-sm` (14px). Focus state is a 1px ring. Disabled state is `opacity-70` with `pointer-events-none`. The `active:scale-[0.97]` press effect is optional but recommended for primary actions.
**Size variants:** `lg` (44px), `default` (40px), `md` (32px), `sm` (36px), `xs` (compact), and three icon sizes (`iconMd` 6px padding, `icon` 4px padding, `iconSm` 2px padding).
### Badges
Inline status indicators and tags. Always pill-shaped (`rounded-full`) with `font-semibold` text. Available in 6 semantic variants: default (primary fill), gray, secondary (muted fill), destructive, emerald (success), pink, purple (outlined), and error (light red fill). Three size tiers: `sm` (16px height, 12px text), `md` (20px height, 14px text), `lg` (24px height, 16px text), plus `tag` (18px height, 11px text) for compact inline labels.
### Cards
Content containers on surfaces. `rounded-lg` border on muted background with `shadow-sm`. Internal structure: header (16px padding, title in semi-bold 16px, optional description in muted 14px), content area (16px horizontal padding), footer (16px padding, flex row). The title uses `leading-tight tracking-tight` for density.
### Inputs
Text inputs use a bordered white background with rounded-lg corners. Placeholder text is in muted gray (#A1A1AA). Three interaction states: default (border color), hover (border darkens to `muted-foreground`), focus (border goes to `foreground`, placeholder becomes transparent). Disabled inputs get muted background and reduced text. A compact `input-edit-node` variant is used for inline editing within nodes.
### Selects & Dropdowns
Select triggers are 32px height with border and right-aligned chevron. Dropdown content uses the popover surface with `shadow-md`, `rounded-md`, and slide-in animation from the opening side. Items highlight with the accent background on focus. Separators use a 1px muted line.
### Dialogs & Modals
Fixed-position overlays with a white `rounded-xl` container, 24px padding, and `shadow-lg`. Enter with a 400ms spring animation (scale 0.95 to 1.0 with clip-path reveal). Close button in the top-right corner. Title in 18px semi-bold, description in muted 14px. Footer uses `flex-row` with right-aligned actions.
### Tabs
Inline tab bars with no background. The active tab gets a 2px bottom border in `currentColor` (primary). Inactive tabs show text in `muted-foreground`, hovering to `primary`. No rounded backgrounds on tab triggers -- the underline is the sole active indicator.
### Form Controls
**Checkbox:** 16px square, `rounded-sm` (4px), border in muted-foreground. Checked state fills with primary and shows a white check icon. **Switch:** 44x24px track with a 16px thumb. Unchecked track uses the `input` color; checked track uses `primary`. Thumb slides 20px with a transition. **Radio:** 16px circle with primary border, filled center dot when selected.
### Tooltips
Bordered popover surface (not solid black as one might expect) with `rounded-md`, `shadow-md`, 14px text, and directional slide-in animation. Content is `z-[99]` to sit above all other UI.
### Alerts
Rounded bordered containers with icon + text layout. Default variant uses standard foreground colors. Destructive variant tints the border and text with the destructive red, and the icon inherits that color.
### Canvas Nodes
The core UI element of Langflow. Each node is a white rounded card on the canvas containing typed input/output ports, configuration fields, and a status indicator.
- **Port dots** use the data type color system to visually encode connection compatibility. When two ports share a compatible type, their colors match, providing immediate visual feedback.
- **Selected nodes** show an indigo ring (`node-selected: #4F46E5`) replacing the standard border.
- **Frozen nodes** display the icy glow effect with a frosted overlay, signaling they are locked from modification.
- **Node toolbar** appears on hover/selection: a floating `rounded-xl` container with icon buttons, 1px border, and `shadow-sm`.
- **Status indicators:** Green (built successfully), red (error), yellow (building), gray (idle). A spinner animation shows during active builds.
### Sidebar
Collapsible panel (19rem expanded, 4rem icon-only) with smooth 300ms width transitions. Menu buttons are 32px height, `rounded-md`, with accent background on hover and active states. Supports nested sub-menus with left border indicators. In dark mode, the sidebar has its own dedicated color tokens (darker surface, brighter primary) for visual separation from the canvas.
## Do's and Don'ts
- Do use the monochromatic core palette for all structural UI -- reserve color for semantic meaning
- Do maintain WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text and interactive elements)
- Do use Inter for all application UI text; JetBrains Mono only for code and machine-readable content
- Do keep data type colors visually distinct -- they encode connection compatibility and must be recognizable at small sizes (port dots, connection lines)
- Do support both light and dark themes -- every color token must have a dark-mode equivalent
- Do use the 4px base grid for all spacing decisions
- Don't introduce new accent colors without a clear semantic role in the system
- Don't use shadows heavier than the node shadow -- the UI should feel flat, layered through tone rather than depth
- Don't mix border-radius values within the same visual group (all buttons in a toolbar share the same radius)
- Don't use Chivo inside the application workspace -- it is reserved for marketing and onboarding contexts
- Don't use opacity below 0.7 for disabled states -- the element must remain readable
- Don't use solid color fills for hover states on ghost/outline buttons -- use the accent or muted surface tokens
- Don't place data type colors on structural UI elements (borders, backgrounds) outside of the node/port system
+61 -4
View File
@@ -171,7 +171,7 @@ unit_tests_looponfail:
lfx_tests: ## run lfx package unit tests
@echo 'Running LFX Package Tests...'
@cd src/lfx && \
uv sync && \
uv sync --dev && \
uv run pytest tests/unit -v --cov=src/lfx --cov-report=xml --cov-report=html --cov-report=term-missing $(args)
integration_tests:
@@ -398,14 +398,13 @@ dcdev_up:
$(DOCKER) compose -f docker/dev.docker-compose.yml up --remove-orphans
lock_base:
cd src/backend/base && uv lock
uv lock
lock_flow:
uv lock
lock: ## lock dependencies
@echo 'Locking dependencies'
cd src/backend/base && uv lock
uv lock
update: ## update dependencies
@@ -490,6 +489,38 @@ lfx_docker_test: ## run LFX tests in Docker
@echo 'Running LFX tests in Docker'
@cd src/lfx && make docker_test
######################
# SDK PACKAGE
######################
sdk_build: ## build the SDK package
@echo 'Building SDK package'
@cd src/sdk && make build
sdk_publish: ## publish SDK package to PyPI
@echo 'Publishing SDK package'
@cd src/sdk && make publish
sdk_publish_testpypi: ## publish SDK package to test PyPI
@echo 'Publishing SDK package to test PyPI'
@cd src/sdk && make publish_test
sdk_test: ## run SDK tests
@echo 'Running SDK tests'
@cd src/sdk && make test
sdk_format: ## format SDK code
@echo 'Formatting SDK code'
@cd src/sdk && make format
sdk_lint: ## lint SDK code
@echo 'Linting SDK code'
@cd src/sdk && make lint
sdk_clean: ## clean SDK build artifacts
@echo 'Cleaning SDK build artifacts'
@cd src/sdk && make clean
# example make alembic-revision message="Add user table"
alembic-revision: ## generate a new migration
@echo 'Generating a new Alembic revision'
@@ -842,7 +873,7 @@ help_backend: ## show backend-specific commands
@echo "$(GREEN)Code Quality:$(NC)"
@echo " $(GREEN)make format_backend$(NC) - Format backend code (ruff)"
@echo " $(GREEN)make format_frontend_check$(NC) - Check frontend formatting (biome)"
@echo " $(GREEN)make lint$(NC) - Run backend linters (mypy)"
@echo " $(GREEN)make lint$(NC) - Run backend linters"
@echo " $(GREEN)make codespell$(NC) - Check spelling errors"
@echo " $(GREEN)make fix_codespell$(NC) - Fix spelling errors automatically"
@echo " $(GREEN)make unsafe_fix$(NC) - Run ruff with unsafe fixes"
@@ -877,6 +908,14 @@ help_backend: ## show backend-specific commands
@echo " $(GREEN)make lfx_docker_dev$(NC) - Start LFX development environment"
@echo " $(GREEN)make lfx_docker_test$(NC) - Run LFX tests in Docker"
@echo ''
@echo "$(GREEN)SDK Package Commands:$(NC)"
@echo " $(GREEN)make sdk_build$(NC) - Build SDK package"
@echo " $(GREEN)make sdk_test$(NC) - Run SDK tests"
@echo " $(GREEN)make sdk_format$(NC) - Format SDK code"
@echo " $(GREEN)make sdk_lint$(NC) - Lint SDK code"
@echo " $(GREEN)make sdk_clean$(NC) - Clean SDK build artifacts"
@echo " $(GREEN)make sdk_publish$(NC) - Publish SDK to PyPI"
@echo ''
@echo "$(GREEN)═══════════════════════════════════════════════════════════════════$(NC)"
@echo ''
@@ -902,6 +941,7 @@ help_test: ## show testing commands
@echo "$(GREEN)Combined Tests:$(NC)"
@echo " $(GREEN)make tests$(NC) - Run all tests (unit + integration + coverage)"
@echo " $(GREEN)make coverage$(NC) - Run tests and generate coverage report"
@echo " $(GREEN)make test_frontend_coverage_full$(NC) - Run tests and generate coverage report"
@echo ''
@echo "$(GREEN)Frontend Tests:$(NC)"
@echo " $(GREEN)make tests_frontend$(NC) - Run Playwright e2e tests"
@@ -919,6 +959,9 @@ help_test: ## show testing commands
@echo " $(GREEN)make test_frontend_snapshots$(NC) - Update Jest snapshots"
@echo " $(GREEN)make test_frontend_config$(NC) - Show Jest configuration"
@echo ''
@echo "$(GREEN)Combined Frontend Test Coverage:$(NC)"
@echo " $(GREEN)make test_frontend_coverage_full$(NC) - Run frontend tests and generate coverage report"
@echo ''
@echo "$(GREEN)Load Testing:$(NC)"
@echo " $(GREEN)make locust$(NC) - Run locust load tests"
@echo " Options: locust_users=10 locust_spawn_rate=1 locust_host=http://localhost:7860"
@@ -979,6 +1022,8 @@ help_advanced: ## show advanced and miscellaneous commands
@echo " $(GREEN)make publish_flow$(NC) - Publish langflow to PyPI"
@echo " $(GREEN)make lfx_publish$(NC) - Publish LFX package to PyPI"
@echo " $(GREEN)make lfx_publish_testpypi$(NC) - Publish LFX to test PyPI"
@echo " $(GREEN)make sdk_publish$(NC) - Publish SDK package to PyPI"
@echo " $(GREEN)make sdk_publish_testpypi$(NC) - Publish SDK to test PyPI"
@echo ''
@echo "$(GREEN)Lock Files:$(NC)"
@echo " $(GREEN)make lock$(NC) - Lock all dependencies"
@@ -1026,6 +1071,18 @@ docs_serve: docs_build ## build and serve documentation locally
@echo "$(GREEN)Serving built documentation...$(NC)"
@cd docs && npm run serve -- --port $(docs_port)
# Comma-separated list; override e.g. suites=curl,javascript,python
# Note: $(or $(suites),a,b,c) is wrong here — GNU make's `or` returns only the first non-empty token.
suites ?= curl,python,javascript
api_examples_local: ## run docs API sample files against a local Langflow server
@echo "$(GREEN)Running docs API examples locally...$(NC)"
@SUITES="$(suites)" EXECUTE_MODE=true ./scripts/test-api-examples-local.sh
api_examples_local_syntax: ## syntax-check docs API sample files locally without execution
@echo "$(GREEN)Running docs API example syntax checks locally...$(NC)"
@SUITES="$(suites)" EXECUTE_MODE=false ./scripts/test-api-examples-local.sh
######################
# INCLUDE FRONTEND MAKEFILE
######################
+33
View File
@@ -166,6 +166,39 @@ test_frontend_coverage_open: test_frontend_coverage ## run tests with coverage a
echo "Coverage report generated at: $(FRONTEND_DIR)/coverage/lcov-report/index.html"; \
fi
######################
# FRONTEND TESTS COVERAGE (JEST + PLAYWRIGHT COMBINED )
######################
# Run full frontend tests (Jest + Playwright) with coverage and merge into HTML
test_frontend_coverage_full: frontend_deps_check
@echo "=== Running Jest unit tests with coverage ==="
@(cd $(FRONTEND_DIR) && npx jest --coverage); echo $$? > /tmp/jest_exit
@echo "=== Running Playwright E2E tests ==="
@(cd $(FRONTEND_DIR) && CI=1 npx playwright test --project=chromium); echo $$? > /tmp/playwright_exit
@echo "=== Merging coverage reports ==="
@mkdir -p $(FRONTEND_DIR)/coverage/combined
# Merge Playwright raw coverage files
@cd $(FRONTEND_DIR) && npx nyc merge coverage/playwright/individual-test coverage/combined/playwright.json || echo "Failed to merge Playwright coverage"
# Generate Playwright HTML report
@cd $(FRONTEND_DIR) && npx nyc report --reporter=html --reporter=text --temp-dir coverage/playwright/individual-test --report-dir coverage/playwright/html-report || echo "Failed to generate Playwright HTML coverage"
# Migrate Jest coverage
@cp $(FRONTEND_DIR)/coverage/jest/coverage-final.json $(FRONTEND_DIR)/coverage/combined/jest.json || echo "Failed to migrate Jest coverage"
# Merge both into final
@cd $(FRONTEND_DIR) && npx nyc merge coverage/combined coverage/combined/total-frontend-coverage.json || echo "Failed to combine Playwright + Jest coverage"
@echo "=== Generating HTML coverage report ==="
@cd $(FRONTEND_DIR) && npx nyc report --reporter=html --reporter=text --temp-dir coverage/combined --report-dir coverage/final-coverage-report || echo "Failed to generate Playwright + Jest coverage html report"
@echo "=== Checking test results ==="
@JEST=$$(cat /tmp/jest_exit); \
PLAY=$$(cat /tmp/playwright_exit); \
rm -f /tmp/jest_exit /tmp/playwright_exit; \
test $$JEST -eq 0 -a $$PLAY -eq 0
######################
# STORYBOOK
######################
+2 -2
View File
@@ -6,8 +6,8 @@ This security policy applies to the Hanzo Flow project on GitHub. We prioritize
### Security/Bugfix Versions
- Fixes are released either as part of the next minor version (e.g., 1.3.0 → 1.4.0) or as an on-demand patch version (e.g., 1.3.0 → 1.3.1)
- Security fixes are given priority and might be enough to cause a new version to be released
- Fixes are released either as part of the next minor version (e.g., 1.3.0 → 1.4.0) or as an on-demand patch version (e.g., 1.3.0 → 1.3.1).
- Security fixes are given priority and might be enough to cause a new version to be released.
## Reporting a Vulnerability
+381
View File
@@ -0,0 +1,381 @@
# Can We Skip CI for Pre-Release Builds? Analysis
**Issue:** [LE-517](https://datastax.jira.com/browse/LE-517)
**Problem:** Pre-release builds (PyPI + Docker) require 45-60 min CI wait
---
## Current Workflow
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 1: Build Packages (15-20 min) - NO CI DEPENDENCY │
├─────────────────────────────────────────────────────────────┤
│ • build-lfx │
│ • build-base │
│ • build-main │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: Cross-Platform Tests (15 min) - NO CI DEPENDENCY │
├─────────────────────────────────────────────────────────────┤
│ • Test on Linux (amd64, arm64) │
│ • Test on macOS (amd64, arm64) │
│ • Test on Windows (amd64) │
│ • Python 3.10, 3.12, 3.13 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Phase 3: CI Suite (45-60 min) ⚠️ BOTTLENECK │
├─────────────────────────────────────────────────────────────┤
│ • Backend tests (Python 3.10-3.13, 5 groups = 20 jobs) │
│ • Frontend E2E tests (Playwright) │
│ • Frontend unit tests (Jest) │
│ • Linting (mypy) │
│ • Docs build │
│ • Template tests │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Phase 4: Publish & Build (12-17 min) - BLOCKED BY CI │
├─────────────────────────────────────────────────────────────┤
│ • publish-base (line 602: needs ci) │
│ • publish-main (line 625: needs ci) │
│ • publish-lfx (line 648: needs ci) │
│ • call_docker_build_base (line 671: needs ci) │
│ • call_docker_build_main (line 683: needs ci) │
└─────────────────────────────────────────────────────────────┘
TOTAL TIME: 87-112 minutes
TIME SPENT WAITING FOR CI: 45-60 minutes (53-67% of total time)
```
---
## The Core Question
**Can we skip CI and still safely publish pre-releases?**
### What CI Validates
| Test Type | Time | Catches | Critical for Pre-Release? |
|-----------|------|---------|---------------------------|
| **Backend Unit Tests** | 15-20 min | Logic errors, edge cases | ⚠️ MEDIUM |
| **Frontend E2E Tests** | 20-30 min | UI workflows, integrations | ⚠️ MEDIUM |
| **Frontend Unit Tests** | 5-10 min | Component logic | ⚠️ LOW |
| **Linting (mypy)** | 5 min | Type errors | ❌ LOW |
| **Docs Build** | 5 min | Documentation issues | ❌ NONE |
| **Template Tests** | 5 min | Starter project issues | ❌ LOW |
### What Cross-Platform Tests Already Validate
✅ **Installation works** on all platforms
✅ **Dependencies resolve** correctly
✅ **Basic imports** don't fail
✅ **Server starts** successfully
**Key Insight:** Cross-platform tests already catch the most critical issues (broken installation, missing dependencies, import errors).
---
## Risk Analysis
### If We Skip CI for Pre-Releases
**What Could Go Wrong:**
1. **Broken functionality** (Medium risk)
- Some features don't work
- Customer discovers during testing
- **Mitigation:** That's the point of pre-release testing
2. **Integration issues** (Low risk)
- Components don't work together
- Customer discovers during testing
- **Mitigation:** Pre-release is for finding these issues
3. **Type errors** (Low risk)
- Runtime errors from type mismatches
- May or may not surface
- **Mitigation:** Not critical for pre-release
4. **Complete failure** (Very low risk)
- Package won't install or start
- **Already caught by cross-platform tests**
- Cross-platform tests include server startup validation
**What Won't Go Wrong:**
❌ Installation failures → Caught by cross-platform tests
❌ Missing dependencies → Caught by cross-platform tests
❌ Import errors → Caught by cross-platform tests
❌ Server won't start → Caught by cross-platform tests
---
## The Real Question
**Not "Can we skip CI?" but "What's the purpose of pre-releases?"**
### Pre-Release Purpose
Pre-releases exist to:
1. Get customer feedback on new features
2. Test in real-world environments
3. Find integration issues
4. Validate before production release
**Key Point:** Customers testing pre-releases EXPECT to find issues. That's why they're testing.
### Production Release vs Pre-Release
| Aspect | Production Release | Pre-Release |
|--------|-------------------|-------------|
| **Quality Bar** | Must be stable | Can have issues |
| **Testing** | Exhaustive | Sufficient |
| **Speed** | Can wait | Time-sensitive |
| **Risk Tolerance** | Zero | Acceptable |
| **CI Required** | YES | DEBATABLE |
---
## Scenarios
### Scenario 1: Code from RC Branch ✅ SAFE TO SKIP
**Context:** Release candidate branch that already passed CI
**Analysis:**
- Code was validated when merged to RC
- No changes since last CI run
- Just packaging existing tested code
**Risk:** **VERY LOW** (code already tested)
**Recommendation:** **Skip CI entirely**
---
### Scenario 2: New Bug Fix ⚠️ ACCEPTABLE RISK
**Context:** Urgent fix needs customer validation
**Analysis:**
- Cross-platform tests catch critical issues
- Customers expect pre-releases may have issues
- Faster feedback loop (hours vs days)
- Can rollback if needed
**Risk:** **MEDIUM** (untested code, but pre-release context)
**Recommendation:** **Skip CI with notification**
---
### Scenario 3: Major Refactoring ❌ DON'T SKIP
**Context:** Large code changes, architectural changes
**Risk:** **HIGH** (many potential issues)
**Recommendation:** **Run full CI**
---
## Recommendation
### YES, we can skip CI for pre-releases
**Rationale:**
1. **Cross-platform tests already validate critical issues**
- Installation works
- Dependencies resolve
- Server starts
- Basic functionality works
2. **Pre-releases are for finding issues**
- Customers expect potential problems
- That's why they're testing pre-releases
- Not production-quality requirements
3. **Time savings are significant**
- 45-60 minutes saved (53-67% faster)
- Enables rapid iteration
- Better customer experience
4. **Risk is acceptable**
- Worst case: broken pre-release
- Impact: Customer reports issue, we fix
- Mitigation: Clear pre-release labeling
### Proposed Implementation
```yaml
on:
workflow_dispatch:
inputs:
skip_ci:
description: "Skip CI for pre-release (use with caution)"
type: boolean
default: false
skip_ci_reason:
description: "Required: Why skip CI?"
type: string
jobs:
validate-skip-ci:
if: ${{ inputs.skip_ci }}
runs-on: ubuntu-latest
steps:
- name: Require justification
run: |
if [ -z "${{ inputs.skip_ci_reason }}" ]; then
echo "Error: Must provide skip_ci_reason"
exit 1
fi
- name: Notify team
run: |
echo "⚠️ CI SKIPPED for ${{ inputs.release_tag }}"
echo "Reason: ${{ inputs.skip_ci_reason }}"
# Send to Slack/Discord
ci:
if: ${{ !inputs.skip_ci }}
uses: ./.github/workflows/ci.yml
# ... existing CI
publish-base:
needs: [build-base, test-cross-platform] # Remove 'ci'
if: |
${{ inputs.release_package_base &&
(inputs.skip_ci || needs.ci.result == 'success') }}
# ... existing publish
publish-main:
needs: [build-main, test-cross-platform, publish-base] # Remove 'ci'
if: |
${{ inputs.release_package_main &&
(inputs.skip_ci || needs.ci.result == 'success') }}
# ... existing publish
call_docker_build_main:
needs: [] # Remove 'ci' dependency
if: |
${{ inputs.build_docker_main &&
(inputs.skip_ci || needs.ci.result == 'success') }}
# ... existing Docker build
```
### Safeguards
1. **Explicit opt-in:** `skip_ci=false` by default
2. **Required justification:** Must explain why skipping
3. **Team notification:** Alert via Slack/Discord
4. **Audit trail:** Log all skip decisions
5. **Clear labeling:** Pre-release tags (rc, beta, alpha)
### Usage Guidelines
**When to skip CI:**
- ✅ Code from RC branch (already tested)
- ✅ Urgent customer testing needed
- ✅ Internal testing only
- ✅ Time-sensitive pre-release
**When NOT to skip CI:**
- ❌ Production releases (never skip)
- ❌ Major refactoring
- ❌ First pre-release of version
- ❌ Dependency updates
---
## Expected Results
### Time Savings
**Current:**
```
Build (20 min) + Cross-platform (15 min) + CI (50 min) + Publish (15 min) = 100 min
```
**With skip_ci:**
```
Build (20 min) + Cross-platform (15 min) + Publish (15 min) = 50 min
```
**Savings: 50 minutes (50% faster)**
### Risk Assessment
**Probability of broken pre-release:** ~10-15%
**Impact if broken:** Low (pre-release context, quick rollback)
**Mitigation:** Cross-platform tests catch 80-90% of critical issues
**Trade-off:** 50 min saved vs 10-15% risk of non-critical issues
---
## Alternative: Better Release Planning?
**The issue suggests:** "investigate if the real solution is better planned release cycles"
**Analysis:**
**Better planning helps but doesn't solve:**
- ❌ Urgent bug fixes
- ❌ Customer-specific issues
- ❌ Unexpected problems
- ❌ Last-minute changes
**Better planning + skip_ci option:**
- ✅ Planned releases use full CI
- ✅ Urgent releases use skip_ci
- ✅ Best of both worlds
**Conclusion:** Better planning is good practice, but skip_ci option is still valuable for urgent scenarios.
---
## Final Recommendation
**Implement `skip_ci` parameter with safeguards.**
**Why:**
1. Cross-platform tests already validate critical issues
2. Pre-releases are for finding issues (acceptable risk)
3. 50% time savings for urgent scenarios
4. Explicit opt-in with justification required
5. Doesn't prevent better release planning
**Implementation Priority:**
**Week 1:** Add `skip_ci` parameter and validation
**Week 2:** Add team notifications and audit logging
**Week 3:** Document usage guidelines
**Week 4:** Monitor usage and adjust as needed
**Success Metrics:**
- Time to pre-release: < 60 minutes (from 100+ minutes)
- Pre-release quality: > 85% work without issues
- Usage: 2-3 times per release cycle for urgent needs
---
## Conclusion
**YES, we can skip CI for pre-releases.**
The 45-60 minute CI wait is unnecessary when:
- Cross-platform tests already validate installation
- Pre-releases are explicitly for testing
- Time savings enable better customer experience
- Risk is acceptable with proper safeguards
**This isn't about lowering quality standards** - it's about recognizing that pre-releases and production releases have different requirements. Pre-releases are for finding issues; production releases are for stability.
**The solution is both:**
- Better release planning (reduce urgent needs)
- skip_ci option (handle urgent needs when they arise)
+24 -12
View File
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -29,13 +29,17 @@ RUN apt-get update \
# deps for building python deps
build-essential \
git \
# npm
npm \
# gcc
gcc \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& ARCH=$(dpkg --print-architecture) \
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION="22.14.0" \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -44,10 +48,11 @@ COPY ./uv.lock /app/uv.lock
COPY ./README.md /app/README.md
COPY ./pyproject.toml /app/pyproject.toml
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
COPY ./src/lfx/README.md /app/src/lfx/README.md
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
COPY ./src/sdk/README.md /app/src/sdk/README.md
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
RUN --mount=type=cache,target=/root/.cache/uv \
RUSTFLAGS='--cfg reqwest_unstable' \
@@ -73,7 +78,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
FROM python:3.12.12-slim-trixie AS runtime
FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
@@ -88,18 +93,25 @@ RUN ARCH=$(dpkg --print-architecture) \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
| head -1) \
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& npm cache clean --force
&& npm install -g npm@latest
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
COPY --from=builder --chown=1000 /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN /app/.venv/bin/pip install --upgrade playwright \
&& /app/.venv/bin/playwright install
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
# compose file) with the non-root user as owner. When the official compose mounts
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
# and permissions into the new volume, so the in-container uid=1000 user can
# write secret_key, profile_pictures, etc. Without this, the volume is created
# as root:root and Langflow crashes during startup with PermissionError on
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
LABEL org.opencontainers.image.title=langflow
LABEL org.opencontainers.image.authors=['Langflow']
+19 -9
View File
@@ -8,7 +8,7 @@
################################
# BUILDER
################################
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
WORKDIR /app
@@ -29,6 +29,7 @@ RUN apt-get update \
# Copy only backend source (excludes frontend)
COPY ./src/backend ./src/backend
COPY ./src/lfx ./src/lfx
COPY ./src/sdk ./src/sdk
# Create venv and install langflow-base with dependencies
# Using uv pip instead of uv sync to avoid workspace complexities
@@ -36,13 +37,14 @@ 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)
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install ./src/lfx "./src/backend/base[complete,postgresql]"
uv pip install ./src/sdk ./src/lfx "./src/backend/base[complete,postgresql]"
################################
# RUNTIME
################################
FROM python:3.12.12-slim-trixie AS runtime
FROM python:3.14-slim-trixie AS runtime
# Install minimal runtime dependencies
RUN apt-get update \
@@ -63,12 +65,11 @@ RUN ARCH=$(dpkg --print-architecture) \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
| head -1) \
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& npm cache clean --force
| tar -xJ -C /usr/local --strip-components=1
# Create non-root user
RUN useradd --uid 1000 --gid 0 --no-create-home --home-dir /app/data user
@@ -78,9 +79,18 @@ COPY --from=builder --chown=1000:0 /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
# Create home directory and ensure proper ownership
# The user needs write access to /app/data (home) and /app (workdir)
# The user needs write access to /app/data (home) and /app (workdir).
# Also pre-create /app/langflow (LANGFLOW_CONFIG_DIR used by the docker_example
# compose file) with the non-root user as owner, so a fresh named volume mounted
# at /app/langflow inherits the correct ownership/permissions and the in-container
# uid=1000 user can write secret_key, profile_pictures, etc. Without this, the
# volume would be initialized as root:root and Langflow would crash with
# PermissionError on /app/langflow/secret_key (issue #10437).
# Note: .venv is already owned by 1000:0 via COPY --chown above, so no recursive chown needed
RUN mkdir -p /app/data && chown -R 1000:0 /app/data && chown 1000:0 /app
RUN mkdir -p /app/data /app/langflow \
&& chown -R 1000:0 /app/data /app/langflow \
&& chmod -R g+rwX /app/langflow \
&& chown 1000:0 /app
LABEL org.opencontainers.image.title=langflow-backend
LABEL org.opencontainers.image.authors=['Langflow']
+17 -9
View File
@@ -10,7 +10,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -42,11 +42,13 @@ COPY ./uv.lock /app/uv.lock
COPY ./README.md /app/README.md
COPY ./pyproject.toml /app/pyproject.toml
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
# Copy lfx metadata files since it's a workspace member
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
COPY ./src/lfx/README.md /app/src/lfx/README.md
# Copy sdk metadata files since it's a workspace member
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
COPY ./src/sdk/README.md /app/src/sdk/README.md
# Install the project's dependencies using the lockfile and settings
# We need to mount the root uv.lock and pyproject.toml to build the base with uv because we're still using uv workspaces
@@ -74,7 +76,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
FROM python:3.12.12-slim-trixie AS runtime
FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
@@ -89,18 +91,24 @@ RUN ARCH=$(dpkg --print-architecture) \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
| head -1) \
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& npm cache clean --force
| tar -xJ -C /usr/local --strip-components=1
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
COPY --from=builder --chown=1000 /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN /app/.venv/bin/pip install --upgrade playwright \
&& /app/.venv/bin/playwright install
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
# compose file) with the non-root user as owner. When the official compose mounts
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
# and permissions into the new volume, so the in-container uid=1000 user can
# write secret_key, profile_pictures, etc. Without this, the volume is created
# as root:root and Langflow crashes during startup with PermissionError on
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
LABEL org.opencontainers.image.title=langflow
LABEL org.opencontainers.image.authors=['Langflow']
+16 -9
View File
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -41,10 +41,11 @@ COPY ./uv.lock /app/uv.lock
COPY ./README.md /app/README.md
COPY ./pyproject.toml /app/pyproject.toml
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
COPY ./src/lfx/README.md /app/src/lfx/README.md
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
COPY ./src/sdk/README.md /app/src/sdk/README.md
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
RUN --mount=type=cache,target=/root/.cache/uv \
RUSTFLAGS='--cfg reqwest_unstable' \
@@ -70,7 +71,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
FROM python:3.12.12-slim-trixie AS runtime
FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
&& apt-get upgrade -y \
@@ -84,18 +85,24 @@ RUN ARCH=$(dpkg --print-architecture) \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
| head -1) \
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& npm cache clean --force
| tar -xJ -C /usr/local --strip-components=1
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
COPY --from=builder --chown=1000 /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN /app/.venv/bin/pip install --upgrade playwright \
&& /app/.venv/bin/playwright install
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
# compose file) with the non-root user as owner. When the official compose mounts
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
# and permissions into the new volume, so the in-container uid=1000 user can
# write secret_key, profile_pictures, etc. Without this, the volume is created
# as root:root and Langflow crashes during startup with PermissionError on
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
LABEL org.opencontainers.image.title=langflow
LABEL org.opencontainers.image.authors=['Langflow']
+16 -9
View File
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -41,10 +41,11 @@ COPY ./uv.lock /app/uv.lock
COPY ./README.md /app/README.md
COPY ./pyproject.toml /app/pyproject.toml
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
COPY ./src/lfx/README.md /app/src/lfx/README.md
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
COPY ./src/sdk/README.md /app/src/sdk/README.md
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
RUN --mount=type=cache,target=/root/.cache/uv \
RUSTFLAGS='--cfg reqwest_unstable' \
@@ -70,7 +71,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
FROM python:3.12.12-slim-trixie AS runtime
FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
@@ -85,18 +86,24 @@ RUN ARCH=$(dpkg --print-architecture) \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
else NODE_ARCH="$ARCH"; fi \
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
| head -1) \
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1 \
&& npm install -g npm@latest \
&& npm cache clean --force
| tar -xJ -C /usr/local --strip-components=1
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
COPY --from=builder --chown=1000 /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN /app/.venv/bin/pip install --upgrade playwright \
&& /app/.venv/bin/playwright install
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
# compose file) with the non-root user as owner. When the official compose mounts
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
# and permissions into the new volume, so the in-container uid=1000 user can
# write secret_key, profile_pictures, etc. Without this, the volume is created
# as root:root and Langflow crashes during startup with PermissionError on
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
LABEL org.opencontainers.image.title=langflow
LABEL org.opencontainers.image.authors=['Langflow']
+3 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
ENV TZ=UTC
WORKDIR /app
@@ -21,10 +21,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=README.md,target=README.md \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=src/backend/base/README.md,target=src/backend/base/README.md \
--mount=type=bind,source=src/backend/base/uv.lock,target=src/backend/base/uv.lock \
--mount=type=bind,source=src/backend/base/pyproject.toml,target=src/backend/base/pyproject.toml \
--mount=type=bind,source=src/lfx/README.md,target=src/lfx/README.md \
--mount=type=bind,source=src/lfx/pyproject.toml,target=src/lfx/pyproject.toml \
--mount=type=bind,source=src/sdk/README.md,target=src/sdk/README.md \
--mount=type=bind,source=src/sdk/pyproject.toml,target=src/sdk/pyproject.toml \
uv sync --frozen --no-install-project --no-dev --extra postgresql
EXPOSE 7860
+1 -1
View File
@@ -48,7 +48,7 @@ Volumes:
### PostgreSQL Service
The `postgres` service uses the `postgres:16` Docker image and exposes port 5432.
The `postgres` service uses the `postgres:16-trixie` Docker image and exposes port 5432. The image is pinned to a specific Debian base (`trixie`, Debian 13) so the `postgres:16` tag cannot silently roll its underlying OS, which would otherwise produce a glibc collation version mismatch warning on existing data volumes.
Environment variables:
+33
View File
@@ -0,0 +1,33 @@
services:
langflow:
image: langflowai/langflow:latest # or another version tag on https://hub.docker.com/r/langflowai/langflow
pull_policy: always # set to 'always' when using 'latest' image
ports:
- "7860:7860"
depends_on:
- postgres
environment:
- LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@postgres:5432/langflow
# This variable defines where the logs, file storage, monitor data and secret keys are stored.
- LANGFLOW_CONFIG_DIR=/app/langflow
volumes:
- langflow-data:/app/langflow
postgres:
# Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
# silently roll its OS underneath us — that roll causes a glibc collation
# version mismatch warning on existing volumes. Matches the langflow image
# base. See https://github.com/langflow-ai/langflow/issues/9608
image: postgres:16-trixie
environment:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
POSTGRES_DB: langflow
ports:
- "5432:5432"
volumes:
- langflow-postgres:/var/lib/postgresql/data
volumes:
langflow-postgres:
langflow-data:
+5 -1
View File
@@ -15,7 +15,11 @@ services:
- hanzoflow-data:/app/hanzoflow
postgres:
image: postgres:16
# Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
# silently roll its OS underneath us — that roll causes a glibc collation
# version mismatch warning on existing volumes. Matches the langflow image
# base. See https://github.com/langflow-ai/langflow/issues/9608
image: postgres:16-trixie
environment:
POSTGRES_USER: hanzoflow
POSTGRES_PASSWORD: hanzoflow
@@ -0,0 +1,310 @@
# 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)
---
## TL;DR
macOS Intel (x86_64) is in its final phase of deprecation across the entire ecosystem — Apple hardware, operating system, GitHub Actions runners, PyTorch, and the broader ML/AI stack. Langflow already has platform exclusion markers for the most critical torch-dependent extras (PR #12469), but additional deprecations in GitHub Actions runners, macOS versions, and upstream dependencies will require further action over the next 12–18 months.
**Key decision required:** Set a timeline to officially drop macOS x86_64 from Langflow's support matrix (recommended: Langflow 2.0 or Q4 2026, whichever comes first).
---
## 1. Deprecation Landscape
### 1.1 Apple Hardware & OS Timeline
| Event | Date | Impact |
|-------|------|--------|
| Last Intel MacBook Pro shipped | June 2022 | No new Intel Mac hardware |
| macOS Ventura 13 (last Intel-supported release rumoured) | Oct 2022 | Still receiving security updates as of early 2026 |
| macOS Sequoia 15 released | Sept 2024 | Supports some Intel Macs — expected to be last |
| macOS 26 (Tahoe) expected | June 2026 | Likely final macOS version supporting any Intel Mac |
| macOS 27 expected | 2027 | **Expected to drop all Intel Mac support** |
**Key fact:** Apple's Rosetta 2 translates x86_64 binaries on Apple Silicon, but native ARM64 builds are preferred. Rosetta support for macOS is expected to continue but individual frameworks (e.g., Metal 3) are ARM-only.
### 1.2 GitHub Actions Runner Deprecation
| Runner | Architecture | Status | Deprecation |
|--------|-------------|--------|-------------|
| `macos-13` | Intel x86_64 | **Deprecated** | Removed Dec 2025 |
| `macos-14` | ARM64 (M1) | Active | Current |
| `macos-15` | ARM64 (M1/M2) | Active | Current |
| `macos-latest` | ARM64 | Active | Points to macos-15 |
| `macos-latest-large` | Intel x86_64 | **Active but costly** | At risk — GitHub shifting to ARM |
| `macos-26` (Tahoe) | ARM64 | Available | Current |
**Key fact:** `macos-latest-large` is the **only** Intel x86_64 runner available. It is one of the most expensive GitHub Actions runners. GitHub has signaled the shift toward ARM-only macOS runners.
### 1.3 PyTorch macOS x86_64 Deprecation
| Event | Date |
|-------|------|
| RFC to deprecate macOS x86_64 builds | Nov 2023 |
| PyTorch 2.2.2 — last Intel Mac wheels (`cp310`, `cp311`, `cp312`) | March 2024 |
| PyTorch 2.3.0 — no Intel Mac wheels at all | April 2024 |
| Python 3.13 released (no PyTorch wheels for Intel Mac) | Oct 2024 |
**Result:** No PyTorch wheel exists for macOS x86_64 + Python ≥ 3.13. PyTorch 2.2.2 is the last version with Intel Mac support, and it only covers Python 3.10–3.12.
### 1.4 Broader ML Ecosystem
| Library | macOS x86_64 Support | Notes |
|---------|---------------------|-------|
| PyTorch | ❌ Dropped (after 2.2.2) | No wheels for Python 3.13+ on Intel |
| MLX | ❌ Never supported | Apple Silicon only (arm64 + Python 3.12+) |
| MLX-VLM | ❌ Never supported | Apple Silicon only |
| Metal SDK | ❌ ARM64 only | Metal 3 requires Apple Silicon |
| TensorFlow | ⚠️ Limited | `tensorflow-macos` deprecated, replaced by `tensorflow-metal` (ARM64) |
| ONNX Runtime | ✅ Still supported | Cross-platform, but CoreML EP is ARM64-optimized |
| sentence-transformers | ⚠️ Transitively broken | Depends on PyTorch — fails on Intel Mac + Python 3.13 |
| Hugging Face Transformers | ⚠️ Transitively broken | Same PyTorch dependency chain |
---
## 2. Current State of Langflow macOS Support
### 2.1 Platform Exclusion Markers in `pyproject.toml`
The following extras have platform markers excluding macOS x86_64 (**as of PR #12469**):
| Extra | Marker | Reason |
|-------|--------|--------|
| `altk` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | PyTorch direct + transitive dep (PR #12469) |
| `langchain-huggingface` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | sentence-transformers → torch (PR #12469) |
| `docling` (binary) | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | docling-ibm-models → torch (pre-existing) |
| `easyocr` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | easyocr → torch (pre-existing) |
| `cuga` | `sys_platform == 'darwin' and platform_machine == 'arm64'` | CUDA alternative, ARM64 only on macOS |
| `mlx` | `sys_platform == 'darwin' and platform_machine == 'arm64' and python_version >= '3.12'` | Apple Silicon ML framework |
| `metal` | No marker (correct — cross-platform) | `metal_sdk==2.5.1` is [getmetal.io](https://getmetal.io) cloud SDK, not Apple Metal |
**Extras with NO macOS exclusion that still work on Intel:**
| Extra | Status | Notes |
|-------|--------|-------|
| `docling-core` | ✅ Works | Metadata-only, no torch dependency |
| `ocrmac` | ✅ Works | `sys_platform == 'darwin'` — uses native Vision framework |
| `langchain-unstructured` | ✅ Works | No torch dependency |
| `graph-retriever` | ✅ Works | No platform-specific deps |
| All non-ML extras | ✅ Works | Database, API, monitoring, etc. |
### 2.2 macOS-Specific Runtime Workarounds
| Workaround | Location | Purpose |
|------------|----------|---------|
| `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` | `__main__.py`, `langflow_launcher.py`, CI workflows | Prevents Objective-C fork safety crashes with gunicorn multiprocessing |
| `os.environ["no_proxy"] = "*"` | `__main__.py`, `langflow_launcher.py` | Avoids proxy-related errors with gunicorn on macOS |
| `os.execv()` re-exec pattern | `langflow_launcher.py` | Sets env vars before Objective-C runtime initializes |
| `brew install protobuf` | CI workflow (AMD64 only) | protoc not available on Intel CI runners |
### 2.3 CI Matrix Coverage
| Platform | Python 3.10 | Python 3.12 | Python 3.13 |
|----------|-------------|-------------|-------------|
| macOS ARM64 | ✅ Stable | ✅ Stable | ✅ Experimental |
| macOS AMD64 (Intel) | ✅ Stable | ✅ Stable | ⚠️ Experimental (`continue-on-error`) |
| Linux AMD64 | ✅ Stable | ✅ Stable | ✅ Experimental |
| Windows AMD64 | ✅ Stable | ✅ Stable | ✅ Experimental |
---
## 3. Impact Assessment
### 3.1 What Works on macOS x86_64 Today
Langflow **core functionality** (flow builder, API, database, all non-ML components) works on macOS Intel with Python 3.10–3.13. The following features are **unavailable**:
- ALTK (Agent Lifecycle Toolkit) components
- HuggingFace/sentence-transformers embeddings
- EasyOCR
- Docling document processing (binary; docling-core metadata still works)
- MLX inference
- Metal GPU acceleration
- CUGA
**This is acceptable** because Intel Macs lack the GPU capabilities (Metal 3, Neural Engine) that make these ML features performant.
### 3.2 What Will Break Next
| Timeline | Event | Impact on Langflow |
|----------|-------|--------------------|
| **Now** | PyTorch has no Intel Mac wheels for Python 3.13+ | Already mitigated (PR #12469) |
| **Q3 2026** | GitHub may deprecate `macos-latest-large` (Intel) | Stable CI tests for macOS Intel would need migration or removal |
| **Q4 2026** | macOS 26 (Tahoe) release — potentially last Intel-supporting macOS | Users on Intel Macs stop receiving macOS updates |
| **2027** | macOS 27 drops Intel Mac support | Intel Mac users stuck on macOS 26; system Python/Homebrew may stop building for x86_64 macOS |
| **2027+** | Homebrew, Python.org may drop macOS x86_64 builds | Intel Mac users can't easily install Python 3.14+ |
### 3.3 CI Cost Exposure
`macos-latest-large` (Intel x86_64) is the most expensive runner in the matrix:
- **GitHub Actions pricing:** macOS large runners cost ~$0.12/min (10× Linux)
- **Current usage:** 4 stable jobs + 1 experimental job = 5 Intel Mac CI jobs per workflow run
- **Estimated cost:** ~$15–25 per full CI run for Intel Mac jobs alone
---
## 4. Recommendations
### 4.1 Immediate (No Action Needed)
PR #12469 has already addressed the most critical issue:
- ✅ `altk` and `langchain-huggingface` excluded on macOS x86_64
- ✅ `docling` and `easyocr` already excluded (pre-existing)
- ✅ `mlx`, `cuga` are ARM64-only
- ✅ Experimental CI uses `continue-on-error: true`
### 4.2 Short-Term (Langflow 1.10 / Q2 2026)
#### ~~R1: Add `platform_machine` marker to `metal` extra~~ — NOT NEEDED
`metal_sdk` is the SDK for [getmetal.io](https://getmetal.io) (a cloud vector search/embedding service), **not** Apple's Metal GPU framework. It's a pure Python package (`py3-none-any.whl`) with no platform-specific dependencies. No marker change needed.
#### R2: Reduce macOS Intel CI to Python 3.12 only
Remove the Python 3.10 stable test on macOS AMD64. Python 3.12 is the most commonly deployed version and provides sufficient coverage:
```yaml
# Remove from stable matrix:
# - os: macos
# arch: amd64
# runner: macos-latest-large
# python-version: "3.10"
```
**Saves:** ~$5–7 per CI run, reduces alert noise.
#### R3: Document macOS support matrix in user-facing docs
Add a support matrix page to `docs/` clarifying which features require Apple Silicon vs. work on Intel, so users have clear expectations.
### 4.3 Medium-Term (Langflow 2.0 / Q4 2026)
#### R4: Drop macOS x86_64 from CI entirely
Once GitHub deprecates `macos-latest-large` or macOS 26 ships:
1. Remove all `macos-latest-large` entries from `cross-platform-test.yml`
2. Remove the `brew install protobuf` step (only needed for Intel runners)
3. Keep `macos-latest` (ARM64) as the sole macOS test target
**Saves:** ~$15–25 per CI run. Reduces matrix complexity.
#### R5: Audit and clean up OBJC fork safety workaround
The `OBJC_DISABLE_INITIALIZE_FORK_SAFETY` workaround in `__main__.py` and `langflow_launcher.py` applies to all macOS, not just Intel. However, once Intel is dropped:
- Verify if the issue persists on Apple Silicon with current gunicorn/uvicorn
- If ARM64-only deployments don't trigger the issue, consider removing the workaround
- If still needed, keep it but document why
#### R6: Officially deprecate macOS x86_64 in release notes
Add a deprecation notice in the Langflow 2.0 release notes:
> **macOS Intel (x86_64) support is deprecated.** Langflow 2.0 is the last version tested on Intel Macs. Future releases will only be tested on Apple Silicon (ARM64). Core functionality may continue to work, but ML features require Apple Silicon.
### 4.4 Long-Term (Langflow 2.x+ / 2027)
#### R7: Remove all macOS x86_64 platform markers
Once Intel Mac support is officially dropped, simplify `pyproject.toml` by removing the `platform_machine != 'x86_64'` guards — they become unnecessary clutter:
```toml
# Before (with Intel exclusion)
altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
# After (Intel dropped from support matrix)
altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0"]
```
#### R8: Evaluate ARM64-only macOS features
With Intel out of the picture, lean into Apple Silicon capabilities:
- MLX-based local inference as a first-class feature
- Metal GPU acceleration for embedding generation
- Neural Engine integration for on-device ML
- CoreML model support via ONNX Runtime CoreML EP
---
## 5. Risk Matrix
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| GitHub removes `macos-latest-large` runner | High (12–18 months) | CI breaks for Intel tests | R4: Drop Intel from CI proactively |
| User complaints about missing ML features on Intel Mac | Low | User frustration | R3: Document support matrix clearly |
| ~~`metal_sdk` fails on Intel Mac install~~ | N/A | N/A | `metal_sdk` is getmetal.io cloud SDK — cross-platform, no issue |
| Python 3.14 drops macOS x86_64 support | Low (2027+) | Core Langflow broken on Intel | R6: Deprecation notice well in advance |
| OBJC fork safety workaround breaks on new macOS | Low | Server startup crash | R5: Audit and test on macOS 26 |
| Upstream deps (numpy, scipy) drop Intel Mac wheels | Medium (2027+) | Broad installation failures | R6/R7: Official deprecation covers this |
---
## 6. Recommended macOS Support Matrix
### Current (Langflow 1.9.x)
| Feature Category | macOS ARM64 (Apple Silicon) | macOS x86_64 (Intel) |
|-----------------|---------------------------|---------------------|
| Core (flows, API, database) | ✅ Full support | ✅ Full support |
| ML/AI components (ALTK, HuggingFace) | ✅ Full support | ❌ Excluded (no PyTorch wheels) |
| Document processing (Docling, EasyOCR) | ✅ Full support | ❌ Excluded (no PyTorch wheels) |
| Local inference (MLX, MLX-VLM) | ✅ Full support (Python 3.12+) | ❌ Not available (ARM64 only) |
| GPU acceleration (Metal, CUGA) | ✅ Full support | ❌ Not available (ARM64 only) |
| Native OCR (ocrmac) | ✅ Full support | ✅ Full support |
### Proposed (Langflow 2.0+)
| Feature Category | macOS ARM64 (Apple Silicon) | macOS x86_64 (Intel) |
|-----------------|---------------------------|---------------------|
| All features | ✅ Full support | ⚠️ Deprecated — untested, best-effort |
---
## 7. Summary of Action Items
| # | Action | Target Release | Priority | Effort |
|---|--------|---------------|----------|--------|
| — | ~~Exclude `altk` and `langchain-huggingface` on macOS x86_64~~ | ~~1.9.x~~ | ~~Critical~~ | ✅ Done (PR #12469) |
| ~~R1~~ | ~~Add ARM64 marker to `metal` extra~~ | — | — | ❌ N/A (`metal_sdk` is getmetal.io cloud SDK, not Apple Metal) |
| R2 | Remove Python 3.10 macOS Intel from stable CI | 1.10 | Low | 4 lines YAML |
| R3 | Document macOS support matrix for users | 1.10 | Medium | New docs page |
| R4 | Drop macOS x86_64 from CI entirely | 2.0 | Medium | ~20 lines YAML |
| R5 | Audit OBJC fork safety on Apple Silicon | 2.0 | Low | Investigation |
| R6 | Official deprecation notice in release notes | 2.0 | Medium | Release docs |
| R7 | Remove x86_64 platform markers from pyproject.toml | 2.x | Low | Cleanup |
| R8 | Evaluate ARM64-only macOS capabilities | 2.x | Low | Feature planning |
---
## Appendix A: Complete Platform Marker Inventory
All `sys_platform` and `platform_machine` markers in `src/backend/base/pyproject.toml`:
```
Line 53: jq → sys_platform != 'win32'
Line 96: ocrmac → sys_platform == 'darwin' (root pyproject.toml)
Line 258: metal → (no marker — correct; getmetal.io cloud SDK, not Apple Metal)
Line 293: altk → sys_platform != 'darwin' or platform_machine != 'x86_64'
Line 297: langchain-huggingface → sys_platform != 'darwin' or platform_machine != 'x86_64'
Line 310: docling → sys_platform != 'darwin' or platform_machine != 'x86_64'
Line 312: easyocr → sys_platform != 'darwin' or platform_machine != 'x86_64'
Line 327: cuga → sys_platform != 'darwin' (non-Mac) + sys_platform == 'darwin' and platform_machine == 'arm64' (Mac ARM)
Line 331: mlx → sys_platform == 'darwin' and platform_machine == 'arm64' and python_version >= '3.12'
Line 345: gassist → sys_platform == 'win32'
```
## Appendix B: Related Issues & PRs
| 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 |
+51
View File
@@ -43,6 +43,57 @@ import customComponent from "!!raw-loader!@hanzoflow/src/lfx/src/lfx/custom/cust
/>
```
## Docusaurus Versioning
The versioning configuration is found in `docusaurus.config.js`.
Versioning example for release version `1.9.x` on top of `1.8.x`:
1. Before release, the docs in the active release branch should already be set to `1.8.x`, the current version.
2. When ready to release `1.9.x`, create a branch and run `npm run docs:version -- 1.9.0` to snapshot the current docs.
3. After creating a new version, update `docusaurus.config.js` to include the 1.9.0 release:
```javascript
docs: {
lastVersion: '1.9.0',
versions: {
'1.9.0': {
label: '1.9.x',
path: '1.9.0',
},
'1.8.0': {
label: '1.8.x',
path: '1.8.0',
},
},
},
```
4. Test the deployment locally:
```bash
npm run build
npm run serve
```
5. Create a pull request to main, and merge to create your new release.
6. To create version 2.0.x, repeat the process: update the active release branch docs to `2.0.x` when you begin working on it, then when ready to release, run `npm run docs:version -- 2.0.0`, update `docusaurus.config.js` with labels using `.x` notation, and merge to main.
- `lastVersion` = the most recent released version (shown as "latest" in the UI).
See the [Docusaurus docs](https://docusaurus.io/docs/versioning) for more info.
### Disable versioning
1. Remove the versions configuration from `docusaurus.config.js`.
2. Delete the `docs/versioned_docs/` and `docs/versioned_sidebars/` directories.
3. Delete `docs/versions.json`.
### References
- [Official Docusaurus Versioning Documentation](https://docusaurus.io/docs/versioning)
- [Docusaurus Versioning Best Practices](https://docusaurus.io/docs/versioning#versioning-best-practices)
### Deployment
Using SSH:
@@ -0,0 +1,189 @@
# Investigation: PyTorch on macOS AMD64 (x86_64) + Python 3.13
**Jira:** LE-172
**Date:** 2026-04-02
**Status:** Complete
---
## TL;DR
**PyTorch officially dropped macOS x86_64 (Intel) binary support after version 2.2.2** (milestone: PyTorch 2.3, March 2024). Since Python 3.13 was released in October 2024 — after this cutoff — **no official PyTorch wheel exists for macOS x86_64 + Python 3.13**. This is an upstream, permanent gap — not a bug in Langflow.
The failing CI job is `test-installation-experimental` on `macos-latest-large` (AMD64) with Python 3.13. The `continue-on-error: true` flag means it doesn't block releases, but the failure is persistent and expected.
---
## Root Cause Analysis
### 1. PyTorch macOS x86_64 Deprecation Timeline
| Event | Date | Reference |
|-------|------|-----------|
| RFC opened to deprecate macOS x86_64 builds | Nov 2023 | [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) |
| PyTorch 2.2.2 released (last x86_64 macOS wheels) | March 2024 | Last version with `cp310`, `cp311`, `cp312` macOS x86_64 wheels |
| PyTorch 2.3.0 released (no macOS x86_64 wheels) | April 2024 | Milestone for x86_64 deprecation |
| Python 3.13 released | Oct 2024 | After PyTorch dropped macOS x86_64 |
**Key fact:** PyTorch 2.2.2 has macOS x86_64 wheels for Python 3.10, 3.11, 3.12 — but **not 3.13**. And PyTorch 2.3+ does not publish macOS x86_64 wheels at all.
### 2. What the UV Resolver Does
When resolving for `macOS x86_64 + Python 3.13`, the uv lockfile shows:
```
torch 2.2.2 → Python < 3.13, macOS x86_64 (has wheels: cp310, cp311, cp312)
torch 2.2.2+cpu → Python >= 3.13, macOS x86_64 (NO WHEELS listed!)
torch 2.10.0 → macOS arm64 (all Python versions)
torch 2.10.0+cpu → Linux/Windows (all Python versions)
```
The resolver picks `torch 2.2.2+cpu` for macOS x86_64 + Python 3.13, but **this version has zero available wheels** for that platform. Installation fails because there's no compatible binary.
### 3. How Torch Gets Pulled In
The main `langflow` package depends on `langflow-base[complete]`, which includes multiple extras that transitively require torch:
#### Dependency Chains to Torch
| Extra | Chain | macOS x86_64 Excluded? |
|-------|-------|----------------------|
| `altk` | `agent-lifecycle-toolkit` → torch (direct dep) | **No** |
| `altk` | `agent-lifecycle-toolkit` → `sentence-transformers` → torch | **No** |
| `altk` | `agent-lifecycle-toolkit` → `trl` → `accelerate` → torch | **No** |
| `docling` | `docling` → `docling-ibm-models` → torch | Partially (docling binary excluded, but `docling-core` is not) |
| `easyocr` | `easyocr` → torch | **Yes** (fully excluded on macOS x86_64) |
| `langchain-huggingface` | `langchain-huggingface` → `sentence-transformers` → torch | **No** |
**The ALTK extra is the primary unguarded path** that forces torch installation on macOS x86_64. The issue was initially noticed with ALTK but the root cause is PyTorch's platform support.
### 4. Existing Mitigations
The codebase already excludes some torch-dependent packages on macOS x86_64:
```toml
# src/backend/base/pyproject.toml
docling = [
"docling-core>=2.36.1,<3.0.0",
"docling>=2.36.1,<3.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'",
]
easyocr = ["easyocr>=1.7.2,<2.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
```
However, **ALTK** and **langchain-huggingface** (which depends on sentence-transformers → torch) have **no such platform exclusion**.
### 5. CI Configuration
The experimental job in `.github/workflows/cross-platform-test.yml`:
- Tests Python 3.13 on all platforms including macOS AMD64
- Uses `continue-on-error: true` so failures don't block releases
- The test-summary job considers experimental failures "acceptable"
---
## Recommendations
### Option A: Exclude torch-dependent extras on macOS x86_64 (Recommended)
Add platform markers to all extras that transitively depend on torch, similar to what's done for `easyocr`:
```toml
# In src/backend/base/pyproject.toml
altk = ["agent-lifecycle-toolkit~=0.4.4; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
langchain-huggingface = ["langchain-huggingface==0.3.1; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
```
**Pros:**
- CI will pass on all experimental platforms
- macOS x86_64 users on Python 3.13 can still use Langflow (just without ALTK/HuggingFace features)
- Consistent with existing pattern for docling/easyocr
**Cons:**
- Reduces feature availability on macOS x86_64
- All Python versions on macOS x86_64 lose these features (not just 3.13)
### Option B: Add Python version + platform compound markers
More surgical approach — only exclude on the exact broken combination:
```toml
altk = ["agent-lifecycle-toolkit~=0.4.4; (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')"]
```
**Pros:**
- Preserves functionality on macOS x86_64 + Python 3.10-3.12
- Only excludes the broken combination
**Cons:**
- More complex markers
- PEP 508 marker evaluation of compound OR conditions can be tricky across tools
### Option C: Remove macOS AMD64 from experimental CI matrix (Simplest)
Since macOS x86_64 is a dying platform (Apple stopped selling Intel Macs in 2022), remove it from CI:
```yaml
# Remove this entry from the experimental matrix:
# - os: macos
# arch: amd64
# runner: macos-latest-large
# python-version: "3.13"
```
**Pros:**
- Simplest fix (one line change)
- Saves CI runner costs (macos-latest-large is expensive)
- Reflects reality that macOS Intel is EOL
**Cons:**
- Loses visibility into macOS x86_64 regressions
- Some users may still be on Intel Macs
### Option D: Keep as-is (Status Quo)
The experimental job already uses `continue-on-error: true` and the test-summary job reports it as acceptable. This failure is a known, documented limitation.
**Pros:**
- No code changes needed
- Maintains CI visibility
**Cons:**
- Persistent red CI job creates alert fatigue
- Could mask other real failures in the experimental matrix
---
## Recommendation
**Option A + Option C combined** is the strongest approach:
1. **Add platform exclusion markers** to `altk` and `langchain-huggingface` extras for macOS x86_64 (Option A). This prevents installation failures for end users on Intel Macs.
2. **Optionally remove macOS AMD64 from the Python 3.13 experimental matrix** (Option C) to reduce CI costs and noise, since the platform is EOL from both Apple and PyTorch.
The combination ensures both end-user experience and CI health. The feature loss on macOS Intel is acceptable because:
- Apple stopped producing Intel Macs in June 2022
- PyTorch dropped Intel Mac support in March 2024
- The HuggingFace/ML ecosystem is rapidly moving to ARM-only on macOS
---
## Appendix: Affected Lockfile Entries
### torch versions resolved by platform
| Version | Platform | Python | Has Wheels? |
|---------|----------|--------|-------------|
| `2.2.2` | macOS x86_64 | < 3.13 | Yes (cp310, cp311, cp312) |
| `2.2.2+cpu` | macOS x86_64 | >= 3.13 | **No** |
| `2.10.0` | macOS arm64 | all | Yes (cp310-cp313) |
| `2.10.0+cpu` | Linux/Windows | all | Yes (cp310-cp313) |
### torchvision versions resolved by platform
| Version | Platform | Python | Has Wheels? |
|---------|----------|--------|-------------|
| `0.17.2` | macOS x86_64 | < 3.13 | Yes (cp310, cp311, cp312) |
| `0.17.2+cpu` | macOS x86_64 | >= 3.13 | **No** |
| `0.25.0` | macOS arm64 | all | Yes |
| `0.25.0+cpu` | Linux/Windows | all | Yes |
+79
View File
@@ -0,0 +1,79 @@
# Anti-Patterns and Scar Tissue
These are battle scars. Every rule here cites a real recurring failure: a commit, a revert, or an explicit project lesson. Read this file when you're about to "just" change something — most of the don't/do rules below started as a one-line "small fix" that broke production.
## Don't / Do (with why)
1. **Don't add or rename a field on a component without regenerating starter projects AND the component index.**
*Why:* commits `1a9f4548c`, `5987421bd`, `bbe1dad7c`, `69d29a4c6`, `67af71450`, `4f9beebc0` are all `fix:` commits cleaning up the same "I added a field and shipped" mistake. CI (`9dad1965c`) now syncs the index on label addition — that's a backstop, not a substitute.
2. **Don't rename a component class, display name, or module path.** If unavoidable, add the new component alongside the old one, set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class, and update every supported version's `file_names_mapping` in tests.
*Why:* class names key saved flows. There is no class-rename map in `setup.py` — the `type_migrations` map there only handles output type strings (`Data` → `JSON`, `DataFrame` → `Table`). See `2a7c56e84` (`IngestionDescriberComponent` → `FileDescriptionGeneratorComponent`) and `f5a0d52197` (starter project rename) for the cleanup churn. See [CONTRACTS.md](./CONTRACTS.md) for the full list.
3. **Don't mock at a different boundary than production calls.** If production uses `subprocess.Popen`, mock `Popen` — not `run`.
*Why:* `a747135d1`, `bc433c375` — mocked tests passed; production failed. **Better: don't mock at all** — project policy is "Avoid mocking in tests." Use real integrations or `MockLanguageModel` for pure-logic LLM paths.
4. **Don't write a Graph test by poking internals.**
*Why:* the canonical pattern is build → connect via `.set()` → call `async_start` → iterate results → validate. Tests that bypass this don't exercise the graph the way users do.
5. **Don't add a retry, broaden an except, or widen a type to make a symptom go away.**
*Why:* "It is not a fix if you didn't have evidence of the change fixing something." See revert chain `9cdbf1b23`, `35aad2f2e`, `e3b90b7351` — fixes that masked rather than solved, then had to be reverted.
6. **Don't bump a dependency speculatively.** Confirm the conflict reproduces locally and the new version actually resolves it.
*Why:* `cf659f0d0`, `ef6a303406`, `38d142a72`, `9029c4b61` — repeated dep-conflict cleanup.
7. **Don't invent model IDs, MCP tool names, or API surface.** Read the registry, the batch dispatcher, and `models.py` first.
*Why:* `9f1402ed9` (`remove fictional gpt-5.3 ids`), `cecc4a6c2` (`expose layout tool as layout_flow to match batch dispatch`).
8. **Don't use raw `langflow run` / `python` when a `uv` command exists.** Always `uv run`. For lfx tests specifically: `uv sync` inside `src/lfx` (not `src/lfx/src/lfx`) to avoid pulling in langflow.
*Why:* lfx must be testable without langflow installed; mixing them masks dependency leaks.
9. **Don't add a database migration without running `make alembic-upgrade` end to end** and `uv run pytest src/backend/tests/unit/test_database.py` sequentially.
*Why:* `d8b9cc38f`, `c30150a2d`, `6585fe661`. `test_database.py` may pass in batch and fail individually — agents have stopped running it.
10. **Don't claim a race condition is fixed without an integration test that reproduced it.** Agent double-execution and queue leaks keep coming back.
*Why:* `8a993ac90`, `42e84d0fd`, `749865b31`, `f30b291f8`.
11. **Don't add `tool_mode=True` to outputs that confuse agents** (e.g., dataframe surfaces).
*Why:* `cbc54108` (`remove tool_mode=True for as_dataframe`), `35aad2f2e` (revert of the original disable). Output tool exposure is a user contract — don't toggle it back and forth.
12. **Don't widen the scope of a PR.** Closely related follow-ups belong in the existing open PR, not a new stacked PR.
13. **Don't ship without running the tests locally.** Typing the test command in the chat is not running it.
14. **Don't make incremental "wip" commits.** One coherent commit per logical change, after everything is done and tested.
15. **Don't push or open PRs without explicit user confirmation.** Pushing during active CI iteration on an already-open PR is fine; opening a new PR or pushing new branches is not.
## Things that look like a fix but aren't
- Adding `try/except Exception: pass` around a flaky call.
- Adding a `time.sleep` to dodge a race instead of awaiting a condition.
- Bumping a dependency version "just in case."
- Marking a failing test `@pytest.mark.skip` without a linked issue.
- Switching `# type: ignore` instead of fixing the type.
- Reverting symptoms (a UI flag, a tool flag) instead of finding the root.
- "Fixing" a test by changing its assertion to match wrong output.
## Before claiming done
- [ ] `make format_backend` and `make lint` clean.
- [ ] Tests for the changed surface ran and passed locally — not just typed out.
- [ ] If a component field/output changed: starter projects regenerated and `component_index.json` rebuilt.
- [ ] If a class/module renamed: old class kept with `legacy=True` + `replacement=[...]`; `file_names_mapping` updated for every supported version.
- [ ] No incremental "wip" commits. One coherent commit per logical change.
- [ ] No "Generated with Claude Code" / `Co-Authored-By: Claude` trailers.
- [ ] No `--no-verify` unless explicitly authorized.
- [ ] PR not pushed and PR not opened until the user explicitly asks.
- [ ] PR description has no test-plan checklist, no Jira links/IDs.
## Where to look for context — don't invent it
- **Starter projects:** `src/backend/base/langflow/initial_setup/starter_projects/`. Any field/output change must regenerate these.
- **Component index:** `src/lfx/src/lfx/_assets/component_index.json` (rebuilt via the index sync workflow added in `9dad1965c`).
- **Output type migrations:** `src/backend/base/langflow/initial_setup/setup.py` `type_migrations` map — handles renames of *output type strings* (e.g., `Data` → `JSON`). It does NOT remap component class names; for class renames use `legacy=True` + `replacement=[...]`.
- **Version mapping for component tests:** `src/backend/tests/constants.py::SUPPORTED_VERSIONS` and each test's `file_names_mapping`.
- **Release notes / changelog:** `docs/docs/Support/release-notes/` — check before claiming behavior is "new"; many features predate the current task.
- **MCP tool catalog:** dispatched via the batch tool — names must match (`cecc4a6c2` is the canonical "I guessed the tool name" failure).
- **Models / providers:** real model IDs come from the provider's listing endpoint or our adapter, never from training-data memory (`9f1402ed9`).
- **Migrations:** `src/backend/base/langflow/alembic/versions/` — always read the latest two before adding a new one.
+81
View File
@@ -0,0 +1,81 @@
# Architecture Boundaries
Langflow is three Python packages and one frontend, layered with one-way dependencies. Most "off-narrative" code is a boundary violation. Read this before adding a new file.
## Package dependency graph (one-way only)
```
frontend (TS) ──HTTP──▶ langflow (api routers, integrations, distribution)
│
▼ may import
langflow-base (services, graph, db, alembic)
│
▼ may import
lfx (executor core, base primitives, components)
│
▼ may import
langchain-core, pydantic, third-party SDKs
```
### Dependency rules
- **`lfx` MUST NOT import `langflow.*` or `langflow-base.*`.** If lfx code needs a service (auth, db, flow lookup), define an interface inside `lfx` and inject the implementation from `langflow`. The repo currently has ~13 upward `from langflow.*` imports inside `src/lfx/src/lfx/components/...` (auth, db, helpers) — these are **known violations**. Do not add more; prefer fixing them.
- **`langflow-base` MAY import `lfx`.** It MUST NOT import vendor-specific component modules from `langflow.components.<vendor>`.
- **`frontend` talks to `langflow` only via HTTP/WebSocket.** No shared filesystem state.
## "Where does this code go?" decision tree
Walk top-down. Stop at the first match.
1. Is it framework-agnostic flow execution, base component classes, or `Component` primitives?
→ `src/lfx/src/lfx/` (`base/` for shared primitives, `components/` for built-ins shipped with lfx).
2. Is it a FastAPI route, auth, db model, alembic migration, or a lifecycle-managed singleton?
→ `src/backend/base/langflow/` (`api/`, `services/X/`, `alembic/versions/`).
3. Is it a vendor integration (OpenAI, Pinecone, Notion, …) — a `Component` subclass that wraps a third-party SDK?
→ `src/lfx/src/lfx/components/<category>/` and update its `__init__.py` alphabetically. Never rename the class.
4. Is it UI, state, or icons?
→ `src/frontend/src/`. If it consumes a new API field, also update `src/frontend/src/types/`.
5. Is it CLI behavior for `lfx run` / `lfx serve`?
→ `src/lfx/src/lfx/cli/`.
6. Is it a SQLAlchemy/SQLModel model change?
→ `services/database/models/` AND `make alembic-revision message="..."` AND apply with `make alembic-upgrade`.
7. Is it a flow JSON schema change?
→ STOP. Existing saved flows must keep loading. Add a version mapping; do not mutate the existing shape. See [CONTRACTS.md](./CONTRACTS.md).
8. Is it shared by both `lfx` and `langflow-base`?
→ `src/lfx/src/lfx/base/`, never `langflow/base/`.
## Dependency direction — bad/good examples
- **Bad:** `from langflow.services.deps import session_scope` inside `src/lfx/...`.
**Good:** Define `lfx.interfaces.SessionProvider`, accept it as a constructor arg; `langflow` wires the concrete `session_scope` at startup.
- **Bad:** `from langflow.components.openai import ...` inside `langflow-base` core (`api/`, `services/`, `graph/`).
**Good:** Components are loaded dynamically via the component registry; core code references `Component` only.
- **Bad:** A new `MyHelperService` that's just functions.
**Good:** Utility functions go in `langflow/helpers/` or `lfx/utils/`. A service inherits from `services/base.Service` and is registered via `services/factory.py`.
## API change protocol
- **`api/v1/`** is the live, stable surface (~25 routers). Existing v1 endpoints MUST stay backwards-compatible: only additive fields, never rename or remove.
- **`api/v2/`** is the **active redesign surface** (`files`, `mcp`, `registration`, `workflow`) — both are mounted at runtime in `api/router.py`. v2 is **not** "future"; the old cursor rule was wrong.
- Add a new endpoint to v2 only if it (a) replaces a v1 endpoint with a breaking shape change, or (b) belongs to one of the four v2 domains. Otherwise extend v1 additively.
- A breaking change to a v1 endpoint is forbidden. Add a v2 sibling and leave v1 in place.
## Cross-cutting change protocol
A change that touches a request/response shape MUST update three places in the same PR:
1. The pydantic model in `langflow/api/v{1,2}/schemas.py` (or the route's local schema).
2. The TypeScript type in `src/frontend/src/types/` consumed by the affected page/store. There is no OpenAPI generator — the types are hand-maintained, so the frontend silently breaks at runtime if you skip this.
3. If the field is persisted: a new alembic revision (`make alembic-revision message=...`) AND a flow-JSON version mapping if the shape lives inside saved flows.
If you cannot do all three in one PR, do not start.
## Service vs utility vs component
- **Service** (`services/<name>/`): lifecycle-managed singleton, inherits `services.base.Service`, registered through `services/factory.py`, accessed via `services/deps.py`. Use when the thing has state, startup/shutdown, or shared connections (db, cache, queue).
- **Utility** (`helpers/`, `utils/`, or `lfx/utils/`): pure or near-pure functions. Use when there is no shared state and no lifecycle.
- **Component** (`src/lfx/src/lfx/components/<category>/`): user-visible node in the graph, subclass of `Component`, with `display_name`, `inputs`, `outputs`. Use only when the user must wire it on the canvas. Do not add a Component to expose internal plumbing.
## `lfx/base/` vs `langflow/base/`
Both exist. Both have `agents/`, `data/`, `models/`, `prompts/`. New shared primitives go in **`src/lfx/src/lfx/base/`**. The `langflow/base/` tree is legacy; do not add to it.
+138
View File
@@ -0,0 +1,138 @@
# Component Development
Components are the unit of work in Langflow. They render as nodes on the canvas, persist into flow JSON, and become tools for Agents. Get the contract right and the rest of the system follows; get it wrong and you orphan every saved flow that referenced your component.
Components live in **`src/lfx/src/lfx/components/<category>/`**. The mirror tree under `src/backend/base/langflow/components/` is legacy stubs — do not add files there.
## Before you add a component (decision checklist)
Stop and check, in order. If any answer is "yes," do not create a new component.
1. **Does one already exist?** Search:
```bash
rg -l 'display_name = ".*<keyword>.*"' src/lfx/src/lfx/components/
```
and check the category folder. The Calculator regret (two of them, see `tools/calculator.py` `legacy = True, replacement = ["helpers.CalculatorComponent"]`) is what happens when this step is skipped.
2. **Is this a thin wrapper around an existing tool/SDK that an Agent could call directly via `tool_mode=True`?** If yes, add `tool_mode=True` to the relevant input on the existing component instead of building a new one.
3. **Does it belong in a vendor bundle** (e.g., `openai/`, `anthropic/`, `datastax/`) rather than in generic `tools/` or `helpers/`? Vendor logic goes in vendor folders so bundles can ship/version independently.
4. **Is this an lfx-runtime concern** (graph execution, schema, IO primitives) or a **Langflow-app concern** (auth, DB, tracing)? Components are lfx; never import `langflow.services.*` from a component.
5. **Does an existing base class cover this category?** Use it:
- LLMs → `LCModelComponent` (see `openai/openai_chat_model.py`)
- Tools → `LCToolComponent` (see `tools/calculator.py`)
- Vector stores → `LCVectorStoreComponent` + `@check_cached_vector_store` (see `vectorstores/local_db.py`)
- Chat IO → `ChatComponent` (see `input_output/chat.py`)
## Scope rules (one component, one job)
- One job per component. If the description has " and " in it, split it.
- **Inputs budget:** aim for ≤8 visible inputs; push the rest behind `advanced=True`. If you need a mode switch (Ingest/Retrieve etc.), use `TabInput` + `update_build_config` to hide irrelevant fields — see `vectorstores/local_db.py`.
- **Outputs:** one primary `Output(method=...)` is the default. Add more only if downstream nodes genuinely need different shapes (Message vs DataFrame vs Data). Don't add an output you don't use.
- Mark inputs an Agent should be able to fill with `tool_mode=True` (e.g., `local_db.py`) — this is how a component becomes an agent tool.
## Breaking-change list (locked once shipped)
Saved flows reference components by string identifiers. Once a component is merged, the following are **frozen** — change them and existing user flows break silently:
- The **class name** (already in our rules).
- The **`name = "..."` class attribute**. **This is the flow-JSON identifier**, not the class name. See `openai_chat_model.py`, `chat.py`.
- Each input **`name=...`**. Renaming `input_value` → `prompt` orphans every edge pointing at it.
- Each output **`name=...`** and its declared return type. Downstream nodes match on both.
- **Default values** and default behavior of existing inputs (a flow saved with the default re-loads with the new default).
To remove or rework a component, **do not edit it in place**. Add the replacement under the right category, then on the old one set:
```python
legacy = True
replacement = ["<category>.<NewClassName>"]
```
(see `tools/calculator.py`, `flow_controls/sub_flow.py`). The UI shows "Updates Available" and migrates users; the old class stays importable.
See [CONTRACTS.md](./CONTRACTS.md) for the full user-facing surface this protects.
## Conventions agents miss
These appear all over the codebase but rarely in old docs.
- **`name`** (class attr): flow-JSON identifier. Always set it explicitly; do not rely on `__class__.__name__`. Example: `chat.py` `name = "ChatInput"`.
- **`legacy = True` + `replacement = [...]`**: deprecation pair. Always together. Example: `tools/calculator.py`.
- **`tool_mode=True`** on an input: exposes the component as an Agent tool with that input as the tool argument. Example: `vectorstores/local_db.py`.
- **`real_time_refresh=True` + `update_build_config(self, build_config, value, name)`**: dynamic form. Use for mode switches and dependent-field hide/show. Examples: `openai/openai_chat_model.py`, `vectorstores/local_db.py`.
- **`metadata = {"keywords": [...]}`**: extra search terms for the component picker. Example: `data_source/sql_executor.py`.
- **`minimized = True`**: render collapsed by default. Example: `input_output/chat.py`.
- **`documentation = "https://docs.langflow.org/..."`**: deep-link from the node UI. Example: `input_output/chat.py`.
## Placement rules
- **Vendor-specific code** → vendor folder (`openai/`, `anthropic/`, `datastax/`, `cohere/`, …). Not `tools/`, not `models/`.
- **Generic, vendor-agnostic helpers** → `helpers/`, `processing/`, `logic/`, `flow_controls/`.
- **IO primitives** → `input_output/`.
- Add the import to the category `__init__.py` in **alphabetical order**.
- **Do not invent a new top-level category.** If you think you need one, that is a signal to discuss with the team first — categories are visible in the UI sidebar and are part of the product surface.
## Icons
Every component needs an icon. Use a Lucide icon when one fits; create a custom SVG only for vendor logos.
### Lucide icon (default)
```python
icon = "calculator" # any Lucide icon name, lowercase
```
See https://lucide.dev/icons for the catalog.
### Custom vendor icon
For brand logos you need a frontend SVG component. The Python `icon` string and the frontend mapping key must match **exactly** (case-sensitive).
1. **Python:** set `icon = "AstraDB"` on the component.
2. **Frontend SVG component** at `src/frontend/src/icons/AstraDB/AstraDB.jsx`:
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path fill={props.isDark ? "#ffffff" : "#0A0A0A"} d="..." />
</svg>
);
```
3. **Wrapper** at `src/frontend/src/icons/AstraDB/index.tsx`:
```tsx
import React, { forwardRef } from "react";
import AstraSVG from "./AstraDB";
export const AstraDBIcon = forwardRef<SVGSVGElement, React.PropsWithChildren<{}>>(
(props, ref) => <AstraSVG ref={ref} isDark={isDark} {...props} />
);
```
4. **Lazy import** in `src/frontend/src/icons/lazyIconImports.ts`:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
5. Verify in the UI in **both light and dark mode**.
### Icon checklist
- [ ] Python `icon = "..."` set.
- [ ] If custom: SVG component with `isDark` prop, wrapper with `forwardRef`, entry in `lazyIconImports.ts`.
- [ ] Light and dark mode both verified.
## Component testing
See [TESTING.md](./TESTING.md) for the full testing contract. Quick reference:
- Inherit from `ComponentTestBaseWithClient` (needs API) or `ComponentTestBaseWithoutClient` (pure logic).
- Provide three fixtures: `component_class`, `default_kwargs`, `file_names_mapping`.
- Use `MockLanguageModel` for pure-logic LLM paths; use `@pytest.mark.api_key_required` for real-API tests.
- For graph behavior, use the Graph test pattern: build, `.set()`, `async_start`, validate. Do not poke graph internals.
## Canonical examples
When in doubt, read these files before starting:
- LLM with dynamic provider switching: `src/lfx/src/lfx/components/openai/openai_chat_model.py`
- Tool with `tool_mode` and legacy/replacement: `src/lfx/src/lfx/components/tools/calculator.py`
- Vector store with cache + tab inputs: `src/lfx/src/lfx/components/vectorstores/local_db.py`
- Chat input with `minimized`: `src/lfx/src/lfx/components/input_output/chat.py`
- Component with searchable metadata: `src/lfx/src/lfx/components/data_source/sql_executor.py`
- Legacy + replacement pattern: `src/lfx/src/lfx/components/flow_controls/sub_flow.py`
+57
View File
@@ -0,0 +1,57 @@
# User-Facing Contracts
These are the surfaces users depend on. Breaking them silently is the most common way an agent ships work that "doesn't connect to the story." Read this before changing anything below.
## The contract surface
| # | Contract | Source of truth | Rule |
|---|----------|-----------------|------|
| 1 | Component class `name` attribute | `class XComponent: name = "X"` | NEVER rename. Used to match nodes in saved flow JSON. |
| 2 | Component class identifier (Python class name) | `class XComponent` | NEVER rename. Combined with `name` for resolution. |
| 3 | Component file path + module | `src/lfx/src/lfx/components/<cat>/<file>.py` | Renames are breaking. The supported workflow is to add the new component alongside the old one and set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class. Update every component's `file_names_mapping` in tests for past `SUPPORTED_VERSIONS` (`src/backend/tests/constants.py`). Regenerate starter projects and rebuild the component index. |
| 4 | Input `name=` and `Output(name=...)` on every component | The component file itself | NEVER rename or remove. Saved flows reference these as keys. Adding new optional inputs is safe; removing or renaming breaks every saved flow that used them. |
| 5 | Output order and `Output.types` | Component `outputs = [...]` list | Reordering changes default selection in old flows. Tightening a type is a breaking change; widening is safe. |
| 6 | Default values for inputs | `default=` / `value=` in input spec | Changing a default silently changes behavior for users who relied on the default. Treat as a breaking change unless the old default was a bug. |
| 7 | Flow JSON schema | `src/backend/base/langflow/initial_setup/starter_projects/*.json` (canonical examples) | Top-level `data.nodes[*].data.node` shape and `data.edges` shape are public. New fields must be optional with defaults. |
| 8 | Public REST API | `docs/docs/API-Reference/api-flows-run.mdx`, `api-build.mdx`, `api-files.mdx`, `api-projects.mdx`, `api-logs.mdx`, `api-monitor.mdx`, `api-users.mdx`. Endpoints with `include_in_schema=False` are internal. | Documented endpoints are stable. `POST /api/v1/run/{flow_id_or_name}` and `POST /api/v1/webhook/{flow_id_or_name}` are user contracts — payload shape and status codes are frozen. |
| 9 | MCP tool exposure | `src/backend/base/langflow/api/v1/mcp.py`, `mcp_projects.py`. Tool-mode toggled by `Output.tool_mode` / outputs named `component_as_tool`. | Removing `tool_mode=True` from an existing component output, or changing its name, breaks every Agent flow using it as a tool. |
| 10 | `Message` / `Data` / `DataFrame` schema | `src/lfx/src/lfx/schema/message.py` (`text`, `sender`, `sender_name`, `session_id`, `flow_id`, `timestamp`, `properties`, `content_blocks`, `category`, `files`, `error`, `edit`, `duration`, `session_metadata`) | Inter-component wire format. Add fields with defaults only. Renaming or retyping any listed field breaks every running flow. |
| 11 | Environment variables | `LANGFLOW_*` (server) defined in `src/backend/base/langflow/services/settings/base.py` and `feature_flags.py`. `LFX_*` (executor) defined in `src/lfx/src/lfx/services/settings/base.py`, except a few read directly via `os.getenv` (e.g., `LFX_DEV` in `src/lfx/src/lfx/interface/components.py`). | Public deployment contract. Renaming requires deprecation cycle reading both names. |
| 12 | Database schema | `src/backend/base/langflow/services/database/models/` + `alembic/versions/`. | NEVER edit a model without `make alembic-revision`. Never edit a past migration. Custom-component Python source is stored in user DBs — refactoring an import path used by `from langflow.X import Y` breaks loading those rows. |
| 13 | Starter project JSON | `src/backend/base/langflow/initial_setup/starter_projects/*.json` | Each references real components by `name`/`type`. Renaming a component, removing an input, or changing an output type breaks loading. After ANY component change, re-load the affected starter project. |
| 14 | Webhook payload shape | `POST /api/v1/webhook/{flow_id_or_name}` | External systems POST here. Response status (202 Accepted) and the `dict` body shape are frozen. |
| 15 | Component index | `src/lfx/src/lfx/_assets/component_index.json` | Generated artifact consumed by the frontend. Any field/output addition requires regeneration; CI enforces this on label add. |
## Before-you-change matrix
| If you are about to... | Check / update |
|---|---|
| Rename a component file | Add the new file alongside; set `legacy = True` + `replacement = [...]` on the old class; update every test's `file_names_mapping`; grep `starter_projects/*.json` for the old type |
| Rename an input `name=` | Don't. Add a new input alongside and deprecate the old via `legacy=True` on the component if necessary; grep `starter_projects/*.json` for the old name |
| Add or rename an output | Regenerate starter projects; rebuild component index |
| Change a default value | Treat as breaking. Grep starter projects + tests for reliance |
| Remove `tool_mode=True` from an output | Search agent flows / starter projects using it as a tool; this is a user-visible regression |
| Add a `Message` field | Must be `Optional` with default; never reorder existing |
| Add a REST endpoint | If user-facing, add to `docs/docs/API-Reference/`; if internal, set `include_in_schema=False` |
| Modify a DB model | `make alembic-revision message="..."`; never edit past revisions |
| Change a `LANGFLOW_*` / `LFX_*` env var | Read old name as fallback for at least one minor version; document in release notes |
| Change a request/response schema | Update pydantic schema + `src/frontend/src/types/` + alembic if persisted, all in one PR |
## Breaking changes that look harmless
These all look like cleanup. They are user-visible regressions.
- **Renaming `input_value` → `text`** on a component: 100% of saved flows referencing that input lose their wiring.
- **Reordering `outputs = [a, b]` to `[b, a]`**: old flows with the default selection now route from the wrong output.
- **Tightening `Output(types=["Message", "Data"])` → `["Message"]`**: edges that resolved as `Data` go invalid on load.
- **Changing a `default="gpt-4o-mini"` to `default="gpt-5"`**: silently re-bills users.
- **Moving `langflow/components/foo/bar.py` → `langflow/components/foo2/bar.py`**: custom components stored in user DBs that `from langflow.components.foo.bar import ...` fail on next load. `file_names_mapping` test passes because it only validates listed historical versions, not user code.
- **Dropping `tool_mode=True` from a vector store's `as_dataframe`**: every Agent that called it as a tool now sees the tool disappear.
- **Adding a required field to `Message`**: every queued message in a running deployment fails to deserialize on next read.
- **Renaming a starter project file**: deep links from docs and tutorials 404.
## Why this matters
Langflow flows are **persisted user artifacts** running in production. The system that loads them is forgiving by design: it tolerates new fields, falls back on missing ones, applies type migrations on load. That tolerance is paid for by every contract in the table above. Break one and the system can no longer route around it — the flow stops working.
Treat this file as a checklist. If your change touches any row, the corresponding rule applies; no exceptions without an explicit deprecation plan.
+33
View File
@@ -0,0 +1,33 @@
# Project Philosophy
Read this file before scoping any non-trivial work. Most off-narrative work comes from skipping it.
Langflow is a **visual flow builder first**. Every change should make sense to someone whose only interface is dragging nodes onto a canvas. Use these principles to scope work; if a proposed change fails one of them, stop and reconsider.
## Tenets
1. **Flows are user artifacts, not implementation details.** Flow JSON lives in users' databases and runs in production. Backwards compatibility for components is non-negotiable: never rename a class name, `name` attribute, input name, or output name; never remove an input or output; never tighten an output type; never remove methods from base classes like `LCModelComponent`. To change a component, add the new one alongside, set `legacy=True` + `replacement=[...]` on the old, and update `file_names_mapping` in tests so saved flows still load. See [CONTRACTS.md](./CONTRACTS.md) for the full surface.
2. **Every backend feature must land on the canvas.** If a capability cannot be expressed as a component (or a property of one) that a non-Python user can wire up, it is an SDK feature and belongs in `lfx`, not `langflow-base`. Backend changes that have no visual surface are almost always off-narrative.
3. **Components are the unit of work, not endpoints or services.** Before adding a route, store, or service, ask: which component does this serve? If the answer is "none yet," the route is premature. New REST endpoints without a component or UI consumer are off-narrative.
4. **`lfx` is the runtime, `langflow-base` is the platform, `langflow` is the distribution.** Components and the graph engine live in `lfx` (`src/lfx/src/lfx/components/`). API, auth, persistence, and multi-user concerns live in `langflow-base`. The `langflow` package is the integration that ships everything together. New code goes in the lowest layer that can host it. Boundaries are enforced one-way: `langflow` → `langflow-base` → `lfx`. See [ARCHITECTURE.md](./ARCHITECTURE.md).
5. **Don't add a config flag for something a builder-user would set on a node.** If a setting affects flow behavior, it's a component input. If it affects deployment, it's an env var. Hidden global flags that change component semantics break the visible-data-flow contract.
6. **Visible data flow beats clever magic.** Implicit context, hidden globals, and side-channel state make flows unreadable on the canvas. Pass data through inputs and outputs. If you find yourself reaching for a global, you're probably building something that doesn't belong in a node.
7. **Composition over capability.** Prefer adding small, single-purpose components users can wire together over one large component with many modes. The `If-Else` / `Current Date` style (one job, clear name, obvious icon) is the target shape.
8. **Every component needs `display_name`, `description`, `icon`, and a sensible category.** A component that doesn't render legibly in the sidebar shouldn't ship. The icon is part of the API, not decoration — pick a Lucide icon that matches the verb.
9. **The Playground is the test harness builder-users see.** A component is "done" when it works end-to-end in a flow on the canvas, not when its unit test passes. Use the Graph test pattern (build, `.set()`, `async_start`, validate) before claiming a feature works.
10. **Two audiences, one product: visual builders and Python devs.** When their needs conflict, the visual builder wins for `langflow-base` features and the Python dev wins for `lfx` SDK features. Don't compromise the canvas to make the SDK prettier, and don't compromise the SDK to make the canvas easier.
11. **It is not a fix if you didn't have evidence of the change fixing something.** Adding error handling, retries, type widening, or skipping a flaky test does not constitute a fix. Reproduce the failure, demonstrate the fix removes it, then ship. See [ANTI-PATTERNS.md](./ANTI-PATTERNS.md).
## How to apply
When scoping a task, walk through the tenets and name the ones the work serves. If a change fails tenet 1, 2, or 3, stop and surface the conflict before writing code. If you can't tell which tenet a change serves, it's probably off-narrative — ask.
+163
View File
@@ -0,0 +1,163 @@
# Testing
Conventions for backend tests. Frontend testing follows the standard Jest/Playwright patterns documented in `src/frontend/`.
## Project policy: avoid mocking
Prefer real integrations. The pattern of "mocked test passes, production fails" has cost us multiple release cycles — see [ANTI-PATTERNS.md](./ANTI-PATTERNS.md) for the relevant commit history. Use mocks only when:
- The dependency is an LLM and the test exercises pure logic → use `MockLanguageModel` from `tests/unit/mock_language_model.py`.
- The dependency is genuinely unreliable and orthogonal to what's being tested.
Otherwise: hit the real thing, mark the test with `@pytest.mark.api_key_required` if it needs credentials, and let CI gate it.
## Built-in fixtures
### `client` (FastAPI test client)
Defined in `src/backend/tests/conftest.py`. Async `httpx.AsyncClient` connected to the full app via `ASGITransport` + `LifespanManager`. Auto-configured with in-memory SQLite and mocked env vars. Skip with `@pytest.mark.noclient`.
```python
async def test_login_endpoint(client):
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
assert response.status_code == 200
```
For authenticated routes, also use the `logged_in_headers` fixture.
## Component test base classes
Located in `src/backend/tests/base.py`.
| Base class | Creates `client`? | Use for |
|---|---|---|
| `ComponentTestBase` | No | Component version testing core logic |
| `ComponentTestBaseWithClient` | Yes | Components that hit backend services during `run()` |
| `ComponentTestBaseWithoutClient` | No | Pure-logic components |
### Required fixtures
Every subclass provides three fixtures:
1. **`component_class`** — the component class under test.
2. **`default_kwargs`** — dict of kwargs to instantiate the component (can be empty).
3. **`file_names_mapping`** — list of `VersionComponentMapping` entries mapping each historical Langflow version (from `src/backend/tests/constants.py::SUPPORTED_VERSIONS`) to module/file names. Use `DID_NOT_EXIST` for versions before the component was added.
```python
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
from langflow.components.my_namespace import MyComponent
class TestMyComponent(ComponentTestBaseWithClient):
@pytest.fixture
def component_class(self):
return MyComponent
@pytest.fixture
def default_kwargs(self):
return {"foo": "bar"}
@pytest.fixture
def file_names_mapping(self):
return [
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
]
```
The base class auto-provides:
- `test_latest_version` — instantiates and asserts `run()` doesn't return `None`.
- `test_all_versions_have_a_file_name_defined` — ensures mapping completeness vs `SUPPORTED_VERSIONS`.
- `test_component_versions` (parameterized) — builds the component from source for each supported version and asserts execution.
If you rename or move a component file, you **must** update `file_names_mapping` for every supported version, or saved flows on those versions will fail to load. See [CONTRACTS.md](./CONTRACTS.md) row 3.
## Graph testing pattern
The canonical pattern for tests that exercise the graph engine:
1. Build the graph with connected components.
2. Connect them via `.set()` calls.
3. Call `async_start` and iterate over the results.
4. Validate the results.
Don't poke graph internals. If a test needs to reach into private state, the test is wrong or the API is wrong — fix the right one.
## Async patterns
```python
@pytest.mark.asyncio
async def test_async_component():
result = await component.async_method()
assert result is not None
```
**Awaiting conditions, not sleeping:** never use `time.sleep` or `asyncio.sleep` to mask a race. Wait on a condition (`asyncio.wait_for`, an event, a queue read with timeout). Sleep-based tests are flaky by construction.
## Pytest markers
- `@pytest.mark.api_key_required` — needs an external API key; CI skips when absent.
- `@pytest.mark.no_blockbuster` — skip blockbuster plugin.
- `@pytest.mark.noclient` — skip the `client` fixture.
- `@pytest.mark.asyncio` — async test (also `pytest-asyncio` auto-mode in some configs).
## Database tests
`test_database.py` may fail in batch and pass individually. If you touch DB models or migrations, run it sequentially as part of your verification:
```bash
uv run pytest src/backend/tests/unit/test_database.py
```
Never edit a past alembic migration. Run `make alembic-upgrade` end-to-end before claiming a migration works.
## API endpoint tests
```python
async def test_flows_endpoint(client, logged_in_headers):
flow_data = {"name": "Test", "data": {"nodes": [], "edges": []}}
response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers)
assert response.status_code == 201
```
For event-stream endpoints, consume the NDJSON stream and validate event order:
```python
async for line in response.aiter_lines():
if not line:
continue
parsed = json.loads(line)
# First event should be vertices_sorted; last should be end.
```
## Flow testing with starter JSON
Use `tests/unit/build_utils.py` helpers:
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events
flow_id = await create_flow(client, json_flow, logged_in_headers)
build_response = await build_flow(client, flow_id, logged_in_headers)
events_response = await get_build_events(client, job_id, logged_in_headers)
```
## Running tests
```bash
make unit_tests # All backend unit tests, parallel
make unit_tests async=false # Sequential
uv run pytest path/to/test.py # Single file
uv run pytest path/to/test.py::test_name # Single test
# lfx tests specifically — must be run after `uv sync` inside src/lfx
cd src/lfx && uv sync && uv run pytest
```
## Verification checklist before claiming "tests pass"
- [ ] You actually ran the command, not just composed it.
- [ ] You ran `test_database.py` sequentially if you touched DB code.
- [ ] You ran the specific test file for any component you changed.
- [ ] No skipped tests without a linked issue.
- [ ] No mocks added at the wrong boundary.
+1021 -95
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
# API Examples (Local Test Harness)
Run all API example suites against a local Langflow server:
```bash
make api_examples_local
```
Run one test suite:
```bash
make api_examples_local suites=python
make api_examples_local suites=javascript
make api_examples_local suites=curl
```
The following examples are not executed in this harness:
- `api-build/build-flow-and-stream-events-2.py`
- `api-build/build-flow-and-stream-events-3.py`
- `api-flows-run/stream-llm-token-responses.py`
- `api-openai-responses/example-streaming-request.py`
- `api-logs/stream-logs.py`
- `api-logs/retrieve-logs-with-optional-parameters.py`
- `api-users/reset-password.py`
+112 -67
View File
@@ -2,6 +2,29 @@
title: Build endpoints
slug: /api-build
---
import CodeSnippet from '@site/src/components/CodeSnippet';
import exampleApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events.sh';
import resultApiBuildResultBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-build-flow-and-stream-events.json';
import exampleApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events-2.sh';
import resultApiBuildResultBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-build-flow-and-stream-events-2.json';
import exampleApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events-3.sh';
import exampleApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/set-start-and-stop-points.sh';
import exampleApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/override-flow-parameters.sh';
import resultApiBuildResultOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-override-flow-parameters.json';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import examplePythonApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events.py';
import exampleJavascriptApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events.js';
import examplePythonApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events-2.py';
import exampleJavascriptApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events-2.js';
import examplePythonApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events-3.py';
import exampleJavascriptApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events-3.js';
import examplePythonApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/set-start-and-stop-points.py';
import exampleJavascriptApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/set-start-and-stop-points.js';
import examplePythonApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/override-flow-parameters.py';
import exampleJavascriptApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/override-flow-parameters.js';
:::info
The `/build` endpoints are used by Langflow's frontend visual editor code.
@@ -21,63 +44,78 @@ This endpoint builds and executes a flow, returning a job ID that can be used to
1. Send a POST request to the `/build/$FLOW_ID/flow` endpoint:
```shell
curl -X POST \
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-d '{
"inputs": {
"input_value": "Tell me a story"
}
}'
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"job_id": "123e4567-e89b-12d3-a456-426614174000"
}
```
<CodeSnippet source={resultApiBuildResultBuildFlowAndStreamEvents} language="json" />
</details>
2. After receiving a job ID from the build endpoint, use the `/build/$JOB_ID/events` endpoint to stream the execution results:
```shell
curl -X GET \
"$LANGFLOW_URL/api/v1/build/123e4567-e89b-12d3-a456-426614174000/events" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{"event": "vertices_sorted", "data": {"ids": ["ChatInput-XtBLx"], "to_run": ["Prompt-x74Ze", "ChatOutput-ylMzN", "ChatInput-XtBLx", "OpenAIModel-d1wOZ"]}}
{"event": "add_message", "data": {"timestamp": "2025-03-03T17:42:23", "sender": "User", "sender_name": "User", "session_id": "d2bbd92b-187e-4c84-b2d4-5df365704201", "text": "Tell me a story", "files": [], "error": false, "edit": false, "properties": {"text_color": "", "background_color": "", "edited": false, "source": {"id": null, "display_name": null, "source": null}, "icon": "", "allow_markdown": false, "positive_feedback": null, "state": "complete", "targets": []}, "category": "message", "content_blocks": [], "id": "28879bd8-6a68-4dd5-b658-74d643a4dd92", "flow_id": "d2bbd92b-187e-4c84-b2d4-5df365704201"}}
// ... Additional events as the flow executes ...
{"event": "end", "data": {}}
```
<CodeSnippet source={resultApiBuildResultBuildFlowAndStreamEvents2} language="json" />
</details>
The `/build/$FLOW_ID/events` endpoint has a `stream` query parameter that defaults to `true`.
To disable streaming and get all events at once, set `?stream=false`.
```shell
curl -X GET \
"$LANGFLOW_URL/api/v1/build/123e4567-e89b-12d3-a456-426614174000/events?stream=false" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents3} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents3} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents3} language="bash" />
</TabItem>
</Tabs>
## Build headers
@@ -105,44 +143,51 @@ Setting `stop_component_id` for a component triggers the same behavior as clicki
The following example stops flow execution at an **OpenAI** component:
```shell
curl -X POST \
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-d '{"stop_component_id": "OpenAIModel-Uksag"}'
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiBuildSetStartAndStopPoints} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiBuildSetStartAndStopPoints} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiBuildSetStartAndStopPoints} language="bash" />
</TabItem>
</Tabs>
### Override flow parameters
The `/build` endpoint also accepts inputs for `data` directly, instead of using the values stored in the Langflow database.
This is useful for running flows without having to pass custom values through the visual editor.
```shell
curl -X POST \
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-d '{
"data": {
"nodes": [],
"edges": []
},
"inputs": {
"input_value": "Your custom input here",
"session": "session_id"
}
}'
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiBuildOverrideFlowParameters} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiBuildOverrideFlowParameters} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiBuildOverrideFlowParameters} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{ "job_id": "0bcc7f23-40b4-4bfa-9b8a-a44181fd1175" }
```
<CodeSnippet source={resultApiBuildResultOverrideFlowParameters} language="json" />
</details>
@@ -150,4 +195,4 @@ curl -X POST \
- [Get Vertex builds](/api-monitor#get-vertex-builds)
- [Delete Vertex builds](/api-monitor#delete-vertex-builds)
- [Session ID](/session-id)
- [Session ID](/session-id)
+326 -176
View File
@@ -2,6 +2,66 @@
title: Files endpoints
slug: /api-files
---
import CodeSnippet from '@site/src/components/CodeSnippet';
import exampleApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v1.sh';
import resultApiFilesResultUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-upload-file-v1.json';
import exampleApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-image-files-v1.sh';
import exampleApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-image-files-v1-2.sh';
import exampleApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/list-files-v1.sh';
import resultApiFilesResultListFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-list-files-v1.json';
import exampleApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/download-file-v1.sh';
import resultApiFilesResultDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-download-file-v1.txt';
import exampleApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-file-v1.sh';
import resultApiFilesResultDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-file-v1.json';
import exampleApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v2.sh';
import resultApiFilesResultUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-upload-file-v2.txt';
import exampleApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v2-2.sh';
import exampleApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/send-files-to-your-flows-v2.sh';
import exampleApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/send-files-to-your-flows-v2-2.sh';
import exampleApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/list-files-v2.sh';
import resultApiFilesResultListFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-list-files-v2.json';
import exampleApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/download-file-v2.sh';
import resultApiFilesResultDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-download-file-v2.txt';
import exampleApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/edit-file-name-v2.sh';
import resultApiFilesResultEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-edit-file-name-v2.json';
import exampleApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-file-v2.sh';
import resultApiFilesResultDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-file-v2.json';
import exampleApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-all-files-v2.sh';
import resultApiFilesResultDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-all-files-v2.json';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import examplePythonApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v1.py';
import exampleJavascriptApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v1.js';
import examplePythonApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-image-files-v1.py';
import exampleJavascriptApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-image-files-v1.js';
import examplePythonApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-image-files-v1-2.py';
import exampleJavascriptApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-image-files-v1-2.js';
import examplePythonApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/list-files-v1.py';
import exampleJavascriptApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/list-files-v1.js';
import examplePythonApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/download-file-v1.py';
import exampleJavascriptApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/download-file-v1.js';
import examplePythonApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-file-v1.py';
import exampleJavascriptApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-file-v1.js';
import examplePythonApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v2.py';
import exampleJavascriptApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v2.js';
import examplePythonApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v2-2.py';
import exampleJavascriptApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v2-2.js';
import examplePythonApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/send-files-to-your-flows-v2.py';
import exampleJavascriptApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/send-files-to-your-flows-v2.js';
import examplePythonApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/send-files-to-your-flows-v2-2.py';
import exampleJavascriptApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/send-files-to-your-flows-v2-2.js';
import examplePythonApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/list-files-v2.py';
import exampleJavascriptApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/list-files-v2.js';
import examplePythonApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/download-file-v2.py';
import exampleJavascriptApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/download-file-v2.js';
import examplePythonApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/edit-file-name-v2.py';
import exampleJavascriptApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/edit-file-name-v2.js';
import examplePythonApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-file-v2.py';
import exampleJavascriptApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-file-v2.js';
import examplePythonApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-all-files-v2.py';
import exampleJavascriptApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-all-files-v2.js';
Use the `/files` endpoints to move files between your local machine and Langflow.
@@ -33,14 +93,23 @@ Use the `/files` endpoints to move files between your local machine and Langflow
Upload a file to the `v1/files/upload/$FLOW_ID` endpoint:
Replace **FILE_NAME** with the uploaded file name.
```bash
curl -X POST \
"$LANGFLOW_URL/api/v1/files/upload/$FLOW_ID" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-F "file=@FILE_NAME.txt"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesUploadFileV1} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV1} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesUploadFileV1} language="bash" />
</TabItem>
</Tabs>
Replace `FILE_NAME.txt` with the name and extension of the file you want to upload.
Not all file types are supported.
@@ -48,12 +117,7 @@ Not all file types are supported.
<details>
<summary>Result</summary>
```json
{
"flowId": "92f9a4c5-cfc8-4656-ae63-1f0881163c28",
"file_path": "92f9a4c5-cfc8-4656-ae63-1f0881163c28/2024-12-30_15-19-43_your_file.txt"
}
```
<CodeSnippet source={resultApiFilesResultUploadFileV1} language="json" />
</details>
@@ -66,12 +130,23 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
1. Attach the image to a `POST /v1/files/upload/$FLOW_ID` request with `--form` (`-F`) and the file path:
```bash
curl -X POST "$LANGFLOW_URL/api/v1/files/upload/$FLOW_ID" \
-H "Content-Type: multipart/form-data" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-F "file=@PATH/TO/FILE.png"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesUploadImageFilesV1} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesUploadImageFilesV1} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesUploadImageFilesV1} language="bash" />
</TabItem>
</Tabs>
A successful request returns the `file_path` for the image in the Langflow file management system in the format `FLOW_ID/TIMESTAMP_FILENAME.TYPE`.
For example:
@@ -88,22 +163,23 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
The following example runs the **Basic Prompting** template flow, passing the image file and the query `describe this image` as input for the **Chat Input** component.
In this case, the file path is specified in `tweaks`.
```bash
curl -X POST \
"$LANGFLOW_URL/api/v1/run/a430cc57-06bb-4c11-be39-d3d4de68d2c4?stream=false" \
-H "Content-Type: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-d '{
"output_type": "chat",
"input_type": "chat",
"tweaks": {
"ChatInput-b67sL": {
"files": "a430cc57-06bb-4c11-be39-d3d4de68d2c4/2024-11-27_14-47-50_image-file.png",
"input_value": "describe this image"
}
}
}'
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesUploadImageFilesV12} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesUploadImageFilesV12} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesUploadImageFilesV12} language="bash" />
</TabItem>
</Tabs>
:::tip
For help with tweaks, use the **Input Schema** in a flow's [**API access** pane](/concepts-publish#api-access).
@@ -114,21 +190,28 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
List all files associated with a specific flow.
```bash
curl -X GET \
"$LANGFLOW_URL/api/v1/files/list/$FLOW_ID" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesListFilesV1} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesListFilesV1} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesListFilesV1} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"files": ["2024-12-30_15-19-43_your_file.txt"]
}
```
<CodeSnippet source={resultApiFilesResultListFilesV1} language="json" />
</details>
@@ -136,20 +219,28 @@ curl -X GET \
Download a specific file from a flow.
```bash
curl -X GET \
"$LANGFLOW_URL/api/v1/files/download/$FLOW_ID/2024-12-30_15-19-43_your_file.txt" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
--output downloaded_file.txt
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesDownloadFileV1} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesDownloadFileV1} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesDownloadFileV1} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```text
File contents downloaded to downloaded_file.txt
```
<CodeSnippet source={resultApiFilesResultDownloadFileV1} language="text" />
</details>
@@ -157,21 +248,28 @@ File contents downloaded to downloaded_file.txt
Delete a specific file from a flow.
```bash
curl -X DELETE \
"$LANGFLOW_URL/api/v1/files/delete/$FLOW_ID/2024-12-30_15-19-43_your_file.txt" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesDeleteFileV1} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesDeleteFileV1} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesDeleteFileV1} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"message": "File 2024-12-30_15-19-43_your_file.txt deleted successfully"
}
```
<CodeSnippet source={resultApiFilesResultDeleteFileV1} language="json" />
</details>
@@ -190,33 +288,51 @@ The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, such as `07
1. To retrieve your current `user_id`, call the `/whoami` endpoint:
```bash
curl -X GET \
"$LANGFLOW_URL/api/v1/users/whoami" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesUploadFileV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesUploadFileV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```
{"id":"07e5b864-e367-4f52-b647-a48035ae7e5e","username":"langflow","profile_image":null,"store_api_key":null,"is_active":true,"is_superuser":true,"create_at":"2025-05-08T17:59:07.855965","updated_at":"2025-05-28T19:00:42.556460","last_login_at":"2025-05-28T19:00:42.554338","optins":{"github_starred":false,"dialog_dismissed":true,"discord_clicked":false,"mcp_dialog_dismissed":true}}
```
<CodeSnippet source={resultApiFilesResultUploadFileV2} language="text" />
</details>
2. In the POST request to `v2/files`, replace **@FILE_NAME.EXTENSION** with the uploaded file name and its extension.
You must include the ampersand (`@`) in the request to instruct curl to upload the contents of the file, not the string `FILE_NAME.EXTENSION`.
```bash
curl -X POST \
"$LANGFLOW_URL/api/v2/files" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-F "file=@FILE_NAME.EXTENSION"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesUploadFileV22} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV22} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesUploadFileV22} language="bash" />
</TabItem>
</Tabs>
The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, and the API returns metadata about the uploaded file:
@@ -247,14 +363,23 @@ The default file limit is 1024 MB. To configure this value, change the `LANGFLOW
Replace **FILE_NAME.EXTENSION** with the name and extension of the file you want to upload.
This is the same step described in [Upload file (v2)](#upload-file-v2), but since you need the filename to upload to your flow, it is included here.
```bash
curl -X POST \
"$LANGFLOW_URL/api/v2/files" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-H "x-api-key: $LANGFLOW_API_KEY" \
-F "file=@FILE_NAME.EXTENSION"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesSendFilesToYourFlowsV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesSendFilesToYourFlowsV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesSendFilesToYourFlowsV2} language="bash" />
</TabItem>
</Tabs>
The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, and the API returns metadata about the uploaded file:
@@ -273,24 +398,23 @@ This component loads files into flows from your local machine or Langflow file m
3. Run the flow, passing the `path` to the `Read-File` component in the `tweaks` object:
```text
curl --request POST \
--url "$LANGFLOW_URL/api/v1/run/$FLOW_ID" \
--header "Content-Type: application/json" \
--header "x-api-key: $LANGFLOW_API_KEY" \
--data '{
"input_value": "what do you see?",
"output_type": "chat",
"input_type": "text",
"tweaks": {
"Read-File-1olS3": {
"path": [
"07e5b864-e367-4f52-b647-a48035ae7e5e/3a290013-fe1e-4d3d-a454-cacae81288f3.pdf"
]
}
}
}'
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesSendFilesToYourFlowsV22} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesSendFilesToYourFlowsV22} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesSendFilesToYourFlowsV22} language="bash" />
</TabItem>
</Tabs>
To get the `Read-File` component's ID, call the [Read flow](/api-flows#read-flow) endpoint or inspect the component in the visual editor.
@@ -300,27 +424,28 @@ This component loads files into flows from your local machine or Langflow file m
List all files associated with your user account.
```bash
curl -X GET \
"$LANGFLOW_URL/api/v2/files" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesListFilesV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesListFilesV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesListFilesV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
[
{
"id": "c7b22c4c-d5e0-4ec9-af97-5d85b7657a34",
"name": "your_file",
"path": "6f17a73e-97d7-4519-a8d9-8e4c0be411bb/c7b22c4c-d5e0-4ec9-af97-5d85b7657a34.txt",
"size": 1234,
"provider": null
}
]
```
<CodeSnippet source={resultApiFilesResultListFilesV2} language="json" />
</details>
@@ -330,20 +455,28 @@ Download a specific file by its ID and file extension.
You must specify the file type you expect in the `--output` value.
```bash
curl -X GET \
"$LANGFLOW_URL/api/v2/files/c7b22c4c-d5e0-4ec9-af97-5d85b7657a34" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY" \
--output downloaded_file.txt
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesDownloadFileV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesDownloadFileV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesDownloadFileV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```text
File contents downloaded to downloaded_file.txt
```
<CodeSnippet source={resultApiFilesResultDownloadFileV2} language="text" />
</details>
@@ -351,25 +484,28 @@ File contents downloaded to downloaded_file.txt
Change a file name.
```bash
curl -X PUT \
"$LANGFLOW_URL/api/v2/files/$FILE_ID?name=new_file_name" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesEditFileNameV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesEditFileNameV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesEditFileNameV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"id": "76543e40-f388-4cb3-b0ee-a1e870aca3d3",
"name": "new_file_name",
"path": "6f17a73e-97d7-4519-a8d9-8e4c0be411bb/76543e40-f388-4cb3-b0ee-a1e870aca3d3.png",
"size": 2728251,
"provider": null
}
```
<CodeSnippet source={resultApiFilesResultEditFileNameV2} language="json" />
</details>
@@ -377,21 +513,28 @@ curl -X PUT \
Delete a specific file by its ID.
```bash
curl -X DELETE \
"$LANGFLOW_URL/api/v2/files/$FILE_ID" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesDeleteFileV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesDeleteFileV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesDeleteFileV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"message": "File deleted successfully"
}
```
<CodeSnippet source={resultApiFilesResultDeleteFileV2} language="json" />
</details>
@@ -399,21 +542,28 @@ curl -X DELETE \
Delete all files associated with your user account.
```bash
curl -X DELETE \
"$LANGFLOW_URL/api/v2/files" \
-H "accept: application/json" \
-H "x-api-key: $LANGFLOW_API_KEY"
```
<Tabs>
<TabItem value="Python" label="Python" default>
<CodeSnippet source={examplePythonApiFilesDeleteAllFilesV2} language="python" />
</TabItem>
<TabItem value="JavaScript" label="JavaScript">
<CodeSnippet source={exampleJavascriptApiFilesDeleteAllFilesV2} language="javascript" />
</TabItem>
<TabItem value="curl" label="curl">
<CodeSnippet source={exampleApiFilesDeleteAllFilesV2} language="bash" />
</TabItem>
</Tabs>
<details>
<summary>Result</summary>
```json
{
"message": "All files deleted successfully"
}
```
<CodeSnippet source={resultApiFilesResultDeleteAllFilesV2} language="json" />
</details>
@@ -423,4 +573,4 @@ This endpoint is deprecated. Use the `/files` endpoints instead.
## See also
* [Manage files](/concepts-file-management)
* [Manage files](/concepts-file-management)

Some files were not shown because too many files have changed in this diff Show More