From 7a884d808e65339d649f3325234d724b839fee78 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Mon, 22 Jun 2026 11:19:25 -0300 Subject: [PATCH 1/4] fix(api/v2): address review findings on the v2 workflows endpoint - 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 --- .../base/langflow/api/v2/adapters/agui.py | 7 +- .../base/langflow/api/v2/adapters/langflow.py | 8 +- .../base/langflow/api/v2/agui_translator.py | 13 ++ .../base/langflow/api/v2/converters.py | 9 +- src/backend/base/langflow/api/v2/workflow.py | 22 +++- .../base/langflow/api/v2/workflow_public.py | 31 +++++ .../api/v2/workflow_reconstruction.py | 43 +++++- .../unit/api/v2/adapters/test_agui_adapter.py | 11 +- .../api/v2/adapters/test_langflow_adapter.py | 7 +- .../tests/unit/api/v2/test_converters.py | 48 +++++++ .../tests/unit/api/v2/test_workflow_agui.py | 123 +++++++++++++++--- .../tests/unit/api/v2/test_workflow_public.py | 74 +++++++++++ .../api/v2/test_workflow_reconstruction.py | 31 ++++- src/lfx/src/lfx/schema/workflow.py | 18 ++- .../lfx/services/settings/groups/security.py | 4 + 15 files changed, 407 insertions(+), 42 deletions(-) diff --git a/src/backend/base/langflow/api/v2/adapters/agui.py b/src/backend/base/langflow/api/v2/adapters/agui.py index ac6981cb6a..56281afee9 100644 --- a/src/backend/base/langflow/api/v2/adapters/agui.py +++ b/src/backend/base/langflow/api/v2/adapters/agui.py @@ -59,9 +59,10 @@ class AGUIAdapter: return [_to_stream_event(e) for e in self._translator.translate("error", {"error": str(error)})] def cancel_events(self, reason: str) -> Iterable[StreamEvent]: - # AG-UI has no cancellation primitive in the local event model; route - # through the translator so any open text lifecycle is closed first. - return [_to_stream_event(e) for e in self._translator.translate("error", {"error": reason})] + # AG-UI has no cancel primitive: close any open text lifecycle, emit a + # CUSTOM cancel marker, then RUN_FINISHED. A user-stop must not replay as + # RUN_ERROR, which clients read as a genuine failure. + return [_to_stream_event(e) for e in self._translator.cancel(reason)] @property def terminal_error_type(self) -> str | None: diff --git a/src/backend/base/langflow/api/v2/adapters/langflow.py b/src/backend/base/langflow/api/v2/adapters/langflow.py index 043e225f35..178b91d4d2 100644 --- a/src/backend/base/langflow/api/v2/adapters/langflow.py +++ b/src/backend/base/langflow/api/v2/adapters/langflow.py @@ -75,6 +75,7 @@ class LangflowAdapter: output_type=resolve_output_type(output_meta.get("output_types"), output_meta.get("vertex_type")), display_name=output_meta.get("display_name"), result_data=build_data.get("data"), + valid=bool(build_data.get("valid", True)), ) output = OutputEvent(component_id=component_id, **component_output.model_dump()) payload = {"event": "output", "data": output.model_dump(mode="json")} @@ -88,8 +89,11 @@ class LangflowAdapter: return (StreamEvent(type="error", data_json=json.dumps(payload)),) def cancel_events(self, reason: str) -> Iterable[StreamEvent]: - payload = {"event": "error", "data": {"error": reason}} - return (StreamEvent(type="error", data_json=json.dumps(payload)),) + # A deliberate user stop is its own terminal, not an ``error``: a + # re-attaching client must be able to tell a cancel apart from a genuine + # failure instead of seeing the stream end in ``error``. + payload = {"event": "cancelled", "data": {"reason": reason}} + return (StreamEvent(type="cancelled", data_json=json.dumps(payload)),) @property def terminal_error_type(self) -> str | None: diff --git a/src/backend/base/langflow/api/v2/agui_translator.py b/src/backend/base/langflow/api/v2/agui_translator.py index 60c447e07d..2fbfc8fbcc 100644 --- a/src/backend/base/langflow/api/v2/agui_translator.py +++ b/src/backend/base/langflow/api/v2/agui_translator.py @@ -151,6 +151,19 @@ class AGUITranslator: return events return [] + def cancel(self, reason: str) -> list[BaseEvent]: + """Close any open text lifecycle, mark the run cancelled, then finish. + + AG-UI has no cancel primitive, so a deliberate stop is surfaced as a + namespaced CUSTOM marker (generic clients ignore it) followed by a clean + ``RUN_FINISHED`` rather than ``RUN_ERROR``, which clients read as a + genuine failure. + """ + events = self._drain_messages() + events.append(CustomEvent(name="langflow.run.cancelled", value={"reason": reason})) + events.append(RunFinishedEvent(run_id=self.run_id, thread_id=self.thread_id)) + return events + def _translate_token(self, data: dict) -> list[BaseEvent]: """Map a ``token`` event to text-message events. diff --git a/src/backend/base/langflow/api/v2/converters.py b/src/backend/base/langflow/api/v2/converters.py index d6a334f7c8..48c2fd0300 100644 --- a/src/backend/base/langflow/api/v2/converters.py +++ b/src/backend/base/langflow/api/v2/converters.py @@ -337,6 +337,7 @@ def build_component_output( output_type: str, display_name: str | None, result_data: Any, + valid: bool = True, ) -> ComponentOutput: """Build a ``ComponentOutput`` from one component's result data. @@ -381,9 +382,15 @@ def build_component_output( if isinstance(result_metadata, dict): metadata.update(result_metadata) + # Derive per-component status instead of hardcoding COMPLETED so a client + # trusting it is not given a false positive: a build that produced an error + # artifact (``output_type == "error"``) or that the graph marked invalid is + # FAILED. This mirrors the agui translator, which already keys node status on + # the same ``valid`` flag, so sync and both stream protocols agree. + status = JobStatus.FAILED if (output_type == "error" or not valid) else JobStatus.COMPLETED return ComponentOutput( type=output_type, - status=JobStatus.COMPLETED, + status=status, display_name=resolved_display_name, content=content, metadata=metadata, diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index 2bbbf4f2ec..766355c10f 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -1040,20 +1040,24 @@ async def _finish_cancelled_background_run(job_id: str) -> None: await bg_run.finish_cancelled(_WORKFLOW_CANCELLED_MESSAGE) -def _register_background_run(job_id: str, bg_run: _BackgroundRun) -> None: +async def _register_background_run(job_id: str, bg_run: _BackgroundRun) -> None: """Register a background run, evicting a completed entry when full. Prefer evicting the oldest *completed* run so a long-running job's re-attach handle survives. If every slot is still active, evict the - oldest one anyway to keep the registry bounded, and log a warning. + oldest one anyway to keep the registry bounded, cancel its still-running + buffer writer so it stops appending into a run no reader can find, and log + a warning. """ if len(_BACKGROUND_RUNS) >= _MAX_BACKGROUND_RUNS: evict_key = next( (key for key, run in _BACKGROUND_RUNS.items() if run.done), None, ) + evicted_running = False if evict_key is None: evict_key = next(iter(_BACKGROUND_RUNS)) + evicted_running = True logger.warning( "Background run registry full with no completed entries; " "evicting still-running job %s to make room for %s", @@ -1061,6 +1065,13 @@ def _register_background_run(job_id: str, bg_run: _BackgroundRun) -> None: job_id, ) _BACKGROUND_RUNS.pop(evict_key, None) + if evicted_running: + # Stop the orphaned buffer writer: without its registry entry no + # reader can find it, but the coroutine keeps appending frames. + # cancel_flow_build raises CancelledError into the build loop, which + # runs the buffer's finally/finish_cancelled path. + with contextlib.suppress(Exception): + await _cancel_workflow_queue_job(evict_key) _BACKGROUND_RUNS[job_id] = bg_run @@ -1175,7 +1186,7 @@ async def execute_workflow_background( ) bg_run = _BackgroundRun(user_id=str(current_user.id), stream_protocol=stream_protocol) - _register_background_run(job_id_str, bg_run) + await _register_background_run(job_id_str, bg_run) try: queue_service = get_queue_service() @@ -1504,6 +1515,11 @@ async def reattach_workflow_events( leaking the existence of other users' runs. """ bg_run = _BACKGROUND_RUNS.get(job_id) + # Live stream re-attach is intentionally owner-only and does not consult the + # authorization plugin: the replay buffer is process-local and keyed by the + # originating user, matching stop_workflow and the active-status path. Only + # the COMPLETED-status reconstruction branch is share-aware (it reloads the + # flow); a share-holder tails via the status endpoint, not this live stream. if bg_run is not None and bg_run.user_id != str(current_user.id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/src/backend/base/langflow/api/v2/workflow_public.py b/src/backend/base/langflow/api/v2/workflow_public.py index 52c9686eee..b90372fe40 100644 --- a/src/backend/base/langflow/api/v2/workflow_public.py +++ b/src/backend/base/langflow/api/v2/workflow_public.py @@ -40,6 +40,7 @@ from lfx.utils.flow_validation import ( validate_flow_for_current_settings, validate_public_flow_no_code_execution, ) +from limits import parse from langflow.api.utils.flow_utils import ( scope_session_to_namespace, @@ -62,6 +63,32 @@ from langflow.services.deps import get_settings_service, session_scope router = APIRouter(prefix="/workflows/public", tags=["Workflow (public)"]) +def _enforce_public_rate_limit(http_request: Request) -> None: + """Throttle anonymous public-flow runs per client IP. + + Each run executes as the flow owner (real CPU/DB/LLM-credit cost), so an + unauthenticated caller must not be able to spin up unbounded concurrent + executions. Mirrors the manual limiter check the login endpoint uses, but + keyed to its own configurable per-minute limit under a dedicated namespace so + it never shares a bucket with login. No-op when rate limiting is disabled. + """ + settings = get_settings_service().settings + if not settings.rate_limit_enabled: + return + limiter = http_request.app.state.limiter + limit_item = parse(f"{settings.public_flow_rate_limit_per_minute}/minute") + # hit() returns False once the window is exhausted for this (namespace, ip) key. + if not limiter._limiter.hit(limit_item, "public-workflow", limiter._key_func(http_request)): # noqa: SLF001 + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "error": "Too many requests", + "code": "RATE_LIMITED", + "message": "Too many public workflow runs from this client. Please retry shortly.", + }, + ) + + @router.post( "", responses=WORKFLOW_EXECUTION_RESPONSES, @@ -88,6 +115,10 @@ async def execute_public_workflow( # collected at import time. from langflow.api.v2.workflow import _stream_event_frames, _unknown_protocol_http_exception + # Throttle before any DB lookup or flow execution so an anonymous flood is + # rejected cheaply, not after spending the flow owner's resources. + _enforce_public_rate_limit(http_request) + real_flow_id = UUID(request.flow_id) # Mirror v1 ``build_public_tmp`` error contract: blocked-component diff --git a/src/backend/base/langflow/api/v2/workflow_reconstruction.py b/src/backend/base/langflow/api/v2/workflow_reconstruction.py index b079de69b2..6c4bd967ec 100644 --- a/src/backend/base/langflow/api/v2/workflow_reconstruction.py +++ b/src/backend/base/langflow/api/v2/workflow_reconstruction.py @@ -20,6 +20,37 @@ if TYPE_CHECKING: from langflow.services.database.models.flow.model import FlowRead +# Bound the structured search so a pathological/cyclic payload can't recurse +# unboundedly; the session_id sits a few levels deep in the persisted Message. +_SESSION_ID_SEARCH_MAX_DEPTH = 8 + + +def _recover_session_id(value: object, depth: int = 0) -> str | None: + """Find the session_id persisted in a terminal vertex_build's structured data. + + The session is serialized inside the terminal Message (e.g. + ``results["message"]["data"]["session_id"]`` and the output rows) at a depth + that varies by component, so search the dict/list structure rather than + depending on one component's layout. Serialized text blobs are strings, not + walked, so table headers that merely contain the word "session_id" are ignored. + """ + if depth > _SESSION_ID_SEARCH_MAX_DEPTH: + return None + if isinstance(value, dict): + candidate = value.get("session_id") + if isinstance(candidate, str) and candidate: + return candidate + for nested in value.values(): + found = _recover_session_id(nested, depth + 1) + if found: + return found + elif isinstance(value, list): + for item in value: + found = _recover_session_id(item, depth + 1) + if found: + return found + return None + async def reconstruct_workflow_response_from_job_id( session: AsyncSession, @@ -66,8 +97,18 @@ async def reconstruct_workflow_response_from_job_id( # Convert vertex_build data to RunOutputs format run_outputs_list = [RunOutputs(inputs={}, outputs=[ResultData(**vb.data)]) for vb in terminal_vertex_builds] + # Recover the session_id the run executed under so GET status can continue the + # same chat/memory thread, instead of always reporting null. It is persisted + # inside the terminal Message (``results[...]["data"]["session_id"]``) at a + # depth that varies by component, so search the structured data. A data-only + # flow has no session to recover and correctly stays None. + session_id = next( + (sid for vb in terminal_vertex_builds if (sid := _recover_session_id(vb.data))), + None, + ) + # Create RunResponse and convert to WorkflowExecutionResponse - run_response = RunResponse(outputs=run_outputs_list, session_id=None) + run_response = RunResponse(outputs=run_outputs_list, session_id=session_id) return run_response_to_workflow_response( run_response=run_response, diff --git a/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py b/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py index 83ce814199..0716c0b488 100644 --- a/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py +++ b/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py @@ -131,17 +131,20 @@ class TestErrorEventsFallback: assert [event.type for event in events] == ["TEXT_MESSAGE_END", "RUN_ERROR"] assert json.loads(events[0].data_json)["messageId"] == "m1" - def test_cancel_events_close_open_text_message_before_run_error(self): - """Cancellation uses adapter-owned framing and preserves AG-UI lifecycle.""" + def test_cancel_events_close_open_text_then_custom_marker_and_run_finished(self): + """A user-stop emits a CUSTOM cancel marker + RUN_FINISHED (never RUN_ERROR), after closing open text.""" adapter = get_stream_adapter("agui", _ctx()) list(adapter.initial_events()) list(adapter.translate("token", {"id": "m1", "chunk": "partial"})) events = list(adapter.cancel_events("cancelled")) - assert [event.type for event in events] == ["TEXT_MESSAGE_END", "RUN_ERROR"] + assert [event.type for event in events] == ["TEXT_MESSAGE_END", "CUSTOM", "RUN_FINISHED"] + assert all(event.type != "RUN_ERROR" for event in events) assert json.loads(events[0].data_json)["messageId"] == "m1" - assert json.loads(events[1].data_json)["message"] == "cancelled" + marker = json.loads(events[1].data_json) + assert marker["name"] == "langflow.run.cancelled" + assert marker["value"]["reason"] == "cancelled" class TestTerminalErrorType: diff --git a/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py b/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py index 3b32d1812c..b45e9d0a71 100644 --- a/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py +++ b/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py @@ -62,12 +62,13 @@ class TestErrorHandling: assert payload == {"event": "error", "data": {"error": "boom"}} assert evt.type == "error" - def test_cancel_events_emit_protocol_error_payload(self): + def test_cancel_events_emit_cancelled_payload_not_error(self): + """A user-stop is its own ``cancelled`` terminal, not ``error``, so a client can tell it from a failure.""" adapter = get_stream_adapter("langflow", _ctx()) [evt] = list(adapter.cancel_events("cancelled")) payload = json.loads(evt.data_json) - assert payload == {"event": "error", "data": {"error": "cancelled"}} - assert evt.type == "error" + assert payload == {"event": "cancelled", "data": {"reason": "cancelled"}} + assert evt.type == "cancelled" def test_terminal_error_type_is_error(self): """Used by the buffer task to decide JobStatus.FAILED.""" diff --git a/src/backend/tests/unit/api/v2/test_converters.py b/src/backend/tests/unit/api/v2/test_converters.py index eaa684016b..b046070c2f 100644 --- a/src/backend/tests/unit/api/v2/test_converters.py +++ b/src/backend/tests/unit/api/v2/test_converters.py @@ -1630,6 +1630,54 @@ class TestBuildComponentOutput: assert output.content is None assert output.metadata == {"component_type": "ChatOutput"} + def test_error_output_type_yields_failed_status(self): + # An error artifact carries type "error"; per-component status must reflect + # the failure instead of the hardcoded COMPLETED a client would trust. + output = build_component_output( + component_id="ChatOutput-abc", + is_output=True, + vertex_type="ChatOutput", + output_type="error", + display_name="Chat Output", + result_data=None, + ) + assert output.status == JobStatus.FAILED + + def test_invalid_build_yields_failed_status(self): + # The stream path passes the vertex ``valid`` flag (the agui translator keys + # node status on the same signal); an invalid build is FAILED, not COMPLETED. + output = build_component_output( + component_id="ChatOutput-abc", + is_output=True, + vertex_type="ChatOutput", + output_type="message", + display_name="Chat Output", + result_data=None, + valid=False, + ) + assert output.status == JobStatus.FAILED + + +class TestGlobalsSyncOnlyDocumented: + """The ``globals`` field is honored on sync only; the schema must say so. + + Stream/background converge on ``generate_flow_events`` which never receives + request globals, so the field description documents the sync-only limitation + the same way ``output_ids`` already documents "Ignored for stream/background". + """ + + def test_workflow_run_request_globals_documents_sync_only(self): + from lfx.schema.workflow import WorkflowRunRequest + + description = WorkflowRunRequest.model_fields["globals"].description + assert "ignored for stream/background" in description.lower() + + def test_workflow_execution_request_globals_documents_sync_only(self): + from lfx.schema.workflow import WorkflowExecutionRequest + + description = WorkflowExecutionRequest.model_fields["globals"].description + assert "ignored for stream/background" in description.lower() + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/src/backend/tests/unit/api/v2/test_workflow_agui.py b/src/backend/tests/unit/api/v2/test_workflow_agui.py index 9633fd5065..86ba3e74a7 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_agui.py +++ b/src/backend/tests/unit/api/v2/test_workflow_agui.py @@ -1039,7 +1039,7 @@ class TestAGUIBackgroundJobStatus: assert (job_id, workflow_module.JobStatus.IN_PROGRESS, False) in updates - async def test_cancelled_agui_buffer_wakes_tail_reader_with_closed_text_and_run_error( + async def test_cancelled_agui_buffer_wakes_tail_reader_with_closed_text_and_run_finished( self, monkeypatch: pytest.MonkeyPatch ): """The owner task must append cancellation before marking replay done.""" @@ -1094,11 +1094,16 @@ class TestAGUIBackgroundJobStatus: tail_frames = await asyncio.wait_for(tail_task, timeout=2) tail_payloads = _sse_payloads(tail_frames) - assert [_sse_payload_type(payload) for payload in tail_payloads] == ["TEXT_MESSAGE_END", "RUN_ERROR"] + assert [_sse_payload_type(payload) for payload in tail_payloads] == [ + "TEXT_MESSAGE_END", + "CUSTOM", + "RUN_FINISHED", + ] assert tail_payloads[0]["messageId"] == "m1" - assert tail_payloads[1]["message"] == "Workflow run cancelled." + assert tail_payloads[1]["name"] == "langflow.run.cancelled" + assert tail_payloads[1]["value"]["reason"] == "Workflow run cancelled." - async def test_cancelled_langflow_buffer_wakes_tail_reader_with_langflow_error( + async def test_cancelled_langflow_buffer_wakes_tail_reader_with_langflow_cancelled( self, monkeypatch: pytest.MonkeyPatch ): """Cancellation framing must stay protocol-native outside AG-UI too.""" @@ -1153,7 +1158,7 @@ class TestAGUIBackgroundJobStatus: tail_frames = await asyncio.wait_for(tail_task, timeout=2) [payload] = _sse_payloads(tail_frames) - assert payload == {"event": "error", "data": {"error": "Workflow run cancelled."}} + assert payload == {"event": "cancelled", "data": {"reason": "Workflow run cancelled."}} async def test_finish_cancelled_background_run_appends_terminal_before_waking_replay( self, monkeypatch: pytest.MonkeyPatch @@ -1212,9 +1217,14 @@ class TestAGUIBackgroundJobStatus: tail_frames = await asyncio.wait_for(tail_task, timeout=2) tail_payloads = _sse_payloads(tail_frames) - assert [_sse_payload_type(payload) for payload in tail_payloads] == ["TEXT_MESSAGE_END", "RUN_ERROR"] + assert [_sse_payload_type(payload) for payload in tail_payloads] == [ + "TEXT_MESSAGE_END", + "CUSTOM", + "RUN_FINISHED", + ] assert tail_payloads[0]["messageId"] == "m1" - assert tail_payloads[1]["message"] == "Workflow run cancelled." + assert tail_payloads[1]["name"] == "langflow.run.cancelled" + assert tail_payloads[1]["value"]["reason"] == "Workflow run cancelled." frames_after_fallback = list(bg_run.frames) buffer_task.cancel() @@ -1332,6 +1342,55 @@ class TestAGUIBackgroundJobStatus: assert body["status"] == "completed" assert "outputs" in body + async def test_completed_background_job_status_recovers_session_id( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A completed background job's GET status must echo the session it ran under. + + Regression: ``reconstruct_workflow_response_from_job_id`` hardcoded + ``session_id=None``, so every completed background job reported a null + session even though the run executed under a real one, breaking the + documented handle for continuing the same chat/memory thread. The session + is recovered from the persisted terminal ``ChatOutputResponse.session_id``. + """ + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, message="hi", mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + # Drain the SSE buffer so the build runs and persists vertex builds. + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + assert "RUN_FINISHED" in events.text + + row = None + for _ in range(100): + async with session_scope() as session: + row = await session.get(_Job, _UUID(job_id)) + if row is not None and row.status.value in ("completed", "failed"): + break + await _asyncio.sleep(0.1) + assert row is not None, "background job row was never created" + assert row.status.value == "completed", f"background job did not complete: {row.status.value!r}" + + status_resp = await client.get(f"api/v2/workflows?job_id={job_id}", headers=headers) + assert status_resp.status_code == 200, status_resp.text + body = status_resp.json() + # _agui_body runs under session "thread-1"; before the fix this was null. + assert body["session_id"] == "thread-1" + async def test_message_with_json_shaped_run_error_payload_does_not_fail_job( self, client: AsyncClient, @@ -1932,54 +1991,76 @@ class TestBackgroundRunsRegistryEviction: to evicting the oldest only when every slot is occupied by a running run. """ - def test_eviction_prefers_completed_runs_over_running_ones(self, monkeypatch): + async def test_eviction_prefers_completed_runs_over_running_ones(self, monkeypatch): from langflow.api.v2 import workflow as workflow_module monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 3) monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + # Evicting a completed run has no live writer to stop, so the cancel path + # must not fire; record any call to prove it doesn't. + cancelled: list[str] = [] + + async def fake_cancel(job_id): + cancelled.append(job_id) + return True + + monkeypatch.setattr(workflow_module, "_cancel_workflow_queue_job", fake_cancel) + long_running = workflow_module._BackgroundRun(user_id="u") # done stays False; this is the run we must protect. - workflow_module._register_background_run("long", long_running) + await workflow_module._register_background_run("long", long_running) # Fill the rest with completed runs. for job_id in ("done1", "done2"): done_run = workflow_module._BackgroundRun(user_id="u") done_run.done = True - workflow_module._register_background_run(job_id, done_run) + await workflow_module._register_background_run(job_id, done_run) # Registry is now at the cap (3): [long, done1, done2]. Adding a new # entry must evict a completed run, not the still-running ``long``. new_run = workflow_module._BackgroundRun(user_id="u") - workflow_module._register_background_run("new", new_run) + await workflow_module._register_background_run("new", new_run) assert "long" in workflow_module._BACKGROUND_RUNS, ( "Still-running background run was evicted in favor of a completed one" ) assert "new" in workflow_module._BACKGROUND_RUNS + assert cancelled == [], "Evicting a completed run must not cancel a queue job" - def test_eviction_falls_back_to_oldest_when_every_run_is_active(self, monkeypatch): + async def test_eviction_falls_back_to_oldest_when_every_run_is_active(self, monkeypatch): """If every slot is occupied by a still-running run, evict the oldest anyway. Unbounded growth would leak memory. The fallback is intentional and - documented; a warning log makes the situation visible. + documented; a warning log makes the situation visible. The evicted run's + buffer writer is cancelled so it stops appending into a run no reader can + find (the bounded-memory guarantee the registry exists to provide). """ from langflow.api.v2 import workflow as workflow_module monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 2) monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + cancelled: list[str] = [] + + async def fake_cancel(job_id): + cancelled.append(job_id) + return True + + monkeypatch.setattr(workflow_module, "_cancel_workflow_queue_job", fake_cancel) + for job_id in ("a", "b"): run = workflow_module._BackgroundRun(user_id="u") - workflow_module._register_background_run(job_id, run) + await workflow_module._register_background_run(job_id, run) - # All running; adding a third must evict the oldest (a). + # All running; adding a third must evict the oldest (a) and cancel it. third = workflow_module._BackgroundRun(user_id="u") - workflow_module._register_background_run("c", third) + await workflow_module._register_background_run("c", third) assert "a" not in workflow_module._BACKGROUND_RUNS assert "b" in workflow_module._BACKGROUND_RUNS assert "c" in workflow_module._BACKGROUND_RUNS + assert cancelled == ["a"], "Evicted still-running run's buffer writer was not cancelled" class TestClearBackgroundRun: @@ -1997,7 +2078,7 @@ class TestClearBackgroundRun: monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) bg_run = workflow_module._BackgroundRun(user_id="u") - workflow_module._register_background_run("job-1", bg_run) + await workflow_module._register_background_run("job-1", bg_run) assert bg_run.done is False await workflow_module._clear_background_run("job-1") @@ -2331,9 +2412,11 @@ class TestStopWorkflowEndToEnd: events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) assert events.status_code == 200 - assert "RUN_ERROR" in events.text - assert "cancelled" in events.text.lower() - assert "RUN_FINISHED" not in events.text + # A deliberate stop replays as a CUSTOM cancel marker + RUN_FINISHED, not + # RUN_ERROR: a re-attaching client must not read a user-stop as a failure. + assert "RUN_ERROR" not in events.text + assert "langflow.run.cancelled" in events.text + assert "RUN_FINISHED" in events.text async with session_scope() as session: row = await session.get(Job, _UUID(job_id)) diff --git a/src/backend/tests/unit/api/v2/test_workflow_public.py b/src/backend/tests/unit/api/v2/test_workflow_public.py index 6d1e6653b7..f6cb4d1363 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_public.py +++ b/src/backend/tests/unit/api/v2/test_workflow_public.py @@ -19,6 +19,7 @@ the mitigations the v2 public endpoint is supposed to inherit from v1: from __future__ import annotations from typing import TYPE_CHECKING, Any +from uuid import uuid4 import pytest from httpx import AsyncClient, codes @@ -140,6 +141,39 @@ async def test_public_endpoint_rejects_tweaks_field(client: AsyncClient, public_ assert response.status_code == codes.UNPROCESSABLE_ENTITY +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_oversized_input_value(client: AsyncClient, public_flow_id): + """An anonymous caller cannot post an arbitrarily large ``input_value``; the wire schema bounds it at 64 KB.""" + _send_unauthenticated(client, "oversized-input-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "x" * (64 * 1024 + 1), + }, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.UNPROCESSABLE_ENTITY + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_oversized_session_id(client: AsyncClient, public_flow_id): + """``session_id`` is bounded too — it is namespaced and persisted per visitor.""" + _send_unauthenticated(client, "oversized-session-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "session_id": "s" * (256 + 1), + }, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.UNPROCESSABLE_ENTITY + + @pytest.mark.benchmark @pytest.mark.security async def test_public_endpoint_rejects_non_stream_mode(client: AsyncClient, public_flow_id): @@ -422,3 +456,43 @@ async def test_public_endpoint_surfaces_value_error_as_400(client: AsyncClient, assert response.status_code == codes.BAD_REQUEST assert response.json().get("detail") == "custom gate failure" + + +@pytest.fixture +def _fresh_limiter(): + """Reset the global limiter singleton so the throttle test starts clean.""" + import langflow.services.rate_limit.service as rate_limit_module + + original = rate_limit_module._limiter + rate_limit_module._limiter = None + yield + rate_limit_module._limiter = original + + +@pytest.mark.security +@pytest.mark.usefixtures("_fresh_limiter") +def test_public_endpoint_throttles_per_ip(monkeypatch): + """The unauthenticated public endpoint throttles per client IP. + + Each run executes as the flow owner (real cost), so an anonymous caller must + not be able to spin up unbounded runs. With the limit set to 2/min, the third + request from the same IP is rejected at the throttle (429) before any flow work. + """ + from fastapi.testclient import TestClient + from langflow.main import create_app + from langflow.services.deps import get_settings_service + + settings = get_settings_service().settings + monkeypatch.setattr(settings, "rate_limit_enabled", True) + monkeypatch.setattr(settings, "public_flow_rate_limit_per_minute", 2) + + app = create_app() + sync_client = TestClient(app) + body = {"flow_id": str(uuid4()), "input_value": "hi"} + + statuses = [sync_client.post("api/v2/workflows/public", json=body).status_code for _ in range(3)] + + # First two pass the throttle (and fail downstream on the nonexistent flow); + # the third exhausts the 2/min window and is rejected at the throttle. + assert statuses[2] == codes.TOO_MANY_REQUESTS, statuses + assert codes.TOO_MANY_REQUESTS not in statuses[:2], statuses diff --git a/src/backend/tests/unit/api/v2/test_workflow_reconstruction.py b/src/backend/tests/unit/api/v2/test_workflow_reconstruction.py index f3b959ccb1..8dda2b93a1 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_reconstruction.py +++ b/src/backend/tests/unit/api/v2/test_workflow_reconstruction.py @@ -12,12 +12,41 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest -from langflow.api.v2.workflow_reconstruction import reconstruct_workflow_response_from_job_id +from langflow.api.v2.workflow_reconstruction import _recover_session_id, reconstruct_workflow_response_from_job_id from langflow.services.database.models.vertex_builds.model import VertexBuildTable from lfx.interface.components import component_cache from lfx.utils.flow_validation import CustomComponentValidationError +class TestRecoverSessionId: + """``_recover_session_id`` digs the persisted session_id out of terminal data. + + The session_id sits inside the serialized terminal Message at a depth that + varies by component, so the search walks the dict/list structure and ignores + serialized text blobs that merely contain the word ``session_id``. + """ + + def test_recovers_session_id_nested_like_persisted_message(self): + # Mirrors the real shape: results["message"]["data"]["session_id"]. + data = {"results": {"message": {"text_key": "text", "data": {"session_id": "thread-1", "text": "hi"}}}} + assert _recover_session_id(data) == "thread-1" + + def test_recovers_session_id_from_list_of_rows(self): + data = {"outputs": {"dataframe": {"message": [{"text": "hi", "session_id": "thread-9"}]}}} + assert _recover_session_id(data) == "thread-9" + + def test_ignores_session_id_appearing_only_inside_serialized_text(self): + # A rendered table header is a string, not a dict key, so it is not matched. + data = {"outputs": {"prompt": {"message": "| sender | session_id | text |"}}} + assert _recover_session_id(data) is None + + def test_returns_none_for_data_only_flow(self): + assert _recover_session_id({"results": {}, "outputs": {"data": {"value": 1}}}) is None + + def test_blank_session_id_is_not_recovered(self): + assert _recover_session_id({"data": {"session_id": ""}}) is None + + class TestWorkflowReconstruction: """Unit tests for workflow reconstruction logic.""" diff --git a/src/lfx/src/lfx/schema/workflow.py b/src/lfx/src/lfx/schema/workflow.py index 11dbe91f03..caf2412cf9 100644 --- a/src/lfx/src/lfx/schema/workflow.py +++ b/src/lfx/src/lfx/schema/workflow.py @@ -22,6 +22,14 @@ GLOBAL_VALUE_MAX_LEN = 64 * 1024 GlobalVarKey = Annotated[str, StringConstraints(min_length=1, max_length=GLOBAL_KEY_MAX_LEN)] GlobalVarValue = Annotated[str, StringConstraints(max_length=GLOBAL_VALUE_MAX_LEN)] +# Bounds on the unauthenticated public-flow request, so an anonymous caller +# can't post arbitrarily large strings that are held in memory and persisted to +# MessageTable. input_value is a chat message (same 64 KB ceiling as a global +# variable value); session_id is namespaced under the visitor's virtual flow id, +# so a key-sized bound is ample. +PublicInputValue = Annotated[str, StringConstraints(max_length=GLOBAL_VALUE_MAX_LEN)] +PublicSessionId = Annotated[str, StringConstraints(max_length=GLOBAL_KEY_MAX_LEN)] + class JobStatus(str, Enum): """Job execution status.""" @@ -126,7 +134,8 @@ class WorkflowExecutionRequest(BaseModel): "Keys may use any printable string up to " f"{GLOBAL_KEY_MAX_LEN} chars; values are capped at " f"{GLOBAL_VALUE_MAX_LEN} chars. Body globals always win over the " - "legacy ``X-LANGFLOW-GLOBAL-VAR-*`` headers." + "legacy ``X-LANGFLOW-GLOBAL-VAR-*`` headers. Honored in sync mode; " + "ignored for stream/background modes." ), ) @@ -249,7 +258,8 @@ class WorkflowRunRequest(BaseModel): "Keys may use any printable string up to " f"{GLOBAL_KEY_MAX_LEN} chars; values are capped at " f"{GLOBAL_VALUE_MAX_LEN} chars. Body globals always win over the " - "legacy ``X-LANGFLOW-GLOBAL-VAR-*`` headers." + "legacy ``X-LANGFLOW-GLOBAL-VAR-*`` headers. Honored in sync mode; " + "ignored for stream/background modes." ), ) @@ -312,8 +322,8 @@ class PublicWorkflowRunRequest(BaseModel): """ flow_id: str = Field(..., description="UUID of the public flow to run.") - input_value: str = Field("", description="Chat-style input value.") - session_id: str | None = Field( + input_value: PublicInputValue = Field("", description="Chat-style input value.") + session_id: PublicSessionId | None = Field( None, description=("Optional caller session. Always namespaced under the visitor's virtual flow id by the endpoint."), ) diff --git a/src/lfx/src/lfx/services/settings/groups/security.py b/src/lfx/src/lfx/services/settings/groups/security.py index fecd02b8c0..e55cef584a 100644 --- a/src/lfx/src/lfx/services/settings/groups/security.py +++ b/src/lfx/src/lfx/services/settings/groups/security.py @@ -78,6 +78,10 @@ class SecuritySettings(BaseModel): """Storage backend for rate limiting. Use 'memory://' for single-server or 'redis://host:port' for multi-server.""" rate_limit_trust_proxy: bool = False """Trust X-Forwarded-For header when behind a reverse proxy. Only enable when behind a trusted proxy.""" + public_flow_rate_limit_per_minute: int = 20 + """Public-flow runs allowed per minute per IP on the unauthenticated /api/v2/workflows/public endpoint. + Each run executes as the flow owner (real CPU/DB/LLM-credit cost), so anonymous callers are throttled + separately from (and more generously than) the login limit. Gated by rate_limit_enabled.""" @field_validator("cors_origins", mode="before") @classmethod From 3a46d44f15615670385b16cd07107911317bc547 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Mon, 22 Jun 2026 12:02:35 -0300 Subject: [PATCH 2/4] test(lfx): register public_flow_rate_limit_per_minute in settings composition --- .../tests/unit/services/settings/test_settings_composition.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lfx/tests/unit/services/settings/test_settings_composition.py b/src/lfx/tests/unit/services/settings/test_settings_composition.py index 467004ce66..6a04908fb6 100644 --- a/src/lfx/tests/unit/services/settings/test_settings_composition.py +++ b/src/lfx/tests/unit/services/settings/test_settings_composition.py @@ -161,6 +161,7 @@ EXPECTED_FIELDS = { "rate_limit_per_minute", "rate_limit_storage_uri", "rate_limit_trust_proxy", + "public_flow_rate_limit_per_minute", "custom_component_admin_only", "allow_components_paths_override", # RuntimeSettings From a21d0d9eab646905c22b99507bdc97cf24162f23 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Mon, 22 Jun 2026 15:05:53 -0300 Subject: [PATCH 3/4] refactor(v2 workflows): split workflow.py and address review blockers 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. --- .../base/langflow/api/v2/converters.py | 3 - src/backend/base/langflow/api/v2/workflow.py | 1007 +---------------- .../langflow/api/v2/workflow_background.py | 390 +++++++ .../langflow/api/v2/workflow_execution.py | 556 +++++++++ .../base/langflow/api/v2/workflow_public.py | 3 +- .../langflow/api/v2/workflow_validation.py | 101 ++ .../tests/unit/api/v2/test_workflow.py | 70 +- .../tests/unit/api/v2/test_workflow_agui.py | 265 +++-- .../tests/unit/api/v2/test_workflow_public.py | 4 +- .../lfx/services/settings/groups/runtime.py | 5 + .../settings/test_settings_composition.py | 1 + 11 files changed, 1326 insertions(+), 1079 deletions(-) create mode 100644 src/backend/base/langflow/api/v2/workflow_background.py create mode 100644 src/backend/base/langflow/api/v2/workflow_execution.py create mode 100644 src/backend/base/langflow/api/v2/workflow_validation.py diff --git a/src/backend/base/langflow/api/v2/converters.py b/src/backend/base/langflow/api/v2/converters.py index 48c2fd0300..47d8bf8f25 100644 --- a/src/backend/base/langflow/api/v2/converters.py +++ b/src/backend/base/langflow/api/v2/converters.py @@ -259,8 +259,6 @@ def _simplify_output_content(content: Any, output_type: str) -> Any: result_data = _extract_nested_value(content, *path) if result_data is not None: return result_data - # TODO: Future scope - Add dataframe-specific extraction logic - # The following code is commented out pending further requirements analysis: if output_type == "dataframe": # For dataframe types, try multiple path combinations in order dataframe_paths = [ @@ -364,7 +362,6 @@ def build_component_output( # Non-output nodes: # - For data types: extract and show content # - For message types: extract metadata only (source, file_path) - # TODO: Future scope - Add support for "dataframe" output type if output_type in ["data", "dataframe"]: content = _simplify_output_content(raw_content, output_type) else: diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index 766355c10f..52ebb395b8 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -1,42 +1,36 @@ """V2 Workflow execution endpoints. -This module implements the V2 Workflow API endpoints for executing flows with -enhanced error handling, timeout protection, and structured responses. +This module implements the V2 Workflow API route handlers for executing flows +with enhanced error handling, timeout protection, and structured responses. +The execution machinery itself lives in sibling modules: + + - ``workflow_validation``: request/permission guards. + - ``workflow_execution``: sync + streaming run-driving. + - ``workflow_background``: durable, re-attachable background runs. Endpoints: - POST /workflow: Execute a workflow (sync, stream, or background modes) - GET /workflow: Get workflow job status by job_id - POST /workflow/stop: Stop a running workflow execution + POST /workflows: Execute a workflow (sync, stream, or background modes) + GET /workflows: Get workflow job status by job_id + POST /workflows/stop: Stop a running workflow execution + GET /workflows/{job_id}/events: Re-attach to a background run's event stream Features: - Comprehensive error handling with structured error responses - Timeout protection for long-running executions - Support for multiple execution modes (sync, stream, background) - Session-cookie or API-key authentication - -Configuration: - EXECUTION_TIMEOUT: Maximum execution time for synchronous workflows (300 seconds) """ from __future__ import annotations import asyncio import contextlib -import json -import time -from collections.abc import AsyncIterator, Callable, Iterable -from copy import deepcopy from typing import Annotated from uuid import UUID, uuid4 -from ag_ui.core import CustomEvent from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status from fastapi.responses import EventSourceResponse, StreamingResponse -from fastapi.sse import format_sse_event -from lfx.events.event_manager import create_default_event_manager -from lfx.graph.graph.base import Graph from lfx.log.logger import logger -from lfx.schema.schema import InputValueRequest from lfx.schema.workflow import ( WORKFLOW_EXECUTION_RESPONSES, WORKFLOW_STATUS_RESPONSES, @@ -49,28 +43,35 @@ from lfx.schema.workflow import ( WorkflowStopResponse, ) from lfx.services.deps import injectable_session_scope_readonly -from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings from pydantic_core import ValidationError as PydanticValidationError from sqlalchemy.exc import OperationalError -from langflow.api.utils import extract_global_variables_from_headers -from langflow.api.v1.schemas import FlowDataRequest, RunResponse from langflow.api.v2.adapters import ( STREAM_ADAPTERS, - StreamAdapter, StreamAdapterContext, - StreamEvent, UnknownStreamProtocolError, available_protocols, get_stream_adapter, ) -from langflow.api.v2.converters import ( - ParsedWorkflowRun, - create_error_response, - parse_workflow_run_request, - run_response_to_workflow_response, +from langflow.api.v2.converters import parse_workflow_run_request +from langflow.api.v2.workflow_background import ( + _BACKGROUND_RUNS, + _cancel_workflow_queue_job, + _finish_cancelled_background_run, + execute_workflow_background, +) +from langflow.api.v2.workflow_execution import ( + _execute_streaming_workflow, + _resolve_execution_timeout, + execute_sync_workflow_with_timeout, ) from langflow.api.v2.workflow_reconstruction import reconstruct_workflow_response_from_job_id +from langflow.api.v2.workflow_validation import ( + _enforce_flow_data_override_owner, + _flow_not_found_privacy_exception, + _reject_unsupported_sync_fields, + _validate_flow_data_for_execution, +) from langflow.exceptions.api import ( WorkflowQueueFullError, WorkflowResourceError, @@ -79,40 +80,15 @@ from langflow.exceptions.api import ( WorkflowValidationError, ) from langflow.helpers.flow import get_flow_by_id_or_endpoint_name -from langflow.processing.process import process_tweaks, run_graph_internal from langflow.services.auth.utils import get_current_user_for_workflow from langflow.services.authorization import FlowAction, ensure_flow_permission -from langflow.services.authorization.fetch import deny_to_404 -from langflow.services.database.models.flow.model import FlowRead from langflow.services.database.models.jobs.model import JobType from langflow.services.database.models.user.model import UserRead -from langflow.services.deps import get_job_service, get_memory_base_service, get_queue_service, get_task_service - -# Configuration constants -EXECUTION_TIMEOUT = 300 # 5 minutes default timeout for sync execution - +from langflow.services.deps import get_job_service router = APIRouter(prefix="/workflows", tags=["Workflow"]) -async def generate_flow_events(*args, **kwargs) -> None: - """Lazily call the v1 build stream to avoid import cycles during router setup.""" - from langflow.api.build import generate_flow_events as _generate_flow_events - - await _generate_flow_events(*args, **kwargs) - - -async def _cancel_workflow_queue_job(job_id: str) -> bool: - """Lazily call the shared build cancellation path to avoid import cycles.""" - from langflow.api.build import cancel_flow_build - from langflow.services.job_queue.service import JobQueueNotFoundError - - try: - return await cancel_flow_build(job_id=job_id, queue_service=get_queue_service()) - except JobQueueNotFoundError: - return False - - def _unknown_protocol_http_exception(exc: UnknownStreamProtocolError) -> HTTPException: """Build the 422 response shared by ``stream`` and ``background`` paths. @@ -132,115 +108,6 @@ def _unknown_protocol_http_exception(exc: UnknownStreamProtocolError) -> HTTPExc ) -def _flow_not_found_privacy_exception(exc: HTTPException, flow_id: str) -> HTTPException: - return deny_to_404(exc, detail=f"Flow with id {flow_id} not found") - - -def _reject_unsupported_sync_fields(parsed: ParsedWorkflowRun) -> None: - """Reject request fields the inline sync path does not execute.""" - if parsed.mode != "sync": - return - - unsupported_fields: list[str] = [] - if parsed.data is not None: - unsupported_fields.append("data") - if parsed.files: - unsupported_fields.append("files") - if parsed.start_component_id is not None: - unsupported_fields.append("start_component_id") - if parsed.stop_component_id is not None: - unsupported_fields.append("stop_component_id") - - if unsupported_fields: - fields = ", ".join(unsupported_fields) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error": "Unsupported sync request fields", - "code": "SYNC_MODE_UNSUPPORTED_FIELDS", - "message": f"mode='sync' does not support request fields: {fields}. Use mode='stream' or " - "mode='background' for live-canvas overrides, files, or partial-run boundaries.", - "fields": unsupported_fields, - }, - ) - - -def _enforce_flow_data_override_owner(parsed: ParsedWorkflowRun, flow: FlowRead, current_user: UserRead) -> None: - """Only the flow owner may execute caller-supplied graph data.""" - if parsed.data is None or flow.user_id == current_user.id: - return - - raise _flow_not_found_privacy_exception( - HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only the flow owner can override flow data during execution", - ), - parsed.flow_id, - ) - - -def _validate_flow_data_for_execution(parsed: ParsedWorkflowRun, flow: FlowRead) -> None: - """Apply the same server-side component policy gate used by v1/public runs.""" - try: - if parsed.data is not None: - validate_flow_for_current_settings(parsed.data) - elif flow.data: - validate_flow_for_current_settings(flow.data) - except CustomComponentValidationError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - except RuntimeError as exc: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc - - -def _validate_output_ids(output_ids: list[str] | None, terminal_node_ids: list[str]) -> None: - """Reject ``output_ids`` that aren't outputs of this flow, BEFORE it runs. - - Checks against the terminal node ids known after graph build but before - execution, so a typo or wrong id costs no compute. ``available`` lists the - flow's real output ids so callers can self-correct. None/empty means "no - selection" and never raises. - """ - if not output_ids: - return - known = set(terminal_node_ids) - unknown = [output_id for output_id in output_ids if output_id not in known] - if unknown: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "error": "Unknown output_ids", - "code": "UNKNOWN_OUTPUT_IDS", - "message": f"output_ids not produced by this flow: {unknown}.", - "available": terminal_node_ids, - }, - ) - - -def _build_run_inputs(parsed: ParsedWorkflowRun) -> list[InputValueRequest] | None: - """Build the graph input list from the AG-UI chat message, if any. - - The last user message becomes a single chat input; an empty message means - the flow runs with no chat input (parameters arrive via tweaks instead). - """ - if not parsed.input_value: - return None - return [InputValueRequest(components=[], input_value=parsed.input_value, type="chat")] - - -def _resolve_request_variables(body_globals: dict[str, str], http_request: Request | None) -> dict[str, str]: - """Merge request-level global variables for a v2 workflow execution. - - v2 workflows take globals from the JSON request body (``globals``). The - ``X-LANGFLOW-GLOBAL-VAR-*`` headers remain a supported transport (the - OpenAI-compatible Responses API passes globals that way); body globals win - on conflict. - """ - header_globals: dict[str, str] = {} - if http_request is not None: - header_globals = extract_global_variables_from_headers(http_request.headers) - return {**header_globals, **dict(body_globals or {})} - - @router.post( "", response_model=None, @@ -373,25 +240,27 @@ async def execute_workflow( ) from e raise except OperationalError as e: + await logger.aexception("Database error fetching flow for workflow execution") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "error": "Service unavailable, Please try again.", "code": "DATABASE_ERROR", - "message": f"Failed to fetch flow: {e!s}", + "message": "Failed to fetch flow. Please try again.", "flow_id": parsed.flow_id, }, ) from e except WorkflowTimeoutError: + timeout_seconds = _resolve_execution_timeout() raise HTTPException( status_code=status.HTTP_408_REQUEST_TIMEOUT, detail={ "error": "Execution timeout", "code": "EXECUTION_TIMEOUT", - "message": f"Workflow execution exceeded {EXECUTION_TIMEOUT} seconds", + "message": f"Workflow execution exceeded {timeout_seconds} seconds", "job_id": str(job_id), "flow_id": str(parsed.flow_id), - "timeout_seconds": EXECUTION_TIMEOUT, + "timeout_seconds": timeout_seconds, }, ) from None except (PydanticValidationError, WorkflowValidationError) as e: @@ -425,800 +294,18 @@ async def execute_workflow( }, ) from err except Exception as err: + await logger.aexception("Unexpected error during workflow execution") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", - "message": f"An unexpected error occurred: {err!s}", + "message": "An unexpected error occurred.", "flow_id": parsed.flow_id, }, ) from err -async def execute_sync_workflow_with_timeout( - parsed: ParsedWorkflowRun, - flow: FlowRead, - job_id: UUID, - current_user: UserRead, - background_tasks: BackgroundTasks, - http_request: Request, -) -> WorkflowExecutionResponse: - """Execute workflow with timeout protection. - - Args: - parsed: The parsed AG-UI run parameters - flow: The flow to execute - job_id: Generated job ID for tracking - current_user: Authenticated user - background_tasks: FastAPI background tasks - http_request: The HTTP request object for extracting headers - - Returns: - WorkflowExecutionResponse with complete results - - Raises: - WorkflowTimeoutError: If execution exceeds timeout - WorkflowValidationError: If flow validation fails - """ - try: - return await asyncio.wait_for( - execute_sync_workflow( - parsed=parsed, - flow=flow, - job_id=job_id, - current_user=current_user, - background_tasks=background_tasks, - http_request=http_request, - ), - timeout=EXECUTION_TIMEOUT, - ) - except asyncio.TimeoutError as e: - raise WorkflowTimeoutError from e - - -async def execute_sync_workflow( - parsed: ParsedWorkflowRun, - flow: FlowRead, - job_id: UUID, - current_user: UserRead, - background_tasks: BackgroundTasks, # noqa: ARG001 - http_request: Request, -) -> WorkflowExecutionResponse: - """Execute workflow synchronously and return complete results. - - This function implements a two-tier error handling strategy: - 1. System-level errors (validation, graph build): Raised as exceptions - 2. Component execution errors: Returned in response body with HTTP 200 - - This approach allows clients to receive partial results even when some - components fail, which is useful for debugging and incremental processing. - - Execution Flow: - 1. Apply tweaks and chat input from the parsed AG-UI request - 2. Validate flow data exists - 3. Extract context from HTTP headers - 4. Build graph from flow data with tweaks applied - 5. Identify terminal nodes for execution - 6. Execute graph and collect results - 7. Convert V1 RunResponse to V2 WorkflowExecutionResponse - - Args: - parsed: The parsed AG-UI run parameters with tweaks and chat input - flow: The flow model from database - job_id: Generated job ID for tracking this execution - current_user: Authenticated user for permission checks - background_tasks: FastAPI background tasks (unused in sync mode) - http_request: The HTTP request object for extracting headers - - Returns: - WorkflowExecutionResponse: Complete execution results with outputs and metadata - - Raises: - WorkflowValidationError: If flow data is None or graph build fails - """ - # Tweaks and chat input come straight from the parsed AG-UI request - tweaks = parsed.tweaks - session_id = parsed.session_id - - # Validate flow data - this is a system error, not execution error - if flow.data is None: - msg = f"Flow {flow.id} has no data. The flow may be corrupted." - raise WorkflowValidationError(msg) - - # Resolve request-level variables: body ``globals`` plus the legacy - # X-LANGFLOW-GLOBAL-VAR-* headers (still used by the Responses API). - # Body globals win on conflict. - request_variables = _resolve_request_variables(parsed.globals, http_request) - - # Build context from request variables (similar to V1's _run_flow_internal) - context = {"request_variables": request_variables} if request_variables else None - - # Build graph - system error if this fails - try: - flow_id_str = str(flow.id) - user_id = str(current_user.id) - # Use deepcopy to prevent mutation of the original flow.data - # process_tweaks modifies nested dictionaries in-place - graph_data = deepcopy(flow.data) - graph_data = process_tweaks(graph_data, tweaks, stream=False) - # Pass context to graph (similar to V1's simple_run_flow) - # This allows components to access request metadata via graph.context - graph = Graph.from_payload( - graph_data, flow_id=flow_id_str, user_id=user_id, flow_name=flow.name, context=context - ) - # Set run_id for tracing/logging (similar to V1's simple_run_flow) - graph.set_run_id(job_id) - except Exception as e: - msg = f"Failed to build graph from flow data: {e!s}" - raise WorkflowValidationError(msg) from e - - # Get terminal nodes - these are the outputs we want - terminal_node_ids = graph.get_terminal_nodes() - - # Validate request-side output selection BEFORE executing: a bad id must cost - # no compute. Raised outside the component-error try/except below, so it - # surfaces as a real 422 rather than a 200-with-failed body. - _validate_output_ids(parsed.output_ids, terminal_node_ids) - - # Execute graph - component errors are caught and returned in response body - job_service = get_job_service() - await job_service.create_job(job_id=job_id, flow_id=flow_id_str, user_id=current_user.id) - try: - task_result, execution_session_id = await job_service.execute_with_status( - job_id=job_id, - run_coro_func=run_graph_internal, - graph=graph, - flow_id=flow_id_str, - session_id=session_id, - inputs=_build_run_inputs(parsed), - outputs=terminal_node_ids, - stream=False, - ) - - # Fire memory-base auto-capture hook — non-blocking background effect. - try: - _run_id_uuid = UUID(graph.run_id) if graph.run_id else None # type-cast only; same run_id set on graph - await get_task_service().fire_and_forget_task( - get_memory_base_service().on_flow_output, - flow_id=flow.id, - session_id=execution_session_id, - job_id=_run_id_uuid, - ) - except (RuntimeError, ValueError, OSError): - await logger.awarning("Memory base hook scheduling failed for flow %s", flow.id, exc_info=True) - - # Build RunResponse - run_response = RunResponse(outputs=task_result, session_id=execution_session_id) - # Convert to WorkflowExecutionResponse - return run_response_to_workflow_response( - run_response=run_response, - flow_id=parsed.flow_id, - job_id=str(job_id), - inputs=parsed.tweaks, - graph=graph, - effective_globals=request_variables, - selected_ids=parsed.output_ids, - ) - - except asyncio.CancelledError: - # Re-raise CancelledError to allow timeout mechanism to work properly - # This ensures asyncio.wait_for() can properly cancel and raise TimeoutError - raise - except asyncio.TimeoutError as e: - # Re-raise TimeoutError to allow timeout mechanism to work properly - # This ensures asyncio.wait_for() can properly cancel and raise TimeoutError - raise WorkflowTimeoutError from e - except Exception as exc: # noqa: BLE001 - # Component execution errors - return in response body with HTTP 200 - # This allows partial results and detailed error information per component - return create_error_response( - flow_id=parsed.flow_id, - job_id=job_id, - inputs=parsed.tweaks, - error=exc, - effective_globals=request_variables, - ) - - -def _single_input_value_request(parsed: ParsedWorkflowRun) -> InputValueRequest | None: - """Build the single chat InputValueRequest the v1 build loop accepts. - - The v1 build path (``generate_flow_events``) takes a single - ``InputValueRequest``; when it receives ``None`` it falls back to - ``InputValueRequest(session=str(flow_id))``, which would wipe out the - caller's session id. We always return one with the parsed session so - component messages stay scoped to the user's active session, even when - there is no chat input (e.g. the playground "Run Flow" button). - """ - if not parsed.session_id and not parsed.input_value: - return None - return InputValueRequest( - components=[], - input_value=parsed.input_value or "", - type="chat", - session=parsed.session_id, - ) - - -_QueueItem = tuple[str | None, bytes | None, float] - - -class _WorkflowEventQueue: - """Bounded EventManager handoff that fails explicitly instead of dropping events.""" - - def __init__(self, maxsize: int) -> None: - self._queue: asyncio.Queue[_QueueItem] = asyncio.Queue(maxsize=maxsize) - self._overflowed = False - self._loop = asyncio.get_running_loop() - self._overflow_task: asyncio.Task[None] | None = None - - @property - def maxsize(self) -> int: - return self._queue.maxsize - - async def get(self) -> _QueueItem: - return await self._queue.get() - - async def put(self, item: _QueueItem) -> None: - if self._overflowed: - return - await self._queue.put(item) - - def put_nowait(self, item: _QueueItem) -> None: - if self._overflowed: - return - try: - self._queue.put_nowait(item) - except asyncio.QueueFull: - self._overflowed = True - self._overflow_task = self._loop.create_task(self._emit_overflow_error()) - - async def _emit_overflow_error(self) -> None: - payload = { - "event": "error", - "data": { - "error": "Workflow event stream exceeded buffering capacity; client is consuming events too slowly." - }, - } - await self._queue.put((f"error-{uuid4()}", json.dumps(payload).encode("utf-8"), time.time())) - await self._queue.put((None, None, time.time())) - - async def aclose(self) -> None: - if self._overflow_task is not None and not self._overflow_task.done(): - self._overflow_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._overflow_task - - -async def _stream_event_frames( - *, - adapter: StreamAdapter, - flow_id: UUID, - flow_name: str | None, - background_tasks: BackgroundTasks, - parsed: ParsedWorkflowRun, - current_user: UserRead, - source_flow_id: UUID | None = None, - run_id: str | None = None, - track_job_status: bool = True, -) -> AsyncIterator[tuple[bytes, str]]: - """Run a flow via the v1 build-vertex loop, dispatch its events through ``adapter``. - - Yields ``(sse_frame_bytes, event_type_str)`` pairs. The consumer - (streaming endpoint, background buffer) frames are pre-formatted with a - monotonic ``id:`` for ``Last-Event-ID`` resume. The ``event_type_str`` is - the adapter's protocol-native type so the buffer task can finalize a - background job's status structurally (no substring matching). - - A failure during the run becomes a terminal protocol event (e.g. - ``RUN_ERROR`` for AG-UI, ``error`` for langflow) routed through the - adapter; closing the consumer cancels the run. - - When the adapter is AG-UI, side-channel ``CustomEvent`` frames carry - the raw Langflow payload alongside the AG-UI translation for the - playground's chat-view. A follow-up retires this once chat-view - consumes the AG-UI ``TEXT_MESSAGE_*`` lifecycle directly. - """ - # EventManager uses put_nowait(), so a plain bounded asyncio.Queue would - # silently drop frames via QueueFull. This adapter keeps memory bounded and - # converts overflow into an explicit stream error + sentinel. - queue = _WorkflowEventQueue(maxsize=_EVENT_QUEUE_MAX_SIZE) - event_manager = create_default_event_manager(queue) - input_request = _single_input_value_request(parsed) - flow_data = FlowDataRequest(**parsed.data) if parsed.data else None - - # Captured from drive()'s exception path so the consumer can yield a - # guaranteed adapter.error_events(...) fallback after the queue loop ends. - # Layered error handling, by design: - # 1. ``event_manager.on_error(...)`` is the cooperative path: the - # translator turns it into the protocol's terminal-error event (e.g. - # RUN_ERROR for AG-UI, ``error`` for langflow) so the buffer's - # structural detector flips the job to FAILED. - # 2. ``adapter.error_events(exc)`` is the dispatcher's guaranteed - # fallback: emitted from the consumer side when ``generate_flow_events`` - # raises before any cooperative terminal error reaches the queue. - # Without this yield, an early failure would leave the stream with no - # terminal error event and the buffer would mark the job COMPLETED. - # 3. The buffer task's ``terminal_error_type`` check fires on either - # RUN_ERROR source, so a single drive() failure cannot result in a - # job marked COMPLETED. - drive_error: BaseException | None = None - - async def drive() -> None: - nonlocal drive_error - try: - await generate_flow_events( - flow_id=flow_id, - background_tasks=background_tasks, - event_manager=event_manager, - inputs=input_request, - data=flow_data, - files=parsed.files, - stop_component_id=parsed.stop_component_id, - start_component_id=parsed.start_component_id, - # Persist vertex builds (keyed by ``run_id``) only for job-tracked - # runs so a background job's status can be reconstructed later. Live - # streams pass no ``run_id`` and keep the no-persist behavior. - log_builds=run_id is not None, - current_user=current_user, - flow_name=flow_name, - source_flow_id=source_flow_id, - run_id=run_id, - track_job_status=track_job_status, - ) - except asyncio.CancelledError: - raise - except Exception as exc: # noqa: BLE001 - drive_error = exc - with contextlib.suppress(Exception): - await event_manager.queue.put((None, None, time.time())) - # generate_flow_events emits on_end and the sentinel on success. - - def _frame(stream_event: StreamEvent, seq: int) -> tuple[bytes, str]: - return ( - format_sse_event(data_str=stream_event.data_json, id=str(seq)), - stream_event.type, - ) - - # The AG-UI playground's chat-view consumes the v1 message payload via a - # side-channel ``CustomEvent``; emitted only when the wire protocol is - # AG-UI. A follow-up retires this once chat-view consumes AG-UI primitives. - emit_side_channel = adapter.name == "agui" - side_channel_events = frozenset({"add_message", "token", "remove_message", "error", "end"}) - terminal_error_type = getattr(adapter, "terminal_error_type", None) - terminal_error_seen = False - - seq = 0 - run_task = asyncio.create_task(drive()) - try: - for event in adapter.initial_events(): - yield _frame(event, seq) - seq += 1 - while True: - _, value, _ = await queue.get() - if value is None: - break - payload = json.loads(value.decode("utf-8")) - event_type = payload.get("event", "") - event_data = payload.get("data") or {} - if emit_side_channel and event_type in side_channel_events: - yield _frame( - StreamEvent( - type="CUSTOM", - data_json=CustomEvent( - name="langflow.event", - value={"event_type": event_type, "data": event_data}, - ).model_dump_json(by_alias=True, exclude_none=True), - ), - seq, - ) - seq += 1 - for event in adapter.translate(event_type, event_data): - if terminal_error_type is not None and event.type == terminal_error_type: - terminal_error_seen = True - yield _frame(event, seq) - seq += 1 - for event in adapter.final_events(): - if terminal_error_type is not None and event.type == terminal_error_type: - terminal_error_seen = True - yield _frame(event, seq) - seq += 1 - # Guaranteed-fallback layer (see drive_error block above). If drive() - # captured an exception and no cooperative terminal error reached the - # stream, emit the adapter's terminal error event(s) here. - if drive_error is not None and not terminal_error_seen: - for event in adapter.error_events(drive_error): - if terminal_error_type is not None and event.type == terminal_error_type: - terminal_error_seen = True - yield _frame(event, seq) - seq += 1 - finally: - if not run_task.done(): - run_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await run_task - await queue.aclose() - - -def _execute_streaming_workflow( - *, - adapter: StreamAdapter, - parsed: ParsedWorkflowRun, - flow: FlowRead, - current_user: UserRead, - background_tasks: BackgroundTasks, -) -> EventSourceResponse: - """Run a workflow live and stream events via ``adapter`` over server-sent events. - - The graph is built inside ``generate_flow_events`` (the v1 build-vertex - loop) so the same per-vertex events the canvas already knows flow through - the adapter. A failure during the run becomes a terminal protocol event - routed through the adapter rather than an HTTP error. - """ - - async def _frames_only() -> AsyncIterator[bytes]: - async for frame, _event_type in _stream_event_frames( - adapter=adapter, - flow_id=flow.id, - flow_name=flow.name, - background_tasks=background_tasks, - parsed=parsed, - current_user=current_user, - ): - yield frame - - return EventSourceResponse( - _frames_only(), - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -_CancelEventsFactory = Callable[[str], Iterable[StreamEvent]] - - -class _BackgroundRun: - """In-memory buffer of a background run's protocol-native SSE frames for re-attach. - - The buffer lives in the process; restarts drop it. Multiple readers can - re-attach concurrently and tail until the run ends. The frames are - already serialized in the protocol the original POST requested via - ``stream_protocol``; re-attach replays them as-is. Mixing protocols - across a single run is not supported. - - Per-run frame count is bounded by ``_MAX_FRAMES_PER_BACKGROUND_RUN`` so a - long verbose run (token-by-token streams, repeated tool calls) cannot - exhaust process memory while ``_MAX_BACKGROUND_RUNS`` only caps the - number of buffers. When the cap is reached the oldest frames are - evicted; re-attach with ``Last-Event-ID`` past that point will start - from the new buffer head (replay loss is preferred over OOM). - """ - - def __init__(self, user_id: str, stream_protocol: str = "agui") -> None: - self.user_id = user_id - self.stream_protocol = stream_protocol - self._cancel_events: _CancelEventsFactory | None = None - self.frames: list[bytes] = [] - # Index of the first frame still in ``frames`` (monotonic across the - # life of the buffer). Once eviction starts, ``frames[i]`` corresponds - # to logical event id ``base_index + i``. - self.base_index = 0 - self.done = False - self._cond = asyncio.Condition() - - def set_cancel_events(self, cancel_events: _CancelEventsFactory) -> None: - self._cancel_events = cancel_events - - @property - def has_cancel_events(self) -> bool: - return self._cancel_events is not None - - def _append_locked(self, frame: bytes) -> None: - self.frames.append(frame) - overflow = len(self.frames) - _MAX_FRAMES_PER_BACKGROUND_RUN - if overflow > 0: - del self.frames[:overflow] - self.base_index += overflow - - async def append(self, frame: bytes) -> None: - async with self._cond: - self._append_locked(frame) - self._cond.notify_all() - - async def finish(self) -> None: - async with self._cond: - self.done = True - self._cond.notify_all() - - async def finish_cancelled(self, reason: str) -> None: - """Append cancellation terminal events and finish replay exactly once.""" - async with self._cond: - if self.done: - return - events: list[StreamEvent] = [] - if self._cancel_events is not None: - with contextlib.suppress(Exception): - events = list(self._cancel_events(reason)) - for event in events: - seq = self.base_index + len(self.frames) - self._append_locked(format_sse_event(data_str=event.data_json, id=str(seq))) - self.done = True - self._cond.notify_all() - - async def replay(self, start_index: int) -> AsyncIterator[bytes]: - """Yield buffered frames from ``start_index`` and tail until done. - - ``start_index`` is in logical event-id space (matches what was emitted - on ``id:`` lines). If the caller's ``Last-Event-ID`` points before the - buffer's current head (frames evicted under memory pressure), we - replay from the head and the caller observes a gap. - """ - idx = max(start_index, 0) - while True: - async with self._cond: - head = self.base_index - tail = head + len(self.frames) - idx = max(idx, head) - while idx >= tail and not self.done: - await self._cond.wait() - head = self.base_index - tail = head + len(self.frames) - idx = max(idx, head) - snapshot = self.frames[idx - head :] - finished = self.done - for frame in snapshot: - yield frame - idx += len(snapshot) - if finished and idx >= self.base_index + len(self.frames): - return - - -# Process-local registry of background runs keyed by job_id, bounded by -# ``_MAX_BACKGROUND_RUNS`` (oldest evicted first). Re-attach reads this. -_MAX_BACKGROUND_RUNS = 100 -# Per-run frame ceiling. Caps memory for a single long/verbose run so a -# token-streaming flow can't exhaust the process. 10k frames covers minutes -# of dense token streams with room to spare; beyond that we evict oldest. -_MAX_FRAMES_PER_BACKGROUND_RUN = 10_000 -# Inline stream queue between the build loop and the SSE consumer. Bounded -# so a slow consumer applies backpressure to the build loop instead of -# letting frames accumulate without bound when the network is slow. -_EVENT_QUEUE_MAX_SIZE = 256 -_BACKGROUND_RUNS: dict[str, _BackgroundRun] = {} -_WORKFLOW_CANCELLED_MESSAGE = "Workflow run cancelled." - - -async def _finalize_job_status(job_uuid: UUID, terminal_status: JobStatus) -> None: - """Update job status to a terminal value, but never overwrite CANCELLED. - - ``stop_workflow`` sets the job to CANCELLED. The buffer task runs in - parallel and reaches its ``finally`` block shortly after; if it - unconditionally wrote COMPLETED/FAILED it would race with the cancellation - and silently overwrite the user's stop intent. Re-read the row first and - skip the update if a cancellation already landed. - """ - job_service = get_job_service() - try: - job = await job_service.get_job_by_job_id(job_id=job_uuid) - except Exception: # noqa: BLE001 - job = None - if job is not None and job.status == JobStatus.CANCELLED: - return - with contextlib.suppress(Exception): - await job_service.update_job_status( - job_uuid, - terminal_status, - finished_timestamp=True, - ) - - -async def _clear_background_run(job_id: str) -> None: - """Pop the background run registry entry and wake any re-attach waiters. - - Used for true cleanup paths that should discard replay state, such as - failed scheduling or test teardown. Cancellation normally finishes through - the owning buffer task so it can append protocol-native terminal events - before replay is marked done. - """ - bg_run = _BACKGROUND_RUNS.pop(job_id, None) - if bg_run is not None: - await bg_run.finish() - - -async def _finish_cancelled_background_run(job_id: str) -> None: - """Finish a stopped local run when no owner task is available to do it.""" - bg_run = _BACKGROUND_RUNS.get(job_id) - if bg_run is None or bg_run.done: - return - if not bg_run.has_cancel_events: - with contextlib.suppress(UnknownStreamProtocolError): - adapter = get_stream_adapter( - bg_run.stream_protocol, - StreamAdapterContext(run_id=job_id, thread_id=job_id), - ) - bg_run.set_cancel_events(adapter.cancel_events) - await bg_run.finish_cancelled(_WORKFLOW_CANCELLED_MESSAGE) - - -async def _register_background_run(job_id: str, bg_run: _BackgroundRun) -> None: - """Register a background run, evicting a completed entry when full. - - Prefer evicting the oldest *completed* run so a long-running job's - re-attach handle survives. If every slot is still active, evict the - oldest one anyway to keep the registry bounded, cancel its still-running - buffer writer so it stops appending into a run no reader can find, and log - a warning. - """ - if len(_BACKGROUND_RUNS) >= _MAX_BACKGROUND_RUNS: - evict_key = next( - (key for key, run in _BACKGROUND_RUNS.items() if run.done), - None, - ) - evicted_running = False - if evict_key is None: - evict_key = next(iter(_BACKGROUND_RUNS)) - evicted_running = True - logger.warning( - "Background run registry full with no completed entries; " - "evicting still-running job %s to make room for %s", - evict_key, - job_id, - ) - _BACKGROUND_RUNS.pop(evict_key, None) - if evicted_running: - # Stop the orphaned buffer writer: without its registry entry no - # reader can find it, but the coroutine keeps appending frames. - # cancel_flow_build raises CancelledError into the build loop, which - # runs the buffer's finally/finish_cancelled path. - with contextlib.suppress(Exception): - await _cancel_workflow_queue_job(evict_key) - _BACKGROUND_RUNS[job_id] = bg_run - - -async def _buffer_background_run( - *, - bg_run: _BackgroundRun, - flow: FlowRead, - parsed: ParsedWorkflowRun, - job_id: str, - current_user: UserRead, - stream_protocol: str, -) -> None: - """Run a background flow, buffer its frames, and finalize job status. - - The adapter is resolved from ``stream_protocol`` so re-attach replays - frames in the protocol the caller requested. Validation of - ``stream_protocol`` happens at the route handler; this function still - guards against ``UnknownStreamProtocolError`` because it runs as a - fire-and-forget background coroutine. If adapter registration breaks - between the route's check and this call (e.g. a registry mutation), we - flip the job to FAILED and exit cleanly rather than dying silently and - leaving the job stuck at QUEUED. - - The buffer's terminal-status detection keys off - ``adapter.terminal_error_type``. - """ - try: - adapter = get_stream_adapter( - stream_protocol, - StreamAdapterContext( - run_id=parsed.run_id or job_id, - thread_id=parsed.session_id or str(flow.id), - ), - ) - except UnknownStreamProtocolError: - # Fire-and-forget coroutine: do not raise, the route already returned. - await bg_run.finish() - job_uuid = UUID(job_id) if isinstance(job_id, str) else job_id - await _finalize_job_status(job_uuid, JobStatus.FAILED) - return - - bg_run.set_cancel_events(adapter.cancel_events) - terminal_error_type = adapter.terminal_error_type - fresh_background_tasks = BackgroundTasks() - errored = False - cancelled = False - job_uuid = UUID(job_id) if isinstance(job_id, str) else job_id - try: - with contextlib.suppress(Exception): - await get_job_service().update_job_status(job_uuid, JobStatus.IN_PROGRESS) - async for frame, event_type in _stream_event_frames( - adapter=adapter, - flow_id=flow.id, - flow_name=flow.name, - background_tasks=fresh_background_tasks, - parsed=parsed, - current_user=current_user, - # Build under the job id so the run's vertex builds are persisted - # keyed by job_id and GET-status reconstruction can find them. - run_id=job_id, - track_job_status=False, - ): - if terminal_error_type is not None and event_type == terminal_error_type: - errored = True - await bg_run.append(frame) - except asyncio.CancelledError: - cancelled = True - await bg_run.finish_cancelled(_WORKFLOW_CANCELLED_MESSAGE) - raise - finally: - if not cancelled: - await bg_run.finish() - # ``generate_flow_events`` queues telemetry, tracing teardown, and - # other callbacks on this ``BackgroundTasks`` instance. In FastAPI's - # request lifecycle those run after the response is sent; the - # background path has no response carrying them, so drain the queue - # explicitly. Suppressed so a single failing telemetry callback does - # not derail job-status finalization. - with contextlib.suppress(Exception): - await fresh_background_tasks() - if not cancelled: - # ``update_job_status`` queries the Job table by its UUID primary key; - # passing the raw string would silently miss every row. - await _finalize_job_status(job_uuid, JobStatus.FAILED if errored else JobStatus.COMPLETED) - - -async def execute_workflow_background( - parsed: ParsedWorkflowRun, - flow: FlowRead, - job_id: JobId, - current_user: UserRead, - http_request: Request, # noqa: ARG001 - stream_protocol: str, -) -> WorkflowJobResponse: - """Run a workflow in the background, buffering protocol-native events for re-attach. - - A job row is created so ``GET /workflows`` and ``POST /workflows/stop`` keep - working. The buffer task is scheduled through the queue service under - ``job_id`` so ``/stop`` can revoke it. Graph construction happens inside - the v1 build-vertex loop driven by ``_stream_event_frames`` with the - adapter selected by ``stream_protocol``; re-attach replays the frames in - that same protocol's wire shape. - """ - try: - flow_id_str = str(flow.id) - job_id_str = str(job_id) - - await get_job_service().create_job( - job_id=job_id, - flow_id=flow_id_str, - user_id=current_user.id, - ) - - bg_run = _BackgroundRun(user_id=str(current_user.id), stream_protocol=stream_protocol) - await _register_background_run(job_id_str, bg_run) - - try: - queue_service = get_queue_service() - queue_service.create_queue(job_id_str) - queue_service.start_job( - job_id_str, - _buffer_background_run( - bg_run=bg_run, - flow=flow, - parsed=parsed, - job_id=job_id_str, - current_user=current_user, - stream_protocol=stream_protocol, - ), - ) - except BaseException: - # If queue creation or scheduling fails after the bg_run is - # registered, the buffer would stay live with ``done=False`` and - # any re-attach client would block on ``_cond.wait()`` forever - # (the task that would call ``finish()`` was never scheduled). - # Clear the registry, mark the job FAILED, then re-raise. - await _clear_background_run(job_id_str) - await _finalize_job_status(job_id, JobStatus.FAILED) - raise - return WorkflowJobResponse(job_id=job_id_str, flow_id=parsed.flow_id, status=JobStatus.QUEUED) - - except (WorkflowResourceError, WorkflowServiceUnavailableError, WorkflowQueueFullError): - raise - except MemoryError as exc: - raise WorkflowResourceError from exc - - @router.get( "", response_model=None, @@ -1264,12 +351,13 @@ async def get_workflow_status( try: job = await job_service.get_job_by_job_id(job_id=job_id, user_id=current_user.id) except Exception as exc: + await logger.aexception("Database error retrieving workflow job %s", job_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", - "message": f"Failed to retrieve job from database: {exc!s}", + "message": "Failed to retrieve job. Please try again.", }, ) from exc @@ -1359,24 +447,26 @@ async def get_workflow_status( except HTTPException: raise except WorkflowTimeoutError as err: + timeout_seconds = _resolve_execution_timeout() raise HTTPException( status_code=status.HTTP_408_REQUEST_TIMEOUT, detail={ "error": "Execution timeout", "code": "EXECUTION_TIMEOUT", - "message": f"Workflow execution exceeded {EXECUTION_TIMEOUT} seconds", + "message": f"Workflow execution exceeded {timeout_seconds} seconds", "job_id": job_id_str, "flow_id": flow_id_str, - "timeout_seconds": EXECUTION_TIMEOUT, + "timeout_seconds": timeout_seconds, }, ) from err except Exception as exc: + await logger.aexception("Unexpected error processing workflow job status for %s", job_id_str) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", - "message": f"Failed to process job status: {exc!s}", + "message": "Failed to process job status.", }, ) from exc @@ -1414,12 +504,13 @@ async def stop_workflow( # 1. Fetch Job job = await job_service.get_job_by_job_id(job_id, user_id=current_user.id) except Exception as exc: + await logger.aexception("Database error retrieving workflow job %s for stop", job_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", - "message": f"Failed to retrieve job status: {exc!s}", + "message": "Failed to retrieve job status. Please try again.", }, ) from exc @@ -1486,12 +577,13 @@ async def stop_workflow( except HTTPException: raise except Exception as exc: + await logger.aexception("Unexpected error stopping workflow job %s", job_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", - "message": f"Failed to stop job: {job_id} - {exc!s}", + "message": f"Failed to stop job {job_id}.", }, ) from exc @@ -1559,9 +651,8 @@ async def reattach_workflow_events( "error": "Background event buffer unavailable", "code": "BACKGROUND_EVENTS_UNAVAILABLE", "message": ( - f"Buffered events for job {job_id} are not available on this worker. " - "Use the workflow status endpoint for this job, or route event re-attach " - "requests to the worker that accepted the background run." + f"Buffered events for job {job_id} are not available here. " + "Use the workflow status endpoint for this job." ), "job_id": job_id, }, diff --git a/src/backend/base/langflow/api/v2/workflow_background.py b/src/backend/base/langflow/api/v2/workflow_background.py new file mode 100644 index 0000000000..a4fdd6aa21 --- /dev/null +++ b/src/backend/base/langflow/api/v2/workflow_background.py @@ -0,0 +1,390 @@ +"""Background (durable, re-attachable) execution for V2 workflows. + +A background run is driven by ``_stream_event_frames`` (from +``workflow_execution``) and its protocol-native SSE frames are buffered in a +process-local ``_BackgroundRun`` so a disconnected client can re-attach and +replay. ``_BACKGROUND_RUNS`` is the bounded registry keyed by job_id; the job +row in the database lets ``GET /workflows`` and ``POST /workflows/stop`` keep +working across re-attaches. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Callable, Iterable +from uuid import UUID + +from fastapi import BackgroundTasks, Request +from fastapi.sse import format_sse_event +from lfx.log.logger import logger +from lfx.schema.workflow import JobId, JobStatus, WorkflowJobResponse + +from langflow.api.v2.adapters import ( + StreamAdapterContext, + StreamEvent, + UnknownStreamProtocolError, + get_stream_adapter, +) +from langflow.api.v2.converters import ParsedWorkflowRun +from langflow.api.v2.workflow_execution import _stream_event_frames +from langflow.exceptions.api import ( + WorkflowQueueFullError, + WorkflowResourceError, + WorkflowServiceUnavailableError, +) +from langflow.services.database.models.flow.model import FlowRead +from langflow.services.database.models.user.model import UserRead +from langflow.services.deps import get_job_service, get_queue_service + + +async def _cancel_workflow_queue_job(job_id: str) -> bool: + """Lazily call the shared build cancellation path to avoid import cycles.""" + from langflow.api.build import cancel_flow_build + from langflow.services.job_queue.service import JobQueueNotFoundError + + try: + return await cancel_flow_build(job_id=job_id, queue_service=get_queue_service()) + except JobQueueNotFoundError: + return False + + +_CancelEventsFactory = Callable[[str], Iterable[StreamEvent]] + + +class _BackgroundRun: + """In-memory buffer of a background run's protocol-native SSE frames for re-attach. + + The buffer lives in the process; restarts drop it. Multiple readers can + re-attach concurrently and tail until the run ends. The frames are + already serialized in the protocol the original POST requested via + ``stream_protocol``; re-attach replays them as-is. Mixing protocols + across a single run is not supported. + + Per-run frame count is bounded by ``_MAX_FRAMES_PER_BACKGROUND_RUN`` so a + long verbose run (token-by-token streams, repeated tool calls) cannot + exhaust process memory while ``_MAX_BACKGROUND_RUNS`` only caps the + number of buffers. When the cap is reached the oldest frames are + evicted; re-attach with ``Last-Event-ID`` past that point will start + from the new buffer head (replay loss is preferred over OOM). + """ + + def __init__(self, user_id: str, stream_protocol: str = "agui") -> None: + self.user_id = user_id + self.stream_protocol = stream_protocol + self._cancel_events: _CancelEventsFactory | None = None + self.frames: list[bytes] = [] + # Index of the first frame still in ``frames`` (monotonic across the + # life of the buffer). Once eviction starts, ``frames[i]`` corresponds + # to logical event id ``base_index + i``. + self.base_index = 0 + self.done = False + self._cond = asyncio.Condition() + + def set_cancel_events(self, cancel_events: _CancelEventsFactory) -> None: + self._cancel_events = cancel_events + + @property + def has_cancel_events(self) -> bool: + return self._cancel_events is not None + + def _append_locked(self, frame: bytes) -> None: + self.frames.append(frame) + overflow = len(self.frames) - _MAX_FRAMES_PER_BACKGROUND_RUN + if overflow > 0: + del self.frames[:overflow] + self.base_index += overflow + + async def append(self, frame: bytes) -> None: + async with self._cond: + self._append_locked(frame) + self._cond.notify_all() + + async def finish(self) -> None: + async with self._cond: + self.done = True + self._cond.notify_all() + + async def finish_cancelled(self, reason: str) -> None: + """Append cancellation terminal events and finish replay exactly once.""" + async with self._cond: + if self.done: + return + events: list[StreamEvent] = [] + if self._cancel_events is not None: + with contextlib.suppress(Exception): + events = list(self._cancel_events(reason)) + for event in events: + seq = self.base_index + len(self.frames) + self._append_locked(format_sse_event(data_str=event.data_json, id=str(seq))) + self.done = True + self._cond.notify_all() + + async def replay(self, start_index: int) -> AsyncIterator[bytes]: + """Yield buffered frames from ``start_index`` and tail until done. + + ``start_index`` is in logical event-id space (matches what was emitted + on ``id:`` lines). If the caller's ``Last-Event-ID`` points before the + buffer's current head (frames evicted under memory pressure), we + replay from the head and the caller observes a gap. + """ + idx = max(start_index, 0) + while True: + async with self._cond: + head = self.base_index + tail = head + len(self.frames) + idx = max(idx, head) + while idx >= tail and not self.done: + await self._cond.wait() + head = self.base_index + tail = head + len(self.frames) + idx = max(idx, head) + snapshot = self.frames[idx - head :] + finished = self.done + for frame in snapshot: + yield frame + idx += len(snapshot) + if finished and idx >= self.base_index + len(self.frames): + return + + +# Process-local registry of background runs keyed by job_id, bounded by +# ``_MAX_BACKGROUND_RUNS`` (oldest evicted first). Re-attach reads this. +_MAX_BACKGROUND_RUNS = 100 +# Per-run frame ceiling. Caps memory for a single long/verbose run so a +# token-streaming flow can't exhaust the process. 10k frames covers minutes +# of dense token streams with room to spare; beyond that we evict oldest. +_MAX_FRAMES_PER_BACKGROUND_RUN = 10_000 +_BACKGROUND_RUNS: dict[str, _BackgroundRun] = {} +_WORKFLOW_CANCELLED_MESSAGE = "Workflow run cancelled." + + +async def _finalize_job_status(job_uuid: UUID, terminal_status: JobStatus) -> None: + """Update job status to a terminal value, but never overwrite CANCELLED. + + ``stop_workflow`` sets the job to CANCELLED. The buffer task runs in + parallel and reaches its ``finally`` block shortly after; if it + unconditionally wrote COMPLETED/FAILED it would race with the cancellation + and silently overwrite the user's stop intent. Re-read the row first and + skip the update if a cancellation already landed. + """ + job_service = get_job_service() + try: + job = await job_service.get_job_by_job_id(job_id=job_uuid) + except Exception: # noqa: BLE001 + job = None + if job is not None and job.status == JobStatus.CANCELLED: + return + with contextlib.suppress(Exception): + await job_service.update_job_status( + job_uuid, + terminal_status, + finished_timestamp=True, + ) + + +async def _clear_background_run(job_id: str) -> None: + """Pop the background run registry entry and wake any re-attach waiters. + + Used for true cleanup paths that should discard replay state, such as + failed scheduling or test teardown. Cancellation normally finishes through + the owning buffer task so it can append protocol-native terminal events + before replay is marked done. + """ + bg_run = _BACKGROUND_RUNS.pop(job_id, None) + if bg_run is not None: + await bg_run.finish() + + +async def _finish_cancelled_background_run(job_id: str) -> None: + """Finish a stopped local run when no owner task is available to do it.""" + bg_run = _BACKGROUND_RUNS.get(job_id) + if bg_run is None or bg_run.done: + return + if not bg_run.has_cancel_events: + with contextlib.suppress(UnknownStreamProtocolError): + adapter = get_stream_adapter( + bg_run.stream_protocol, + StreamAdapterContext(run_id=job_id, thread_id=job_id), + ) + bg_run.set_cancel_events(adapter.cancel_events) + await bg_run.finish_cancelled(_WORKFLOW_CANCELLED_MESSAGE) + + +async def _register_background_run(job_id: str, bg_run: _BackgroundRun) -> None: + """Register a background run, evicting a completed entry when full. + + Prefer evicting the oldest *completed* run so a long-running job's + re-attach handle survives. If every slot is still active, evict the + oldest one anyway to keep the registry bounded, cancel its still-running + buffer writer so it stops appending into a run no reader can find, and log + a warning. + """ + if len(_BACKGROUND_RUNS) >= _MAX_BACKGROUND_RUNS: + evict_key = next( + (key for key, run in _BACKGROUND_RUNS.items() if run.done), + None, + ) + evicted_running = False + if evict_key is None: + evict_key = next(iter(_BACKGROUND_RUNS)) + evicted_running = True + logger.warning( + "Background run registry full with no completed entries; " + "evicting still-running job %s to make room for %s", + evict_key, + job_id, + ) + _BACKGROUND_RUNS.pop(evict_key, None) + if evicted_running: + # Stop the orphaned buffer writer: without its registry entry no + # reader can find it, but the coroutine keeps appending frames. + # cancel_flow_build raises CancelledError into the build loop, which + # runs the buffer's finally/finish_cancelled path. + with contextlib.suppress(Exception): + await _cancel_workflow_queue_job(evict_key) + _BACKGROUND_RUNS[job_id] = bg_run + + +async def _buffer_background_run( + *, + bg_run: _BackgroundRun, + flow: FlowRead, + parsed: ParsedWorkflowRun, + job_id: str, + current_user: UserRead, + stream_protocol: str, +) -> None: + """Run a background flow, buffer its frames, and finalize job status. + + The adapter is resolved from ``stream_protocol`` so re-attach replays + frames in the protocol the caller requested. Validation of + ``stream_protocol`` happens at the route handler; this function still + guards against ``UnknownStreamProtocolError`` because it runs as a + fire-and-forget background coroutine. If adapter registration breaks + between the route's check and this call (e.g. a registry mutation), we + flip the job to FAILED and exit cleanly rather than dying silently and + leaving the job stuck at QUEUED. + + The buffer's terminal-status detection keys off + ``adapter.terminal_error_type``. + """ + try: + adapter = get_stream_adapter( + stream_protocol, + StreamAdapterContext( + run_id=parsed.run_id or job_id, + thread_id=parsed.session_id or str(flow.id), + ), + ) + except UnknownStreamProtocolError: + # Fire-and-forget coroutine: do not raise, the route already returned. + await bg_run.finish() + job_uuid = UUID(job_id) if isinstance(job_id, str) else job_id + await _finalize_job_status(job_uuid, JobStatus.FAILED) + return + + bg_run.set_cancel_events(adapter.cancel_events) + terminal_error_type = adapter.terminal_error_type + fresh_background_tasks = BackgroundTasks() + errored = False + cancelled = False + job_uuid = UUID(job_id) if isinstance(job_id, str) else job_id + try: + with contextlib.suppress(Exception): + await get_job_service().update_job_status(job_uuid, JobStatus.IN_PROGRESS) + async for frame, event_type in _stream_event_frames( + adapter=adapter, + flow_id=flow.id, + flow_name=flow.name, + background_tasks=fresh_background_tasks, + parsed=parsed, + current_user=current_user, + # Build under the job id so the run's vertex builds are persisted + # keyed by job_id and GET-status reconstruction can find them. + run_id=job_id, + track_job_status=False, + ): + if terminal_error_type is not None and event_type == terminal_error_type: + errored = True + await bg_run.append(frame) + except asyncio.CancelledError: + cancelled = True + await bg_run.finish_cancelled(_WORKFLOW_CANCELLED_MESSAGE) + raise + finally: + if not cancelled: + await bg_run.finish() + # ``generate_flow_events`` queues telemetry, tracing teardown, and + # other callbacks on this ``BackgroundTasks`` instance. In FastAPI's + # request lifecycle those run after the response is sent; the + # background path has no response carrying them, so drain the queue + # explicitly. Suppressed so a single failing telemetry callback does + # not derail job-status finalization. + with contextlib.suppress(Exception): + await fresh_background_tasks() + if not cancelled: + # ``update_job_status`` queries the Job table by its UUID primary key; + # passing the raw string would silently miss every row. + await _finalize_job_status(job_uuid, JobStatus.FAILED if errored else JobStatus.COMPLETED) + + +async def execute_workflow_background( + parsed: ParsedWorkflowRun, + flow: FlowRead, + job_id: JobId, + current_user: UserRead, + http_request: Request, # noqa: ARG001 + stream_protocol: str, +) -> WorkflowJobResponse: + """Run a workflow in the background, buffering protocol-native events for re-attach. + + A job row is created so ``GET /workflows`` and ``POST /workflows/stop`` keep + working. The buffer task is scheduled through the queue service under + ``job_id`` so ``/stop`` can revoke it. Graph construction happens inside + the v1 build-vertex loop driven by ``_stream_event_frames`` with the + adapter selected by ``stream_protocol``; re-attach replays the frames in + that same protocol's wire shape. + """ + try: + flow_id_str = str(flow.id) + job_id_str = str(job_id) + + await get_job_service().create_job( + job_id=job_id, + flow_id=flow_id_str, + user_id=current_user.id, + ) + + bg_run = _BackgroundRun(user_id=str(current_user.id), stream_protocol=stream_protocol) + await _register_background_run(job_id_str, bg_run) + + try: + queue_service = get_queue_service() + queue_service.create_queue(job_id_str) + queue_service.start_job( + job_id_str, + _buffer_background_run( + bg_run=bg_run, + flow=flow, + parsed=parsed, + job_id=job_id_str, + current_user=current_user, + stream_protocol=stream_protocol, + ), + ) + except BaseException: + # If queue creation or scheduling fails after the bg_run is + # registered, the buffer would stay live with ``done=False`` and + # any re-attach client would block on ``_cond.wait()`` forever + # (the task that would call ``finish()`` was never scheduled). + # Clear the registry, mark the job FAILED, then re-raise. + await _clear_background_run(job_id_str) + await _finalize_job_status(job_id, JobStatus.FAILED) + raise + return WorkflowJobResponse(job_id=job_id_str, flow_id=parsed.flow_id, status=JobStatus.QUEUED) + + except (WorkflowResourceError, WorkflowServiceUnavailableError, WorkflowQueueFullError): + raise + except MemoryError as exc: + raise WorkflowResourceError from exc diff --git a/src/backend/base/langflow/api/v2/workflow_execution.py b/src/backend/base/langflow/api/v2/workflow_execution.py new file mode 100644 index 0000000000..83df935cc3 --- /dev/null +++ b/src/backend/base/langflow/api/v2/workflow_execution.py @@ -0,0 +1,556 @@ +"""Synchronous and streaming execution for V2 workflows. + +This module owns the run-driving machinery shared by every execution mode: + + - ``execute_sync_workflow`` / ``execute_sync_workflow_with_timeout``: the + inline sync path that returns a complete ``WorkflowExecutionResponse``. + - ``_stream_event_frames``: the single chokepoint that drives the v1 + build-vertex loop and dispatches its events through a ``StreamAdapter``. + The streaming route, the public endpoint, and the background buffer all + consume it. + - ``_execute_streaming_workflow``: wraps ``_stream_event_frames`` in an SSE + response for live streaming. + +Configuration: + EXECUTION_TIMEOUT: Maximum execution time for synchronous workflows (300 seconds). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import time +from collections.abc import AsyncIterator +from copy import deepcopy +from uuid import UUID, uuid4 + +from ag_ui.core import CustomEvent +from fastapi import BackgroundTasks, Request +from fastapi.responses import EventSourceResponse +from fastapi.sse import format_sse_event +from lfx.events.event_manager import create_default_event_manager +from lfx.graph.graph.base import Graph +from lfx.log.logger import logger +from lfx.schema.schema import InputValueRequest +from lfx.schema.workflow import WorkflowExecutionResponse + +from langflow.api.utils import extract_global_variables_from_headers +from langflow.api.v1.schemas import FlowDataRequest, RunResponse +from langflow.api.v2.adapters import StreamAdapter, StreamEvent +from langflow.api.v2.converters import ParsedWorkflowRun, create_error_response, run_response_to_workflow_response +from langflow.api.v2.workflow_validation import _validate_output_ids +from langflow.exceptions.api import WorkflowTimeoutError, WorkflowValidationError +from langflow.processing.process import process_tweaks, run_graph_internal +from langflow.services.database.models.flow.model import FlowRead +from langflow.services.database.models.user.model import UserRead +from langflow.services.deps import get_job_service, get_memory_base_service, get_settings_service, get_task_service + +# Configuration constants +EXECUTION_TIMEOUT = 300 # 5 minutes default timeout for sync execution, used as a fallback + + +def _resolve_execution_timeout() -> int: + """Wall-clock ceiling for a single workflow run, from settings. + + Falls back to ``EXECUTION_TIMEOUT`` if the settings service is unavailable + (e.g. a fire-and-forget background coroutine running during teardown). + """ + try: + return get_settings_service().settings.workflow_execution_timeout + except Exception: # noqa: BLE001 + return EXECUTION_TIMEOUT + + +# Inline stream queue between the build loop and the SSE consumer. Bounded +# so a slow consumer applies backpressure to the build loop instead of +# letting frames accumulate without bound when the network is slow. +_EVENT_QUEUE_MAX_SIZE = 256 + + +async def generate_flow_events(*args, **kwargs) -> None: + """Lazily call the v1 build stream to avoid import cycles during router setup.""" + from langflow.api.build import generate_flow_events as _generate_flow_events + + await _generate_flow_events(*args, **kwargs) + + +def _resolve_request_variables(body_globals: dict[str, str], http_request: Request | None) -> dict[str, str]: + """Merge request-level global variables for a v2 workflow execution. + + v2 workflows take globals from the JSON request body (``globals``). The + ``X-LANGFLOW-GLOBAL-VAR-*`` headers remain a supported transport (the + OpenAI-compatible Responses API passes globals that way); body globals win + on conflict. + """ + header_globals: dict[str, str] = {} + if http_request is not None: + header_globals = extract_global_variables_from_headers(http_request.headers) + return {**header_globals, **dict(body_globals or {})} + + +def _build_run_inputs(parsed: ParsedWorkflowRun) -> list[InputValueRequest] | None: + """Build the graph input list from the AG-UI chat message, if any. + + The last user message becomes a single chat input; an empty message means + the flow runs with no chat input (parameters arrive via tweaks instead). + """ + if not parsed.input_value: + return None + return [InputValueRequest(components=[], input_value=parsed.input_value, type="chat")] + + +def _single_input_value_request(parsed: ParsedWorkflowRun) -> InputValueRequest | None: + """Build the single chat InputValueRequest the v1 build loop accepts. + + The v1 build path (``generate_flow_events``) takes a single + ``InputValueRequest``; when it receives ``None`` it falls back to + ``InputValueRequest(session=str(flow_id))``, which would wipe out the + caller's session id. We always return one with the parsed session so + component messages stay scoped to the user's active session, even when + there is no chat input (e.g. the playground "Run Flow" button). + """ + if not parsed.session_id and not parsed.input_value: + return None + return InputValueRequest( + components=[], + input_value=parsed.input_value or "", + type="chat", + session=parsed.session_id, + ) + + +_QueueItem = tuple[str | None, bytes | None, float] + + +class _WorkflowEventQueue: + """Bounded EventManager handoff that fails explicitly instead of dropping events.""" + + def __init__(self, maxsize: int) -> None: + self._queue: asyncio.Queue[_QueueItem] = asyncio.Queue(maxsize=maxsize) + self._overflowed = False + self._loop = asyncio.get_running_loop() + self._overflow_task: asyncio.Task[None] | None = None + + @property + def maxsize(self) -> int: + return self._queue.maxsize + + async def get(self) -> _QueueItem: + return await self._queue.get() + + async def put(self, item: _QueueItem) -> None: + if self._overflowed: + return + await self._queue.put(item) + + def put_nowait(self, item: _QueueItem) -> None: + if self._overflowed: + return + try: + self._queue.put_nowait(item) + except asyncio.QueueFull: + self._overflowed = True + self._overflow_task = self._loop.create_task(self._emit_overflow_error()) + + async def _emit_overflow_error(self) -> None: + payload = { + "event": "error", + "data": { + "error": "Workflow event stream exceeded buffering capacity; client is consuming events too slowly." + }, + } + await self._queue.put((f"error-{uuid4()}", json.dumps(payload).encode("utf-8"), time.time())) + await self._queue.put((None, None, time.time())) + + async def aclose(self) -> None: + if self._overflow_task is not None and not self._overflow_task.done(): + self._overflow_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._overflow_task + + +async def _stream_event_frames( + *, + adapter: StreamAdapter, + flow_id: UUID, + flow_name: str | None, + background_tasks: BackgroundTasks, + parsed: ParsedWorkflowRun, + current_user: UserRead, + source_flow_id: UUID | None = None, + run_id: str | None = None, + track_job_status: bool = True, +) -> AsyncIterator[tuple[bytes, str]]: + """Run a flow via the v1 build-vertex loop, dispatch its events through ``adapter``. + + Yields ``(sse_frame_bytes, event_type_str)`` pairs. The consumer + (streaming endpoint, background buffer) frames are pre-formatted with a + monotonic ``id:`` for ``Last-Event-ID`` resume. The ``event_type_str`` is + the adapter's protocol-native type so the buffer task can finalize a + background job's status structurally (no substring matching). + + A failure during the run becomes a terminal protocol event (e.g. + ``RUN_ERROR`` for AG-UI, ``error`` for langflow) routed through the + adapter; closing the consumer cancels the run. + + When the adapter is AG-UI, side-channel ``CustomEvent`` frames carry + the raw Langflow payload alongside the AG-UI translation for the + playground's chat-view. A follow-up retires this once chat-view + consumes the AG-UI ``TEXT_MESSAGE_*`` lifecycle directly. + """ + # EventManager uses put_nowait(), so a plain bounded asyncio.Queue would + # silently drop frames via QueueFull. This adapter keeps memory bounded and + # converts overflow into an explicit stream error + sentinel. + queue = _WorkflowEventQueue(maxsize=_EVENT_QUEUE_MAX_SIZE) + event_manager = create_default_event_manager(queue) + input_request = _single_input_value_request(parsed) + flow_data = FlowDataRequest(**parsed.data) if parsed.data else None + # Single wall-clock ceiling for every mode that drives this loop (stream, + # background, public). Sync uses its own asyncio.wait_for upstream. + execution_timeout = _resolve_execution_timeout() + + # Captured from drive()'s exception path so the consumer can yield a + # guaranteed adapter.error_events(...) fallback after the queue loop ends. + # Layered error handling, by design: + # 1. ``event_manager.on_error(...)`` is the cooperative path: the + # translator turns it into the protocol's terminal-error event (e.g. + # RUN_ERROR for AG-UI, ``error`` for langflow) so the buffer's + # structural detector flips the job to FAILED. + # 2. ``adapter.error_events(exc)`` is the dispatcher's guaranteed + # fallback: emitted from the consumer side when ``generate_flow_events`` + # raises before any cooperative terminal error reaches the queue. + # Without this yield, an early failure would leave the stream with no + # terminal error event and the buffer would mark the job COMPLETED. + # 3. The buffer task's ``terminal_error_type`` check fires on either + # RUN_ERROR source, so a single drive() failure cannot result in a + # job marked COMPLETED. + drive_error: BaseException | None = None + + async def drive() -> None: + nonlocal drive_error + try: + await asyncio.wait_for( + generate_flow_events( + flow_id=flow_id, + background_tasks=background_tasks, + event_manager=event_manager, + inputs=input_request, + data=flow_data, + files=parsed.files, + stop_component_id=parsed.stop_component_id, + start_component_id=parsed.start_component_id, + # Persist vertex builds (keyed by ``run_id``) only for job-tracked + # runs so a background job's status can be reconstructed later. Live + # streams pass no ``run_id`` and keep the no-persist behavior. + log_builds=run_id is not None, + current_user=current_user, + flow_name=flow_name, + source_flow_id=source_flow_id, + run_id=run_id, + track_job_status=track_job_status, + ), + timeout=execution_timeout, + ) + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + # Wall-clock ceiling hit. Surface it as a sanitized terminal error + # through the guaranteed-fallback block below: stream/public clients + # see a clean RUN_ERROR/error and a background job is marked FAILED. + # No internal detail reaches the wire (coordinates with I3). + await logger.awarning( + "Workflow run %s exceeded %ss execution ceiling", run_id or flow_id, execution_timeout + ) + drive_error = WorkflowTimeoutError("Workflow execution timed out.") + with contextlib.suppress(Exception): + await event_manager.queue.put((None, None, time.time())) + except Exception as exc: # noqa: BLE001 + await logger.aexception("Workflow run %s failed during event generation", run_id or flow_id) + drive_error = exc + with contextlib.suppress(Exception): + await event_manager.queue.put((None, None, time.time())) + # generate_flow_events emits on_end and the sentinel on success. + + def _frame(stream_event: StreamEvent, seq: int) -> tuple[bytes, str]: + return ( + format_sse_event(data_str=stream_event.data_json, id=str(seq)), + stream_event.type, + ) + + # The AG-UI playground's chat-view consumes the v1 message payload via a + # side-channel ``CustomEvent``; emitted only when the wire protocol is + # AG-UI. A follow-up retires this once chat-view consumes AG-UI primitives. + emit_side_channel = adapter.name == "agui" + side_channel_events = frozenset({"add_message", "token", "remove_message", "error", "end"}) + terminal_error_type = getattr(adapter, "terminal_error_type", None) + terminal_error_seen = False + + seq = 0 + run_task = asyncio.create_task(drive()) + try: + for event in adapter.initial_events(): + yield _frame(event, seq) + seq += 1 + while True: + _, value, _ = await queue.get() + if value is None: + break + payload = json.loads(value.decode("utf-8")) + event_type = payload.get("event", "") + event_data = payload.get("data") or {} + if emit_side_channel and event_type in side_channel_events: + yield _frame( + StreamEvent( + type="CUSTOM", + data_json=CustomEvent( + name="langflow.event", + value={"event_type": event_type, "data": event_data}, + ).model_dump_json(by_alias=True, exclude_none=True), + ), + seq, + ) + seq += 1 + for event in adapter.translate(event_type, event_data): + if terminal_error_type is not None and event.type == terminal_error_type: + terminal_error_seen = True + yield _frame(event, seq) + seq += 1 + for event in adapter.final_events(): + if terminal_error_type is not None and event.type == terminal_error_type: + terminal_error_seen = True + yield _frame(event, seq) + seq += 1 + # Guaranteed-fallback layer (see drive_error block above). If drive() + # captured an exception and no cooperative terminal error reached the + # stream, emit the adapter's terminal error event(s) here. + if drive_error is not None and not terminal_error_seen: + for event in adapter.error_events(drive_error): + if terminal_error_type is not None and event.type == terminal_error_type: + terminal_error_seen = True + yield _frame(event, seq) + seq += 1 + finally: + if not run_task.done(): + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + await queue.aclose() + + +def _execute_streaming_workflow( + *, + adapter: StreamAdapter, + parsed: ParsedWorkflowRun, + flow: FlowRead, + current_user: UserRead, + background_tasks: BackgroundTasks, +) -> EventSourceResponse: + """Run a workflow live and stream events via ``adapter`` over server-sent events. + + The graph is built inside ``generate_flow_events`` (the v1 build-vertex + loop) so the same per-vertex events the canvas already knows flow through + the adapter. A failure during the run becomes a terminal protocol event + routed through the adapter rather than an HTTP error. + """ + + async def _frames_only() -> AsyncIterator[bytes]: + async for frame, _event_type in _stream_event_frames( + adapter=adapter, + flow_id=flow.id, + flow_name=flow.name, + background_tasks=background_tasks, + parsed=parsed, + current_user=current_user, + ): + yield frame + + return EventSourceResponse( + _frames_only(), + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +async def execute_sync_workflow_with_timeout( + parsed: ParsedWorkflowRun, + flow: FlowRead, + job_id: UUID, + current_user: UserRead, + background_tasks: BackgroundTasks, + http_request: Request, +) -> WorkflowExecutionResponse: + """Execute workflow with timeout protection. + + Args: + parsed: The parsed AG-UI run parameters + flow: The flow to execute + job_id: Generated job ID for tracking + current_user: Authenticated user + background_tasks: FastAPI background tasks + http_request: The HTTP request object for extracting headers + + Returns: + WorkflowExecutionResponse with complete results + + Raises: + WorkflowTimeoutError: If execution exceeds timeout + WorkflowValidationError: If flow validation fails + """ + try: + return await asyncio.wait_for( + execute_sync_workflow( + parsed=parsed, + flow=flow, + job_id=job_id, + current_user=current_user, + background_tasks=background_tasks, + http_request=http_request, + ), + timeout=_resolve_execution_timeout(), + ) + except asyncio.TimeoutError as e: + raise WorkflowTimeoutError from e + + +async def execute_sync_workflow( + parsed: ParsedWorkflowRun, + flow: FlowRead, + job_id: UUID, + current_user: UserRead, + background_tasks: BackgroundTasks, # noqa: ARG001 + http_request: Request, +) -> WorkflowExecutionResponse: + """Execute workflow synchronously and return complete results. + + This function implements a two-tier error handling strategy: + 1. System-level errors (validation, graph build): Raised as exceptions + 2. Component execution errors: Returned in response body with HTTP 200 + + This approach allows clients to receive partial results even when some + components fail, which is useful for debugging and incremental processing. + + Execution Flow: + 1. Apply tweaks and chat input from the parsed AG-UI request + 2. Validate flow data exists + 3. Extract context from HTTP headers + 4. Build graph from flow data with tweaks applied + 5. Identify terminal nodes for execution + 6. Execute graph and collect results + 7. Convert V1 RunResponse to V2 WorkflowExecutionResponse + + Args: + parsed: The parsed AG-UI run parameters with tweaks and chat input + flow: The flow model from database + job_id: Generated job ID for tracking this execution + current_user: Authenticated user for permission checks + background_tasks: FastAPI background tasks (unused in sync mode) + http_request: The HTTP request object for extracting headers + + Returns: + WorkflowExecutionResponse: Complete execution results with outputs and metadata + + Raises: + WorkflowValidationError: If flow data is None or graph build fails + """ + # Tweaks and chat input come straight from the parsed AG-UI request + tweaks = parsed.tweaks + session_id = parsed.session_id + + # Validate flow data - this is a system error, not execution error + if flow.data is None: + msg = f"Flow {flow.id} has no data. The flow may be corrupted." + raise WorkflowValidationError(msg) + + # Resolve request-level variables: body ``globals`` plus the legacy + # X-LANGFLOW-GLOBAL-VAR-* headers (still used by the Responses API). + # Body globals win on conflict. + request_variables = _resolve_request_variables(parsed.globals, http_request) + + # Build context from request variables (similar to V1's _run_flow_internal) + context = {"request_variables": request_variables} if request_variables else None + + # Build graph - system error if this fails + try: + flow_id_str = str(flow.id) + user_id = str(current_user.id) + # Use deepcopy to prevent mutation of the original flow.data + # process_tweaks modifies nested dictionaries in-place + graph_data = deepcopy(flow.data) + graph_data = process_tweaks(graph_data, tweaks, stream=False) + # Pass context to graph (similar to V1's simple_run_flow) + # This allows components to access request metadata via graph.context + graph = Graph.from_payload( + graph_data, flow_id=flow_id_str, user_id=user_id, flow_name=flow.name, context=context + ) + # Set run_id for tracing/logging (similar to V1's simple_run_flow) + graph.set_run_id(job_id) + except Exception as e: + msg = f"Failed to build graph from flow data: {e!s}" + raise WorkflowValidationError(msg) from e + + # Get terminal nodes - these are the outputs we want + terminal_node_ids = graph.get_terminal_nodes() + + # Validate request-side output selection BEFORE executing: a bad id must cost + # no compute. Raised outside the component-error try/except below, so it + # surfaces as a real 422 rather than a 200-with-failed body. + _validate_output_ids(parsed.output_ids, terminal_node_ids) + + # Execute graph - component errors are caught and returned in response body + job_service = get_job_service() + await job_service.create_job(job_id=job_id, flow_id=flow_id_str, user_id=current_user.id) + try: + task_result, execution_session_id = await job_service.execute_with_status( + job_id=job_id, + run_coro_func=run_graph_internal, + graph=graph, + flow_id=flow_id_str, + session_id=session_id, + inputs=_build_run_inputs(parsed), + outputs=terminal_node_ids, + stream=False, + ) + + # Fire memory-base auto-capture hook — non-blocking background effect. + try: + _run_id_uuid = UUID(graph.run_id) if graph.run_id else None # type-cast only; same run_id set on graph + await get_task_service().fire_and_forget_task( + get_memory_base_service().on_flow_output, + flow_id=flow.id, + session_id=execution_session_id, + job_id=_run_id_uuid, + ) + except (RuntimeError, ValueError, OSError): + await logger.awarning("Memory base hook scheduling failed for flow %s", flow.id, exc_info=True) + + # Build RunResponse + run_response = RunResponse(outputs=task_result, session_id=execution_session_id) + # Convert to WorkflowExecutionResponse + return run_response_to_workflow_response( + run_response=run_response, + flow_id=parsed.flow_id, + job_id=str(job_id), + inputs=parsed.tweaks, + graph=graph, + effective_globals=request_variables, + selected_ids=parsed.output_ids, + ) + + except asyncio.CancelledError: + # Re-raise CancelledError to allow timeout mechanism to work properly + # This ensures asyncio.wait_for() can properly cancel and raise TimeoutError + raise + except asyncio.TimeoutError as e: + # Re-raise TimeoutError to allow timeout mechanism to work properly + # This ensures asyncio.wait_for() can properly cancel and raise TimeoutError + raise WorkflowTimeoutError from e + except Exception as exc: # noqa: BLE001 + # Component execution errors - return in response body with HTTP 200 + # This allows partial results and detailed error information per component + return create_error_response( + flow_id=parsed.flow_id, + job_id=job_id, + inputs=parsed.tweaks, + error=exc, + effective_globals=request_variables, + ) diff --git a/src/backend/base/langflow/api/v2/workflow_public.py b/src/backend/base/langflow/api/v2/workflow_public.py index b90372fe40..1f68a3e48b 100644 --- a/src/backend/base/langflow/api/v2/workflow_public.py +++ b/src/backend/base/langflow/api/v2/workflow_public.py @@ -113,7 +113,8 @@ async def execute_public_workflow( # Lazy-import to avoid the circular ``v2.workflow`` -> ``api.build`` -> # ``v1.chat`` -> ``api.build`` cycle that fires when ``v2.__init__`` is # collected at import time. - from langflow.api.v2.workflow import _stream_event_frames, _unknown_protocol_http_exception + from langflow.api.v2.workflow import _unknown_protocol_http_exception + from langflow.api.v2.workflow_execution import _stream_event_frames # Throttle before any DB lookup or flow execution so an anonymous flood is # rejected cheaply, not after spending the flow owner's resources. diff --git a/src/backend/base/langflow/api/v2/workflow_validation.py b/src/backend/base/langflow/api/v2/workflow_validation.py new file mode 100644 index 0000000000..82a75afaf4 --- /dev/null +++ b/src/backend/base/langflow/api/v2/workflow_validation.py @@ -0,0 +1,101 @@ +"""Request and permission guards for V2 workflow execution. + +These run before a flow executes: they reject unsupported field combinations, +enforce owner-only overrides, apply the server-side component policy gate, and +validate caller-supplied output selection. They raise ``HTTPException`` so the +route handlers can surface a structured error without running the graph. +""" + +from __future__ import annotations + +from fastapi import HTTPException, status +from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings + +from langflow.api.v2.converters import ParsedWorkflowRun +from langflow.services.authorization.fetch import deny_to_404 +from langflow.services.database.models.flow.model import FlowRead +from langflow.services.database.models.user.model import UserRead + + +def _flow_not_found_privacy_exception(exc: HTTPException, flow_id: str) -> HTTPException: + return deny_to_404(exc, detail=f"Flow with id {flow_id} not found") + + +def _reject_unsupported_sync_fields(parsed: ParsedWorkflowRun) -> None: + """Reject request fields the inline sync path does not execute.""" + if parsed.mode != "sync": + return + + unsupported_fields: list[str] = [] + if parsed.data is not None: + unsupported_fields.append("data") + if parsed.files: + unsupported_fields.append("files") + if parsed.start_component_id is not None: + unsupported_fields.append("start_component_id") + if parsed.stop_component_id is not None: + unsupported_fields.append("stop_component_id") + + if unsupported_fields: + fields = ", ".join(unsupported_fields) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error": "Unsupported sync request fields", + "code": "SYNC_MODE_UNSUPPORTED_FIELDS", + "message": f"mode='sync' does not support request fields: {fields}. Use mode='stream' or " + "mode='background' for live-canvas overrides, files, or partial-run boundaries.", + "fields": unsupported_fields, + }, + ) + + +def _enforce_flow_data_override_owner(parsed: ParsedWorkflowRun, flow: FlowRead, current_user: UserRead) -> None: + """Only the flow owner may execute caller-supplied graph data.""" + if parsed.data is None or flow.user_id == current_user.id: + return + + raise _flow_not_found_privacy_exception( + HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the flow owner can override flow data during execution", + ), + parsed.flow_id, + ) + + +def _validate_flow_data_for_execution(parsed: ParsedWorkflowRun, flow: FlowRead) -> None: + """Apply the same server-side component policy gate used by v1/public runs.""" + try: + if parsed.data is not None: + validate_flow_for_current_settings(parsed.data) + elif flow.data: + validate_flow_for_current_settings(flow.data) + except CustomComponentValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + + +def _validate_output_ids(output_ids: list[str] | None, terminal_node_ids: list[str]) -> None: + """Reject ``output_ids`` that aren't outputs of this flow, BEFORE it runs. + + Checks against the terminal node ids known after graph build but before + execution, so a typo or wrong id costs no compute. ``available`` lists the + flow's real output ids so callers can self-correct. None/empty means "no + selection" and never raises. + """ + if not output_ids: + return + known = set(terminal_node_ids) + unknown = [output_id for output_id in output_ids if output_id not in known] + if unknown: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error": "Unknown output_ids", + "code": "UNKNOWN_OUTPUT_IDS", + "message": f"output_ids not produced by this flow: {unknown}.", + "available": terminal_node_ids, + }, + ) diff --git a/src/backend/tests/unit/api/v2/test_workflow.py b/src/backend/tests/unit/api/v2/test_workflow.py index 3f365865c5..675b282d76 100644 --- a/src/backend/tests/unit/api/v2/test_workflow.py +++ b/src/backend/tests/unit/api/v2/test_workflow.py @@ -21,6 +21,7 @@ from langflow.services.database.models.flow.model import Flow from langflow.services.database.models.jobs.model import Job, JobType from lfx.schema.workflow import JobStatus from lfx.services.deps import session_scope +from sqlalchemy.exc import OperationalError class TestWorkflowDeveloperAPIProtection: @@ -322,7 +323,7 @@ class TestWorkflowStop: with ( patch("langflow.api.v2.workflow.get_job_service") as mock_get_job_service, - patch("langflow.api.v2.workflow.get_queue_service", return_value=MissingQueueService()), + patch("langflow.api.v2.workflow_background.get_queue_service", return_value=MissingQueueService()), ): mock_job_service = MagicMock() mock_job_service.get_job_by_job_id = AsyncMock(return_value=mock_job) @@ -737,7 +738,7 @@ class TestWorkflowIDORProtection: try: # Mock only the task service to prevent real background execution - with patch("langflow.api.v2.workflow.get_task_service") as mock_task_svc: + with patch("langflow.api.v2.workflow_execution.get_task_service") as mock_task_svc: mock_task_service = MagicMock() mock_task_service.fire_and_forget_task = AsyncMock() mock_task_svc.return_value = mock_task_service @@ -792,20 +793,20 @@ class TestValidateOutputIds: """ def test_no_selection_is_a_noop(self): - from langflow.api.v2.workflow import _validate_output_ids + from langflow.api.v2.workflow_validation import _validate_output_ids # None and empty both mean "no selection" -> never raises. _validate_output_ids(None, ["ChatOutput-a"]) _validate_output_ids([], ["ChatOutput-a"]) def test_all_known_ids_pass(self): - from langflow.api.v2.workflow import _validate_output_ids + from langflow.api.v2.workflow_validation import _validate_output_ids _validate_output_ids(["ChatOutput-a"], ["ChatOutput-a", "ChatOutput-b"]) def test_unknown_id_raises_422_listing_available(self): from fastapi import HTTPException - from langflow.api.v2.workflow import _validate_output_ids + from langflow.api.v2.workflow_validation import _validate_output_ids with pytest.raises(HTTPException) as exc: _validate_output_ids(["ChatOutput-z"], ["ChatOutput-a", "ChatOutput-b"]) @@ -813,3 +814,62 @@ class TestValidateOutputIds: detail = exc.value.detail assert "ChatOutput-z" in str(detail) assert detail["available"] == ["ChatOutput-a", "ChatOutput-b"] + + +class TestWorkflowErrorSanitization: + """Internal exception text must not leak into client error bodies (I3). + + Each handler logs the full exception server-side and returns a generic, + code-tagged message. ``SENTINEL`` stands in for a DSN / driver / stack + string that must never reach the client. + """ + + SENTINEL = "internal-detail-xyzzy-must-not-leak" + + async def test_execute_workflow_db_error_is_generic(self, client: AsyncClient, created_api_key): + with patch("langflow.api.v2.workflow.get_flow_by_id_or_endpoint_name") as mock_get_flow: + mock_get_flow.side_effect = OperationalError("SELECT 1", {}, Exception(self.SENTINEL)) + headers = {"x-api-key": created_api_key.api_key} + response = await client.post("api/v2/workflows", json={"flow_id": str(uuid4())}, headers=headers) + assert response.status_code == 503 + result = response.json() + assert result["detail"]["code"] == "DATABASE_ERROR" + assert result["detail"]["message"] == "Failed to fetch flow. Please try again." + assert self.SENTINEL not in str(result) + + async def test_execute_workflow_unexpected_error_is_generic(self, client: AsyncClient, created_api_key): + with patch("langflow.api.v2.workflow.get_flow_by_id_or_endpoint_name") as mock_get_flow: + mock_get_flow.side_effect = ValueError(self.SENTINEL) + headers = {"x-api-key": created_api_key.api_key} + response = await client.post("api/v2/workflows", json={"flow_id": str(uuid4())}, headers=headers) + assert response.status_code == 500 + result = response.json() + assert result["detail"]["code"] == "INTERNAL_SERVER_ERROR" + assert result["detail"]["message"] == "An unexpected error occurred." + assert self.SENTINEL not in str(result) + + async def test_get_status_db_error_is_generic(self, client: AsyncClient, created_api_key): + with patch("langflow.api.v2.workflow.get_job_service") as mock_get_job_service: + mock_service = MagicMock() + mock_service.get_job_by_job_id = AsyncMock(side_effect=RuntimeError(self.SENTINEL)) + mock_get_job_service.return_value = mock_service + headers = {"x-api-key": created_api_key.api_key} + response = await client.get(f"api/v2/workflows?job_id={uuid4()}", headers=headers) + assert response.status_code == 500 + result = response.json() + assert result["detail"]["code"] == "INTERNAL_SERVER_ERROR" + assert result["detail"]["message"] == "Failed to retrieve job. Please try again." + assert self.SENTINEL not in str(result) + + async def test_stop_workflow_db_error_is_generic(self, client: AsyncClient, created_api_key): + with patch("langflow.api.v2.workflow.get_job_service") as mock_get_job_service: + mock_service = MagicMock() + mock_service.get_job_by_job_id = AsyncMock(side_effect=RuntimeError(self.SENTINEL)) + mock_get_job_service.return_value = mock_service + headers = {"x-api-key": created_api_key.api_key} + response = await client.post("api/v2/workflows/stop", json={"job_id": str(uuid4())}, headers=headers) + assert response.status_code == 500 + result = response.json() + assert result["detail"]["code"] == "INTERNAL_SERVER_ERROR" + assert result["detail"]["message"] == "Failed to retrieve job status. Please try again." + assert self.SENTINEL not in str(result) diff --git a/src/backend/tests/unit/api/v2/test_workflow_agui.py b/src/backend/tests/unit/api/v2/test_workflow_agui.py index 86ba3e74a7..e7582721f8 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_agui.py +++ b/src/backend/tests/unit/api/v2/test_workflow_agui.py @@ -345,6 +345,7 @@ class TestV2WorkflowAdmission: async def test_private_route_applies_component_policy_gate(self, monkeypatch: pytest.MonkeyPatch): """The authenticated v2 route must run server-side component policy validation.""" from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_validation as wf_val from lfx.schema.workflow import WorkflowRunRequest from lfx.utils.flow_validation import CustomComponentValidationError @@ -364,7 +365,7 @@ class TestV2WorkflowAdmission: monkeypatch.setattr(workflow_module, "get_flow_by_id_or_endpoint_name", AsyncMock(return_value=flow)) monkeypatch.setattr(workflow_module, "ensure_flow_permission", AsyncMock(return_value=None)) - monkeypatch.setattr(workflow_module, "validate_flow_for_current_settings", _reject) + monkeypatch.setattr(wf_val, "validate_flow_for_current_settings", _reject) with pytest.raises(HTTPException) as exc_info: await workflow_module.execute_workflow( @@ -383,9 +384,9 @@ class TestAGUIStreaming: async def test_stream_event_queue_ignores_late_sentinel_after_overflow(self): """A normal completion sentinel must not race ahead of the overflow error.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_execution as wf_exec - queue = workflow_module._WorkflowEventQueue(maxsize=1) + queue = wf_exec._WorkflowEventQueue(maxsize=1) try: payload = json.dumps({"event": "token", "data": {"chunk": "0"}}).encode() queue.put_nowait(("token-0", payload, time.time())) @@ -397,7 +398,7 @@ class TestAGUIStreaming: async def test_stream_event_handoff_overflow_emits_error(self, monkeypatch: pytest.MonkeyPatch): """EventManager put_nowait must not silently drop frames when the stream buffer fills.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.adapters import StreamEvent from langflow.api.v2.converters import ParsedWorkflowRun @@ -423,12 +424,12 @@ class TestAGUIStreaming: def translate(self, event_type, _event_data): return [StreamEvent(type=event_type, data_json="{}")] - monkeypatch.setattr(workflow_module, "_EVENT_QUEUE_MAX_SIZE", 2) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_exec, "_EVENT_QUEUE_MAX_SIZE", 2) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) event_types = [ event_type - async for _frame, event_type in workflow_module._stream_event_frames( + async for _frame, event_type in wf_exec._stream_event_frames( adapter=FakeAdapter(), flow_id=uuid4(), flow_name="flow", @@ -441,6 +442,45 @@ class TestAGUIStreaming: assert seen_maxsize == [2] assert event_types == ["token", "token", "error"] + @pytest.mark.parametrize(("stream_protocol", "terminal_type"), [("agui", "RUN_ERROR"), ("langflow", "error")]) + async def test_stream_enforces_execution_timeout( + self, + monkeypatch: pytest.MonkeyPatch, + stream_protocol: str, + terminal_type: str, + ): + """A run that exceeds the wall-clock ceiling ends in a sanitized terminal error, not a hang.""" + from langflow.api.v2 import workflow_execution as wf_exec + from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter + from langflow.api.v2.converters import ParsedWorkflowRun + + async def hanging_generate_flow_events(**_kwargs): + await asyncio.sleep(5) + + # Tiny ceiling + a producer that never returns: the timeout must fire. + monkeypatch.setattr(wf_exec, "_resolve_execution_timeout", lambda: 0.05) + monkeypatch.setattr(wf_exec, "generate_flow_events", hanging_generate_flow_events) + + adapter = get_stream_adapter(stream_protocol, StreamAdapterContext(run_id="run", thread_id="thread")) + + collected: list[tuple[bytes, str]] = [ + (frame, event_type) + async for frame, event_type in wf_exec._stream_event_frames( + adapter=adapter, + flow_id=uuid4(), + flow_name="flow", + background_tasks=SimpleNamespace(add_task=lambda *_args, **_kwargs: None), + parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="stream"), + current_user=SimpleNamespace(id=uuid4()), + ) + ] + + event_types = [event_type for _frame, event_type in collected] + body = b"".join(frame for frame, _event_type in collected).decode() + # Exactly one terminal error, carrying the sanitized message (no internal detail). + assert event_types.count(terminal_type) == 1 + assert "Workflow execution timed out." in body + @pytest.mark.parametrize(("stream_protocol", "terminal_type"), [("agui", "RUN_ERROR"), ("langflow", "error")]) async def test_stream_error_path_emits_one_terminal_error( self, @@ -449,7 +489,7 @@ class TestAGUIStreaming: terminal_type: str, ): """A producer that reports on_error then raises must not triple-emit terminal errors.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter from langflow.api.v2.converters import ParsedWorkflowRun @@ -458,7 +498,7 @@ class TestAGUIStreaming: message = "outer" raise RuntimeError(message) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) adapter = get_stream_adapter( stream_protocol, @@ -466,7 +506,7 @@ class TestAGUIStreaming: ) event_types = [ event_type - async for _frame, event_type in workflow_module._stream_event_frames( + async for _frame, event_type in wf_exec._stream_event_frames( adapter=adapter, flow_id=uuid4(), flow_name="flow", @@ -487,7 +527,7 @@ class TestAGUIStreaming: terminal_type: str, ): """A producer that raises before on_error still emits one terminal error.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter from langflow.api.v2.converters import ParsedWorkflowRun @@ -495,7 +535,7 @@ class TestAGUIStreaming: message = "early boom" raise RuntimeError(message) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) adapter = get_stream_adapter( stream_protocol, @@ -503,7 +543,7 @@ class TestAGUIStreaming: ) frames = [ frame - async for frame, _event_type in workflow_module._stream_event_frames( + async for frame, _event_type in wf_exec._stream_event_frames( adapter=adapter, flow_id=uuid4(), flow_name="flow", @@ -521,7 +561,7 @@ class TestAGUIStreaming: async def test_agui_stream_emits_end_side_channel_for_build_duration(self, monkeypatch: pytest.MonkeyPatch): """The AG-UI stream must preserve v1 end payloads for chat build-duration persistence.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.converters import ParsedWorkflowRun async def fake_generate_flow_events(**kwargs): @@ -542,11 +582,11 @@ class TestAGUIStreaming: def translate(self, _event_type, _event_data): return [] - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) frames = [ frame - async for frame, _event_type in workflow_module._stream_event_frames( + async for frame, _event_type in wf_exec._stream_event_frames( adapter=FakeAdapter(), flow_id=uuid4(), flow_name="flow", @@ -967,7 +1007,7 @@ class TestAGUIBackgroundJobStatus: async def test_background_buffer_binds_build_rows_to_returned_job_id(self, monkeypatch: pytest.MonkeyPatch): """Status reconstruction needs vertex_build rows logged under the public background job id.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2.converters import ParsedWorkflowRun job_id = uuid4() @@ -983,12 +1023,12 @@ class TestAGUIBackgroundJobStatus: captured.update(kwargs) yield b"data: {}\n\n", "RUN_FINISHED" - monkeypatch.setattr(workflow_module, "get_stream_adapter", lambda *_args, **_kwargs: FakeAdapter()) - monkeypatch.setattr(workflow_module, "_stream_event_frames", fake_stream_event_frames) - monkeypatch.setattr(workflow_module, "_finalize_job_status", AsyncMock()) + monkeypatch.setattr(wf_bg, "get_stream_adapter", lambda *_args, **_kwargs: FakeAdapter()) + monkeypatch.setattr(wf_bg, "_stream_event_frames", fake_stream_event_frames) + monkeypatch.setattr(wf_bg, "_finalize_job_status", AsyncMock()) - bg_run = workflow_module._BackgroundRun(user_id=str(uuid4())) - await workflow_module._buffer_background_run( + bg_run = wf_bg._BackgroundRun(user_id=str(uuid4())) + await wf_bg._buffer_background_run( bg_run=bg_run, flow=SimpleNamespace(id=uuid4(), name="flow"), parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="background"), @@ -1003,6 +1043,7 @@ class TestAGUIBackgroundJobStatus: async def test_background_buffer_marks_job_in_progress(self, monkeypatch: pytest.MonkeyPatch): """A background job should leave QUEUED once its buffer task starts executing.""" from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2.converters import ParsedWorkflowRun job_id = uuid4() @@ -1022,13 +1063,13 @@ class TestAGUIBackgroundJobStatus: async def fake_stream_event_frames(**_kwargs): yield b"data: {}\n\n", "RUN_FINISHED" - monkeypatch.setattr(workflow_module, "get_stream_adapter", lambda *_args, **_kwargs: FakeAdapter()) - monkeypatch.setattr(workflow_module, "get_job_service", lambda: FakeJobService()) - monkeypatch.setattr(workflow_module, "_stream_event_frames", fake_stream_event_frames) - monkeypatch.setattr(workflow_module, "_finalize_job_status", AsyncMock()) + monkeypatch.setattr(wf_bg, "get_stream_adapter", lambda *_args, **_kwargs: FakeAdapter()) + monkeypatch.setattr(wf_bg, "get_job_service", lambda: FakeJobService()) + monkeypatch.setattr(wf_bg, "_stream_event_frames", fake_stream_event_frames) + monkeypatch.setattr(wf_bg, "_finalize_job_status", AsyncMock()) - bg_run = workflow_module._BackgroundRun(user_id=str(uuid4())) - await workflow_module._buffer_background_run( + bg_run = wf_bg._BackgroundRun(user_id=str(uuid4())) + await wf_bg._buffer_background_run( bg_run=bg_run, flow=SimpleNamespace(id=uuid4(), name="flow"), parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="background"), @@ -1043,7 +1084,8 @@ class TestAGUIBackgroundJobStatus: self, monkeypatch: pytest.MonkeyPatch ): """The owner task must append cancellation before marking replay done.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.converters import ParsedWorkflowRun started = asyncio.Event() @@ -1061,13 +1103,13 @@ class TestAGUIBackgroundJobStatus: tail = bg_run.base_index + len(bg_run.frames) return [frame async for frame in bg_run.replay(tail)] - monkeypatch.setattr(workflow_module, "get_job_service", lambda: FakeJobService()) - monkeypatch.setattr(workflow_module, "_finalize_job_status", AsyncMock()) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_bg, "get_job_service", lambda: FakeJobService()) + monkeypatch.setattr(wf_bg, "_finalize_job_status", AsyncMock()) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) - bg_run = workflow_module._BackgroundRun(user_id=str(uuid4()), stream_protocol="agui") + bg_run = wf_bg._BackgroundRun(user_id=str(uuid4()), stream_protocol="agui") buffer_task = asyncio.create_task( - workflow_module._buffer_background_run( + wf_bg._buffer_background_run( bg_run=bg_run, flow=SimpleNamespace(id=uuid4(), name="flow"), parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="background"), @@ -1107,7 +1149,8 @@ class TestAGUIBackgroundJobStatus: self, monkeypatch: pytest.MonkeyPatch ): """Cancellation framing must stay protocol-native outside AG-UI too.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.converters import ParsedWorkflowRun started = asyncio.Event() @@ -1125,13 +1168,13 @@ class TestAGUIBackgroundJobStatus: tail = bg_run.base_index + len(bg_run.frames) return [frame async for frame in bg_run.replay(tail)] - monkeypatch.setattr(workflow_module, "get_job_service", lambda: FakeJobService()) - monkeypatch.setattr(workflow_module, "_finalize_job_status", AsyncMock()) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_bg, "get_job_service", lambda: FakeJobService()) + monkeypatch.setattr(wf_bg, "_finalize_job_status", AsyncMock()) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) - bg_run = workflow_module._BackgroundRun(user_id=str(uuid4()), stream_protocol="langflow") + bg_run = wf_bg._BackgroundRun(user_id=str(uuid4()), stream_protocol="langflow") buffer_task = asyncio.create_task( - workflow_module._buffer_background_run( + wf_bg._buffer_background_run( bg_run=bg_run, flow=SimpleNamespace(id=uuid4(), name="flow"), parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="background"), @@ -1164,7 +1207,8 @@ class TestAGUIBackgroundJobStatus: self, monkeypatch: pytest.MonkeyPatch ): """The stop fallback must not wake replay readers before cancellation is buffered.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg + from langflow.api.v2 import workflow_execution as wf_exec from langflow.api.v2.converters import ParsedWorkflowRun job_id = str(uuid4()) @@ -1183,15 +1227,15 @@ class TestAGUIBackgroundJobStatus: tail = bg_run.base_index + len(bg_run.frames) return [frame async for frame in bg_run.replay(tail)] - monkeypatch.setattr(workflow_module, "get_job_service", lambda: FakeJobService()) - monkeypatch.setattr(workflow_module, "_finalize_job_status", AsyncMock()) - monkeypatch.setattr(workflow_module, "generate_flow_events", fake_generate_flow_events) - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "get_job_service", lambda: FakeJobService()) + monkeypatch.setattr(wf_bg, "_finalize_job_status", AsyncMock()) + monkeypatch.setattr(wf_exec, "generate_flow_events", fake_generate_flow_events) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) - bg_run = workflow_module._BackgroundRun(user_id=str(uuid4()), stream_protocol="agui") - workflow_module._BACKGROUND_RUNS[job_id] = bg_run + bg_run = wf_bg._BackgroundRun(user_id=str(uuid4()), stream_protocol="agui") + wf_bg._BACKGROUND_RUNS[job_id] = bg_run buffer_task = asyncio.create_task( - workflow_module._buffer_background_run( + wf_bg._buffer_background_run( bg_run=bg_run, flow=SimpleNamespace(id=uuid4(), name="flow"), parsed=ParsedWorkflowRun(flow_id=str(uuid4()), input_value="hi", mode="background"), @@ -1213,7 +1257,7 @@ class TestAGUIBackgroundJobStatus: tail_task = asyncio.create_task(collect_tail(bg_run)) await asyncio.sleep(0) - await workflow_module._finish_cancelled_background_run(job_id) + await wf_bg._finish_cancelled_background_run(job_id) tail_frames = await asyncio.wait_for(tail_task, timeout=2) tail_payloads = _sse_payloads(tail_frames) @@ -1236,7 +1280,7 @@ class TestAGUIBackgroundJobStatus: buffer_task.cancel() with contextlib.suppress(asyncio.CancelledError): await buffer_task - await workflow_module._clear_background_run(job_id) + await wf_bg._clear_background_run(job_id) async def test_message_text_containing_run_error_literal_does_not_fail_job( self, @@ -1619,7 +1663,7 @@ class TestAGUIBackgroundTasksLifecycle: monkeypatch: pytest.MonkeyPatch, ): """After a background run ends, the BackgroundTasks queue must be invoked.""" - from langflow.api.v2 import workflow as _workflow_module + from langflow.api.v2 import workflow_background as wf_bg from starlette.background import BackgroundTasks as _Real instances: list[_Real] = [] @@ -1636,7 +1680,7 @@ class TestAGUIBackgroundTasksLifecycle: self.called = True return await super().__call__(*args, **kwargs) - monkeypatch.setattr(_workflow_module, "BackgroundTasks", RecordingBackgroundTasks) + monkeypatch.setattr(wf_bg, "BackgroundTasks", RecordingBackgroundTasks) headers = {"x-api-key": created_api_key.api_key} start = await client.post( @@ -1992,10 +2036,10 @@ class TestBackgroundRunsRegistryEviction: """ async def test_eviction_prefers_completed_runs_over_running_ones(self, monkeypatch): - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 3) - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "_MAX_BACKGROUND_RUNS", 3) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) # Evicting a completed run has no live writer to stop, so the cancel path # must not fire; record any call to prove it doesn't. @@ -2005,27 +2049,25 @@ class TestBackgroundRunsRegistryEviction: cancelled.append(job_id) return True - monkeypatch.setattr(workflow_module, "_cancel_workflow_queue_job", fake_cancel) + monkeypatch.setattr(wf_bg, "_cancel_workflow_queue_job", fake_cancel) - long_running = workflow_module._BackgroundRun(user_id="u") + long_running = wf_bg._BackgroundRun(user_id="u") # done stays False; this is the run we must protect. - await workflow_module._register_background_run("long", long_running) + await wf_bg._register_background_run("long", long_running) # Fill the rest with completed runs. for job_id in ("done1", "done2"): - done_run = workflow_module._BackgroundRun(user_id="u") + done_run = wf_bg._BackgroundRun(user_id="u") done_run.done = True - await workflow_module._register_background_run(job_id, done_run) + await wf_bg._register_background_run(job_id, done_run) # Registry is now at the cap (3): [long, done1, done2]. Adding a new # entry must evict a completed run, not the still-running ``long``. - new_run = workflow_module._BackgroundRun(user_id="u") - await workflow_module._register_background_run("new", new_run) + new_run = wf_bg._BackgroundRun(user_id="u") + await wf_bg._register_background_run("new", new_run) - assert "long" in workflow_module._BACKGROUND_RUNS, ( - "Still-running background run was evicted in favor of a completed one" - ) - assert "new" in workflow_module._BACKGROUND_RUNS + assert "long" in wf_bg._BACKGROUND_RUNS, "Still-running background run was evicted in favor of a completed one" + assert "new" in wf_bg._BACKGROUND_RUNS assert cancelled == [], "Evicting a completed run must not cancel a queue job" async def test_eviction_falls_back_to_oldest_when_every_run_is_active(self, monkeypatch): @@ -2036,10 +2078,10 @@ class TestBackgroundRunsRegistryEviction: buffer writer is cancelled so it stops appending into a run no reader can find (the bounded-memory guarantee the registry exists to provide). """ - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 2) - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "_MAX_BACKGROUND_RUNS", 2) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) cancelled: list[str] = [] @@ -2047,19 +2089,19 @@ class TestBackgroundRunsRegistryEviction: cancelled.append(job_id) return True - monkeypatch.setattr(workflow_module, "_cancel_workflow_queue_job", fake_cancel) + monkeypatch.setattr(wf_bg, "_cancel_workflow_queue_job", fake_cancel) for job_id in ("a", "b"): - run = workflow_module._BackgroundRun(user_id="u") - await workflow_module._register_background_run(job_id, run) + run = wf_bg._BackgroundRun(user_id="u") + await wf_bg._register_background_run(job_id, run) # All running; adding a third must evict the oldest (a) and cancel it. - third = workflow_module._BackgroundRun(user_id="u") - await workflow_module._register_background_run("c", third) + third = wf_bg._BackgroundRun(user_id="u") + await wf_bg._register_background_run("c", third) - assert "a" not in workflow_module._BACKGROUND_RUNS - assert "b" in workflow_module._BACKGROUND_RUNS - assert "c" in workflow_module._BACKGROUND_RUNS + assert "a" not in wf_bg._BACKGROUND_RUNS + assert "b" in wf_bg._BACKGROUND_RUNS + assert "c" in wf_bg._BACKGROUND_RUNS assert cancelled == ["a"], "Evicted still-running run's buffer writer was not cancelled" @@ -2073,26 +2115,26 @@ class TestClearBackgroundRun: """ async def test_clear_pops_registry_entry_and_finishes_buffer(self, monkeypatch): - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) - bg_run = workflow_module._BackgroundRun(user_id="u") - await workflow_module._register_background_run("job-1", bg_run) + bg_run = wf_bg._BackgroundRun(user_id="u") + await wf_bg._register_background_run("job-1", bg_run) assert bg_run.done is False - await workflow_module._clear_background_run("job-1") + await wf_bg._clear_background_run("job-1") - assert "job-1" not in workflow_module._BACKGROUND_RUNS + assert "job-1" not in wf_bg._BACKGROUND_RUNS assert bg_run.done is True async def test_clear_is_a_noop_for_unknown_job_id(self, monkeypatch): - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) # Must not raise even when nothing is registered. - await workflow_module._clear_background_run("nope") + await wf_bg._clear_background_run("nope") class TestBackgroundRunReplayConcurrentReaders: @@ -2108,9 +2150,9 @@ class TestBackgroundRunReplayConcurrentReaders: async def test_two_concurrent_readers_each_receive_all_frames(self): import asyncio as _asyncio - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - bg_run = workflow_module._BackgroundRun(user_id="u") + bg_run = wf_bg._BackgroundRun(user_id="u") async def consume() -> list[bytes]: return [frame async for frame in bg_run.replay(start_index=0)] @@ -2145,9 +2187,9 @@ class TestBackgroundRunReplayConcurrentReaders: """ import asyncio as _asyncio - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - bg_run = workflow_module._BackgroundRun(user_id="u") + bg_run = wf_bg._BackgroundRun(user_id="u") # Buffer two frames before any reader attaches. await bg_run.append(b"early-1") @@ -2169,9 +2211,9 @@ class TestBackgroundRunReplayConcurrentReaders: """``start_index`` honors the Last-Event-ID hand-off semantics.""" import asyncio as _asyncio - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg - bg_run = workflow_module._BackgroundRun(user_id="u") + bg_run = wf_bg._BackgroundRun(user_id="u") await bg_run.append(b"f0") await bg_run.append(b"f1") await bg_run.append(b"f2") @@ -2213,7 +2255,7 @@ class TestBufferBackgroundRunUnknownProtocolGuard: """The defensive UnknownStreamProtocolError path finalizes job + buffer cleanly.""" from uuid import uuid4 as _uuid4 - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2.adapters import STREAM_ADAPTERS as _REGISTRY from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.database.models.flow.model import Flow, FlowRead @@ -2236,16 +2278,16 @@ class TestBufferBackgroundRunUnknownProtocolGuard: user_row = await session.get(_User, created_api_key.user_id) user = UserRead.model_validate(user_row, from_attributes=True) - bg_run = workflow_module._BackgroundRun(user_id=str(created_api_key.user_id)) + bg_run = wf_bg._BackgroundRun(user_id=str(created_api_key.user_id)) # ``get_stream_adapter`` reads the registry dict from # ``adapters.registry`` directly. Rebinding the imported reference on - # ``workflow_module`` has no effect on the lookup; we have to mutate the + # ``workflow_background`` has no effect on the lookup; we have to mutate the # shared dict in place and restore it afterwards. snapshot = dict(_REGISTRY) _REGISTRY.clear() try: - await workflow_module._buffer_background_run( + await wf_bg._buffer_background_run( bg_run=bg_run, flow=flow, parsed=ParsedWorkflowRun(flow_id=str(empty_flow), mode="background"), @@ -2268,7 +2310,7 @@ class TestBufferBackgroundRunUnknownProtocolGuard: """The coroutine is fire-and-forget; it must swallow the registry mismatch cleanly.""" from uuid import uuid4 as _uuid4 - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2.adapters import STREAM_ADAPTERS as _REGISTRY from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.database.models.flow.model import Flow, FlowRead @@ -2289,13 +2331,13 @@ class TestBufferBackgroundRunUnknownProtocolGuard: user_row = await session.get(_User, created_api_key.user_id) user = UserRead.model_validate(user_row, from_attributes=True) - bg_run = workflow_module._BackgroundRun(user_id=str(created_api_key.user_id)) + bg_run = wf_bg._BackgroundRun(user_id=str(created_api_key.user_id)) snapshot = dict(_REGISTRY) _REGISTRY.clear() try: # Must not raise, even though the protocol is unknown. - await workflow_module._buffer_background_run( + await wf_bg._buffer_background_run( bg_run=bg_run, flow=flow, parsed=ParsedWorkflowRun(flow_id=str(empty_flow), mode="background"), @@ -2317,7 +2359,7 @@ class TestExecuteWorkflowBackgroundQueueOwnership: via the queue cancel side-channel plus the persisted workflow Job row, so registering them would let the polling watchdog reclaim long runs. """ - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2.converters import ParsedWorkflowRun job_id = uuid4() @@ -2342,11 +2384,11 @@ class TestExecuteWorkflowBackgroundQueueOwnership: started_jobs.append(seen_job_id) task_coro.close() - monkeypatch.setattr(workflow_module, "get_job_service", lambda: FakeJobService()) - monkeypatch.setattr(workflow_module, "get_queue_service", lambda: FakeQueueService()) - monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + monkeypatch.setattr(wf_bg, "get_job_service", lambda: FakeJobService()) + monkeypatch.setattr(wf_bg, "get_queue_service", lambda: FakeQueueService()) + monkeypatch.setattr(wf_bg, "_BACKGROUND_RUNS", {}) - response = await workflow_module.execute_workflow_background( + response = await wf_bg.execute_workflow_background( parsed=ParsedWorkflowRun(flow_id=str(flow_id), mode="background"), flow=SimpleNamespace(id=flow_id, name="Background Flow"), job_id=job_id, @@ -2380,6 +2422,7 @@ class TestStopWorkflowEndToEnd: from uuid import UUID as _UUID from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg from langflow.services.database.models.jobs.model import Job, JobStatus headers = {"x-api-key": created_api_key.api_key} @@ -2423,7 +2466,7 @@ class TestStopWorkflowEndToEnd: assert row is not None assert row.status == JobStatus.CANCELLED finally: - await workflow_module._clear_background_run(job_id) + await wf_bg._clear_background_run(job_id) async def test_stop_cancels_queue_owned_background_task_when_task_service_noops( self, @@ -2433,7 +2476,8 @@ class TestStopWorkflowEndToEnd: monkeypatch, ): """The v2 background runner is owned by the queue service, not TaskService.""" - from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg + from langflow.api.v2 import workflow_execution as wf_exec from langflow.services.deps import get_queue_service from langflow.services.job_queue.service import JobQueueNotFoundError @@ -2453,8 +2497,8 @@ class TestStopWorkflowEndToEnd: async def revoke_task(self, _task_id): return True - monkeypatch.setattr(workflow_module, "_buffer_background_run", _never_finishes) - monkeypatch.setattr(workflow_module, "get_task_service", lambda: NoopTaskService()) + monkeypatch.setattr(wf_bg, "_buffer_background_run", _never_finishes) + monkeypatch.setattr(wf_exec, "get_task_service", lambda: NoopTaskService()) headers = {"x-api-key": created_api_key.api_key} try: @@ -2484,7 +2528,7 @@ class TestStopWorkflowEndToEnd: if job_id is not None: with contextlib.suppress(BaseException): await get_queue_service().cleanup_job(job_id) - await workflow_module._clear_background_run(job_id) + await wf_bg._clear_background_run(job_id) async def test_stop_signals_cross_worker_queue_owner_before_marking_cancelled( self, @@ -2492,6 +2536,7 @@ class TestStopWorkflowEndToEnd: ): """Redis-backed jobs owned by another worker must be cancelled by pub/sub signal.""" from langflow.api.v2 import workflow as workflow_module + from langflow.api.v2 import workflow_background as wf_bg job_id = uuid4() current_user_id = uuid4() @@ -2535,7 +2580,7 @@ class TestStopWorkflowEndToEnd: job_service = FakeJobService() queue_service = CrossWorkerQueueService() monkeypatch.setattr(workflow_module, "get_job_service", lambda: job_service) - monkeypatch.setattr(workflow_module, "get_queue_service", lambda: queue_service) + monkeypatch.setattr(wf_bg, "get_queue_service", lambda: queue_service) response = await workflow_module.stop_workflow( workflow_module.WorkflowStopRequest(job_id=job_id), diff --git a/src/backend/tests/unit/api/v2/test_workflow_public.py b/src/backend/tests/unit/api/v2/test_workflow_public.py index f6cb4d1363..a4e2dce902 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_public.py +++ b/src/backend/tests/unit/api/v2/test_workflow_public.py @@ -40,7 +40,7 @@ def _stub_generate_flow_events(monkeypatch, captured: dict) -> None: impersonation, and source_flow_id propagation without needing the full streaming pipeline. """ - import langflow.api.v2.workflow as workflow_module + from langflow.api.v2 import workflow_execution async def _fake_generate_flow_events(**kwargs: Any) -> None: captured.update(kwargs) @@ -49,7 +49,7 @@ def _stub_generate_flow_events(monkeypatch, captured: dict) -> None: await kwargs["event_manager"].queue.put((None, None, time.time())) - monkeypatch.setattr(workflow_module, "generate_flow_events", _fake_generate_flow_events) + monkeypatch.setattr(workflow_execution, "generate_flow_events", _fake_generate_flow_events) def _send_unauthenticated(client: AsyncClient, client_id: str) -> None: diff --git a/src/lfx/src/lfx/services/settings/groups/runtime.py b/src/lfx/src/lfx/services/settings/groups/runtime.py index 14f2c9ea38..d83b1d5b25 100644 --- a/src/lfx/src/lfx/services/settings/groups/runtime.py +++ b/src/lfx/src/lfx/services/settings/groups/runtime.py @@ -63,6 +63,11 @@ class RuntimeSettings(BaseModel): worker_timeout: int = 300 """Timeout for the API calls in seconds.""" + workflow_execution_timeout: int = 300 + """Wall-clock ceiling in seconds for a single v2 workflow run, applied to every + execution mode. Sync runs raise a 408; stream, background, and public runs emit + the protocol's terminal-error event and (for background) mark the job failed.""" + public_flow_cleanup_interval: int = Field(default=3600, gt=600) """The interval in seconds at which public temporary flows will be cleaned up. Default is 1 hour (3600 seconds). Minimum is 600 seconds (10 minutes).""" diff --git a/src/lfx/tests/unit/services/settings/test_settings_composition.py b/src/lfx/tests/unit/services/settings/test_settings_composition.py index 6a04908fb6..933525384c 100644 --- a/src/lfx/tests/unit/services/settings/test_settings_composition.py +++ b/src/lfx/tests/unit/services/settings/test_settings_composition.py @@ -133,6 +133,7 @@ EXPECTED_FIELDS = { "dev", "event_delivery", "worker_timeout", + "workflow_execution_timeout", "public_flow_cleanup_interval", "public_flow_expiration", "webhook_polling_interval", From 7116ea18427ead02e1e9dc71752576da35293cb6 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Tue, 23 Jun 2026 12:54:54 -0300 Subject: [PATCH 4/4] refactor(lfx): extract v2 workflow contract layer into lfx.workflow 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. --- src/backend/base/langflow/api/v2/workflow.py | 10 ++--- .../langflow/api/v2/workflow_background.py | 6 +-- .../langflow/api/v2/workflow_execution.py | 4 +- .../base/langflow/api/v2/workflow_public.py | 16 ++++---- .../api/v2/workflow_reconstruction.py | 2 +- .../langflow/api/v2/workflow_validation.py | 2 +- .../unit/api/v2/test_output_event_parity.py | 6 +-- .../tests/unit/api/v2/test_workflow_agui.py | 38 +++++++++---------- src/lfx/src/lfx/workflow/__init__.py | 14 +++++++ .../src/lfx/workflow}/adapters/__init__.py | 12 ++++-- .../src/lfx/workflow}/adapters/agui.py | 14 ++++--- .../src/lfx/workflow}/adapters/langflow.py | 11 +++--- .../src/lfx/workflow}/agui_translator.py | 0 .../v2 => lfx/src/lfx/workflow}/converters.py | 17 +++++++-- .../tests/unit/workflow}/__init__.py | 0 .../tests/unit/workflow/adapters/__init__.py | 0 .../workflow}/adapters/test_agui_adapter.py | 2 +- .../adapters/test_langflow_adapter.py | 2 +- .../unit/workflow}/adapters/test_registry.py | 2 +- .../unit/workflow}/test_agui_translator.py | 2 +- .../tests/unit/workflow}/test_converters.py | 24 ++++++------ 21 files changed, 108 insertions(+), 76 deletions(-) create mode 100644 src/lfx/src/lfx/workflow/__init__.py rename src/{backend/base/langflow/api/v2 => lfx/src/lfx/workflow}/adapters/__init__.py (92%) rename src/{backend/base/langflow/api/v2 => lfx/src/lfx/workflow}/adapters/agui.py (91%) rename src/{backend/base/langflow/api/v2 => lfx/src/lfx/workflow}/adapters/langflow.py (94%) rename src/{backend/base/langflow/api/v2 => lfx/src/lfx/workflow}/agui_translator.py (100%) rename src/{backend/base/langflow/api/v2 => lfx/src/lfx/workflow}/converters.py (98%) rename src/{backend/tests/unit/api/v2/adapters => lfx/tests/unit/workflow}/__init__.py (100%) create mode 100644 src/lfx/tests/unit/workflow/adapters/__init__.py rename src/{backend/tests/unit/api/v2 => lfx/tests/unit/workflow}/adapters/test_agui_adapter.py (99%) rename src/{backend/tests/unit/api/v2 => lfx/tests/unit/workflow}/adapters/test_langflow_adapter.py (99%) rename src/{backend/tests/unit/api/v2 => lfx/tests/unit/workflow}/adapters/test_registry.py (99%) rename src/{backend/tests/unit/api/v2 => lfx/tests/unit/workflow}/test_agui_translator.py (99%) rename src/{backend/tests/unit/api/v2 => lfx/tests/unit/workflow}/test_converters.py (99%) diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index 52ebb395b8..3065973547 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -43,17 +43,17 @@ from lfx.schema.workflow import ( WorkflowStopResponse, ) from lfx.services.deps import injectable_session_scope_readonly -from pydantic_core import ValidationError as PydanticValidationError -from sqlalchemy.exc import OperationalError - -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( STREAM_ADAPTERS, StreamAdapterContext, UnknownStreamProtocolError, available_protocols, get_stream_adapter, ) -from langflow.api.v2.converters import parse_workflow_run_request +from lfx.workflow.converters import parse_workflow_run_request +from pydantic_core import ValidationError as PydanticValidationError +from sqlalchemy.exc import OperationalError + from langflow.api.v2.workflow_background import ( _BACKGROUND_RUNS, _cancel_workflow_queue_job, diff --git a/src/backend/base/langflow/api/v2/workflow_background.py b/src/backend/base/langflow/api/v2/workflow_background.py index a4fdd6aa21..be561d8359 100644 --- a/src/backend/base/langflow/api/v2/workflow_background.py +++ b/src/backend/base/langflow/api/v2/workflow_background.py @@ -19,14 +19,14 @@ from fastapi import BackgroundTasks, Request from fastapi.sse import format_sse_event from lfx.log.logger import logger from lfx.schema.workflow import JobId, JobStatus, WorkflowJobResponse - -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( StreamAdapterContext, StreamEvent, UnknownStreamProtocolError, get_stream_adapter, ) -from langflow.api.v2.converters import ParsedWorkflowRun +from lfx.workflow.converters import ParsedWorkflowRun + from langflow.api.v2.workflow_execution import _stream_event_frames from langflow.exceptions.api import ( WorkflowQueueFullError, diff --git a/src/backend/base/langflow/api/v2/workflow_execution.py b/src/backend/base/langflow/api/v2/workflow_execution.py index 83df935cc3..64d9ac5f12 100644 --- a/src/backend/base/langflow/api/v2/workflow_execution.py +++ b/src/backend/base/langflow/api/v2/workflow_execution.py @@ -34,11 +34,11 @@ from lfx.graph.graph.base import Graph from lfx.log.logger import logger from lfx.schema.schema import InputValueRequest from lfx.schema.workflow import WorkflowExecutionResponse +from lfx.workflow.adapters import StreamAdapter, StreamEvent +from lfx.workflow.converters import ParsedWorkflowRun, create_error_response, run_response_to_workflow_response from langflow.api.utils import extract_global_variables_from_headers from langflow.api.v1.schemas import FlowDataRequest, RunResponse -from langflow.api.v2.adapters import StreamAdapter, StreamEvent -from langflow.api.v2.converters import ParsedWorkflowRun, create_error_response, run_response_to_workflow_response from langflow.api.v2.workflow_validation import _validate_output_ids from langflow.exceptions.api import WorkflowTimeoutError, WorkflowValidationError from langflow.processing.process import process_tweaks, run_graph_internal diff --git a/src/backend/base/langflow/api/v2/workflow_public.py b/src/backend/base/langflow/api/v2/workflow_public.py index 1f68a3e48b..b84b3b7ad3 100644 --- a/src/backend/base/langflow/api/v2/workflow_public.py +++ b/src/backend/base/langflow/api/v2/workflow_public.py @@ -40,6 +40,14 @@ from lfx.utils.flow_validation import ( validate_flow_for_current_settings, validate_public_flow_no_code_execution, ) +from lfx.workflow.adapters import ( + STREAM_ADAPTERS, + StreamAdapterContext, + UnknownStreamProtocolError, + available_protocols, + get_stream_adapter, +) +from lfx.workflow.converters import ParsedWorkflowRun from limits import parse from langflow.api.utils.flow_utils import ( @@ -47,14 +55,6 @@ from langflow.api.utils.flow_utils import ( validate_public_files, verify_public_flow_and_get_user, ) -from langflow.api.v2.adapters import ( - STREAM_ADAPTERS, - StreamAdapterContext, - UnknownStreamProtocolError, - available_protocols, - get_stream_adapter, -) -from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.auth.utils import get_current_user_optional from langflow.services.database.models.flow.model import Flow from langflow.services.database.models.user.model import User, UserRead diff --git a/src/backend/base/langflow/api/v2/workflow_reconstruction.py b/src/backend/base/langflow/api/v2/workflow_reconstruction.py index 6c4bd967ec..2d187ab070 100644 --- a/src/backend/base/langflow/api/v2/workflow_reconstruction.py +++ b/src/backend/base/langflow/api/v2/workflow_reconstruction.py @@ -10,9 +10,9 @@ from typing import TYPE_CHECKING from lfx.graph.graph.base import Graph from lfx.graph.schema import ResultData, RunOutputs +from lfx.workflow.converters import run_response_to_workflow_response from langflow.api.v1.schemas import RunResponse -from langflow.api.v2.converters import run_response_to_workflow_response from langflow.services.database.models.vertex_builds.crud import get_vertex_builds_by_job_id if TYPE_CHECKING: diff --git a/src/backend/base/langflow/api/v2/workflow_validation.py b/src/backend/base/langflow/api/v2/workflow_validation.py index 82a75afaf4..9963fe87f9 100644 --- a/src/backend/base/langflow/api/v2/workflow_validation.py +++ b/src/backend/base/langflow/api/v2/workflow_validation.py @@ -10,8 +10,8 @@ from __future__ import annotations from fastapi import HTTPException, status from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings +from lfx.workflow.converters import ParsedWorkflowRun -from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.authorization.fetch import deny_to_404 from langflow.services.database.models.flow.model import FlowRead from langflow.services.database.models.user.model import UserRead diff --git a/src/backend/tests/unit/api/v2/test_output_event_parity.py b/src/backend/tests/unit/api/v2/test_output_event_parity.py index e24eb6f69d..fdd50df35e 100644 --- a/src/backend/tests/unit/api/v2/test_output_event_parity.py +++ b/src/backend/tests/unit/api/v2/test_output_event_parity.py @@ -15,9 +15,9 @@ import json from unittest.mock import Mock from langflow.api.build import _output_meta_for_vertex -from langflow.api.v2.adapters import StreamAdapterContext -from langflow.api.v2.adapters.langflow import LangflowAdapter -from langflow.api.v2.converters import build_component_output, resolve_output_type +from lfx.workflow.adapters import StreamAdapterContext +from lfx.workflow.adapters.langflow import LangflowAdapter +from lfx.workflow.converters import build_component_output, resolve_output_type def _adapter() -> LangflowAdapter: diff --git a/src/backend/tests/unit/api/v2/test_workflow_agui.py b/src/backend/tests/unit/api/v2/test_workflow_agui.py index e7582721f8..b69060f286 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_agui.py +++ b/src/backend/tests/unit/api/v2/test_workflow_agui.py @@ -399,8 +399,8 @@ class TestAGUIStreaming: async def test_stream_event_handoff_overflow_emits_error(self, monkeypatch: pytest.MonkeyPatch): """EventManager put_nowait must not silently drop frames when the stream buffer fills.""" from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.adapters import StreamEvent - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.adapters import StreamEvent + from lfx.workflow.converters import ParsedWorkflowRun seen_maxsize: list[int] = [] @@ -451,8 +451,8 @@ class TestAGUIStreaming: ): """A run that exceeds the wall-clock ceiling ends in a sanitized terminal error, not a hang.""" from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.adapters import StreamAdapterContext, get_stream_adapter + from lfx.workflow.converters import ParsedWorkflowRun async def hanging_generate_flow_events(**_kwargs): await asyncio.sleep(5) @@ -490,8 +490,8 @@ class TestAGUIStreaming: ): """A producer that reports on_error then raises must not triple-emit terminal errors.""" from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.adapters import StreamAdapterContext, get_stream_adapter + from lfx.workflow.converters import ParsedWorkflowRun async def fake_generate_flow_events(**kwargs): kwargs["event_manager"].on_error(data={"error": "inner"}) @@ -528,8 +528,8 @@ class TestAGUIStreaming: ): """A producer that raises before on_error still emits one terminal error.""" from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.adapters import StreamAdapterContext, get_stream_adapter - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.adapters import StreamAdapterContext, get_stream_adapter + from lfx.workflow.converters import ParsedWorkflowRun async def fake_generate_flow_events(**_kwargs): message = "early boom" @@ -562,7 +562,7 @@ class TestAGUIStreaming: async def test_agui_stream_emits_end_side_channel_for_build_duration(self, monkeypatch: pytest.MonkeyPatch): """The AG-UI stream must preserve v1 end payloads for chat build-duration persistence.""" from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun async def fake_generate_flow_events(**kwargs): event_queue = kwargs["event_manager"].queue @@ -1008,7 +1008,7 @@ class TestAGUIBackgroundJobStatus: async def test_background_buffer_binds_build_rows_to_returned_job_id(self, monkeypatch: pytest.MonkeyPatch): """Status reconstruction needs vertex_build rows logged under the public background job id.""" from langflow.api.v2 import workflow_background as wf_bg - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun job_id = uuid4() captured: dict = {} @@ -1044,7 +1044,7 @@ class TestAGUIBackgroundJobStatus: """A background job should leave QUEUED once its buffer task starts executing.""" from langflow.api.v2 import workflow as workflow_module from langflow.api.v2 import workflow_background as wf_bg - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun job_id = uuid4() updates: list[tuple[object, object, bool]] = [] @@ -1086,7 +1086,7 @@ class TestAGUIBackgroundJobStatus: """The owner task must append cancellation before marking replay done.""" from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun started = asyncio.Event() @@ -1151,7 +1151,7 @@ class TestAGUIBackgroundJobStatus: """Cancellation framing must stay protocol-native outside AG-UI too.""" from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun started = asyncio.Event() @@ -1209,7 +1209,7 @@ class TestAGUIBackgroundJobStatus: """The stop fallback must not wake replay readers before cancellation is buffered.""" from langflow.api.v2 import workflow_background as wf_bg from langflow.api.v2 import workflow_execution as wf_exec - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun job_id = str(uuid4()) started = asyncio.Event() @@ -2256,13 +2256,13 @@ class TestBufferBackgroundRunUnknownProtocolGuard: from uuid import uuid4 as _uuid4 from langflow.api.v2 import workflow_background as wf_bg - from langflow.api.v2.adapters import STREAM_ADAPTERS as _REGISTRY - from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.database.models.flow.model import Flow, FlowRead from langflow.services.database.models.jobs.model import Job, JobStatus from langflow.services.database.models.user.model import User as _User from langflow.services.database.models.user.model import UserRead from langflow.services.deps import get_job_service + from lfx.workflow.adapters import STREAM_ADAPTERS as _REGISTRY + from lfx.workflow.converters import ParsedWorkflowRun # Real Job row so update_job_status can flip it. job_id = _uuid4() @@ -2311,12 +2311,12 @@ class TestBufferBackgroundRunUnknownProtocolGuard: from uuid import uuid4 as _uuid4 from langflow.api.v2 import workflow_background as wf_bg - from langflow.api.v2.adapters import STREAM_ADAPTERS as _REGISTRY - from langflow.api.v2.converters import ParsedWorkflowRun from langflow.services.database.models.flow.model import Flow, FlowRead from langflow.services.database.models.user.model import User as _User from langflow.services.database.models.user.model import UserRead from langflow.services.deps import get_job_service + from lfx.workflow.adapters import STREAM_ADAPTERS as _REGISTRY + from lfx.workflow.converters import ParsedWorkflowRun job_id = _uuid4() await get_job_service().create_job( @@ -2360,7 +2360,7 @@ class TestExecuteWorkflowBackgroundQueueOwnership: so registering them would let the polling watchdog reclaim long runs. """ from langflow.api.v2 import workflow_background as wf_bg - from langflow.api.v2.converters import ParsedWorkflowRun + from lfx.workflow.converters import ParsedWorkflowRun job_id = uuid4() current_user_id = uuid4() diff --git a/src/lfx/src/lfx/workflow/__init__.py b/src/lfx/src/lfx/workflow/__init__.py new file mode 100644 index 0000000000..5f37f03283 --- /dev/null +++ b/src/lfx/src/lfx/workflow/__init__.py @@ -0,0 +1,14 @@ +"""V2 workflow contract layer shared by the langflow backend and ``lfx serve``. + +This package holds the protocol-agnostic pieces of the V2 workflow API: + + - ``adapters``: the ``StreamAdapter`` protocol, the ``langflow``/``agui`` SSE + adapters, and the registry (``get_stream_adapter`` / ``available_protocols``). + - ``agui_translator``: translates EventManager events into AG-UI events. + - ``converters``: parses ``WorkflowRunRequest`` and builds the structured + ``WorkflowExecutionResponse``. + +It depends only on ``lfx.schema.workflow`` and ``ag_ui`` (no langflow imports), +so both runtimes consume one contract. The langflow backend layers its stateful +"vehicle" (database, job queue, auth) on top. +""" diff --git a/src/backend/base/langflow/api/v2/adapters/__init__.py b/src/lfx/src/lfx/workflow/adapters/__init__.py similarity index 92% rename from src/backend/base/langflow/api/v2/adapters/__init__.py rename to src/lfx/src/lfx/workflow/adapters/__init__.py index 75c9006300..5cf104a2ff 100644 --- a/src/backend/base/langflow/api/v2/adapters/__init__.py +++ b/src/lfx/src/lfx/workflow/adapters/__init__.py @@ -11,12 +11,16 @@ Adding a new protocol is one new module under ``adapters/`` plus one from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, ClassVar, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable from pydantic import BaseModel +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import Any, ClassVar + @dataclass(frozen=True) class StreamEvent: @@ -117,5 +121,5 @@ def available_protocols() -> list[str]: # Built-in adapter registrations happen here, after the registry is defined. # Import for side-effect: each module calls ``register_stream_adapter``. -from langflow.api.v2.adapters import agui as _agui # noqa: E402, F401 -from langflow.api.v2.adapters import langflow as _langflow # noqa: E402, F401 +from lfx.workflow.adapters import agui as _agui # noqa: E402, F401 +from lfx.workflow.adapters import langflow as _langflow # noqa: E402, F401 diff --git a/src/backend/base/langflow/api/v2/adapters/agui.py b/src/lfx/src/lfx/workflow/adapters/agui.py similarity index 91% rename from src/backend/base/langflow/api/v2/adapters/agui.py rename to src/lfx/src/lfx/workflow/adapters/agui.py index 56281afee9..887d5bb4f9 100644 --- a/src/backend/base/langflow/api/v2/adapters/agui.py +++ b/src/lfx/src/lfx/workflow/adapters/agui.py @@ -7,17 +7,19 @@ Per-run state lives on the wrapped translator instance. from __future__ import annotations -from collections.abc import Iterable -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar -from ag_ui.core import BaseEvent - -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( StreamAdapterContext, StreamEvent, register_stream_adapter, ) -from langflow.api.v2.agui_translator import AGUITranslator +from lfx.workflow.agui_translator import AGUITranslator + +if TYPE_CHECKING: + from collections.abc import Iterable + + from ag_ui.core import BaseEvent def _to_stream_event(event: BaseEvent) -> StreamEvent: diff --git a/src/backend/base/langflow/api/v2/adapters/langflow.py b/src/lfx/src/lfx/workflow/adapters/langflow.py similarity index 94% rename from src/backend/base/langflow/api/v2/adapters/langflow.py rename to src/lfx/src/lfx/workflow/adapters/langflow.py index 178b91d4d2..dd00222b9e 100644 --- a/src/backend/base/langflow/api/v2/adapters/langflow.py +++ b/src/lfx/src/lfx/workflow/adapters/langflow.py @@ -8,17 +8,18 @@ clients (curl users, the v1 frontend) can read it without learning anything new. from __future__ import annotations import json -from collections.abc import Iterable -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from lfx.schema.workflow import OutputEvent - -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( StreamAdapterContext, StreamEvent, register_stream_adapter, ) -from langflow.api.v2.converters import build_component_output, resolve_output_type +from lfx.workflow.converters import build_component_output, resolve_output_type + +if TYPE_CHECKING: + from collections.abc import Iterable class LangflowAdapter: diff --git a/src/backend/base/langflow/api/v2/agui_translator.py b/src/lfx/src/lfx/workflow/agui_translator.py similarity index 100% rename from src/backend/base/langflow/api/v2/agui_translator.py rename to src/lfx/src/lfx/workflow/agui_translator.py diff --git a/src/backend/base/langflow/api/v2/converters.py b/src/lfx/src/lfx/workflow/converters.py similarity index 98% rename from src/backend/base/langflow/api/v2/converters.py rename to src/lfx/src/lfx/workflow/converters.py index 47d8bf8f25..4d6983da94 100644 --- a/src/backend/base/langflow/api/v2/converters.py +++ b/src/lfx/src/lfx/workflow/converters.py @@ -24,7 +24,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from lfx.schema.workflow import ( ComponentOutput, @@ -41,7 +41,18 @@ from lfx.schema.workflow import ( if TYPE_CHECKING: from lfx.graph.graph.base import Graph - from langflow.api.v1.schemas import RunResponse + +class RunResponseLike(Protocol): + """Structural type for a v1-style run response. + + Defined here so this module never imports from langflow. The backend's + ``RunResponse`` (a list of ``outputs`` plus a ``session_id``) satisfies it + structurally, and lfx's own run path can supply any object with the same + shape. + """ + + outputs: list[Any] | None + session_id: str | None @dataclass(frozen=True) @@ -469,7 +480,7 @@ def _resolve_output(outputs: dict[str, ComponentOutput], selected_ids: list[str] def run_response_to_workflow_response( - run_response: RunResponse, + run_response: RunResponseLike, flow_id: str, job_id: str, inputs: dict[str, Any], diff --git a/src/backend/tests/unit/api/v2/adapters/__init__.py b/src/lfx/tests/unit/workflow/__init__.py similarity index 100% rename from src/backend/tests/unit/api/v2/adapters/__init__.py rename to src/lfx/tests/unit/workflow/__init__.py diff --git a/src/lfx/tests/unit/workflow/adapters/__init__.py b/src/lfx/tests/unit/workflow/adapters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py b/src/lfx/tests/unit/workflow/adapters/test_agui_adapter.py similarity index 99% rename from src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py rename to src/lfx/tests/unit/workflow/adapters/test_agui_adapter.py index 0716c0b488..90a8356f55 100644 --- a/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py +++ b/src/lfx/tests/unit/workflow/adapters/test_agui_adapter.py @@ -10,7 +10,7 @@ from __future__ import annotations import json import pytest -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( StreamAdapterContext, get_stream_adapter, ) diff --git a/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py b/src/lfx/tests/unit/workflow/adapters/test_langflow_adapter.py similarity index 99% rename from src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py rename to src/lfx/tests/unit/workflow/adapters/test_langflow_adapter.py index b45e9d0a71..ab28288cb3 100644 --- a/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py +++ b/src/lfx/tests/unit/workflow/adapters/test_langflow_adapter.py @@ -10,7 +10,7 @@ from __future__ import annotations import json import pytest -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( StreamAdapterContext, get_stream_adapter, ) diff --git a/src/backend/tests/unit/api/v2/adapters/test_registry.py b/src/lfx/tests/unit/workflow/adapters/test_registry.py similarity index 99% rename from src/backend/tests/unit/api/v2/adapters/test_registry.py rename to src/lfx/tests/unit/workflow/adapters/test_registry.py index 04b2c22b3e..cd0f72a5f7 100644 --- a/src/backend/tests/unit/api/v2/adapters/test_registry.py +++ b/src/lfx/tests/unit/workflow/adapters/test_registry.py @@ -9,7 +9,7 @@ registers under a string name; the endpoint dispatches by name and returns from __future__ import annotations import pytest -from langflow.api.v2.adapters import ( +from lfx.workflow.adapters import ( STREAM_ADAPTERS, StreamAdapterContext, StreamEvent, diff --git a/src/backend/tests/unit/api/v2/test_agui_translator.py b/src/lfx/tests/unit/workflow/test_agui_translator.py similarity index 99% rename from src/backend/tests/unit/api/v2/test_agui_translator.py rename to src/lfx/tests/unit/workflow/test_agui_translator.py index 6d237e8cfc..abb1054e10 100644 --- a/src/backend/tests/unit/api/v2/test_agui_translator.py +++ b/src/lfx/tests/unit/workflow/test_agui_translator.py @@ -25,7 +25,7 @@ from ag_ui.core import ( ToolCallResultEvent, ToolCallStartEvent, ) -from langflow.api.v2.agui_translator import AGUITranslator +from lfx.workflow.agui_translator import AGUITranslator def test_run_lifecycle_emits_started_and_finished(): diff --git a/src/backend/tests/unit/api/v2/test_converters.py b/src/lfx/tests/unit/workflow/test_converters.py similarity index 99% rename from src/backend/tests/unit/api/v2/test_converters.py rename to src/lfx/tests/unit/workflow/test_converters.py index b046070c2f..830fc2ca7d 100644 --- a/src/backend/tests/unit/api/v2/test_converters.py +++ b/src/lfx/tests/unit/workflow/test_converters.py @@ -27,7 +27,15 @@ from unittest.mock import Mock from uuid import uuid4 import pytest -from langflow.api.v2.converters import ( +from lfx.schema.workflow import ( + ComponentOutput, + ErrorDetail, + JobStatus, + OutputReason, + WorkflowExecutionResponse, + WorkflowJobResponse, +) +from lfx.workflow.converters import ( _build_metadata_for_non_output, _extract_file_path, _extract_model_source, @@ -42,14 +50,6 @@ from langflow.api.v2.converters import ( create_job_response, run_response_to_workflow_response, ) -from lfx.schema.workflow import ( - ComponentOutput, - ErrorDetail, - JobStatus, - OutputReason, - WorkflowExecutionResponse, - WorkflowJobResponse, -) def _setup_graph_get_vertex(graph: Mock, vertices: list[Mock]) -> None: @@ -1127,8 +1127,8 @@ class TestParseWorkflowRunRequest: """``parse_workflow_run_request`` projects ``WorkflowRunRequest`` onto ``ParsedWorkflowRun``.""" def test_minimal_body_round_trips_with_defaults(self): - from langflow.api.v2.converters import parse_workflow_run_request from lfx.schema.workflow import WorkflowRunRequest + from lfx.workflow.converters import parse_workflow_run_request parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID)) @@ -1144,8 +1144,8 @@ class TestParseWorkflowRunRequest: assert parsed.files is None def test_full_body_round_trip(self): - from langflow.api.v2.converters import parse_workflow_run_request from lfx.schema.workflow import WorkflowMode, WorkflowRunRequest + from lfx.workflow.converters import parse_workflow_run_request request = WorkflowRunRequest( flow_id=_VALID_UUID, @@ -1174,8 +1174,8 @@ class TestParseWorkflowRunRequest: def test_run_id_is_always_none_on_the_parsed_record(self): """The endpoint generates run_id; callers cannot supply it via the body.""" - from langflow.api.v2.converters import parse_workflow_run_request from lfx.schema.workflow import WorkflowRunRequest + from lfx.workflow.converters import parse_workflow_run_request parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID)) assert parsed.run_id is None