Skip to content

feat(lfx-serve): lfx owns the v2 workflow router via a host-DI seam - #13816

Merged
ogabrielluiz merged 45 commits into
release-1.11.0from
feat/lfx-workflow-router-seam
Jul 8, 2026
Merged

feat(lfx-serve): lfx owns the v2 workflow router via a host-DI seam#13816
ogabrielluiz merged 45 commits into
release-1.11.0from
feat/lfx-workflow-router-seam

Conversation

@ogabrielluiz

@ogabrielluiz ogabrielluiz commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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 WorkflowHost Protocol plus a create_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:

  • bare lfx serve passes a no-db ServeWorkflowHost (supports_background=False), which collapses the duplicated serve handler into the shared router. Its surface stays exactly POST /workflows.
  • langflow passes a LangflowWorkflowHost (auth -> UserRead in its own session, get_flow_by_id_or_endpoint_name, ensure_flow_permission, readonly session, background submit). UserRead/FlowAction/JobService stay 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_response pluggable 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

  • New no-mock cross-host contract test in lfx pins the request/response and SSE shape so the two hosts can't drift. lfx suite: 16 passed in an isolated lfx env (no langflow).
  • The full v2 workflow backend suite (run, reconstruction, agui, public) stays green: 124 passed. Routes are byte-identical (FLOW_NOT_FOUND 404, stream-protocol 422, agui + reconstruction), no double-registration, bare serve still exactly POST /workflows.

Summary by CodeRabbit

  • New Features

    • Added a newer workflow API route structure with improved support for sync, streaming, and background execution.
    • Expanded workflow authorization and identity handling so requests are evaluated more consistently.
  • Bug Fixes

    • Made error responses more consistent, including clearer handling for missing workflows, permission denials, and database issues.
    • Improved streaming behavior to avoid runaway buffering and ensure responses end cleanly.
  • Chores

    • Updated internal routing and baseline records to match the latest scan and endpoint layout.

ogabrielluiz and others added 30 commits June 1, 2026 15:39
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.
@jordanrfrazier

Copy link
Copy Markdown
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.
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 7, 2026
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.
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 7, 2026
@ogabrielluiz

Copy link
Copy Markdown
Contributor Author

Hey @dkaushik94, all three are addressed now:

  • Sync no_env_fallback leak: run_workflow_sync wasn't activating the isolation contextvar, so a sync run under LFX_SERVE_NO_ENV_FALLBACK still read os.environ while stream was isolated. It now activates and resets around the run like the stream path, with a no-mock real-graph test that the flag is live mid-run and reset after.
  • authorize_flow_action bare 500: it only caught HTTPException, so an OperationalError from the audit write leaked a 500. Added the same OperationalError to 503 / Exception to 500 arms the fetch path already has, with a test that a DB lock during authorize returns the 503 contract.
  • Path parity: serve now mounts the shared router at /api/v2/workflows, so it's the same URL as the backend and you switch runtimes by changing the host, not the path. serve's other routes stay root-level.

@jordanrfrazier both failure-behavior changes have tests now, per your point. Commits f593334 and 284369f.

@github-actions github-actions Bot added the lgtm This PR has been approved by a maintainer label Jul 7, 2026
Base automatically changed from feat/lfx-serve-v2 to release-1.11.0 July 8, 2026 02:30
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 8, 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.
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 8, 2026
@ogabrielluiz
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.
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 8, 2026
@ogabrielluiz
ogabrielluiz added this pull request to the merge queue Jul 8, 2026
Merged via the queue into release-1.11.0 with commit db50d03 Jul 8, 2026
159 checks passed
@ogabrielluiz
ogabrielluiz deleted the feat/lfx-workflow-router-seam branch July 8, 2026 14:57
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request lgtm This PR has been approved by a maintainer scaling-up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants