feat(lfx-serve): lfx owns the v2 workflow router via a host-DI seam - #13816
Merged
Conversation
Rebased onto release-1.10.0. The base independently rebuilt the v2 workflows backend (RBAC, body globals, share-aware fetch); keep our forward design and conform its auth to that work: 1. Auth: keep get_current_user_for_workflow (session-or-API-key authN that does not hold a DB connection during the inline run, avoiding the SQLite lock contention api_key_security would cause) and enforce the base's RBAC on top: ensure_flow_permission(EXECUTE) before run, (READ) before status reconstruct, with widen_for_shares fetch. 2. Port the base's request-body globals onto the v2 WorkflowRunRequest. The X-LANGFLOW-GLOBAL-VAR-* headers stay supported (the Responses API passes globals that way); body globals win on conflict. Converters echo the effective globals via effective_globals. 3. Public endpoint keeps the v1 build_public_tmp posture (access_type==PUBLIC, run-as-owner); RBAC applies to the authenticated endpoint only. 4. Preserve the base's post-build KB-cache invalidation in the AG-UI build path. The endpoint, AG-UI bridge, pluggable stream adapters, public endpoint, and re-attach are unchanged.
The synchronous /api/v2/workflows response keyed every result under its component id, so reading the answer meant knowing an id you can't predict. Surface two additive fields: - output_text: the flow's single text answer (ChatOutput/TextOutput). None when the flow has zero or multiple text outputs, so callers read outputs rather than the shortcut guessing which channel is the answer. - session_id: echoes the resolved session so chat/memory callers can continue the same thread (v1 /run returned this; v2 had dropped it). outputs is unchanged, so this is non-breaking.
…ponse
Pin the sync-response shortcuts on the v2 workflows endpoint:
- output_text surfaces the lone ChatOutput/TextOutput text and stays None for
non-output message nodes, data-only flows, and multi-text flows
- session_id echoes the resolved session; the error response exposes neither
- each outputs entry exposes only {type, status, content, metadata}, with the
component id carried by the dict key
Also drop the component_id kwarg the converter passed to ComponentOutput, which
has no such field and silently dropped it.
Replace the flat output_text shortcut with an `output` object carrying the resolved text answer plus a `reason` that explains why it resolved that way (single/multiple/none/non_string/failed), so a null answer is always diagnosable instead of silently None. `reason` follows the LLM-domain finish_reason/stop_reason convention, distinct from the lifecycle status. Also add `display_name` to each ComponentOutput (the stable component id stays the dict key) and a computed `has_errors` flag derived from errors.
Let a sync caller name the output(s) they want via output_ids so output.text resolves deterministically (reason=single) on multi-output flows instead of going null. Selection is steer-only: it picks the answer among the named outputs without filtering the outputs map. Invalid ids are rejected with 422 before the flow runs (and before any job row is created), so a typo costs no compute. Resolution considers selected outputs that actually fired, so branching flows resolve to whichever candidate ran.
Give v2-workflows sync and the langflow stream protocol one parser. The stream now emits a normalized "output" event per terminal output carrying an OutputEvent (the ComponentOutput shape sync returns in outputs[id], plus component_id). A shared build_component_output() backs both the sync converter and the adapter, and the build loop ships authoritative vertex metadata as an additive output_meta key on end_vertex (existing consumers read build_data and ignore it). This is access-pattern parity (one parser, same fields, same terminal set), not byte-identical content: the stream reuses the v1 build path whose display serialization differs from sync's run_graph output.
…kflows-agui # Conflicts: # src/frontend/src/stores/flowStore.ts
The v2 public endpoint only ran validate_flow_for_current_settings and skipped validate_public_flow_no_code_execution, which the v1 build_public_tmp path applies. A public flow containing a Python interpreter/REPL (or the legacy Python Code Structured tool, Smart Transform lambda) was therefore an unauthenticated server-side code-execution primitive (report H1-3754930). Mirror v1: import the validator and call it right after the public-access gate. PublicFlowValidationError subclasses CustomComponentValidationError, so the existing handler already sanitizes it to a 400 'This flow cannot be executed.' without leaking the blocked component class names. Add a non-mocking test that builds a public flow with a real PythonREPLComponent and asserts the sanitized 400 (verified RED: returns 200 without the gate). LE-1389
…rtex builds A completed background job's GET status 500'd with 'No vertex builds found for job_id'. The background build path differed from the sync path twice: 1. generate_flow_events minted a fresh run_id instead of using job_id, so vertex builds were keyed by an id the status query never uses. Thread run_id through _stream_event_frames -> generate_flow_events and pass job_id from the background buffer so graph.run_id == job_id (the sync path already does graph.set_run_id(job_id)). 2. The SSE build loop (build_vertices) only persisted builds when log_builds was set and never passed job_id. Tie log_builds to job-tracked runs (run_id present) and pass job_id=graph.run_id on the persist call. Job-tracked runs also persist streaming terminal vertices so reconstruction is complete; the live build path (run_id is None) keeps its original behavior, so the v1 build path is unchanged. Test: a real background run polled to completion, then GET status asserts a reconstructed 200 (verified RED: 500 'No vertex builds found' before the fix). Covers the non-streaming flow. v1 build path unchanged (35 build tests pass); AG-UI suite 46 pass. LE-1389
Adopt 1.11.0's composed group-mixin Settings for lfx settings/base.py (all v2 settings already present in the new groups); port the Python 3.14 cors_origins ['*']->'*' fix into groups/security.py.
… of dropping them Parallel components stream tokens for different message ids interleaved. The translator tracked a single open message: the first foreign token closed the open message and tombstoned its id, so every later event for it was dropped and its remaining text never reached the client. Tokens for a message that cannot take the wire now buffer until the open message genuinely ends (its add_message finalizer), then flush in arrival order; complete messages landing mid-stream buffer the same way instead of interleaving a second START. end/error drain all buffers before the terminal event. The wire still carries at most one open text message, so the stream stays AG-UI-conformant.
… purge removed buffers A partial add_message re-fire (the agent path emits these at tool start/end for a message it is still streaming) was treated as the finalizer: it closed and tombstoned the id, so the post-tool answer was dropped. Only a non-partial add_message finalizes now; state defaults to complete, so payloads without properties are unchanged. remove_message now purges a buffered message and tombstones its id, so text the backend retracted is not flushed to the client later.
…ai/langflow into codex/v2-workflows-agui-merge # Conflicts: # src/backend/base/langflow/api/v2/agui_translator.py # src/backend/tests/unit/api/v2/test_agui_translator.py
ts-jest compiles with target es5; without downlevelIteration, [...set] and for...of over a Set/Map emit ES5 that yields nothing. That silently broke the AG-UI bridge tests: runningNodeIds spread, markRunningNodesFailed, and restoreOriginalBuildStatuses all iterated empty. Production (Vite/SWC, modern target) was never affected; only the ts-jest harness was. Fixes the 3 failing jest tests on this branch with no other suite changes (4994/4994 pass).
A branch component (If-Else, Conditional Router) reports its not-taken vertices in build_data.inactivated_vertices, but the AG-UI translator only emitted the branch node's own success/error status and dropped that list. The canvas seeds every planned node as pending from vertices_sorted; skipped vertices then get no build_start/end_vertex, so they stayed stuck on pending instead of rendering as inactive (the v1 build path marked them INACTIVE). The translator now appends an inactive STATE_DELTA op per inactivated vertex, and the frontend bridge maps the new inactive status to BuildStatus.INACTIVE and tears its edges down like a completed node. Fixes the If-Else regression in general-bugs-reset-flow-run.spec.ts.
build.py keeps reporting a conditionally-excluded vertex in inactivated_vertices on every subsequent end_vertex (the excluded set persists until the ConditionalRouter clears it), so the translator was putting the same inactive STATE_DELTA on the wire once per remaining vertex. Track emitted inactive nodes and skip re-emitting; drop a node from the set when it actually runs again (build_start/end_vertex) so a loop re-activation can still re-emit inactive later.
- recover session_id for completed background jobs from the persisted terminal message instead of always returning null, so GET status can continue the same chat/memory thread - replay a user-cancel as a CUSTOM cancel marker + RUN_FINISHED (agui) and a `cancelled` terminal (langflow) instead of RUN_ERROR, so a re-attaching client no longer reads a deliberate stop as a failure - cancel the evicted still-running buffer writer when the background-run registry is full, so it stops appending into a run no reader can find - derive per-component status from the error artifact / valid flag instead of hardcoding COMPLETED, and stop the langflow adapter dropping `valid` - throttle the unauthenticated public endpoint per IP and bound its input_value/session_id length - document the sync-only scope of request-body globals - document that live event re-attach is intentionally owner-only
Splits the ~1.5k-line workflow.py into focused modules and folds in the execution-timeout and error-sanitization fixes from Cristhianzl's review of #13307. - B1: workflow.py now holds only the four route handlers. Validation guards move to workflow_validation, the sync/stream run loop to workflow_execution, and the durable background machinery to workflow_background (layered, acyclic). - I1: add workflow_execution_timeout (default 300) and apply a single wall-clock ceiling across sync, stream, background, and public via _stream_event_frames. A timeout becomes a sanitized terminal error and marks a background job failed. - I3: the route error handlers no longer echo raw exception text. They return a generic, code-tagged message and log the full exception server-side. - R1: remove the "commented out / future scope" comments that sat over live dataframe-extraction code in converters.py. - R4: drop the worker-routing internals from the reattach 409 message. Tests cover the timeout terminal-error path and the error-body sanitization, and the settings field-count guard is updated for the new setting.
Moves the protocol-agnostic pieces of the v2 workflows API out of the langflow backend into lfx so both the backend and `lfx serve` can share one contract. First step toward giving lfx (the production runtime) the v2 workflows API. - Move api/v2/adapters/, agui_translator.py, and converters.py to lfx/workflow/. They depend only on lfx.schema.workflow and ag_ui (already an lfx dep), so lfx carries the contract with zero langflow imports. - Decouple the one langflow reference: converters typed run_response against langflow.api.v1.schemas.RunResponse (TYPE_CHECKING only). Replaced with a local RunResponseLike Protocol (outputs + session_id), the only attributes used. - Repoint the six backend v2 workflow modules to import from lfx.workflow. - Move the five protocol-agnostic contract tests into src/lfx/tests/unit/workflow/ (run in the lfx-only env). test_output_event_parity and test_workflow_agui stay in langflow (they need langflow.api.build) with repointed imports. Coverage unchanged: 201 contract tests pass in the lfx-only env, 191 backend v2 tests pass; 392 total, same as before the move.
Gives the production runtime (lfx serve) the same v2 contract as the langflow backend's POST /api/v2/workflows, built on the shared lfx.workflow layer. - New POST /workflows endpoint: WorkflowRunRequest in, WorkflowExecutionResponse (sync) or an SSE stream (langflow/agui protocols) out. flow_id resolves against the serve registry; per-request deepcopy+stamp mirrors the run/stream endpoints. - sync runs via run_graph_internal (the same primitive the backend sync path uses) so the converter sees the aggregated RunOutputs shape. - stream drives the run with a token-stream EventManager wired into execute_graph_with_capture and feeds queue events through the shared StreamAdapter; emits a terminal end so the adapter closes the run cleanly. - background and public modes are rejected (422); tweaks/data/files/globals and partial-run boundaries are rejected too (no per-request graph rebuild yet). - execute_graph_with_capture gains an optional event_manager param. Tests build a real ChatInput->ChatOutput flow (no mocks) and exercise sync, both stream protocols, and the guards. 329 lfx serve + contract tests pass.
lfx now exports a WorkflowHost Protocol + create_workflow_router() factory. The router owns the env-neutral handler body (run dispatch, single SSE loop, error mapping, dev-api guard); the host supplies auth/flow-lookup/session. Bare serve uses a no-db ServeWorkflowHost (supports_background=False), which collapses the duplicated serve handler into the shared router. Background/durable routes register only when host.supports_background is True, so bare serve's surface stays exactly POST /workflows. developer_api_guard is a factory flag (default True) so serve keeps its current open behavior. Adds a no-mock cross-host contract test pinning the request/response and SSE shape so the two hosts can't drift.
Langflow now mounts the shared lfx workflow router for POST /api/v2/workflows via a LangflowWorkflowHost (auth->UserRead in its own session, flow lookup, ensure_flow_permission, readonly session, background submit). The durable status/stop/events routes stay LF-rich on an LF-owned background router, so only one handler exists per method+path. The public workflow router is untouched. LF streaming and sync stay LF-specific via host.run_sync / host.stream_response seams (LF keeps the v1 build loop with agui side-channel + vertex persistence; bare serve keeps lfx's lean defaults). create_workflow_router gains auto_register_job_routes so LF can enable background submit without the router auto-registering its generic job routes.
Collaborator
|
@dkaushik94's comments looked good, agreed on the general point that if any failure behaviors changed we should add some tests and sign off on each. Otherwise lgtm |
Two points from Debojit's review: - Sync /workflows bypassed the no_env_fallback isolation. run_workflow_sync activated request_variables but not the no_env_fallback contextvar, so a sync run under LFX_SERVE_NO_ENV_FALLBACK=1 still resolved credentials from os.environ while stream (via execute_graph_with_capture) was isolated. Activate it around the run and reset in finally, mirroring the stream path. - authorize_flow_action caught only HTTPException, so an OperationalError from ensure_flow_permission's audit write (DB lock under inline-run contention) escaped as a bare 500 instead of the retryable 503 DATABASE_ERROR contract the fetch path keeps. Add the same OperationalError -> 503 / Exception -> 500 arms. Tests: a no-mock real-graph test that the sync path activates the isolation mid-run and resets after, and a 503-on-DB-lock test through the host wiring.
Per Debojit's review: serve exposed the shared workflow router at /workflows while the langflow backend serves it at /api/v2/workflows, so switching runtimes meant changing the URL path, not just the host. Mount the serve router under /api/v2 so the path is identical across runtimes; a client points at a different host/environment with no path change. serve's other routes (/flows, /health) stay root-level. Updates the serve integration tests and docstring.
Contributor
Author
|
Hey @dkaushik94, all three are addressed now:
@jordanrfrazier both failure-behavior changes have tests now, per your point. Commits f593334 and 284369f. |
dkaushik94
approved these changes
Jul 7, 2026
jordanrfrazier
approved these changes
Jul 7, 2026
Resolve conflicts after #13783 (feat/lfx-serve-v2) squash-merged to release: - serve_workflow.py / serve_app.py / test_serve_workflow.py: keep the seam's ServeWorkflowHost + create_workflow_router wiring, drop the now-superseded add_v2_workflow_routes design that release still carried. - .secrets.baseline: regenerated against the merged tree. Restore the serve v2 workflow identity threading release shipped (erichare's P1) that the seam refactor had dropped: bare serve v2 workflows now honor the jwt/header identity like /run does. ServeWorkflowHost.resolve_caller resolves the verified user id (401 on bad token, before execution) and surfaces it via _run_user_id; the lfx-default run/stream path pins it onto the graph (apply_run_defaults on sync, execute_graph_with_capture on stream). Ported the three identity endpoint tests to the seam's execution point.
ogabrielluiz
enabled auto-merge
July 8, 2026 13:22
… route assertions release-1.11.0's FastAPI bump makes include_router mount the /api/v2 workflow router as a lazy _IncludedRouter wrapper with no .path, so the three create_serve_app route-introspection tests hit AttributeError. Skip wrappers (these assert directly-mounted serve paths only), matching how the langflow backend already handles the same >=0.137 behavior.
ogabrielluiz
added a commit
that referenced
this pull request
Jul 8, 2026
Brings the v2 workflow router seam (#13816) and the rest of release onto the durable background execution branch. Same rule as the stacked branches: take release's structure and hygiene, keep the durable semantics on top. api/v2/workflow.py is now release's seam skeleton (resolve_flow_for_execution, authorize_flow_action, run_sync_with_mapping, build_stream_response) with the durable route bodies (status reconstruct with the Job.result fallback, the stop signal ordering, service.events replay) and the durable facade on background submit. Helpers moved to release's workflow_execution.py. Two things the plain merge would have dropped: - Release's workflow_execution.py does not pass tweaks to generate_flow_events, so taking it wholesale silently reverted 02e06fe (apply request tweaks on the streaming and background paths). Restored. - idempotency_key never survived the parse boundary: ParsedWorkflowRun did not carry the field, so the dedupe and DuplicateJobError -> 409 could never fire through the seam. Threaded through. Durable runs also key vertex builds by job_id (run_id=str(job_id)) so a completed job's status reconstructs its outputs and recovers the session_id instead of falling back to the leaner Job.result rebuild. Migration chain is re-parented onto release's head, so there is a single alembic head.
ogabrielluiz
added a commit
that referenced
this pull request
Jul 8, 2026
merge: sync release-1.11.0 into cz/hitl-v2 Brings the v2 workflow router seam (#13816) and the rest of release onto the HITL branch. Merged as a real merge commit (not squash) so cz/hitl-v2 stays a descendant of release-1.11.0 and future release merges do not re-conflict.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #13783. This is the answer to Debojit's question on that PR: have lfx own the v2 workflow router so we only work on these APIs across the LF <> LFX seam, instead of keeping two copies of the handler.
Stacked on
feat/lfx-serve-v2, so review/merge that one first.What this does
lfx exports a
WorkflowHostProtocol plus acreate_workflow_router(host)factory. The router owns the env-neutral handler body (request parsing, stream-protocol validation, run dispatch, error -> HTTP mapping, the developer-api guard). The host supplies only the DB/tenant-bound pieces: resolve the caller, fetch-and-authorize a flow, the request session, and whether background runs exist.Both runtimes mount the same router:
lfx servepasses a no-dbServeWorkflowHost(supports_background=False), which collapses the duplicated serve handler into the shared router. Its surface stays exactlyPOST /workflows.LangflowWorkflowHost(auth ->UserReadin its own session,get_flow_by_id_or_endpoint_name,ensure_flow_permission, readonly session, background submit).UserRead/FlowAction/JobServicestay confined to the langflow side.What I had to change from the original idea
The plan assumed one shared SSE loop. It turns out langflow's streaming is genuinely richer than lfx's: it drives the v1 build-vertex loop with the agui side-channel, vertex-build persistence, and the job timeout. So I made
run_sync/stream_responsepluggable on the host instead of forcing langflow onto lfx's leaner loop. langflow keeps its own execution path, bare serve keeps lfx's defaults. The shared part is the routing skeleton, the auth/flow/authz wiring, the error mapping, and the request/response + SSE contract.The durable routes (GET status, POST stop, GET events) stay LF-rich on an LF-owned background router, so there's one handler per method+path. The public workflow router is untouched here (separate follow-up).
Tests
POST /workflows.Summary by CodeRabbit
New Features
Bug Fixes
Chores