diff --git a/src/backend/base/langflow/api/router.py b/src/backend/base/langflow/api/router.py index c55cb765b6..d0e14465ba 100644 --- a/src/backend/base/langflow/api/router.py +++ b/src/backend/base/langflow/api/router.py @@ -39,6 +39,7 @@ from langflow.api.v1.voice_mode import router as voice_mode_router from langflow.api.v2 import files_router as files_router_v2 from langflow.api.v2 import mcp_router as mcp_router_v2 from langflow.api.v2 import registration_router as registration_router_v2 +from langflow.api.v2 import workflow_public_router as workflow_public_router_v2 from langflow.api.v2 import workflow_router as workflow_router_v2 router_v1 = APIRouter( @@ -124,6 +125,7 @@ router_v2.include_router(files_router_v2) router_v2.include_router(mcp_router_v2) router_v2.include_router(registration_router_v2) router_v2.include_router(workflow_router_v2) +router_v2.include_router(workflow_public_router_v2) router = APIRouter( prefix="/api", diff --git a/src/backend/base/langflow/api/utils/__init__.py b/src/backend/base/langflow/api/utils/__init__.py index e109e96e72..d3dea76426 100644 --- a/src/backend/base/langflow/api/utils/__init__.py +++ b/src/backend/base/langflow/api/utils/__init__.py @@ -45,6 +45,7 @@ from langflow.api.utils.flow_utils import ( build_graph_from_db_no_cache, cascade_delete_flow, scope_session_to_namespace, + validate_public_files, verify_public_flow_and_get_user, ) @@ -90,5 +91,6 @@ __all__ = [ "remove_api_keys", "scope_session_to_namespace", "validate_is_component", + "validate_public_files", "verify_public_flow_and_get_user", ] diff --git a/src/backend/base/langflow/api/utils/flow_utils.py b/src/backend/base/langflow/api/utils/flow_utils.py index 54500614fa..9ff9e3e10b 100644 --- a/src/backend/base/langflow/api/utils/flow_utils.py +++ b/src/backend/base/langflow/api/utils/flow_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import uuid from typing import TYPE_CHECKING @@ -131,6 +132,42 @@ async def cascade_delete_flow(session: AsyncSession, flow_id: uuid.UUID) -> None raise RuntimeError(msg, e) from e +# Public flow file paths must be ``{source_flow_id}/{safe_basename}`` — uploads +# under that namespace are the only legitimate inputs for an unauthenticated +# build. Anything else (absolute paths, traversal, foreign flow_ids) is a +# probe at the arbitrary-file-read class of bug (GHSA-rcjh-r59h-gq37). +_PUBLIC_FILE_PATH_RE = re.compile( + r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/([^/\\]+)$" +) +_PUBLIC_FILE_REJECTED_SUBSTRINGS = ("\x00", "..", "\\") + + +def validate_public_files(files: list[str] | None, source_flow_id: uuid.UUID) -> None: + """Reject file references that aren't ``{source_flow_id}/{basename}``. + + Mitigates GHSA-rcjh-r59h-gq37: an unauthenticated build must not be + able to address files outside its own flow's storage namespace. + Called from any endpoint that accepts caller-supplied file references + under a public-access boundary. + """ + if not files: + return + expected_flow_id = str(source_flow_id).lower() + for entry in files: + if not isinstance(entry, str) or not entry: + raise HTTPException(status_code=400, detail="Invalid file entry") + if any(token in entry for token in _PUBLIC_FILE_REJECTED_SUBSTRINGS): + raise HTTPException(status_code=400, detail="Invalid file path") + match = _PUBLIC_FILE_PATH_RE.match(entry) + if not match: + raise HTTPException(status_code=400, detail="Invalid file path format") + flow_id_segment, basename = match.group(1), match.group(2) + if flow_id_segment.lower() != expected_flow_id: + raise HTTPException(status_code=400, detail="File not in this flow's namespace") + if basename in (".", ".."): + raise HTTPException(status_code=400, detail="Invalid filename") + + def compute_virtual_flow_id(identifier: str | uuid.UUID, flow_id: uuid.UUID) -> uuid.UUID: """Compute a deterministic virtual flow ID for session/message isolation. diff --git a/src/backend/base/langflow/api/v1/chat.py b/src/backend/base/langflow/api/v1/chat.py index 7affe5d774..20620fb148 100644 --- a/src/backend/base/langflow/api/v1/chat.py +++ b/src/backend/base/langflow/api/v1/chat.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import re import time import traceback import uuid @@ -33,6 +32,7 @@ from langflow.api.utils import ( get_top_level_vertices, parse_exception, scope_session_to_namespace, + validate_public_files, verify_public_flow_and_get_user, ) from langflow.api.v1.schemas import ( @@ -720,34 +720,9 @@ async def build_flow_and_stream(flow_id, inputs, background_tasks, current_user) ) -# Public flow file paths must be `{source_flow_id}/{safe_basename}` — uploads -# under that namespace are the only legitimate inputs for an unauthenticated -# build. Anything else (absolute paths, traversal, foreign flow_ids) is a -# probe at the arbitrary-file-read class of bug. -_PUBLIC_FILE_PATH_RE = re.compile( - r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/([^/\\]+)$" -) -_PUBLIC_FILE_REJECTED_SUBSTRINGS = ("\x00", "..", "\\") - - -def _validate_public_files(files: list[str] | None, source_flow_id: uuid.UUID) -> None: - """Reject file references that aren't `{source_flow_id}/{basename}`.""" - if not files: - return - expected_flow_id = str(source_flow_id).lower() - for entry in files: - if not isinstance(entry, str) or not entry: - raise HTTPException(status_code=400, detail="Invalid file entry") - if any(token in entry for token in _PUBLIC_FILE_REJECTED_SUBSTRINGS): - raise HTTPException(status_code=400, detail="Invalid file path") - match = _PUBLIC_FILE_PATH_RE.match(entry) - if not match: - raise HTTPException(status_code=400, detail="Invalid file path format") - flow_id_segment, basename = match.group(1), match.group(2) - if flow_id_segment.lower() != expected_flow_id: - raise HTTPException(status_code=400, detail="File not in this flow's namespace") - if basename in (".", ".."): - raise HTTPException(status_code=400, detail="Invalid filename") +# NOTE: ``validate_public_files`` (the canonical helper that mitigates +# GHSA-rcjh-r59h-gq37) was moved to ``langflow.api.utils.flow_utils`` so v2's +# public workflow endpoint shares the exact same gate. Keep it imported above. @router.post("/build_public_tmp/{flow_id}/flow") @@ -810,7 +785,7 @@ async def build_public_tmp( # Reject caller-supplied file references that aren't scoped to this # public flow's own storage namespace. Done before any flow lookup so # malformed requests fail fast and don't touch the DB. - _validate_public_files(files, flow_id) + validate_public_files(files, flow_id) # Verify this is a public flow and get the associated user client_id = request.cookies.get("client_id") diff --git a/src/backend/base/langflow/api/v2/__init__.py b/src/backend/base/langflow/api/v2/__init__.py index b3a6e06f57..81576bc3c3 100644 --- a/src/backend/base/langflow/api/v2/__init__.py +++ b/src/backend/base/langflow/api/v2/__init__.py @@ -4,5 +4,12 @@ from .files import router as files_router from .mcp import router as mcp_router from .registration import router as registration_router from .workflow import router as workflow_router +from .workflow_public import router as workflow_public_router -__all__ = ["files_router", "mcp_router", "registration_router", "workflow_router"] +__all__ = [ + "files_router", + "mcp_router", + "registration_router", + "workflow_public_router", + "workflow_router", +] diff --git a/src/backend/base/langflow/api/v2/adapters/__init__.py b/src/backend/base/langflow/api/v2/adapters/__init__.py new file mode 100644 index 0000000000..3ffe9e18e2 --- /dev/null +++ b/src/backend/base/langflow/api/v2/adapters/__init__.py @@ -0,0 +1,118 @@ +"""Stream-protocol adapters for the v2 workflows endpoint. + +Each adapter translates Langflow ``EventManager`` events into a wire protocol's +SSE event bodies. Adapters register under a string name; the endpoint dispatches +by name based on the ``stream_protocol`` body field. Unknown protocols return +422 with the available list. + +Adding a new protocol is one new module under ``adapters/`` plus one +``register_stream_adapter`` call. No endpoint changes. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any, ClassVar, Protocol, runtime_checkable + +from pydantic import BaseModel + + +@dataclass(frozen=True) +class StreamEvent: + """One translated event ready to be wrapped in an SSE frame. + + ``type`` is the protocol-native event type string (e.g. ``"RUN_ERROR"`` + for AG-UI, ``"error"`` for langflow). The buffer task uses it to decide + terminal job status. ``data_json`` is the already-serialized JSON string + to place in the SSE ``data:`` field. + """ + + type: str + data_json: str + + +class StreamAdapterContext(BaseModel): + """Per-run context handed to an adapter at construction. + + Extensible: future adapters can need more fields. Add them here when a + real adapter genuinely needs them, not speculatively. + """ + + run_id: str + thread_id: str + + +@runtime_checkable +class StreamAdapter(Protocol): + """The contract every stream adapter implements. + + Adapters are stateful per-run instances. The dispatcher calls + ``initial_events`` once before draining the EventManager queue, then + ``translate`` per event, then either ``final_events`` (clean end) or + ``error_events`` (mid-stream failure). The caller frames the returned + ``StreamEvent``s as SSE. + """ + + name: ClassVar[str] + + def initial_events(self) -> Iterable[StreamEvent]: + """Events to emit before the run starts (e.g., ``RUN_STARTED``).""" + + def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]: + """Translate one ``EventManager`` event into zero or more wire events.""" + + def final_events(self) -> Iterable[StreamEvent]: + """Events to emit after a clean end (e.g., ``RUN_FINISHED``).""" + + def error_events(self, error: BaseException) -> Iterable[StreamEvent]: + """Events to emit when the run errors mid-stream.""" + + @property + def terminal_error_type(self) -> str | None: + """Event-type the buffer task watches to flip a background job to FAILED. + + Returning ``None`` means the adapter never signals a terminal error + via event type; the buffer task falls back to other signals. + """ + + +StreamAdapterFactory = Callable[[StreamAdapterContext], StreamAdapter] + + +class UnknownStreamProtocolError(LookupError): + """Raised by ``get_stream_adapter`` when no adapter is registered under ``name``.""" + + def __init__(self, name: str, available: list[str]) -> None: + self.name = name + self.available = available + super().__init__( + f"Unknown stream_protocol {name!r}; available: {available!r}", + ) + + +STREAM_ADAPTERS: dict[str, StreamAdapterFactory] = {} + + +def register_stream_adapter(name: str, factory: StreamAdapterFactory) -> None: + """Register an adapter factory under ``name`` (idempotent overwrite).""" + STREAM_ADAPTERS[name] = factory + + +def get_stream_adapter(name: str, context: StreamAdapterContext) -> StreamAdapter: + """Build the adapter registered under ``name``; raise if unknown.""" + factory = STREAM_ADAPTERS.get(name) + if factory is None: + raise UnknownStreamProtocolError(name, available_protocols()) + return factory(context) + + +def available_protocols() -> list[str]: + """Sorted list of registered protocol names. Drives the 422 error body.""" + return sorted(STREAM_ADAPTERS) + + +# 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 diff --git a/src/backend/base/langflow/api/v2/adapters/agui.py b/src/backend/base/langflow/api/v2/adapters/agui.py new file mode 100644 index 0000000000..495e46cfdd --- /dev/null +++ b/src/backend/base/langflow/api/v2/adapters/agui.py @@ -0,0 +1,66 @@ +"""The ``agui`` stream adapter: wraps ``AGUITranslator`` for the registry. + +Frames each AG-UI ``BaseEvent`` as JSON via ``model_dump_json(by_alias=True, +exclude_none=True)`` so the wire shape stays camelCase and omits unset fields. +Per-run state lives on the wrapped translator instance. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, ClassVar + +from ag_ui.core import BaseEvent, RunErrorEvent + +from langflow.api.v2.adapters import ( + StreamAdapterContext, + StreamEvent, + register_stream_adapter, +) +from langflow.api.v2.agui_translator import AGUITranslator + + +def _to_stream_event(event: BaseEvent) -> StreamEvent: + """Frame one AG-UI event for SSE consumption.""" + type_attr = event.type + type_str = type_attr.value if hasattr(type_attr, "value") else str(type_attr) + return StreamEvent( + type=type_str, + data_json=event.model_dump_json(by_alias=True, exclude_none=True), + ) + + +class AGUIAdapter: + """Wraps ``AGUITranslator``; one instance per run.""" + + name: ClassVar[str] = "agui" + + def __init__(self, context: StreamAdapterContext) -> None: + self.context = context + self._translator = AGUITranslator( + run_id=context.run_id, + thread_id=context.thread_id, + ) + + def initial_events(self) -> Iterable[StreamEvent]: + return [_to_stream_event(e) for e in self._translator.start()] + + def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]: + return [_to_stream_event(e) for e in self._translator.translate(event_type, event_data)] + + def final_events(self) -> Iterable[StreamEvent]: + # RUN_FINISHED rides on the translator's ``end`` translation; no extra + # framing is emitted here. Present so ``StreamAdapter`` is satisfied. + return () + + def error_events(self, error: BaseException) -> Iterable[StreamEvent]: + # Fallback path: emitted by the dispatcher when on_error itself fails + # or when no error event has reached the translator. + return [_to_stream_event(RunErrorEvent(message=str(error)))] + + @property + def terminal_error_type(self) -> str | None: + return "RUN_ERROR" + + +register_stream_adapter("agui", AGUIAdapter) diff --git a/src/backend/base/langflow/api/v2/adapters/langflow.py b/src/backend/base/langflow/api/v2/adapters/langflow.py new file mode 100644 index 0000000000..215956f121 --- /dev/null +++ b/src/backend/base/langflow/api/v2/adapters/langflow.py @@ -0,0 +1,50 @@ +r"""The ``langflow`` stream adapter: passthrough of EventManager events. + +Wire shape: ``data: {"event": "", "data": {...}}\n\n`` per SSE frame. +This is the same shape v1's ``build_flow`` already streams, so existing +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 langflow.api.v2.adapters import ( + StreamAdapterContext, + StreamEvent, + register_stream_adapter, +) + + +class LangflowAdapter: + """Passthrough adapter: each EventManager event becomes one wire event.""" + + name: ClassVar[str] = "langflow" + + def __init__(self, context: StreamAdapterContext) -> None: + self.context = context + + def initial_events(self) -> Iterable[StreamEvent]: + return () + + def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]: + payload = {"event": event_type, "data": event_data} + # ``default=str`` keeps non-JSON-serializable values (e.g. component + # objects logged by add_message) from crashing the stream. + return (StreamEvent(type=event_type, data_json=json.dumps(payload, default=str)),) + + def final_events(self) -> Iterable[StreamEvent]: + return () + + def error_events(self, error: BaseException) -> Iterable[StreamEvent]: + payload = {"event": "error", "data": {"error": str(error)}} + return (StreamEvent(type="error", data_json=json.dumps(payload)),) + + @property + def terminal_error_type(self) -> str | None: + return "error" + + +register_stream_adapter("langflow", LangflowAdapter) diff --git a/src/backend/base/langflow/api/v2/agui_translator.py b/src/backend/base/langflow/api/v2/agui_translator.py new file mode 100644 index 0000000000..727e71e56e --- /dev/null +++ b/src/backend/base/langflow/api/v2/agui_translator.py @@ -0,0 +1,302 @@ +"""AG-UI translator. + +Converts Langflow ``EventManager`` events into AG-UI protocol events. The +``EventManager`` queue is Langflow's internal event seam; this translator is the +one place that maps that vocabulary onto AG-UI, so the v2 workflows endpoint can +stream a standard AG-UI event stream. + +One Langflow event may map to several AG-UI events, so ``translate`` returns a +list. The translator is stateful: one instance per run. +""" + +from __future__ import annotations + +import json + +from ag_ui.core import ( + BaseEvent, + CustomEvent, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + StateDeltaEvent, + StateSnapshotEvent, + StepFinishedEvent, + StepStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) + +# Langflow content-block types with no standard AG-UI primitive. They ride as +# CUSTOM events namespaced ``langflow.*``; generic AG-UI clients ignore them. +_CUSTOM_CONTENT_TYPES = frozenset({"json", "code", "media", "error"}) + + +class AGUITranslator: + """Translates Langflow ``EventManager`` events into AG-UI protocol events. + + Use one instance per run. Call :meth:`start` once to open the run, then + :meth:`translate` for each ``EventManager`` event. + """ + + def __init__(self, run_id: str, thread_id: str) -> None: + self.run_id = run_id + self.thread_id = thread_id + # Id of the text message currently being streamed by ``token`` events, + # or ``None`` when no message is open. + self._open_message_id: str | None = None + # Message ids already emitted as a complete (non-streamed) text message. + self._emitted_text_message_ids: set[str] = set() + # Tool-call ids already emitted as TOOL_CALL_START / already resolved + # with a TOOL_CALL_RESULT. ``add_message`` can re-fire with the same + # (append-only) content_blocks, so emissions must be deduplicated. + self._started_tool_calls: set[str] = set() + self._resulted_tool_calls: set[str] = set() + # Custom content blocks already emitted as CUSTOM events, mapped to a + # fingerprint of their last-emitted payload so in-place updates re-emit. + self._emitted_content_state: dict[str, str] = {} + + def start(self) -> list[BaseEvent]: + """Open the run. + + Emits ``RUN_STARTED`` and an empty node-graph ``STATE_SNAPSHOT``. The + snapshot establishes ``/nodes`` so every later node ``STATE_DELTA`` has a + parent to patch, regardless of which execution path drives the run. + """ + return [ + RunStartedEvent(run_id=self.run_id, thread_id=self.thread_id), + StateSnapshotEvent(snapshot={"nodes": {}}), + ] + + def translate(self, event_type: str, data: dict) -> list[BaseEvent]: + """Map one ``EventManager`` event to zero or more AG-UI events.""" + if event_type == "token": + return self._translate_token(data) + if event_type == "vertices_sorted": + return self._translate_vertices_sorted(data) + if event_type == "build_start": + return self._translate_build_start(data) + if event_type == "end_vertex": + return self._translate_end_vertex(data) + if event_type == "add_message": + return self._translate_add_message(data) + if event_type == "log": + return [CustomEvent(name="langflow.log", value=data)] + if event_type == "remove_message": + return [CustomEvent(name="langflow.message.removed", value={"message_id": str(data.get("id") or "")})] + + # Only terminal events close an open text message. Non-terminal events + # (build_start, end_vertex, log, ...) interleave with tokens of the same + # streamed message and must stay transparent, or the message would be + # split into multiple START/END pairs reusing an already-ended id. + if event_type == "end": + events = self._close_open_message() + events.append(RunFinishedEvent(run_id=self.run_id, thread_id=self.thread_id)) + return events + if event_type == "error": + events = self._close_open_message() + # The ``error`` payload varies by emission path: a full ErrorMessage + # dump carries the reason in ``text``; the minimal path sends + # ``{"error": str}``. + message = data.get("text") or data.get("error") or "Unknown error" + events.append(RunErrorEvent(message=str(message))) + return events + return [] + + def _translate_token(self, data: dict) -> list[BaseEvent]: + """Map a ``token`` event to text-message events. + + The first token of a message opens it with ``TEXT_MESSAGE_START``; a + token for a different message id closes the previous one first. A + token whose id was already ended via ``add_message`` (or a prior + token boundary) is dropped: re-opening it would emit a second + ``TEXT_MESSAGE_START`` for an id the protocol considers closed. + """ + message_id = str(data.get("id") or "") + if not message_id: + # Without a stable id the AG-UI lifecycle (START/CONTENT/END) cannot + # be correlated. Dropping the event is preferable to emitting a + # malformed stream with empty message_ids. + return [] + if message_id in self._emitted_text_message_ids and self._open_message_id != message_id: + return [] + chunk = data.get("chunk", "") + events: list[BaseEvent] = [] + if self._open_message_id != message_id: + events.extend(self._close_open_message()) + events.append(TextMessageStartEvent(message_id=message_id, role="assistant")) + self._open_message_id = message_id + events.append(TextMessageContentEvent(message_id=message_id, delta=chunk)) + return events + + def _translate_vertices_sorted(self, data: dict) -> list[BaseEvent]: + """Map ``vertices_sorted`` to a ``STATE_SNAPSHOT`` of the node graph. + + Seeds every node that will run with ``pending`` status so the canvas can + render the graph before execution begins. ``to_run`` is the full run set; + ``ids`` (the first layer only) is the fallback. + """ + node_ids = data.get("to_run") or data.get("ids") or [] + snapshot = {"nodes": {node_id: {"status": "pending", "output": None} for node_id in node_ids}} + return [StateSnapshotEvent(snapshot=snapshot)] + + def _translate_build_start(self, data: dict) -> list[BaseEvent]: + """Map a per-node ``build_start`` to a ``STEP_STARTED`` + a running ``STATE_DELTA``. + + The graph-level ``build_start`` (the ``/build`` path) carries no ``id`` and + is a no-op here: ``RUN_STARTED`` already signals the run beginning. + """ + node_id = data.get("id") + if not node_id: + return [] + return [ + StepStartedEvent(step_name=node_id), + StateDeltaEvent(delta=[self._set_node(node_id, "running", None)]), + ] + + def _translate_end_vertex(self, data: dict) -> list[BaseEvent]: + """Map ``end_vertex`` to a ``STEP_FINISHED`` + a ``STATE_DELTA`` for status and output.""" + build_data = data.get("build_data") or {} + node_id = build_data.get("id") + if not node_id: + return [] + status = "success" if build_data.get("valid") else "error" + return [ + StepFinishedEvent(step_name=node_id), + StateDeltaEvent(delta=[self._set_node(node_id, status, build_data.get("data"))]), + ] + + def _translate_add_message(self, data: dict) -> list[BaseEvent]: + """Map an ``add_message`` to text-message and tool-call events. + + ``add_message`` can fire repeatedly for one message as its content grows; + emissions are deduplicated by message id and tool-call id. + """ + message_id = str(data.get("id") or "") + events: list[BaseEvent] = [] + + # Content blocks: tool_use becomes tool-call events, the Langflow-specific + # content types become namespaced CUSTOM events. + for block_index, block in enumerate(data.get("content_blocks") or []): + if not isinstance(block, dict): + continue + for content_index, content in enumerate(block.get("contents") or []): + if not isinstance(content, dict): + continue + content_type = content.get("type") + if content_type == "tool_use": + events.extend(self._translate_tool_use(message_id, block_index, content_index, content)) + elif content_type in _CUSTOM_CONTENT_TYPES: + events.extend( + self._translate_custom_content(message_id, block, block_index, content_index, content) + ) + + # Message text. + if message_id and message_id == self._open_message_id: + # Finalizer of a token-streamed message: close it. The text was + # already streamed token by token, so it must not be re-emitted now + # or by any later add_message that re-fires for the same id. + self._emitted_text_message_ids.add(message_id) + events.extend(self._close_open_message()) + else: + text = data.get("text") or "" + # Skip text-message lifecycle emission without a stable message_id; + # tool-call events above are namespaced by block/content index so + # they can still ride a missing id, but TEXT_MESSAGE_* cannot. + if text and message_id and message_id not in self._emitted_text_message_ids: + self._emitted_text_message_ids.add(message_id) + events.append(TextMessageStartEvent(message_id=message_id, role="assistant")) + events.append(TextMessageContentEvent(message_id=message_id, delta=text)) + events.append(TextMessageEndEvent(message_id=message_id)) + return events + + def _translate_tool_use( + self, message_id: str, block_index: int, content_index: int, content: dict + ) -> list[BaseEvent]: + """Map one ``tool_use`` content block to tool-call lifecycle events. + + ``ToolContent`` has no id, so a stable tool-call id is derived from the + tool's position in the (append-only) content_blocks structure. + """ + tool_call_id = f"{message_id}:tool:{block_index}:{content_index}" + events: list[BaseEvent] = [] + + if tool_call_id not in self._started_tool_calls: + self._started_tool_calls.add(tool_call_id) + tool_input = content.get("tool_input") + if tool_input is None: + tool_input = content.get("input") + events.append( + ToolCallStartEvent( + tool_call_id=tool_call_id, + tool_call_name=content.get("name") or "tool", + parent_message_id=message_id, + ) + ) + events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=json.dumps(tool_input))) + events.append(ToolCallEndEvent(tool_call_id=tool_call_id)) + + if tool_call_id not in self._resulted_tool_calls: + error = content.get("error") + result = error if error is not None else content.get("output") + if result is not None: + self._resulted_tool_calls.add(tool_call_id) + events.append( + ToolCallResultEvent( + message_id=message_id, + tool_call_id=tool_call_id, + content=result if isinstance(result, str) else json.dumps(result), + ) + ) + return events + + def _translate_custom_content( + self, message_id: str, block: dict, block_index: int, content_index: int, content: dict + ) -> list[BaseEvent]: + """Map a Langflow-specific content block to a namespaced CUSTOM event. + + Deduplicated by content state: a re-fired ``add_message`` whose block is + unchanged emits nothing, but an in-place update to the block re-emits. + """ + key = f"{message_id}:content:{block_index}:{content_index}" + fingerprint = json.dumps(content, sort_keys=True, default=str) + if self._emitted_content_state.get(key) == fingerprint: + return [] + self._emitted_content_state[key] = fingerprint + return [ + CustomEvent( + name=f"langflow.content.{content['type']}", + value={"message_id": message_id, "block_title": block.get("title"), "content": content}, + ) + ] + + @staticmethod + def _set_node(node_id: str, status: str, output: object) -> dict: + """Build the RFC 6902 op that writes a node's state. + + ``add`` on ``/nodes/{id}`` is create-or-replace: it applies whether or not + the node was pre-seeded by a ``vertices_sorted`` snapshot, so the + translator does not depend on event ordering across execution paths. + """ + return {"op": "add", "path": f"/nodes/{node_id}", "value": {"status": status, "output": output}} + + def _close_open_message(self) -> list[BaseEvent]: + """Emit ``TEXT_MESSAGE_END`` for the open message, if any. + + The closed id is recorded in ``_emitted_text_message_ids`` so a later + token (e.g. an interleaved ``A, B, A`` sequence) cannot re-open it + and emit a second ``TEXT_MESSAGE_START`` for an id the protocol + already considers closed. + """ + if self._open_message_id is None: + return [] + closed_id = self._open_message_id + end = TextMessageEndEvent(message_id=closed_id) + self._emitted_text_message_ids.add(closed_id) + self._open_message_id = None + return [end] diff --git a/src/backend/base/langflow/api/v2/converters.py b/src/backend/base/langflow/api/v2/converters.py index c09e333736..55e0c5d0f8 100644 --- a/src/backend/base/langflow/api/v2/converters.py +++ b/src/backend/base/langflow/api/v2/converters.py @@ -5,7 +5,7 @@ and the existing V1 schemas, enabling reuse of existing execution logic while presenting a new API interface. Key Functions: - - parse_flat_inputs: Converts flat input format to tweaks structure + - parse_workflow_run_request: Parse the native WorkflowRunRequest body - run_response_to_workflow_response: Converts V1 RunResponse to V2 WorkflowExecutionResponse - create_error_response: Creates standardized error responses - create_job_response: Creates background job responses @@ -22,6 +22,7 @@ Internal Helpers: from __future__ import annotations +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import TYPE_CHECKING, Any @@ -30,9 +31,9 @@ from lfx.schema.workflow import ( ErrorDetail, JobId, JobStatus, - WorkflowExecutionRequest, WorkflowExecutionResponse, WorkflowJobResponse, + WorkflowRunRequest, ) if TYPE_CHECKING: @@ -41,55 +42,53 @@ if TYPE_CHECKING: from langflow.api.v1.schemas import RunResponse -def parse_flat_inputs(inputs: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], str | None]: - """Parse flat inputs structure into tweaks and session_id. +@dataclass(frozen=True) +class ParsedWorkflowRun: + """Langflow run parameters extracted from a native ``WorkflowRunRequest``.""" - Format: {"component_id.param": value} - Example: {"ChatInput-abc.input_value": "hi", "LLM-xyz.temperature": 0.7} + flow_id: str | None + tweaks: dict[str, Any] = field(default_factory=dict) + input_value: str = "" + session_id: str | None = None + run_id: str | None = None + mode: str = "stream" + start_component_id: str | None = None + stop_component_id: str | None = None + # Optional live flow data (nodes + edges) overriding the DB copy. Lets the + # canvas run with unsaved tweaks the user has made but not yet persisted. + data: dict[str, Any] | None = None + # Optional runtime file references the graph build needs. + files: list[str] | None = None + # Request-level global variables from the JSON body (v2 body-globals). + globals: dict[str, str] = field(default_factory=dict) - All parameters (including input_value) are treated as tweaks. - The graph's topological sort handles execution order automatically. + +def parse_workflow_run_request(request: WorkflowRunRequest) -> ParsedWorkflowRun: + """Extract Langflow run parameters from the native ``WorkflowRunRequest`` body. + + Every field on the request is first-class; no escape-hatch flattening of + ``forwardedProps`` is needed. ``run_id`` is generated by the endpoint, not + the caller, so it is left ``None`` here. Args: - inputs: The inputs dictionary from WorkflowExecutionRequest + request: The v2 native body shape. Returns: - Tuple of (tweaks_dict, session_id) - - tweaks_dict: {component_id: {param: value}} - - session_id: Session ID if provided - - Example: - >>> inputs = { - ... "ChatInput-abc.input_value": "hello", - ... "ChatInput-abc.session_id": "session-123", - ... "LLM-xyz.temperature": 0.7 - ... } - >>> tweaks, session_id = parse_flat_inputs(inputs) - >>> tweaks - {'ChatInput-abc': {'input_value': 'hello'}, 'LLM-xyz': {'temperature': 0.7}} - >>> session_id - 'session-123' + ParsedWorkflowRun: the Langflow run parameters. """ - tweaks: dict[str, dict[str, Any]] = {} - session_id: str | None = None - - for key, value in inputs.items(): - if "." in key: - # Split component_id.param - component_id, param_name = key.split(".", 1) - - # Extract session_id if present (use first one found) - if param_name == "session_id" and not session_id: - session_id = value - # Build tweaks for all parameters - if component_id not in tweaks: - tweaks[component_id] = {} - tweaks[component_id][param_name] = value - # No dot - treat as component-level dict (for backward compatibility) - elif isinstance(value, dict): - tweaks[key] = value - - return tweaks, session_id + return ParsedWorkflowRun( + flow_id=request.flow_id, + tweaks=request.tweaks, + input_value=request.input_value, + session_id=request.session_id, + run_id=None, + mode=request.mode.value, + start_component_id=request.start_component_id, + stop_component_id=request.stop_component_id, + data=request.data, + files=request.files, + globals=dict(request.globals or {}), + ) def _extract_nested_value(data: Any, *keys: str) -> Any: @@ -384,7 +383,7 @@ def run_response_to_workflow_response( run_response: RunResponse, flow_id: str, job_id: str, - workflow_request: WorkflowExecutionRequest, + inputs: dict[str, Any], graph: Graph, effective_globals: dict[str, str] | None = None, ) -> WorkflowExecutionResponse: @@ -409,12 +408,11 @@ def run_response_to_workflow_response( run_response: The V1 response from simple_run_flow containing execution results flow_id: The flow identifier job_id: The generated job ID for tracking this execution - workflow_request: Original workflow request (inputs are echoed back in response) + inputs: Request inputs echoed back in the response graph: The Graph instance used for terminal node detection and vertex metadata - effective_globals: Optional override for the ``globals`` echoed in the response. - When provided, the response reports the effective merged set (body globals plus - any legacy header-supplied globals). When omitted, ``workflow_request.globals`` - is echoed verbatim. + effective_globals: The ``globals`` echoed in the response — the effective merged set + (body globals plus any legacy header-supplied globals). When omitted, an empty + globals map is echoed. Returns: WorkflowExecutionResponse: V2 schema response with structured outputs @@ -455,7 +453,7 @@ def run_response_to_workflow_response( output_key, component_output = _process_terminal_vertex(vertex, output_data_map) outputs[output_key] = component_output - response_globals = effective_globals if effective_globals is not None else dict(workflow_request.globals or {}) + response_globals = effective_globals if effective_globals is not None else {} return WorkflowExecutionResponse( flow_id=flow_id, @@ -463,7 +461,7 @@ def run_response_to_workflow_response( object="response", status=JobStatus.COMPLETED, errors=[], - inputs=workflow_request.inputs or {}, + inputs=inputs or {}, globals=response_globals, outputs=outputs, metadata={}, @@ -492,7 +490,7 @@ def create_job_response(job_id: str, flow_id: str) -> WorkflowJobResponse: def create_error_response( flow_id: str, job_id: JobId, - workflow_request: WorkflowExecutionRequest, + inputs: dict[str, Any], error: Exception, effective_globals: dict[str, str] | None = None, ) -> WorkflowExecutionResponse: @@ -501,7 +499,7 @@ def create_error_response( Args: flow_id: The flow ID job_id: The job ID - workflow_request: Original request + inputs: Request inputs echoed back in the response error: The exception that occurred effective_globals: Optional override for the ``globals`` echoed in the response. See :func:`run_response_to_workflow_response` for details. @@ -513,7 +511,7 @@ def create_error_response( error=str(error), code="EXECUTION_ERROR", details={"flow_id": flow_id, "error_type": type(error).__name__} ) - response_globals = effective_globals if effective_globals is not None else dict(workflow_request.globals or {}) + response_globals = effective_globals if effective_globals is not None else {} return WorkflowExecutionResponse( flow_id=flow_id, @@ -521,7 +519,7 @@ def create_error_response( object="response", status=JobStatus.FAILED, errors=[error_detail], - inputs=workflow_request.inputs or {}, + inputs=inputs or {}, globals=response_globals, outputs={}, metadata={}, diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index c2f5a56924..34714201d5 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -9,11 +9,10 @@ Endpoints: POST /workflow/stop: Stop a running workflow execution Features: - - Developer API protection (requires developer_api_enabled setting) - Comprehensive error handling with structured error responses - Timeout protection for long-running executions - Support for multiple execution modes (sync, stream, background) - - API key authentication required for all endpoints + - Session-cookie or API-key authentication Configuration: EXECUTION_TIMEOUT: Maximum execution time for synchronous workflows (300 seconds) @@ -22,34 +21,53 @@ Configuration: from __future__ import annotations import asyncio +import contextlib +import json +import time +from collections.abc import AsyncIterator 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 StreamingResponse +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, JobId, JobStatus, - WorkflowExecutionRequest, WorkflowExecutionResponse, WorkflowJobResponse, + WorkflowRunRequest, WorkflowStopRequest, WorkflowStopResponse, ) -from lfx.services.deps import get_settings_service, injectable_session_scope_readonly +from lfx.services.deps import injectable_session_scope_readonly from pydantic_core import ValidationError as PydanticValidationError from sqlalchemy.exc import OperationalError +from langflow.api.build import generate_flow_events from langflow.api.utils import extract_global_variables_from_headers -from langflow.api.v1.schemas import RunResponse +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_flat_inputs, + parse_workflow_run_request, run_response_to_workflow_response, ) from langflow.api.v2.workflow_reconstruction import reconstruct_workflow_response_from_job_id @@ -62,76 +80,62 @@ from langflow.exceptions.api import ( ) 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 api_key_security +from langflow.services.auth.utils import get_current_user_for_workflow from langflow.services.authorization import FlowAction, ensure_flow_permission 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_task_service +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 -def check_developer_api_enabled() -> None: - """Check if developer API is enabled. +router = APIRouter(prefix="/workflows", tags=["Workflow"]) - This dependency function protects all workflow endpoints by verifying that - the developer API feature is enabled in the application settings. - Raises: - HTTPException: 403 Forbidden if developer_api_enabled setting is False +def _unknown_protocol_http_exception(exc: UnknownStreamProtocolError) -> HTTPException: + """Build the 422 response shared by ``stream`` and ``background`` paths. - Note: - This is used as a router-level dependency to protect all workflow endpoints. + Both branches validate ``stream_protocol`` against the live adapter registry + so the error body is identical: callers can switch ``mode`` without + learning a second error shape. ``available`` lists the registered protocol + names so clients can self-correct. """ - settings = get_settings_service().settings - if not settings.developer_api_enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "Developer API disabled", - "code": "DEVELOPER_API_DISABLED", - "message": "Developer API is not enabled. Contact administrator to enable this feature.", - }, - ) + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error": "Unknown stream_protocol", + "code": "UNKNOWN_STREAM_PROTOCOL", + "message": f"Unknown stream_protocol {exc.name!r}.", + "available": exc.available, + }, + ) -router = APIRouter(prefix="/workflows", tags=["Workflow"], dependencies=[Depends(check_developer_api_enabled)]) +def _build_run_inputs(parsed: ParsedWorkflowRun) -> list[InputValueRequest] | None: + """Build the graph input list from the AG-UI chat message, if any. - -def _resolve_request_variables( - workflow_request: WorkflowExecutionRequest, http_request: Request | None -) -> dict[str, str]: - """Resolve request-level global variables for a v2 workflow execution. - - v2 workflows take globals from the JSON request body so arbitrary Unicode - values can be transported safely. The legacy ``X-LANGFLOW-GLOBAL-VAR-*`` - headers are still honored for one release for backwards compatibility, - but a deprecation warning is logged and body globals always win on - conflict. + 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). """ - body_globals = dict(workflow_request.globals or {}) - legacy_globals: dict[str, str] = {} + 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: - legacy_globals = extract_global_variables_from_headers(http_request.headers) - if legacy_globals: - logger.warning( - "X-LANGFLOW-GLOBAL-VAR-* headers are deprecated on /api/v2/workflows; " - "send request-level globals in the JSON body instead. Header-based " - "globals will be removed in a future Langflow release." - ) - # Body wins on conflict. - return {**legacy_globals, **body_globals} - - -def _build_graph_context(request_variables: dict[str, str]) -> dict | None: - """Build the optional graph context for a v2 workflow execution. - - Returns None when there are no request variables so the downstream graph - init can short-circuit on falsy context. - """ - return {"request_variables": request_variables} if request_variables else None + header_globals = extract_global_variables_from_headers(http_request.headers) + return {**header_globals, **dict(body_globals or {})} @router.post( @@ -143,57 +147,58 @@ def _build_graph_context(request_variables: dict[str, str]) -> dict | None: description="Execute a workflow with support for sync, stream, and background modes", ) async def execute_workflow( - workflow_request: WorkflowExecutionRequest, + request: WorkflowRunRequest, background_tasks: BackgroundTasks, http_request: Request, - api_key_user: Annotated[UserRead, Depends(api_key_security)], + current_user: Annotated[UserRead, Depends(get_current_user_for_workflow)], ) -> WorkflowExecutionResponse | WorkflowJobResponse | StreamingResponse: - """Execute a workflow with support for multiple execution modes. + """Execute a flow from a native ``WorkflowRunRequest`` body. - **background** and **stream** can't be true at the same time. - This endpoint supports three execution modes: - - **Synchronous** (background=False, stream=False): Returns complete results immediately - - **Streaming** (stream=True): Returns server-sent events in real-time (not yet implemented) - - **Background** (background=True): Starts job and returns job ID (not yet implemented) + ``mode`` selects the execution path: + - **sync** (default): run inline, return ``WorkflowExecutionResponse``. + - **stream**: return an SSE stream in the protocol named by + ``stream_protocol`` (``langflow`` default, ``agui`` opt-in). + - **background**: queue a job, return ``WorkflowJobResponse`` with a + ``links.events`` URL for re-attach. - Error Handling Strategy: - - System errors (404, 500, 503, 504): Returned as HTTP error responses - - Component execution errors: Returned as HTTP 200 with errors in response body - - Args: - workflow_request: The workflow execution request containing flow_id, inputs, and mode flags - background_tasks: FastAPI background tasks for async operations - http_request: The HTTP request, used to honor deprecated ``X-LANGFLOW-GLOBAL-VAR-*`` headers - api_key_user: Authenticated user from API key - - Returns: - - WorkflowExecutionResponse: For synchronous execution (HTTP 200) - - WorkflowJobResponse: For background execution (HTTP 202, not yet implemented) - - StreamingResponse: For streaming execution (not yet implemented) + Error Handling: + - System errors (404, 500, 503, 504): HTTP error responses. + - Component execution errors: HTTP 200 with errors in the body. + - Unknown ``stream_protocol``: 422 with the available list. Raises: HTTPException: - - 403: Developer API disabled - - 404: Flow not found or user lacks access - - 500: Invalid flow data or validation error - - 501: Streaming or background mode not yet implemented - - 503: Database unavailable - - 504: Execution timeout exceeded + - 404: Flow not found or user lacks access. + - 400: Invalid flow data or validation error. + - 422: Unknown ``stream_protocol``. + - 500: Internal server error. + - 503: Database unavailable. + - 408: Execution timeout exceeded. """ + parsed = parse_workflow_run_request(request) job_id = uuid4() + # Validate ``stream_protocol`` for every mode, even ``sync``: the field is + # part of the request contract regardless of which path runs. A name-only + # membership check here avoids instantiating an adapter the sync path will + # never use. + if request.stream_protocol not in STREAM_ADAPTERS: + raise _unknown_protocol_http_exception( + UnknownStreamProtocolError(request.stream_protocol, available_protocols()) + ) + try: - # Validate flow exists and user has permission. The lookup becomes - # share-aware when an authorization plugin is registered, so we must - # also enforce ``flow:execute`` explicitly — otherwise an API key - # with cross-user fetch enabled would bypass policy here. + # Share-aware fetch + RBAC: resolve the flow and enforce flow:execute. + # The lookup widens to shared flows when an authorization plugin is + # registered, so we enforce the action explicitly — otherwise an API + # key with cross-user fetch enabled would bypass policy here. flow = await get_flow_by_id_or_endpoint_name( - workflow_request.flow_id, - api_key_user.id, + parsed.flow_id, + current_user.id, widen_for_shares=True, ) await ensure_flow_permission( - api_key_user, + current_user, FlowAction.EXECUTE, flow_id=flow.id, flow_user_id=flow.user_id, @@ -201,35 +206,47 @@ async def execute_workflow( folder_id=getattr(flow, "folder_id", None), ) - # Background mode execution - if workflow_request.background: - return await execute_workflow_background( - workflow_request=workflow_request, + if parsed.mode == "sync": + return await execute_sync_workflow_with_timeout( + parsed=parsed, flow=flow, job_id=job_id, - api_key_user=api_key_user, + current_user=current_user, + background_tasks=background_tasks, http_request=http_request, ) - # Streaming mode (to be implemented) - if workflow_request.stream: - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail={ - "error": "Not implemented", - "code": "NOT_IMPLEMENTED", - "message": "Streaming execution not yet implemented", - }, + if parsed.mode == "background": + # Background owns its own adapter construction inside + # ``_buffer_background_run`` because the fire-and-forget coroutine + # needs its own ``StreamAdapterContext`` (different ``run_id``). + # The name-only check above already covered the 422 contract. + return await execute_workflow_background( + parsed=parsed, + flow=flow, + job_id=job_id, + current_user=current_user, + http_request=http_request, + stream_protocol=request.stream_protocol, ) - # Synchronous execution (default) - return await execute_sync_workflow_with_timeout( - workflow_request=workflow_request, + # Stream mode: the adapter instance drives the SSE frame loop, so we + # construct it here. ``get_stream_adapter`` can no longer raise + # ``UnknownStreamProtocolError`` on this path unless the registry + # mutates mid-request. + adapter = get_stream_adapter( + request.stream_protocol, + StreamAdapterContext( + run_id=str(job_id), + thread_id=parsed.session_id or str(flow.id), + ), + ) + return _execute_streaming_workflow( + adapter=adapter, + parsed=parsed, flow=flow, - job_id=job_id, - api_key_user=api_key_user, + current_user=current_user, background_tasks=background_tasks, - http_request=http_request, ) except HTTPException as e: @@ -240,8 +257,8 @@ async def execute_workflow( detail={ "error": "Flow not found", "code": "FLOW_NOT_FOUND", - "message": f"Flow '{workflow_request.flow_id}' does not exist. Verify the flow_id and try again.", - "flow_id": workflow_request.flow_id, + "message": f"Flow '{parsed.flow_id}' does not exist. Verify the flow_id and try again.", + "flow_id": parsed.flow_id, }, ) from e raise @@ -252,7 +269,7 @@ async def execute_workflow( "error": "Service unavailable, Please try again.", "code": "DATABASE_ERROR", "message": f"Failed to fetch flow: {e!s}", - "flow_id": workflow_request.flow_id, + "flow_id": parsed.flow_id, }, ) from e except WorkflowTimeoutError: @@ -263,7 +280,7 @@ async def execute_workflow( "code": "EXECUTION_TIMEOUT", "message": f"Workflow execution exceeded {EXECUTION_TIMEOUT} seconds", "job_id": str(job_id), - "flow_id": str(workflow_request.flow_id), + "flow_id": str(parsed.flow_id), "timeout_seconds": EXECUTION_TIMEOUT, }, ) from None @@ -274,7 +291,7 @@ async def execute_workflow( "error": "Workflow validation error", "code": "INVALID_FLOW_DATA", "message": str(e), - "flow_id": workflow_request.flow_id, + "flow_id": parsed.flow_id, }, ) from e except WorkflowServiceUnavailableError as err: @@ -284,7 +301,7 @@ async def execute_workflow( "error": "Service unavailable", "code": "QUEUE_SERVICE_UNAVAILABLE", "message": str(err), - "flow_id": workflow_request.flow_id, + "flow_id": parsed.flow_id, }, ) from err except (WorkflowResourceError, WorkflowQueueFullError, MemoryError) as err: @@ -294,7 +311,7 @@ async def execute_workflow( "error": "Service busy", "code": "SERVICE_BUSY", "message": "The service is currently unable to handle the request due to resource limits.", - "flow_id": workflow_request.flow_id, + "flow_id": parsed.flow_id, }, ) from err except Exception as err: @@ -304,28 +321,28 @@ async def execute_workflow( "error": "Internal server error", "code": "INTERNAL_SERVER_ERROR", "message": f"An unexpected error occurred: {err!s}", - "flow_id": workflow_request.flow_id, + "flow_id": parsed.flow_id, }, ) from err async def execute_sync_workflow_with_timeout( - workflow_request: WorkflowExecutionRequest, + parsed: ParsedWorkflowRun, flow: FlowRead, job_id: UUID, - api_key_user: UserRead, + current_user: UserRead, background_tasks: BackgroundTasks, - http_request: Request | None = None, + http_request: Request, ) -> WorkflowExecutionResponse: """Execute workflow with timeout protection. Args: - workflow_request: The workflow execution request + parsed: The parsed AG-UI run parameters flow: The flow to execute job_id: Generated job ID for tracking - api_key_user: Authenticated user + current_user: Authenticated user background_tasks: FastAPI background tasks - http_request: The HTTP request, used to honor deprecated ``X-LANGFLOW-GLOBAL-VAR-*`` headers + http_request: The HTTP request object for extracting headers Returns: WorkflowExecutionResponse with complete results @@ -337,10 +354,10 @@ async def execute_sync_workflow_with_timeout( try: return await asyncio.wait_for( execute_sync_workflow( - workflow_request=workflow_request, + parsed=parsed, flow=flow, job_id=job_id, - api_key_user=api_key_user, + current_user=current_user, background_tasks=background_tasks, http_request=http_request, ), @@ -351,12 +368,12 @@ async def execute_sync_workflow_with_timeout( async def execute_sync_workflow( - workflow_request: WorkflowExecutionRequest, + parsed: ParsedWorkflowRun, flow: FlowRead, job_id: UUID, - api_key_user: UserRead, + current_user: UserRead, background_tasks: BackgroundTasks, # noqa: ARG001 - http_request: Request | None = None, + http_request: Request, ) -> WorkflowExecutionResponse: """Execute workflow synchronously and return complete results. @@ -368,21 +385,21 @@ async def execute_sync_workflow( components fail, which is useful for debugging and incremental processing. Execution Flow: - 1. Parse flat inputs into tweaks and session_id + 1. Apply tweaks and chat input from the parsed AG-UI request 2. Validate flow data exists - 3. Extract context from request globals + 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: - workflow_request: The workflow execution request with inputs and configuration + 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 - api_key_user: Authenticated user for permission checks + current_user: Authenticated user for permission checks background_tasks: FastAPI background tasks (unused in sync mode) - http_request: The HTTP request, used to honor deprecated ``X-LANGFLOW-GLOBAL-VAR-*`` headers + http_request: The HTTP request object for extracting headers Returns: WorkflowExecutionResponse: Complete execution results with outputs and metadata @@ -390,25 +407,27 @@ async def execute_sync_workflow( Raises: WorkflowValidationError: If flow data is None or graph build fails """ - # Parse flat inputs structure - tweaks, session_id = parse_flat_inputs(workflow_request.inputs or {}) + # 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) - # V2 workflows take request-level variables from the JSON body so arbitrary - # Unicode strings transport safely. The legacy X-LANGFLOW-GLOBAL-VAR-* - # headers are still honored for one release with a deprecation warning; - # body globals win on conflict. - request_variables = _resolve_request_variables(workflow_request, http_request) - context = _build_graph_context(request_variables) + # 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(api_key_user.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) @@ -429,7 +448,7 @@ async def execute_sync_workflow( # 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=api_key_user.id) + 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, @@ -437,7 +456,7 @@ async def execute_sync_workflow( graph=graph, flow_id=flow_id_str, session_id=session_id, - inputs=None, + inputs=_build_run_inputs(parsed), outputs=terminal_node_ids, stream=False, ) @@ -459,9 +478,9 @@ async def execute_sync_workflow( # Convert to WorkflowExecutionResponse return run_response_to_workflow_response( run_response=run_response, - flow_id=workflow_request.flow_id, + flow_id=parsed.flow_id, job_id=str(job_id), - workflow_request=workflow_request, + inputs=parsed.tweaks, graph=graph, effective_globals=request_variables, ) @@ -478,116 +497,492 @@ async def execute_sync_workflow( # 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=workflow_request.flow_id, + flow_id=parsed.flow_id, job_id=job_id, - workflow_request=workflow_request, + 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, + ) + + +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, +) -> 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. + """ + # Bounded so a slow consumer can apply backpressure on the build loop + # instead of growing without bound. The build loop awaits ``queue.put`` + # which yields control back to the consumer between frames. + queue: asyncio.Queue = asyncio.Queue(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 even when on_error itself + # raises (its body and the sentinel-put are wrapped in suppress so + # they cannot prevent shutdown but can fail silently). Without this + # yield, an on_error failure would leave the stream with no terminal + # error event at all 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, + log_builds=False, + current_user=current_user, + flow_name=flow_name, + source_flow_id=source_flow_id, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + drive_error = exc + with contextlib.suppress(Exception): + event_manager.on_error(data={"error": str(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"}) + + 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): + yield _frame(event, seq) + seq += 1 + for event in adapter.final_events(): + yield _frame(event, seq) + seq += 1 + # Guaranteed-fallback layer (see drive_error block above). If drive() + # captured an exception, emit the adapter's terminal error event(s) + # here even if on_error already produced one: an extra RUN_ERROR is + # cheap and only a hard error path will hit this. The duplicate is + # the cost of guaranteeing the consumer sees a terminal error event + # even when the cooperative on_error layer failed. + if drive_error is not None: + for event in adapter.error_events(drive_error): + yield _frame(event, seq) + seq += 1 + finally: + if not run_task.done(): + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + +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"}, + ) + + +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) -> None: + self.user_id = user_id + 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() + + async def append(self, frame: bytes) -> None: + async with self._cond: + self.frames.append(frame) + overflow = len(self.frames) - _MAX_FRAMES_PER_BACKGROUND_RUN + if overflow > 0: + del self.frames[:overflow] + self.base_index += overflow + self._cond.notify_all() + + async def finish(self) -> None: + async with self._cond: + 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] = {} + + +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. + + Called from ``stop_workflow`` after revoking the buffer task. Without + this, ``_cond.wait()`` inside ``_BackgroundRun.replay`` can hang + indefinitely (the buffer is never marked done because the task that + would have called ``finish()`` was cancelled mid-execution), and the + cancelled ``_BackgroundRun`` lingers in memory until LRU evicts it. + """ + bg_run = _BACKGROUND_RUNS.pop(job_id, None) + if bg_run is not None: + await bg_run.finish() + + +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. + """ + if len(_BACKGROUND_RUNS) >= _MAX_BACKGROUND_RUNS: + evict_key = next( + (key for key, run in _BACKGROUND_RUNS.items() if run.done), + None, + ) + if evict_key is None: + evict_key = next(iter(_BACKGROUND_RUNS)) + 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) + _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 + + terminal_error_type = adapter.terminal_error_type + fresh_background_tasks = BackgroundTasks() + errored = False + try: + 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, + ): + if terminal_error_type is not None and event_type == terminal_error_type: + errored = True + await bg_run.append(frame) + finally: + 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() + # ``update_job_status`` queries the Job table by its UUID primary key; + # passing the raw string would silently miss every row. + job_uuid = UUID(job_id) if isinstance(job_id, str) else job_id + await _finalize_job_status(job_uuid, JobStatus.FAILED if errored else JobStatus.COMPLETED) + # Fire memory-base auto-capture hook on successful runs only. Matches + # the sync mode wiring above and the v1 build-pipeline wiring in + # ``api/build.py``. ``fire_and_forget_task`` because we are already a + # background coroutine and the hook must not block job finalization. + if not errored: + try: + await get_task_service().fire_and_forget_task( + get_memory_base_service().on_flow_output, + flow_id=flow.id, + session_id=parsed.session_id or str(flow.id), + job_id=job_uuid, + ) + except (RuntimeError, ValueError, OSError): + await logger.awarning("Memory base hook scheduling failed for flow %s", flow.id, exc_info=True) + + async def execute_workflow_background( - workflow_request: WorkflowExecutionRequest, + parsed: ParsedWorkflowRun, flow: FlowRead, job_id: JobId, - api_key_user: UserRead, - http_request: Request | None = None, + current_user: UserRead, + http_request: Request, # noqa: ARG001 + stream_protocol: str, ) -> WorkflowJobResponse: - """Execute workflow in the background and return job ID for the user to track the execution status.""" + """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: - # Parse flat inputs structure - tweaks, session_id = parse_flat_inputs(workflow_request.inputs or {}) - - # Validate flow data - if flow.data is None: - msg = f"Flow {flow.id} has no data" - raise ValueError(msg) - - # V2 workflows take request-level variables from the JSON body so arbitrary - # Unicode strings transport safely. The legacy X-LANGFLOW-GLOBAL-VAR-* - # headers are still honored for one release with a deprecation warning; - # body globals win on conflict. - request_variables = _resolve_request_variables(workflow_request, http_request) - context = _build_graph_context(request_variables) - - # Build the graph once flow_id_str = str(flow.id) - user_id = str(api_key_user.id) - graph_data = deepcopy(flow.data) - graph_data = process_tweaks(graph_data, tweaks, stream=False) - graph = Graph.from_payload( - graph_data, flow_id=flow_id_str, user_id=user_id, flow_name=flow.name, context=context - ) - graph.set_run_id(job_id) + job_id_str = str(job_id) - # Get terminal nodes - terminal_node_ids = graph.get_terminal_nodes() - - # Launch background task - task_service = get_task_service() - job_service = get_job_service() - - # Create job synchronously to ensure it exists before background task starts - # and so we can return a valid job status immediately - await job_service.create_job( + await get_job_service().create_job( job_id=job_id, flow_id=flow_id_str, - user_id=api_key_user.id, + user_id=current_user.id, ) - # Closure captures flow identity for the memory-base hook. - # run_id is the same as job_id — graph.set_run_id(job_id) was called above. - _hook_flow_id = flow.id - _hook_run_id = job_id + bg_run = _BackgroundRun(user_id=str(current_user.id)) + _register_background_run(job_id_str, bg_run) - async def _run_and_notify(**kwargs): - """Thin wrapper: execute graph then fire memory-base hook as a background effect. - - The hook is dispatched non-blocking after graph completion. Any failure in - the hook is swallowed so it never affects the job status of the graph run. - """ - result = await run_graph_internal(**kwargs) - _, _effective_session_id = result - try: - # Direct await — we are already inside a background task; awaiting here - # is non-blocking from the client's perspective and avoids the race - # condition that arises when dispatching a second fire_and_forget from - # within an already-running fire_and_forget task. - await get_memory_base_service().on_flow_output( - flow_id=_hook_flow_id, - session_id=_effective_session_id, - job_id=_hook_run_id, - ) - except Exception: # noqa: BLE001 - await logger.awarning( - "Memory base hook failed for flow %s, but workflow succeeded.", _hook_flow_id, exc_info=True - ) - return result - - await task_service.fire_and_forget_task( - job_service.execute_with_status, - job_id=job_id, - run_coro_func=_run_and_notify, - graph=graph, - flow_id=flow_id_str, - session_id=session_id, - inputs=None, - outputs=terminal_node_ids, - stream=False, - ) - status = JobStatus.QUEUED - return WorkflowJobResponse( - job_id=str(job_id), - flow_id=workflow_request.flow_id, - status=status, - globals=request_variables, - ) + 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): - # Re-raise infrastructure/resource errors to be handled by the endpoint raise - except ValueError as exc: - raise WorkflowValidationError(str(exc)) from exc except MemoryError as exc: raise WorkflowResourceError from exc @@ -601,14 +996,14 @@ async def execute_workflow_background( description="Get status of workflow job by job ID", ) async def get_workflow_status( - api_key_user: Annotated[UserRead, Depends(api_key_security)], + current_user: Annotated[UserRead, Depends(get_current_user_for_workflow)], job_id: Annotated[JobId | None, Query(description="Job ID to query")] = None, session: Annotated[object, Depends(injectable_session_scope_readonly)] = None, ) -> WorkflowExecutionResponse | WorkflowJobResponse: """Get workflow job status and results. Args: - api_key_user: Authenticated user from API key + current_user: Authenticated user (session cookie or API key) job_id: Optional job ID to query specific job session: Database session for querying vertex builds @@ -635,7 +1030,7 @@ async def get_workflow_status( job_service = get_job_service() try: - job = await job_service.get_job_by_job_id(job_id=job_id, user_id=api_key_user.id) + job = await job_service.get_job_by_job_id(job_id=job_id, user_id=current_user.id) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -675,15 +1070,15 @@ async def get_workflow_status( try: # If job is completed, reconstruct full workflow response from vertex_builds if job.status == JobStatus.COMPLETED: - # Get the flow (share-aware fetch — also enforce flow:read so an - # API key with cross-user fetch cannot read foreign job output). + # Share-aware fetch + RBAC: enforce flow:read so an API key with + # cross-user fetch cannot read another user's job output. flow = await get_flow_by_id_or_endpoint_name( flow_id_str, - api_key_user.id, + current_user.id, widen_for_shares=True, ) await ensure_flow_permission( - api_key_user, + current_user, FlowAction.READ, flow_id=flow.id, flow_user_id=flow.user_id, @@ -696,7 +1091,7 @@ async def get_workflow_status( session=session, flow=flow, job_id=job_id_str, - user_id=str(api_key_user.id), + user_id=str(current_user.id), ) if job.status == JobStatus.FAILED: @@ -761,7 +1156,7 @@ async def get_workflow_status( ) async def stop_workflow( request: WorkflowStopRequest, - api_key_user: Annotated[UserRead, Depends(api_key_security)], + current_user: Annotated[UserRead, Depends(get_current_user_for_workflow)], ) -> WorkflowStopResponse: """Stop a running workflow execution by job_id. @@ -769,7 +1164,7 @@ async def stop_workflow( Args: request: Stop request containing job_id and optional force flag - api_key_user: Authenticated user from API key + current_user: Authenticated user (session cookie or API key) Returns: WorkflowStopResponse: Confirmation of stop request with final job status @@ -785,8 +1180,8 @@ async def stop_workflow( task_service = get_task_service() try: - # 1. Fetch Job and verify ownership - job = await job_service.get_job_by_job_id(job_id, user_id=api_key_user.id) + # 1. Fetch Job + job = await job_service.get_job_by_job_id(job_id, user_id=current_user.id) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -826,6 +1221,9 @@ async def stop_workflow( try: revoked = await task_service.revoke_task(job_id) await job_service.update_job_status(job_id, JobStatus.CANCELLED) + # Release the in-memory buffer and wake any re-attach waiters so they + # see a clean stream-end instead of hanging on ``_cond.wait()``. + await _clear_background_run(str(job_id)) message = f"Job {job_id} cancelled successfully." if revoked else f"Job {job_id} is already cancelled." return WorkflowStopResponse(job_id=str(job_id), message=message) @@ -851,3 +1249,47 @@ async def stop_workflow( "message": f"Failed to stop job: {job_id} - {exc!s}", }, ) from exc + + +@router.get( + "/{job_id}/events", + response_model=None, + summary="Re-attach to a background run", + description="Replay the buffered protocol-native events for a background run and tail until it ends.", +) +async def reattach_workflow_events( + job_id: str, + http_request: Request, + current_user: Annotated[UserRead, Depends(get_current_user_for_workflow)], +) -> EventSourceResponse: + """Stream a background run's buffered events, replaying from ``Last-Event-ID``. + + The buffer is process-local and frames are already serialized in the + protocol the original POST requested via ``stream_protocol``; this handler + replays them as-is. Cross-user access is rejected with 404 to avoid + leaking the existence of other users' runs. + """ + bg_run = _BACKGROUND_RUNS.get(job_id) + if bg_run is None or bg_run.user_id != str(current_user.id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "Background run not found", + "code": "JOB_NOT_FOUND", + "message": f"No buffered events for job {job_id}.", + "job_id": job_id, + }, + ) + + last_event_id = http_request.headers.get("Last-Event-ID") + start_index = 0 + if last_event_id: + try: + start_index = int(last_event_id) + 1 + except ValueError: + start_index = 0 + + return EventSourceResponse( + bg_run.replay(start_index), + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/src/backend/base/langflow/api/v2/workflow_public.py b/src/backend/base/langflow/api/v2/workflow_public.py new file mode 100644 index 0000000000..0626b4df40 --- /dev/null +++ b/src/backend/base/langflow/api/v2/workflow_public.py @@ -0,0 +1,179 @@ +"""V2 public workflow endpoint. + +Counterpart of ``/api/v1/build_public_tmp/{flow_id}/flow`` for the v2 +streaming pipeline. The shareable playground POSTs here so visitors can +run a public flow without owning it. + +The body is intentionally narrower than ``/api/v2/workflows`` (no +``data``, no ``tweaks``): visitors must never override the stored flow +definition. The handler also enforces the public-access mitigations: + +- ``access_type == PUBLIC`` (others 403, checked before any policy + validation so private flows leak nothing about themselves). +- Per-visitor ``virtual_flow_id = uuid5(identifier, flow_id)`` used as + the storage flow_id, mirroring v1's build_public_tmp. +- Caller-supplied ``session_id`` namespaced under the virtual flow id + (CVE-2026-33017). +- File-path validation (GHSA-rcjh-r59h-gq37). +- AUTO_LOGIN parity: when ``AUTO_LOGIN=true`` the backend ignores + ``authenticated_user.id`` and uses ``client_id`` for the UUID v5 + derivation, matching the frontend's ``useGetFlowId`` so the popup's + chat-view filter actually matches what the backend stores. +- Owner impersonation: the run executes under the flow owner's + permissions, never the visitor's. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Annotated +from uuid import UUID, uuid4 + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status +from fastapi.responses import EventSourceResponse +from lfx.schema.workflow import ( + WORKFLOW_EXECUTION_RESPONSES, + PublicWorkflowRunRequest, +) +from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings + +from langflow.api.utils.flow_utils import ( + scope_session_to_namespace, + 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 +from langflow.services.deps import get_settings_service, session_scope + +router = APIRouter(prefix="/workflows/public", tags=["Workflow (public)"]) + + +@router.post( + "", + responses=WORKFLOW_EXECUTION_RESPONSES, + summary="Execute Public Workflow", + description=( + "Stream a public flow on behalf of a shareable-playground visitor. " + "Mirrors the security posture of /api/v1/build_public_tmp." + ), +) +async def execute_public_workflow( + request: PublicWorkflowRunRequest, + background_tasks: BackgroundTasks, + http_request: Request, + authenticated_user: Annotated[User | None, Depends(get_current_user_optional)] = None, +) -> EventSourceResponse: + """Run a PUBLIC flow on behalf of a shareable-playground visitor. + + Stream-mode only. The handler enforces all of the public-access + mitigations documented in the module docstring before any flow + execution begins. + """ + # 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 + + real_flow_id = UUID(request.flow_id) + + # Mirror v1 ``build_public_tmp`` error contract: blocked-component + # validation must surface as a sanitized 400 (the raw message names + # the disabled component classes); other ValueErrors from the gate + # sequence become 400 with the message preserved. + try: + # File path validation — done before any DB lookup so malformed + # requests fail fast and don't touch the database (GHSA-rcjh-r59h-gq37). + validate_public_files(request.files, real_flow_id) + + # Identifier resolution. The frontend's ``useGetFlowId`` uses + # ``client_id`` for the UUID v5 derivation when AUTO_LOGIN is on, + # so the backend must match here or the popup's chat-view filter + # would drop every broadcast message. + client_id = http_request.cookies.get("client_id") + auth_settings = get_settings_service().auth_settings + authenticated_user_id = authenticated_user.id if authenticated_user and not auth_settings.AUTO_LOGIN else None + + # access_type == PUBLIC + virtual_flow_id, run as the flow owner. + owner_user, virtual_flow_id = await verify_public_flow_and_get_user( + flow_id=real_flow_id, + client_id=client_id, + authenticated_user_id=authenticated_user_id, + ) + + # Defends CVE-2026-33017: scope caller's session into the (identifier, flow_id) namespace. + scoped_session = ( + scope_session_to_namespace(request.session_id, str(virtual_flow_id)) + if request.session_id is not None + else None + ) + + # Validate stored flow data after the public-access gate so private + # flows never trigger validation side effects. + async with session_scope() as session: + flow = await session.get(Flow, real_flow_id) + if flow and flow.data: + validate_flow_for_current_settings(flow.data) + flow_name = flow.name if flow else None + except CustomComponentValidationError as exc: + # The raw message embeds the blocked component class names; do + # not leak it to an anonymous visitor. + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This flow cannot be executed.") from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + if request.stream_protocol not in STREAM_ADAPTERS: + raise _unknown_protocol_http_exception( + UnknownStreamProtocolError(request.stream_protocol, available_protocols()) + ) + + job_id = uuid4() + adapter = get_stream_adapter( + request.stream_protocol, + StreamAdapterContext( + run_id=str(job_id), + thread_id=scoped_session or str(virtual_flow_id), + ), + ) + + # The narrower public schema has no ``data``/``tweaks`` fields; we + # carry only the partial-run knobs into ParsedWorkflowRun. + parsed = ParsedWorkflowRun( + flow_id=str(virtual_flow_id), + input_value=request.input_value, + session_id=scoped_session, + run_id=None, + mode="stream", + start_component_id=request.start_component_id, + stop_component_id=request.stop_component_id, + data=None, + files=request.files, + ) + + owner_user_read = UserRead.model_validate(owner_user, from_attributes=True) + + async def _frames_only() -> AsyncIterator[bytes]: + async for frame, _event_type in _stream_event_frames( + adapter=adapter, + flow_id=virtual_flow_id, + flow_name=flow_name, + background_tasks=background_tasks, + parsed=parsed, + current_user=owner_user_read, + source_flow_id=real_flow_id, + ): + yield frame + + return EventSourceResponse( + _frames_only(), + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/src/backend/base/langflow/api/v2/workflow_reconstruction.py b/src/backend/base/langflow/api/v2/workflow_reconstruction.py index a70318d40e..b079de69b2 100644 --- a/src/backend/base/langflow/api/v2/workflow_reconstruction.py +++ b/src/backend/base/langflow/api/v2/workflow_reconstruction.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING from lfx.graph.graph.base import Graph from lfx.graph.schema import ResultData, RunOutputs -from lfx.schema.workflow import WorkflowExecutionRequest from langflow.api.v1.schemas import RunResponse from langflow.api.v2.converters import run_response_to_workflow_response @@ -69,12 +68,11 @@ async def reconstruct_workflow_response_from_job_id( # Create RunResponse and convert to WorkflowExecutionResponse run_response = RunResponse(outputs=run_outputs_list, session_id=None) - workflow_request = WorkflowExecutionRequest(flow_id=flow_id_str, inputs={}) return run_response_to_workflow_response( run_response=run_response, flow_id=flow_id_str, job_id=job_id, - workflow_request=workflow_request, + inputs={}, graph=graph, ) diff --git a/src/backend/base/langflow/services/auth/utils.py b/src/backend/base/langflow/services/auth/utils.py index 44ec17f275..52c95dfa11 100644 --- a/src/backend/base/langflow/services/auth/utils.py +++ b/src/backend/base/langflow/services/auth/utils.py @@ -9,7 +9,7 @@ from fastapi import Depends, HTTPException, Request, Security, WebSocket, WebSoc from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer from fastapi.security.utils import get_authorization_scheme_param from lfx.log.logger import logger -from lfx.services.deps import injectable_session_scope +from lfx.services.deps import injectable_session_scope, session_scope from langflow.services.auth.exceptions import ( AuthenticationError, @@ -227,6 +227,36 @@ async def get_current_user_for_sse( ) from e +async def get_current_user_for_workflow( + token: Annotated[str | None, Security(oauth2_login)], + query_param: Annotated[str | None, Security(api_key_query)], + header_param: Annotated[str | None, Security(api_key_header)], +) -> UserRead: + """Combined session-or-API-key auth that does not hold a DB session. + + Resolves the user from a session cookie/token *or* an API key inside a + short-lived session that is committed and closed before the path operation + runs. Unlike `get_current_active_user` (a generator dependency whose session + stays open for the whole request), this is required by endpoints that + execute a graph inline: a held auth connection contends with the run's own + writes (on SQLite it blocks the run's INSERTs with "database is locked"). + """ + from langflow.services.database.models.user.model import UserRead + + async with session_scope() as db: + try: + user = await _auth_service().get_current_user(token, query_param, header_param, db) + except AuthenticationError as e: + raise _auth_error_to_http(e) from e + active_user = await _auth_service().get_current_active_user(user) + if active_user is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + return UserRead.model_validate(active_user, from_attributes=True) + + async def get_optional_user( token: Annotated[str | None, Security(oauth2_login)], query_param: Annotated[str | None, Security(api_key_query)], diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 77ad8cd279..afee89b81b 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -88,6 +88,7 @@ dependencies = [ "networkx>=3.4.2,<4.0.0", "json-repair>=0.30.3,<1.0.0", "mcp>=1.17.0,<2.0.0", + "ag-ui-protocol==0.1.18", "aiosqlite>=0.20.0,<1.0.0", "greenlet>=3.1.1,<4.0.0", "jsonquerylang>=1.1.1,<2.0.0", diff --git a/src/backend/tests/unit/api/v2/adapters/__init__.py b/src/backend/tests/unit/api/v2/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/backend/tests/unit/api/v2/adapters/test_agui_adapter.py new file mode 100644 index 0000000000..4ba523f4af --- /dev/null +++ b/src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py @@ -0,0 +1,179 @@ +"""Tests for the ``agui`` stream adapter. + +The agui adapter wraps the existing ``AGUITranslator`` and frames its +``BaseEvent`` outputs for SSE. AG-UI's wire shape is the model's +``model_dump_json(by_alias=True, exclude_none=True)``. +""" + +from __future__ import annotations + +import json + +import pytest +from langflow.api.v2.adapters import ( + StreamAdapterContext, + get_stream_adapter, +) + + +def _ctx() -> StreamAdapterContext: + return StreamAdapterContext(run_id="run-7", thread_id="thread-7") + + +class TestInitialEvents: + """The agui adapter opens the run with RUN_STARTED + STATE_SNAPSHOT.""" + + def test_initial_events_emit_run_started_and_state_snapshot(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.initial_events()) + types = [e.type for e in events] + assert types == ["RUN_STARTED", "STATE_SNAPSHOT"] + + def test_run_started_carries_run_and_thread_ids(self): + adapter = get_stream_adapter("agui", _ctx()) + [run_started, _snapshot] = list(adapter.initial_events()) + payload = json.loads(run_started.data_json) + assert payload["runId"] == "run-7" + assert payload["threadId"] == "thread-7" + + +class TestTranslation: + """Each ``EventManager`` event maps to its AG-UI equivalent through the translator.""" + + def test_token_emits_text_message_start_then_content(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.translate("token", {"id": "m1", "chunk": "Hello"})) + types = [e.type for e in events] + assert "TEXT_MESSAGE_START" in types + assert "TEXT_MESSAGE_CONTENT" in types + + def test_end_emits_run_finished(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.translate("end", {})) + assert any(e.type == "RUN_FINISHED" for e in events) + + def test_error_emits_run_error_with_message(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.translate("error", {"error": "graph build failed"})) + run_error = next(e for e in events if e.type == "RUN_ERROR") + payload = json.loads(run_error.data_json) + assert payload["message"] == "graph build failed" + + +class TestFinalAndErrorEvents: + """``final_events`` is empty (RUN_FINISHED comes via translate('end')).""" + + def test_final_events_is_empty(self): + adapter = get_stream_adapter("agui", _ctx()) + assert list(adapter.final_events()) == [] + + +class TestErrorEventsFallback: + """``error_events`` is the dispatcher's guaranteed fallback when ``on_error`` itself raises. + + ``_stream_event_frames`` calls ``adapter.error_events(exc)`` whenever the + translator's own error path fails or no error event reached it. It must + always return at least one ``RUN_ERROR`` frame carrying ``str(exc)``, for + every exception type. + """ + + def test_error_events_returns_non_empty_sequence(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.error_events(ValueError("bad input"))) + assert len(events) >= 1 + + def test_error_events_payload_carries_str_of_exception(self): + adapter = get_stream_adapter("agui", _ctx()) + exc = RuntimeError("graph build failed at vertex X") + events = list(adapter.error_events(exc)) + payload = json.loads(events[0].data_json) + assert payload["message"] == str(exc) + + @pytest.mark.parametrize( + "exc", + [ + ValueError("value error message"), + RuntimeError("runtime error message"), + Exception("base exception message"), + KeyError("missing key"), + TypeError("type error message"), + ], + ) + def test_error_events_works_for_various_exception_types(self, exc): + """Every exception type yields exactly one RUN_ERROR carrying its str() form.""" + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.error_events(exc)) + assert len(events) == 1 + assert events[0].type == "RUN_ERROR" + payload = json.loads(events[0].data_json) + # ``str(KeyError("k"))`` is ``"'k'"`` (extra quotes); compare against str(exc). + assert payload["message"] == str(exc) + + def test_error_events_does_not_consume_translator_state(self): + """The fallback path must work even before ``initial_events`` has been drained. + + The dispatcher can hit ``error_events`` before any translator state is + opened; the result must still be a valid RUN_ERROR frame. + """ + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.error_events(RuntimeError("early failure"))) + assert len(events) == 1 + assert events[0].type == "RUN_ERROR" + + +class TestTerminalErrorType: + """Buffer task watches for AG-UI RUN_ERROR to flip the job to FAILED.""" + + def test_terminal_error_type_is_run_error(self): + adapter = get_stream_adapter("agui", _ctx()) + assert adapter.terminal_error_type == "RUN_ERROR" + + +class TestPerInstance: + """Each call to ``get_stream_adapter`` returns a fresh adapter (per-run state).""" + + def test_two_instances_have_independent_state(self): + a = get_stream_adapter("agui", _ctx()) + b = get_stream_adapter("agui", StreamAdapterContext(run_id="r2", thread_id="t2")) + + # Drain initial events to advance state. + list(a.initial_events()) + list(a.translate("token", {"id": "ma", "chunk": "x"})) + list(b.initial_events()) + + # b's translate of token "mb" should open a fresh message; a's state + # must not leak into b. If state leaked, b would see ma as still open. + b_events = list(b.translate("token", {"id": "mb", "chunk": "y"})) + types_b = [e.type for e in b_events] + # Fresh START for b's first token confirms isolated state. + assert "TEXT_MESSAGE_START" in types_b + + +class TestWireFraming: + """``data_json`` is the AG-UI camelCase wire shape via ``model_dump_json(by_alias=True)``.""" + + def test_run_started_uses_camel_case_field_names(self): + adapter = get_stream_adapter("agui", _ctx()) + [run_started, _snapshot] = list(adapter.initial_events()) + # camelCase, not snake_case + assert "runId" in run_started.data_json + assert "threadId" in run_started.data_json + assert "run_id" not in run_started.data_json + + def test_data_json_excludes_none_fields(self): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.translate("token", {"id": "m1", "chunk": "x"})) + for evt in events: + payload = json.loads(evt.data_json) + for value in payload.values(): + assert value is not None, f"None leaked into wire: {evt.data_json}" + + +@pytest.mark.parametrize( + "unknown_event_type", + ["something_brand_new", "vertices_unknown", "telemetry"], +) +def test_unknown_event_types_yield_no_events(unknown_event_type): + adapter = get_stream_adapter("agui", _ctx()) + events = list(adapter.translate(unknown_event_type, {})) + assert events == [] 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 new file mode 100644 index 0000000000..a44ce267b1 --- /dev/null +++ b/src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py @@ -0,0 +1,114 @@ +"""Tests for the ``langflow`` stream adapter. + +The langflow adapter is a passthrough: it takes Langflow EventManager events +and emits them as ``{"event": "", "data": {...}}`` for SSE consumers +that already know the v1 build-flow wire shape. +""" + +from __future__ import annotations + +import json + +import pytest +from langflow.api.v2.adapters import ( + StreamAdapterContext, + get_stream_adapter, +) + + +def _ctx() -> StreamAdapterContext: + return StreamAdapterContext(run_id="run-1", thread_id="thread-1") + + +class TestPassthroughShape: + """Each translated event becomes one StreamEvent with the v1 wire shape.""" + + def test_translate_emits_one_event_per_input(self): + adapter = get_stream_adapter("langflow", _ctx()) + events = list(adapter.translate("token", {"chunk": "Hello"})) + assert len(events) == 1 + + def test_translated_data_matches_v1_build_flow_shape(self): + adapter = get_stream_adapter("langflow", _ctx()) + [evt] = list(adapter.translate("token", {"chunk": "Hello", "id": "m1"})) + payload = json.loads(evt.data_json) + assert payload == {"event": "token", "data": {"chunk": "Hello", "id": "m1"}} + + def test_event_type_is_set_to_input_event_type(self): + adapter = get_stream_adapter("langflow", _ctx()) + [evt] = list(adapter.translate("end_vertex", {"node_id": "x", "valid": True})) + assert evt.type == "end_vertex" + + +class TestNoFramingFromInitialAndFinal: + """The langflow adapter has no setup or teardown frames; SSE itself terminates the stream.""" + + def test_initial_events_is_empty(self): + adapter = get_stream_adapter("langflow", _ctx()) + assert list(adapter.initial_events()) == [] + + def test_final_events_is_empty(self): + adapter = get_stream_adapter("langflow", _ctx()) + assert list(adapter.final_events()) == [] + + +class TestErrorHandling: + """Errors mid-run emit a single ``error`` event and are terminal for the run.""" + + def test_error_events_emit_error_payload(self): + adapter = get_stream_adapter("langflow", _ctx()) + [evt] = list(adapter.error_events(RuntimeError("boom"))) + payload = json.loads(evt.data_json) + assert payload == {"event": "error", "data": {"error": "boom"}} + assert evt.type == "error" + + def test_terminal_error_type_is_error(self): + """Used by the buffer task to decide JobStatus.FAILED.""" + adapter = get_stream_adapter("langflow", _ctx()) + assert adapter.terminal_error_type == "error" + + +class TestPayloadSerialization: + """``data`` is serialized to a JSON string; non-JSON values are coerced safely.""" + + def test_dict_with_nested_values_round_trips(self): + adapter = get_stream_adapter("langflow", _ctx()) + data = {"text": "hi", "meta": {"k": [1, 2, 3]}} + [evt] = list(adapter.translate("add_message", data)) + assert json.loads(evt.data_json) == {"event": "add_message", "data": data} + + def test_non_serializable_values_fall_back_to_str(self): + adapter = get_stream_adapter("langflow", _ctx()) + + class Custom: + def __str__(self) -> str: + return "" + + [evt] = list(adapter.translate("log", {"obj": Custom()})) + payload = json.loads(evt.data_json) + # The adapter should not raise; the custom object is stringified. + assert payload["data"]["obj"] == "" + + +class TestEventTypeForwarding: + """Every Langflow EventManager event type round-trips through the adapter.""" + + @pytest.mark.parametrize( + "event_type", + [ + "token", + "vertices_sorted", + "build_start", + "end_vertex", + "add_message", + "remove_message", + "log", + "end", + "error", + ], + ) + def test_known_event_types_are_passed_through(self, event_type): + adapter = get_stream_adapter("langflow", _ctx()) + [evt] = list(adapter.translate(event_type, {"k": "v"})) + assert evt.type == event_type + assert json.loads(evt.data_json)["event"] == event_type diff --git a/src/backend/tests/unit/api/v2/adapters/test_registry.py b/src/backend/tests/unit/api/v2/adapters/test_registry.py new file mode 100644 index 0000000000..6ed65e6024 --- /dev/null +++ b/src/backend/tests/unit/api/v2/adapters/test_registry.py @@ -0,0 +1,123 @@ +"""Contract tests for the stream-adapter registry. + +The registry pattern lets us add new wire protocols (AG-UI, OpenAI Responses, +Vercel AI SDK, ...) without changing the v2 workflows endpoint. Each adapter +registers under a string name; the endpoint dispatches by name and returns +422 with the available list on unknown values. +""" + +from __future__ import annotations + +import pytest +from langflow.api.v2.adapters import ( + STREAM_ADAPTERS, + StreamAdapterContext, + StreamEvent, + UnknownStreamProtocolError, + available_protocols, + get_stream_adapter, + register_stream_adapter, +) + + +@pytest.fixture +def isolated_registry(): + """Snapshot and restore ``STREAM_ADAPTERS`` so tests don't leak registrations.""" + snapshot = dict(STREAM_ADAPTERS) + try: + yield STREAM_ADAPTERS + finally: + STREAM_ADAPTERS.clear() + STREAM_ADAPTERS.update(snapshot) + + +def _ctx() -> StreamAdapterContext: + return StreamAdapterContext(run_id="run-1", thread_id="thread-1") + + +class _NoopAdapter: + """Minimal adapter implementing the Protocol surface.""" + + name = "noop" + + def __init__(self, context: StreamAdapterContext) -> None: + self.context = context + + def initial_events(self): + return [] + + def translate(self, event_type, _event_data): + return [StreamEvent(type=event_type, data_json="{}")] + + def final_events(self): + return [] + + def error_events(self, _error): + return [] + + @property + def terminal_error_type(self): + return None + + +class TestBuiltinProtocols: + """The built-in adapters are registered at import time.""" + + def test_langflow_protocol_is_available_by_default(self): + assert "langflow" in available_protocols() + + def test_agui_protocol_is_available_by_default(self): + assert "agui" in available_protocols() + + +class TestRegistration: + """Registering a new protocol makes it available via lookup.""" + + def test_registered_adapter_can_be_resolved(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect + register_stream_adapter("noop", _NoopAdapter) + + adapter = get_stream_adapter("noop", _ctx()) + + assert isinstance(adapter, _NoopAdapter) + assert adapter.context.run_id == "run-1" + assert adapter.context.thread_id == "thread-1" + + def test_available_protocols_includes_new_registration(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect + register_stream_adapter("noop", _NoopAdapter) + assert "noop" in available_protocols() + + def test_available_protocols_is_sorted(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect + register_stream_adapter("zzz-last", _NoopAdapter) + register_stream_adapter("aaa-first", _NoopAdapter) + names = available_protocols() + assert names == sorted(names) + + +class TestUnknownProtocol: + """Looking up an unknown protocol raises with the available list.""" + + def test_unknown_protocol_raises_with_available_list(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect + with pytest.raises(UnknownStreamProtocolError) as exc: + get_stream_adapter("nonexistent", _ctx()) + + assert exc.value.name == "nonexistent" + assert "langflow" in exc.value.available + # The error should be inspectable; the string form should mention both + # the unknown name and the available names so a 422 detail can include them. + msg = str(exc.value) + assert "nonexistent" in msg + assert "langflow" in msg + + +class TestStreamEvent: + """``StreamEvent`` is the contract between adapter and SSE framer.""" + + def test_stream_event_is_immutable(self): + evt = StreamEvent(type="token", data_json='{"event":"token"}') + with pytest.raises((AttributeError, TypeError, Exception)): + evt.type = "other" # type: ignore[misc] + + def test_stream_event_carries_type_and_serialized_data(self): + evt = StreamEvent(type="RUN_STARTED", data_json='{"type":"RUN_STARTED"}') + assert evt.type == "RUN_STARTED" + assert evt.data_json == '{"type":"RUN_STARTED"}' diff --git a/src/backend/tests/unit/api/v2/test_agui_translator.py b/src/backend/tests/unit/api/v2/test_agui_translator.py new file mode 100644 index 0000000000..0797105bc7 --- /dev/null +++ b/src/backend/tests/unit/api/v2/test_agui_translator.py @@ -0,0 +1,700 @@ +"""Unit tests for the AG-UI translator. + +The translator consumes Langflow ``EventManager`` events and emits AG-UI +protocol events. These tests feed real ``EventManager`` event payloads and +assert on the emitted AG-UI event objects. No mocks: the translator is a pure, +stateful transformation with no I/O. +""" + +from __future__ import annotations + +from ag_ui.core import ( + CustomEvent, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + StateDeltaEvent, + StateSnapshotEvent, + StepFinishedEvent, + StepStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from langflow.api.v2.agui_translator import AGUITranslator + + +def test_run_lifecycle_emits_started_and_finished(): + t = AGUITranslator(run_id="r1", thread_id="t1") + + started = t.start() + ended = t.translate("end", {}) + + assert isinstance(started[0], RunStartedEvent) + assert started[0].run_id == "r1" + assert started[0].thread_id == "t1" + assert isinstance(ended[0], RunFinishedEvent) + assert ended[0].run_id == "r1" + assert ended[0].thread_id == "t1" + + +def test_error_emits_run_error(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("error", {"error": "boom"}) + + assert isinstance(out[0], RunErrorEvent) + assert "boom" in out[0].message + + +def test_error_reads_text_from_error_message_payload(): + """``error`` can carry a full ErrorMessage dump whose reason is in ``text``.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("error", {"text": "component blew up", "category": "error"}) + + assert isinstance(out[0], RunErrorEvent) + assert "component blew up" in out[0].message + + +def test_token_sequence_emits_start_contents_then_end_on_boundary(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + first = t.translate("token", {"chunk": "Hel", "id": "m1"}) + second = t.translate("token", {"chunk": "lo", "id": "m1"}) + ended = t.translate("end", {}) + + # First token opens the message and carries its delta. + assert isinstance(first[0], TextMessageStartEvent) + assert first[0].message_id == "m1" + assert isinstance(first[1], TextMessageContentEvent) + assert first[1].message_id == "m1" + assert first[1].delta == "Hel" + + # Subsequent tokens are content only, same message id. + assert len(second) == 1 + assert isinstance(second[0], TextMessageContentEvent) + assert second[0].message_id == "m1" + assert second[0].delta == "lo" + + # The end boundary closes the open message before finishing the run. + assert isinstance(ended[0], TextMessageEndEvent) + assert ended[0].message_id == "m1" + assert isinstance(ended[1], RunFinishedEvent) + + +def test_new_message_id_closes_previous_message_and_opens_new(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "a", "id": "m1"}) + out = t.translate("token", {"chunk": "b", "id": "m2"}) + + assert isinstance(out[0], TextMessageEndEvent) + assert out[0].message_id == "m1" + assert isinstance(out[1], TextMessageStartEvent) + assert out[1].message_id == "m2" + assert isinstance(out[2], TextMessageContentEvent) + assert out[2].message_id == "m2" + assert out[2].delta == "b" + + +def test_error_boundary_closes_open_text_message(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "partial", "id": "m1"}) + out = t.translate("error", {"error": "boom"}) + + assert isinstance(out[0], TextMessageEndEvent) + assert out[0].message_id == "m1" + assert isinstance(out[1], RunErrorEvent) + + +def test_interleaved_non_terminal_event_does_not_split_open_message(): + """A non-terminal event between tokens must not close the streamed message. + + Langflow's agent streams one message id for its whole response while other + events (build_start, add_message for other messages, log) interleave. Closing + the message on those would split it into two START/END pairs reusing an + already-ended id, which is malformed AG-UI. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "Hel", "id": "m1"}) + interleaved = t.translate("build_start", {"id": "node-x"}) + more = t.translate("token", {"chunk": "lo", "id": "m1"}) + + # The interleaved non-terminal event must not close the open message. + assert all(not isinstance(e, TextMessageEndEvent) for e in interleaved) + # The continuing token must not re-open an already-open message. + assert all(not isinstance(e, TextMessageStartEvent) for e in more) + assert isinstance(more[0], TextMessageContentEvent) + assert more[0].message_id == "m1" + assert more[0].delta == "lo" + + +def test_token_for_already_ended_message_id_is_dropped(): + """A ``token`` arriving after ``add_message`` ended that id must not re-open it. + + Some Langflow agents fire ``add_message`` with the final text first, which + emits START/CONTENT/END for the id and records it in + ``_emitted_text_message_ids``. Late tokens for the same id used to emit a + fresh ``TextMessageStartEvent`` for an already-ended message id, which is + malformed AG-UI. The translator must drop those tokens silently. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + # add_message emits START/CONTENT/END for m1 and records m1 as emitted. + add_message_events = t.translate("add_message", {"id": "m1", "text": "hello", "sender": "AI"}) + assert any(isinstance(e, TextMessageEndEvent) for e in add_message_events) + + # A late token for the same id must not re-open the ended message. + late = t.translate("token", {"chunk": "x", "id": "m1"}) + + assert all(not isinstance(e, TextMessageStartEvent) for e in late), ( + f"Late token re-opened an already-ended message id; emitted: {late}" + ) + + +def test_token_after_boundary_close_is_dropped(): + """An interleaved token sequence ``A, B, A`` must not re-open id A. + + Switching from id A to id B closes A through ``_close_open_message``. A + later token for A used to slip past the dedup guard because + ``_close_open_message`` was the only finalizer that did not record the + closed id in ``_emitted_text_message_ids``. The translator must treat a + token-boundary close the same way it treats an ``add_message`` close. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + a1 = t.translate("token", {"chunk": "hi", "id": "m1"}) + assert any(isinstance(e, TextMessageStartEvent) and e.message_id == "m1" for e in a1) + + # Switching to a new id closes m1 via _close_open_message. + b = t.translate("token", {"chunk": "yo", "id": "m2"}) + assert any(isinstance(e, TextMessageEndEvent) and e.message_id == "m1" for e in b) + assert any(isinstance(e, TextMessageStartEvent) and e.message_id == "m2" for e in b) + + # A late token for m1 must be dropped, not re-open the ended message. + late = t.translate("token", {"chunk": "again", "id": "m1"}) + assert all(not isinstance(e, TextMessageStartEvent) for e in late), ( + f"Token boundary did not mark m1 as ended; emitted: {late}" + ) + assert late == [], f"Expected no events for late token after boundary close; got {late}" + + +def test_vertices_sorted_emits_state_snapshot_of_all_nodes(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("vertices_sorted", {"ids": ["a"], "to_run": ["a", "b", "c"]}) + + assert len(out) == 1 + assert isinstance(out[0], StateSnapshotEvent) + nodes = out[0].snapshot["nodes"] + # The snapshot covers the full run set (to_run), not just the first layer. + assert set(nodes) == {"a", "b", "c"} + for node in nodes.values(): + assert node["status"] == "pending" + assert node["output"] is None + + +def test_vertices_sorted_falls_back_to_ids_when_to_run_absent(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("vertices_sorted", {"ids": ["a", "b"]}) + + assert isinstance(out[0], StateSnapshotEvent) + assert set(out[0].snapshot["nodes"]) == {"a", "b"} + + +def test_start_establishes_empty_node_state(): + """start() must seed the state container so later node deltas always apply.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + + started = t.start() + + assert isinstance(started[0], RunStartedEvent) + snapshots = [e for e in started if isinstance(e, StateSnapshotEvent)] + assert snapshots + assert snapshots[0].snapshot == {"nodes": {}} + + +def test_build_start_for_node_emits_step_started_and_running_delta(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("build_start", {"id": "node-x"}) + + assert isinstance(out[0], StepStartedEvent) + assert out[0].step_name == "node-x" + assert isinstance(out[1], StateDeltaEvent) + op = out[1].delta[0] + assert op["op"] == "add" + assert op["path"] == "/nodes/node-x" + assert op["value"] == {"status": "running", "output": None} + + +def test_graph_level_build_start_with_no_id_is_noop(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + # The graph-level build_start (the /build path) carries no node id; + # RUN_STARTED already signals the run beginning. + assert t.translate("build_start", {}) == [] + + +def test_end_vertex_success_emits_step_finished_and_state_delta(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate( + "end_vertex", + {"build_data": {"id": "node-x", "valid": True, "data": {"outputs": {"out": "hello"}}}}, + ) + + assert isinstance(out[0], StepFinishedEvent) + assert out[0].step_name == "node-x" + assert isinstance(out[1], StateDeltaEvent) + op = out[1].delta[0] + assert op["op"] == "add" + assert op["path"] == "/nodes/node-x" + assert op["value"] == {"status": "success", "output": {"outputs": {"out": "hello"}}} + + +def test_end_vertex_failure_sets_error_status(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": False, "data": None}}) + + op = out[1].delta[0] + assert op["value"]["status"] == "error" + + +def test_node_state_deltas_use_add_so_they_apply_without_vertices_sorted(): + """build_start/end_vertex must not depend on vertices_sorted seeding the node. + + Per-node build events and vertices_sorted come from different Langflow + execution paths that do not co-occur. start() establishes ``/nodes`` and node + deltas use ``add`` on ``/nodes/{id}`` (create-or-replace), so the JSON Patch + applies cleanly whether or not a STATE_SNAPSHOT seeded the node first. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() # no vertices_sorted in this path + + build = t.translate("build_start", {"id": "node-x"}) + end = t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": True, "data": "ok"}}) + + for events in (build, end): + delta = next(e for e in events if isinstance(e, StateDeltaEvent)) + op = delta.delta[0] + assert op["op"] == "add" + assert op["path"] == "/nodes/node-x" + + +def test_end_vertex_does_not_close_open_text_message(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "Hel", "id": "m1"}) + t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": True, "data": {}}}) + more = t.translate("token", {"chunk": "lo", "id": "m1"}) + + assert all(not isinstance(e, TextMessageStartEvent) for e in more) + assert isinstance(more[0], TextMessageContentEvent) + assert more[0].message_id == "m1" + + +def test_token_without_message_id_is_dropped(): + """Token events with missing ``id`` cannot drive the AG-UI lifecycle. + + A TextMessageStart/Content/End triple requires a stable id to correlate + chunks. Emitting events with ``message_id=""`` produces malformed + streams that some AG-UI clients reject; drop the event instead. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + no_id = t.translate("token", {"chunk": "hello"}) + none_id = t.translate("token", {"chunk": "hello", "id": None}) + empty_id = t.translate("token", {"chunk": "hello", "id": ""}) + + assert no_id == [] + assert none_id == [] + assert empty_id == [] + + +def test_add_message_without_message_id_skips_text_lifecycle(): + """``add_message`` without an id must not emit TextMessage* events. + + Tool-call sub-events can still ride a missing id (they namespace by + block/content index), but the text lifecycle needs a stable id. + """ + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("add_message", {"text": "hello"}) + + assert all(not isinstance(e, TextMessageStartEvent) for e in out) + assert all(not isinstance(e, TextMessageContentEvent) for e in out) + assert all(not isinstance(e, TextMessageEndEvent) for e in out) + + +def test_add_message_plain_text_emits_a_text_message(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("add_message", {"id": "m1", "text": "The weather is sunny."}) + + assert isinstance(out[0], TextMessageStartEvent) + assert out[0].message_id == "m1" + assert isinstance(out[1], TextMessageContentEvent) + assert out[1].delta == "The weather is sunny." + assert isinstance(out[2], TextMessageEndEvent) + assert out[2].message_id == "m1" + + +def test_add_message_finalizing_a_streamed_message_does_not_duplicate_text(): + """A streamed message's add_message finalizer only closes it, never re-emits text.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "The weather ", "id": "m1"}) + t.translate("token", {"chunk": "is sunny.", "id": "m1"}) + out = t.translate("add_message", {"id": "m1", "text": "The weather is sunny."}) + + assert all(not isinstance(e, TextMessageStartEvent) for e in out) + assert all(not isinstance(e, TextMessageContentEvent) for e in out) + assert isinstance(out[0], TextMessageEndEvent) + assert out[0].message_id == "m1" + + +def test_add_message_tool_use_emits_tool_call_lifecycle(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate( + "add_message", + { + "id": "m1", + "text": "", + "content_blocks": [ + { + "title": "Agent Steps", + "contents": [ + { + "type": "tool_use", + "name": "search", + "tool_input": {"query": "weather"}, + "output": "sunny", + "error": None, + } + ], + } + ], + }, + ) + + starts = [e for e in out if isinstance(e, ToolCallStartEvent)] + args = [e for e in out if isinstance(e, ToolCallArgsEvent)] + ends = [e for e in out if isinstance(e, ToolCallEndEvent)] + results = [e for e in out if isinstance(e, ToolCallResultEvent)] + assert len(starts) == 1 + assert starts[0].tool_call_name == "search" + assert starts[0].parent_message_id == "m1" + assert len(args) == 1 + assert "weather" in args[0].delta + assert len(ends) == 1 + assert len(results) == 1 + assert "sunny" in results[0].content + # The four events share one tool_call_id. + assert {starts[0].tool_call_id, args[0].tool_call_id, ends[0].tool_call_id, results[0].tool_call_id} == { + starts[0].tool_call_id + } + + +def test_tool_use_error_is_reported_via_tool_call_result(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate( + "add_message", + { + "id": "m1", + "content_blocks": [ + { + "title": "Agent Steps", + "contents": [ + { + "type": "tool_use", + "name": "search", + "tool_input": {}, + "output": None, + "error": "rate limited", + } + ], + } + ], + }, + ) + + results = [e for e in out if isinstance(e, ToolCallResultEvent)] + assert len(results) == 1 + assert "rate limited" in results[0].content + + +def test_repeated_add_message_does_not_re_emit_the_same_tool_call(): + """A tool call already emitted is not re-emitted when add_message re-fires.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + payload = { + "id": "m1", + "content_blocks": [ + { + "title": "Agent Steps", + "contents": [ + {"type": "tool_use", "name": "search", "tool_input": {"q": "x"}, "output": "done", "error": None} + ], + } + ], + } + first = t.translate("add_message", payload) + second = t.translate("add_message", payload) + + assert len([e for e in first if isinstance(e, ToolCallStartEvent)]) == 1 + assert second == [] + + +def test_repeated_add_message_after_streamed_finalizer_does_not_duplicate_text(): + """A streamed message finalized once must not re-emit text on a later add_message.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate("token", {"chunk": "Hello", "id": "m1"}) + t.translate("add_message", {"id": "m1", "text": "Hello"}) # finalizes the streamed message + again = t.translate("add_message", {"id": "m1", "text": "Hello"}) # re-fire + + assert again == [] + + +def test_malformed_content_block_is_skipped_not_crashed(): + """A null or non-dict entry in content_blocks is skipped, not fatal.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("add_message", {"id": "m1", "text": "hi", "content_blocks": [None, "garbage"]}) + + assert any(isinstance(e, TextMessageStartEvent) for e in out) + + +def test_custom_content_types_emit_langflow_custom_events(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate( + "add_message", + { + "id": "m1", + "content_blocks": [ + { + "title": "Steps", + "contents": [ + {"type": "json", "data": {"k": "v"}}, + {"type": "code", "code": "print(1)", "language": "python"}, + {"type": "media", "urls": ["http://x/img.png"], "caption": "pic"}, + {"type": "error", "reason": "boom", "traceback": "trace"}, + ], + } + ], + }, + ) + + customs = {e.name: e for e in out if isinstance(e, CustomEvent)} + assert set(customs) == { + "langflow.content.json", + "langflow.content.code", + "langflow.content.media", + "langflow.content.error", + } + assert customs["langflow.content.json"].value["message_id"] == "m1" + assert customs["langflow.content.json"].value["block_title"] == "Steps" + assert customs["langflow.content.code"].value["content"]["code"] == "print(1)" + + +def test_log_event_emits_langflow_log_custom_event(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("log", {"message": "hi", "type": "text", "name": "Log 1", "component_id": "c1"}) + + assert len(out) == 1 + assert isinstance(out[0], CustomEvent) + assert out[0].name == "langflow.log" + assert out[0].value["component_id"] == "c1" + + +def test_remove_message_emits_langflow_removed_custom_event(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + out = t.translate("remove_message", {"id": "m9"}) + + assert len(out) == 1 + assert isinstance(out[0], CustomEvent) + assert out[0].name == "langflow.message.removed" + assert out[0].value["message_id"] == "m9" + + +def test_repeated_custom_content_block_is_not_re_emitted(): + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + payload = { + "id": "m1", + "content_blocks": [{"title": "Steps", "contents": [{"type": "json", "data": {"k": "v"}}]}], + } + + first = t.translate("add_message", payload) + second = t.translate("add_message", payload) + + assert len([e for e in first if isinstance(e, CustomEvent)]) == 1 + assert second == [] + + +def test_updated_custom_content_block_is_re_emitted(): + """If a content block's payload changes on a later add_message, the update is emitted.""" + t = AGUITranslator(run_id="r1", thread_id="t1") + t.start() + + t.translate( + "add_message", + {"id": "m1", "content_blocks": [{"title": "S", "contents": [{"type": "json", "data": {}}]}]}, + ) + out = t.translate( + "add_message", + {"id": "m1", "content_blocks": [{"title": "S", "contents": [{"type": "json", "data": {"k": "v"}}]}]}, + ) + + customs = [e for e in out if isinstance(e, CustomEvent)] + assert len(customs) == 1 + assert customs[0].value["content"]["data"] == {"k": "v"} + + +# --- Full-sequence integration coverage --------------------------------------- + + +def _run_sequence(translator: AGUITranslator, events: list[tuple[str, dict]]) -> list: + """Feed start() then a list of (event_type, data) pairs, collecting all output.""" + out = list(translator.start()) + for event_type, data in events: + out.extend(translator.translate(event_type, data)) + return out + + +def _assert_well_formed(events: list) -> None: + """Assert an emitted AG-UI stream is structurally valid.""" + assert events, "expected a non-empty event stream" + assert isinstance(events[0], RunStartedEvent) + assert isinstance(events[-1], (RunFinishedEvent, RunErrorEvent)) + + open_messages: set[str] = set() + seen_messages: set[str] = set() + for event in events: + if isinstance(event, TextMessageStartEvent): + assert event.message_id not in open_messages, "text message started while already open" + assert event.message_id not in seen_messages, "text message id reused after it ended" + open_messages.add(event.message_id) + seen_messages.add(event.message_id) + elif isinstance(event, TextMessageContentEvent): + assert event.message_id in open_messages, "text content for a message that is not open" + elif isinstance(event, TextMessageEndEvent): + assert event.message_id in open_messages, "text message ended without being open" + open_messages.discard(event.message_id) + assert not open_messages, "text messages left unclosed" + + started_tools: set[str] = set() + ended_tools: set[str] = set() + for event in events: + if isinstance(event, ToolCallStartEvent): + assert event.tool_call_id not in started_tools, "tool call started twice" + started_tools.add(event.tool_call_id) + elif isinstance(event, ToolCallArgsEvent): + assert event.tool_call_id in started_tools, "tool args before tool start" + elif isinstance(event, ToolCallEndEvent): + ended_tools.add(event.tool_call_id) + assert started_tools == ended_tools, "every tool call needs a matching START and END" + + +_AGENT_STEPS = [ + { + "title": "Agent Steps", + "contents": [ + {"type": "tool_use", "name": "search", "tool_input": {"q": "weather"}, "output": "sunny", "error": None} + ], + } +] + + +def test_full_agent_flow_sequence_is_well_formed(): + """A realistic ChatInput -> Agent -> ChatOutput run yields a well-formed stream.""" + t = AGUITranslator(run_id="run-1", thread_id="sess-1") + sequence = [ + ("vertices_sorted", {"ids": ["ChatInput-a"], "to_run": ["ChatInput-a", "Agent-b", "ChatOutput-c"]}), + ("build_start", {}), + ("end_vertex", {"build_data": {"id": "ChatInput-a", "valid": True, "data": {"outputs": {}}}}), + # The agent surfaces its tool step on a partial message update. + ("add_message", {"id": "m1", "text": "", "properties": {"state": "partial"}, "content_blocks": _AGENT_STEPS}), + # The agent streams its final answer token by token. + ("token", {"chunk": "It is ", "id": "m1"}), + ("token", {"chunk": "sunny.", "id": "m1"}), + # The complete message finalizes the streamed answer. + ( + "add_message", + {"id": "m1", "text": "It is sunny.", "properties": {"state": "complete"}, "content_blocks": _AGENT_STEPS}, + ), + ("end_vertex", {"build_data": {"id": "Agent-b", "valid": True, "data": {"outputs": {}}}}), + ("end_vertex", {"build_data": {"id": "ChatOutput-c", "valid": True, "data": {"outputs": {}}}}), + ("end", {"build_duration": 1.23}), + ] + + out = _run_sequence(t, sequence) + + _assert_well_formed(out) + assert isinstance(out[-1], RunFinishedEvent) + # The tool call appears in two add_message events but is emitted exactly once. + assert len([e for e in out if isinstance(e, ToolCallStartEvent)]) == 1 + assert len([e for e in out if isinstance(e, ToolCallResultEvent)]) == 1 + # The streamed message has exactly one START/END pair. + assert len([e for e in out if isinstance(e, TextMessageStartEvent)]) == 1 + assert len([e for e in out if isinstance(e, TextMessageEndEvent)]) == 1 + # Every node ran: three STEP_FINISHED events. + assert len([e for e in out if isinstance(e, StepFinishedEvent)]) == 3 + + +def test_full_sequence_ending_in_error_is_well_formed(): + """A run that errors mid-stream still closes the open message and ends in RUN_ERROR.""" + t = AGUITranslator(run_id="run-2", thread_id="sess-2") + sequence = [ + ("vertices_sorted", {"ids": ["ChatInput-a"], "to_run": ["ChatInput-a", "Agent-b"]}), + ("token", {"chunk": "partial answer", "id": "m1"}), + ("error", {"text": "the agent crashed"}), + ] + + out = _run_sequence(t, sequence) + + _assert_well_formed(out) + assert isinstance(out[-1], RunErrorEvent) + assert "the agent crashed" in out[-1].message diff --git a/src/backend/tests/unit/api/v2/test_converters.py b/src/backend/tests/unit/api/v2/test_converters.py index f86f847d78..d61dec65f2 100644 --- a/src/backend/tests/unit/api/v2/test_converters.py +++ b/src/backend/tests/unit/api/v2/test_converters.py @@ -4,7 +4,7 @@ This test module provides extensive coverage of the converter functions that transform between V2 workflow schemas and V1 schemas. Tests include: Test Coverage: - - Input parsing and transformation (parse_flat_inputs) + - Native body parsing (parse_workflow_run_request) - Nested value extraction from various data structures - Text extraction from different message formats - Model source and file path extraction @@ -37,13 +37,11 @@ from langflow.api.v2.converters import ( _simplify_output_content, create_error_response, create_job_response, - parse_flat_inputs, run_response_to_workflow_response, ) from lfx.schema.workflow import ( ErrorDetail, JobStatus, - WorkflowExecutionRequest, WorkflowExecutionResponse, WorkflowJobResponse, ) @@ -60,194 +58,6 @@ def _setup_graph_get_vertex(graph: Mock, vertices: list[Mock]) -> None: graph.get_vertex = Mock(side_effect=lambda vid: vertex_map.get(vid)) -class TestParseFlatInputs: - """Test suite for parse_flat_inputs function.""" - - def test_parse_flat_inputs_basic(self): - """Test basic flat input parsing with component_id.param format.""" - inputs = { - "ChatInput-abc.input_value": "hello", - "LLM-xyz.temperature": 0.7, - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "ChatInput-abc": {"input_value": "hello"}, - "LLM-xyz": {"temperature": 0.7}, - } - assert session_id is None - - def test_parse_flat_inputs_with_session_id(self): - """Test parsing with session_id extraction.""" - inputs = { - "ChatInput-abc.input_value": "hello", - "ChatInput-abc.session_id": "session-123", - "LLM-xyz.temperature": 0.7, - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "ChatInput-abc": {"input_value": "hello", "session_id": "session-123"}, - "LLM-xyz": {"temperature": 0.7}, - } - assert session_id == "session-123" - - def test_parse_flat_inputs_multiple_session_ids(self): - """Test that first session_id is used when multiple are provided.""" - inputs = { - "ChatInput-abc.session_id": "session-first", - "ChatInput-xyz.session_id": "session-second", - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert session_id == "session-first" - assert tweaks == { - "ChatInput-abc": {"session_id": "session-first"}, - "ChatInput-xyz": {"session_id": "session-second"}, - } - - def test_parse_flat_inputs_dict_values(self): - """Test backward compatibility with dict values (no dot notation).""" - inputs = { - "ChatInput-abc": {"input_value": "hello", "temperature": 0.5}, - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == {"ChatInput-abc": {"input_value": "hello", "temperature": 0.5}} - assert session_id is None - - def test_parse_flat_inputs_mixed_formats(self): - """Test mixed flat and dict formats.""" - inputs = { - "ChatInput-abc.input_value": "hello", - "LLM-xyz": {"temperature": 0.7, "max_tokens": 100}, - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "ChatInput-abc": {"input_value": "hello"}, - "LLM-xyz": {"temperature": 0.7, "max_tokens": 100}, - } - assert session_id is None - - def test_parse_flat_inputs_empty(self): - """Test with empty inputs.""" - tweaks, session_id = parse_flat_inputs({}) - - assert tweaks == {} - assert session_id is None - - def test_parse_flat_inputs_multiple_params_same_component(self): - """Test multiple parameters for the same component.""" - inputs = { - "LLM-xyz.temperature": 0.7, - "LLM-xyz.max_tokens": 100, - "LLM-xyz.top_p": 0.9, - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "LLM-xyz": { - "temperature": 0.7, - "max_tokens": 100, - "top_p": 0.9, - } - } - assert session_id is None - - def test_parse_flat_inputs_malformed_key_no_dot(self): - """Test handling of non-dict value without dot notation (edge case).""" - # Non-dict values without dots are ignored (not valid component.param format) - inputs = { - "ChatInput-abc.input_value": "hello", - "invalid_key_no_dot": "should be ignored", - "LLM-xyz.temperature": 0.7, - } - tweaks, session_id = parse_flat_inputs(inputs) - - # Only valid dot-notation keys should be parsed - assert tweaks == { - "ChatInput-abc": {"input_value": "hello"}, - "LLM-xyz": {"temperature": 0.7}, - } - assert session_id is None - - def test_parse_flat_inputs_null_values(self): - """Test handling of None/null values in inputs.""" - inputs = { - "ChatInput-abc.input_value": None, - "LLM-xyz.temperature": 0.7, - "DataNode-123.data": None, - } - tweaks, session_id = parse_flat_inputs(inputs) - - # None values should be preserved - assert tweaks == { - "ChatInput-abc": {"input_value": None}, - "LLM-xyz": {"temperature": 0.7}, - "DataNode-123": {"data": None}, - } - assert session_id is None - - def test_parse_flat_inputs_deeply_nested_dict(self): - """Test handling of deeply nested dict structures (backward compatibility).""" - inputs = {"Component-abc": {"level1": {"level2": {"level3": {"value": "deeply nested"}}}}} - tweaks, session_id = parse_flat_inputs(inputs) - - # Deeply nested dicts should be preserved as-is - assert tweaks == {"Component-abc": {"level1": {"level2": {"level3": {"value": "deeply nested"}}}}} - assert session_id is None - - def test_parse_flat_inputs_empty_collections(self): - """Test handling of empty lists and dicts as values.""" - inputs = { - "Component-1.list_param": [], - "Component-2.dict_param": {}, - "Component-3.string_param": "", - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "Component-1": {"list_param": []}, - "Component-2": {"dict_param": {}}, - "Component-3": {"string_param": ""}, - } - assert session_id is None - - def test_parse_flat_inputs_explicit_none_values(self): - """Test explicit None checks - None should be preserved, not treated as missing.""" - inputs = { - "Component-1.optional_param": None, - "Component-2.required_param": "value", - "Component-3.another_none": None, - } - tweaks, session_id = parse_flat_inputs(inputs) - - # None values should be explicitly preserved - assert tweaks == { - "Component-1": {"optional_param": None}, - "Component-2": {"required_param": "value"}, - "Component-3": {"another_none": None}, - } - assert session_id is None - - def test_parse_flat_inputs_special_characters_in_keys(self): - """Test handling of special characters (dashes, underscores) in component IDs and parameter names.""" - inputs = { - "Component-with-dashes.param_with_underscores": "value1", - "Component_123.param-456": "value2", - "Component_With_Underscores.param_name": "value3", - } - tweaks, session_id = parse_flat_inputs(inputs) - - assert tweaks == { - "Component-with-dashes": {"param_with_underscores": "value1"}, - "Component_123": {"param-456": "value2"}, - "Component_With_Underscores": {"param_name": "value3"}, - } - assert session_id is None - - class TestExtractNestedValue: """Test suite for _extract_nested_value helper function with realistic payload structures.""" @@ -863,10 +673,10 @@ class TestCreateErrorResponse: """Test error response structure.""" flow_id = "flow-123" job_id = uuid4() - request = WorkflowExecutionRequest(flow_id=flow_id, inputs={"test": "input"}) + request_inputs = {"test": "input"} error = ValueError("Test error message") - response = create_error_response(flow_id, str(job_id), request, error) + response = create_error_response(flow_id, str(job_id), request_inputs, error) assert isinstance(response, WorkflowExecutionResponse) assert response.flow_id == flow_id @@ -879,7 +689,7 @@ class TestCreateErrorResponse: """Test error details in response.""" error = RuntimeError("Runtime error occurred") job_id = str(uuid4()) - response = create_error_response("flow-1", job_id, WorkflowExecutionRequest(flow_id="flow-1", inputs={}), error) + response = create_error_response("flow-1", job_id, {}, error) error_detail = response.errors[0] assert isinstance(error_detail, ErrorDetail) @@ -891,19 +701,18 @@ class TestCreateErrorResponse: def test_create_error_response_preserves_inputs(self): """Test that original inputs are preserved in error response.""" inputs = {"component.param": "value"} - request = WorkflowExecutionRequest(flow_id="flow-1", inputs=inputs) + request_inputs = inputs error = Exception("Error") - response = create_error_response("flow-1", str(uuid4()), request, error) + response = create_error_response("flow-1", str(uuid4()), request_inputs, error) assert response.inputs == inputs def test_create_error_response_preserves_globals(self): """Test that body globals are preserved in error response.""" globals_ = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"} - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}, globals=globals_) error = Exception("Error") - response = create_error_response("flow-1", str(uuid4()), request, error) + response = create_error_response("flow-1", str(uuid4()), {}, error, effective_globals=globals_) assert response.globals == globals_ @@ -937,11 +746,11 @@ class TestRunResponseToWorkflowResponse: run_response.outputs = [run_output] # Create request - request = WorkflowExecutionRequest(flow_id="flow-123", inputs={"test": "input"}) + request_inputs = {"test": "input"} # Convert job_id = uuid4() - response = run_response_to_workflow_response(run_response, "flow-123", str(job_id), request, graph) + response = run_response_to_workflow_response(run_response, "flow-123", str(job_id), request_inputs, graph) assert isinstance(response, WorkflowExecutionResponse) assert response.flow_id == "flow-123" @@ -960,9 +769,10 @@ class TestRunResponseToWorkflowResponse: run_response.outputs = [] globals_ = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"} - request = WorkflowExecutionRequest(flow_id="flow-123", inputs={}, globals=globals_) - response = run_response_to_workflow_response(run_response, "flow-123", str(uuid4()), request, graph) + response = run_response_to_workflow_response( + run_response, "flow-123", str(uuid4()), {}, graph, effective_globals=globals_ + ) assert response.globals == globals_ @@ -992,10 +802,10 @@ class TestRunResponseToWorkflowResponse: run_output.outputs = [result_data] run_response.outputs = [run_output] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) assert "llm-123" in response.outputs output = response.outputs["llm-123"] @@ -1028,10 +838,10 @@ class TestRunResponseToWorkflowResponse: run_response = Mock() run_response.outputs = [] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Should use IDs instead of duplicate display names assert "output-1" in response.outputs @@ -1065,10 +875,10 @@ class TestRunResponseToWorkflowResponse: run_output.outputs = [result_data] run_response.outputs = [run_output] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Data type non-output nodes should show content assert response.outputs["data-123"].content == {"result": "42"} @@ -1092,10 +902,10 @@ class TestRunResponseToWorkflowResponse: run_response = Mock() run_response.outputs = [] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) assert "output-123" in response.outputs @@ -1110,10 +920,10 @@ class TestRunResponseToWorkflowResponse: run_response.outputs = [] inputs = {"component.param": "value"} - request = WorkflowExecutionRequest(flow_id="flow-1", inputs=inputs) + request_inputs = inputs job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) assert response.inputs == inputs def test_run_response_vector_store_terminal(self): @@ -1140,10 +950,10 @@ class TestRunResponseToWorkflowResponse: run_output.outputs = [result_data] run_response.outputs = [run_output] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Data type non-output nodes should show content assert "pinecone-123" in response.outputs @@ -1176,10 +986,10 @@ class TestRunResponseToWorkflowResponse: run_output.outputs = [result_data] run_response.outputs = [run_output] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Should include content and metadata assert "retriever-456" in response.outputs @@ -1201,10 +1011,10 @@ class TestRunResponseToWorkflowResponse: run_response = Mock() run_response.outputs = None - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) assert response.outputs == {} assert response.status == JobStatus.COMPLETED @@ -1228,11 +1038,11 @@ class TestRunResponseToWorkflowResponse: run_response = Mock() run_response.outputs = [] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} # Should handle gracefully without crashing job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Should use ID as fallback when display_name is None assert "corrupted-123" in response.outputs @@ -1263,16 +1073,77 @@ class TestRunResponseToWorkflowResponse: run_output.outputs = [result_data] run_response.outputs = [run_output] - request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}) + request_inputs = {} # Should handle gracefully - vertex won't match result_data job_id = str(uuid4()) - response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph) + response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph) # Output should exist but with no content (no matching result_data) assert "output-123" in response.outputs assert response.outputs["output-123"].content is None +_VALID_UUID = "67ccd2be-17f0-8190-81ff-3bb2cf6508e6" + + +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 + + parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID)) + + assert parsed.flow_id == _VALID_UUID + assert parsed.tweaks == {} + assert parsed.input_value == "" + assert parsed.session_id is None + assert parsed.run_id is None + assert parsed.mode == "sync" + assert parsed.start_component_id is None + assert parsed.stop_component_id is None + assert parsed.data is None + 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 + + request = WorkflowRunRequest( + flow_id=_VALID_UUID, + input_value="hello", + tweaks={"ChatInput-abc": {"some_param": "v"}}, + session_id="session-123", + mode=WorkflowMode.STREAM, + stream_protocol="agui", + data={"nodes": [], "edges": []}, + files=["a.txt", "b.png"], + start_component_id="ChatInput-abc", + stop_component_id="ChatOutput-xyz", + ) + + parsed = parse_workflow_run_request(request) + + assert parsed.flow_id == _VALID_UUID + assert parsed.tweaks == {"ChatInput-abc": {"some_param": "v"}} + assert parsed.input_value == "hello" + assert parsed.session_id == "session-123" + assert parsed.mode == "stream" + assert parsed.start_component_id == "ChatInput-abc" + assert parsed.stop_component_id == "ChatOutput-xyz" + assert parsed.data == {"nodes": [], "edges": []} + assert parsed.files == ["a.txt", "b.png"] + + 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 + + parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID)) + assert parsed.run_id is None + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/src/backend/tests/unit/api/v2/test_workflow.py b/src/backend/tests/unit/api/v2/test_workflow.py index bf2ca153fb..bc54b1f476 100644 --- a/src/backend/tests/unit/api/v2/test_workflow.py +++ b/src/backend/tests/unit/api/v2/test_workflow.py @@ -1,161 +1,35 @@ -"""Comprehensive unit tests for V2 Workflow API endpoints. +"""Unit tests for the V2 Workflow status, stop, and IDOR-protection endpoints. -This test module provides extensive coverage of the workflow execution endpoints, -including authentication, authorization, error handling, and execution modes. - -Test Coverage: - - Developer API protection (enabled/disabled scenarios) - - API key authentication requirements - - Flow validation and error handling - - Database error handling - - Execution timeout protection - - Synchronous execution with various component types - - Error response structure validation - - Multiple execution modes (sync, stream, background) +The AG-UI request contract for ``POST /workflows`` is covered, no-mocks, in +``test_workflow_agui.py``. This module covers the ``GET /workflows`` status +endpoint, ``POST /workflows/stop``, and cross-user ownership enforcement. Test Organization: - - TestWorkflowDeveloperAPIProtection: Tests developer API feature flag - - TestWorkflowErrorHandling: Tests comprehensive error scenarios - - TestWorkflowSyncExecution: Tests successful execution flows - -Test Strategy: - - Uses real database with proper cleanup - - Mocks external dependencies (LLM APIs, file operations) - - Tests both success and failure paths - - Validates response structure and status codes + - TestWorkflowDeveloperAPIProtection: endpoint reachability (status, stop) + - TestWorkflowStatus: workflow status retrieval + - TestWorkflowStop: stopping a running workflow + - TestWorkflowIDORProtection: cross-user ownership enforcement """ -import asyncio from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 import pytest from httpx import AsyncClient -from langflow.exceptions.api import WorkflowValidationError 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 - - -def _mock_empty_graph() -> MagicMock: - graph = MagicMock() - graph.vertices = [] - graph.get_terminal_nodes.return_value = [] - graph.run_id = None - - def set_run_id(job_id): - graph.run_id = str(job_id) - - graph.set_run_id.side_effect = set_run_id - return graph class TestWorkflowDeveloperAPIProtection: """Test developer API protection for workflow endpoints.""" - @pytest.fixture - def mock_settings_dev_api_disabled(self): - """Mock settings with developer API disabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = False - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - - async def test_execute_workflow_blocked_when_dev_api_disabled( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_disabled, # noqa: ARG002 - ): - """Test workflow execution is blocked when developer API is disabled.""" - request_data = { - "flow_id": "550e8400-e29b-41d4-a716-446655440000", - "background": False, - "stream": False, - "inputs": None, - } - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post( - "api/v2/workflows", - json=request_data, - headers=headers, - ) - - assert response.status_code == 403 - result = response.json() - assert result["detail"]["code"] == "DEVELOPER_API_DISABLED" - assert "Developer API" in result["detail"]["message"] - - async def test_stop_workflow_blocked_when_dev_api_disabled( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_disabled, # noqa: ARG002 - ): - """Test POST workflows/stop endpoint is blocked when developer API is disabled.""" - request_data = {"job_id": "550e8400-e29b-41d4-a716-446655440001"} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post( - "api/v2/workflows/stop", - json=request_data, - headers=headers, - ) - - assert response.status_code == 403 - result = response.json() - assert result["detail"]["code"] == "DEVELOPER_API_DISABLED" - - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - - async def test_execute_workflow_allowed_when_dev_api_enabled_flow_not_found( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test POST workflow execution is allowed when developer API is enabled - flow not found.""" - request_data = { - "flow_id": "550e8400-e29b-41d4-a716-446655440000", # Non-existent flow ID - "background": False, - "stream": False, - "inputs": None, - } - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post( - "api/v2/workflows", - json=request_data, - headers=headers, - ) - - # Should return 404 because flow doesn't exist, NOT because endpoint is disabled - assert response.status_code == 404 - result = response.json() - assert result["detail"]["code"] == "FLOW_NOT_FOUND" - assert "550e8400-e29b-41d4-a716-446655440000" in result["detail"]["flow_id"] - async def test_get_workflow_allowed_when_dev_api_enabled_job_not_found( self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET workflow endpoint is allowed when developer API is enabled - job not found.""" headers = {"x-api-key": created_api_key.api_key} @@ -174,7 +48,6 @@ class TestWorkflowDeveloperAPIProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test POST workflow/stop endpoint is allowed when developer API is enabled - job not found.""" request_data = { @@ -195,79 +68,10 @@ class TestWorkflowDeveloperAPIProtection: assert "550e8400-e29b-41d4-a716-446655440001" in result["detail"]["job_id"] assert "This endpoint is not available" not in response.text - async def test_get_workflow_blocked_when_dev_api_disabled( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_disabled, # noqa: ARG002 - ): - """Test GET workflow endpoint is blocked when developer API is disabled.""" - headers = {"x-api-key": created_api_key.api_key} - response = await client.get( - "api/v2/workflows?job_id=550e8400-e29b-41d4-a716-446655440001", - headers=headers, - ) - - assert response.status_code == 403 - result = response.json() - assert result["detail"]["code"] == "DEVELOPER_API_DISABLED" - - async def test_execute_workflow_allowed_when_dev_api_enabled_flow_exists( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test POST /workflow allowed when dev API enabled - flow exists and executes.""" - flow_id = uuid4() - - # Create a flow in the database using the established pattern - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Test flow for API testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow.id), "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post( - "api/v2/workflows", - json=request_data, - headers=headers, - ) - - # Should return 200 because flow is valid (empty nodes/edges is valid) - # The execution will complete successfully with no outputs - assert response.status_code == 200 - result = response.json() - - # Verify response contains expected fields with proper structure - assert "outputs" in result or "errors" in result - if "outputs" in result: - assert isinstance(result["outputs"], dict) - if "errors" in result: - assert isinstance(result["errors"], list) - - finally: - # Clean up the flow following established pattern - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - async def test_get_workflow_allowed_when_dev_api_enabled_job_exists( self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow allowed when dev API enabled - job exists (501 not implemented).""" # Since job management isn't implemented, we'll test with any job_id @@ -287,7 +91,6 @@ class TestWorkflowDeveloperAPIProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test POST /workflow/stop allowed when dev API enabled - job exists (501 not implemented).""" # Since job management isn't implemented, we'll test with any job_id @@ -306,1263 +109,14 @@ class TestWorkflowDeveloperAPIProtection: assert result["detail"]["code"] == "JOB_NOT_FOUND" assert "This endpoint is not available" not in response.text - async def test_all_endpoints_require_api_key_authentication( - self, - client: AsyncClient, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that all workflow endpoints require API key authentication.""" - # Test POST /workflow without API key - request_data = { - "flow_id": "550e8400-e29b-41d4-a716-446655440000", - "background": False, - "stream": False, - "inputs": None, - } - - response = await client.post( - "api/v2/workflows", - json=request_data, - ) - # The API returns 403 Forbidden for missing API keys (not 401 Unauthorized) - # This is the correct behavior according to the api_key_security implementation - assert response.status_code == 403 - assert "API key must be passed" in response.json()["detail"] - - -class TestWorkflowErrorHandling: - """Test comprehensive error handling for workflow endpoints.""" - - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - - async def test_flow_not_found_returns_404_with_error_code( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that non-existent flow returns 404 with FLOW_NOT_FOUND error code.""" - flow_id = str(uuid4()) - request_data = {"flow_id": flow_id, "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 404 - result = response.json() - assert result["detail"]["code"] == "FLOW_NOT_FOUND" - assert result["detail"]["flow_id"] == flow_id - assert "Verify the flow_id" in result["detail"]["message"] - - async def test_database_error_returns_503( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that database errors return 503 with DATABASE_ERROR code.""" - flow_id = str(uuid4()) - request_data = {"flow_id": flow_id, "background": False, "stream": False, "inputs": None} - - # Mock get_flow_by_id_or_endpoint_name to raise OperationalError - with patch("langflow.api.v2.workflow.get_flow_by_id_or_endpoint_name") as mock_get_flow: - mock_get_flow.side_effect = OperationalError("statement", "params", "orig") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 503 - result = response.json() - assert result["detail"]["code"] == "DATABASE_ERROR" - assert "Failed to fetch flow" in result["detail"]["message"] - assert result["detail"]["flow_id"] == flow_id - - async def test_flow_with_no_data_returns_500( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that flow with no data returns 500 with INVALID_FLOW_DATA code.""" - flow_id = uuid4() - - # Create a flow with no data - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Flow with no data", - description="Test flow with no data", - data=None, # No data - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 400 - result = response.json() - assert result["detail"]["code"] == "INVALID_FLOW_DATA" - assert "has no data" in result["detail"]["message"] - assert result["detail"]["flow_id"] == str(flow_id) - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_graph_build_failure_returns_500( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that graph build failure returns 500 with INVALID_FLOW_DATA code.""" - flow_id = uuid4() - - # Create a flow with invalid data that will fail validation/graph building - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Flow with invalid data", - description="Test flow with invalid graph data", - data={"invalid": "data"}, # Invalid graph data (missing 'nodes' field) - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 400 - result = response.json() - assert result["detail"]["code"] == "INVALID_FLOW_DATA" - # The error message should indicate invalid flow data structure - error_msg = result["detail"]["message"].lower() - assert "invalid data structure" in error_msg or "must have nodes" in error_msg - assert result["detail"]["flow_id"] == str(flow_id) - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_execution_timeout_with_real_delay( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that execution timeout works with real async delay.""" - flow_id = uuid4() - - # Create a valid flow - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Test flow for timeout", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - # Mock execute_sync_workflow to sleep longer than timeout - async def slow_execution(*args, **kwargs): # noqa: ARG001 - await asyncio.sleep(2) # Sleep for 2 seconds - return MagicMock() - - # Temporarily reduce timeout for testing - with ( - patch("langflow.api.v2.workflow.execute_sync_workflow", side_effect=slow_execution), - patch("langflow.api.v2.workflow.EXECUTION_TIMEOUT", 0.5), # 0.5 second timeout - ): - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 408 - result = response.json() - assert result["detail"]["code"] == "EXECUTION_TIMEOUT" - assert "exceeded" in result["detail"]["message"] - assert result["detail"]["flow_id"] == str(flow_id) - assert "job_id" in result["detail"] - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_background_mode_returns_501( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that background mode returns 501 with NOT_IMPLEMENTED code.""" - flow_id = uuid4() - - # Create a valid flow - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Test flow", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": True, # Background mode - "stream": False, - "inputs": None, - } - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - # Now background mode is partially implemented and should NOT return 501 - # It should return a WorkflowJobResponse (wrapped in WorkflowExecutionResponse or similar) - assert response.status_code == 200 - result = response.json() - assert result["object"] == "job" - assert result["status"] == "queued" - assert result["flow_id"] == str(flow_id) - assert "links" in result - assert "status" in result["links"] - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_streaming_mode_returns_501( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that streaming mode returns 501 with NOT_IMPLEMENTED code.""" - flow_id = uuid4() - - # Create a valid flow - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Test flow", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": True, # Streaming mode - "inputs": None, - } - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 501 - result = response.json() - assert result["detail"]["code"] == "NOT_IMPLEMENTED" - assert "Streaming execution not yet implemented" in result["detail"]["message"] - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_error_response_structure( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that all error responses have consistent structure.""" - flow_id = str(uuid4()) - request_data = {"flow_id": flow_id, "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 404 - result = response.json() - - # Verify error structure - assert "detail" in result - assert "error" in result["detail"] - assert "code" in result["detail"] - assert "message" in result["detail"] - assert "flow_id" in result["detail"] - - # Verify types - assert isinstance(result["detail"]["error"], str) - assert isinstance(result["detail"]["code"], str) - assert isinstance(result["detail"]["message"], str) - assert isinstance(result["detail"]["flow_id"], str) - - async def test_workflow_validation_error_propagation( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that WorkflowValidationError is properly caught and converted to 500.""" - flow_id = uuid4() - - # Create a flow - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Test flow", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - # Mock execute_sync_workflow to raise WorkflowValidationError - with patch("langflow.api.v2.workflow.execute_sync_workflow") as mock_execute: - mock_execute.side_effect = WorkflowValidationError("Test validation error") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 400 - result = response.json() - assert result["detail"]["code"] == "INVALID_FLOW_DATA" - assert "Test validation error" in result["detail"]["message"] - - finally: - # Clean up - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - # Test GET /workflow without API key - response = await client.get("api/v2/workflows?job_id=550e8400-e29b-41d4-a716-446655440001") - assert response.status_code == 403 - assert "API key must be passed" in response.json()["detail"] - - -class TestWorkflowSyncExecution: - """Test synchronous workflow execution with realistic component mocking.""" - - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - - async def test_sync_execution_with_empty_flow_returns_200( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test sync execution with empty flow returns 200 with empty outputs.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Empty Flow", - description="Flow with no nodes", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - # Verify response structure - assert "flow_id" in result - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - - # Verify outputs or errors are present with actual content - assert "outputs" in result or "errors" in result - if "outputs" in result: - assert isinstance(result["outputs"], dict) - if "errors" in result: - assert isinstance(result["errors"], list) - # session_id is only present if provided in inputs - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_component_error_returns_200_with_error_in_body( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that component execution errors return 200 with error in response body.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Flow for testing component errors", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - # Mock run_graph_internal to raise a component execution error - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - mock_run.side_effect = Exception("Component execution failed: LLM API key not configured") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - # Component errors should return 200 with error in body - assert response.status_code == 200 - result = response.json() - - # Verify error is in response body (via create_error_response) - assert "errors" in result - assert len(result["errors"]) > 0 - assert "Component execution failed" in str(result["errors"][0]) - assert result["status"] == "failed" - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_with_chat_input_output( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test sync execution with ChatInput and ChatOutput components.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Chat Flow", - description="Flow with chat input/output", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - # Input format: component_id.param = value - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": { - "ChatInput-abc123.input_value": "Hello, how are you?", - "ChatInput-abc123.session_id": "session-456", - }, - } - - # Mock successful execution with ChatOutput - mock_result_data = MagicMock() - mock_result_data.component_id = "ChatOutput-xyz789" - mock_result_data.outputs = {"message": {"message": "I'm doing well, thank you for asking!", "type": "text"}} - mock_result_data.metadata = {} - - # Wrap ResultData in RunOutputs - mock_run_output = MagicMock() - mock_run_output.outputs = [mock_result_data] - - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - # run_graph_internal returns tuple[list[RunOutputs], str] - mock_run.return_value = ([mock_run_output], "session-456") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - # Verify response structure - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - assert "outputs" in result - # Note: Detailed content validation requires proper graph/vertex mocking - # which is beyond the scope of unit tests. Integration tests should validate content. - - # Verify inputs were echoed back - assert "inputs" in result - assert result["inputs"] == request_data["inputs"] - - # Verify session_id is present when provided in inputs - if "session_id" in result: - assert result["session_id"] == "session-456" - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_uses_body_globals_with_unicode( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """V2 workflow globals are accepted from the JSON body and passed to graph context.""" - flow_id = uuid4() - body_globals = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"} - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Unicode Globals Flow", - description="Flow for body globals testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": {"ChatInput-abc.input_value": "what is 2+2"}, - "globals": body_globals, - } - graph = _mock_empty_graph() - mock_job_service = MagicMock() - mock_job_service.create_job = AsyncMock() - mock_job_service.execute_with_status = AsyncMock(return_value=([], "session-unicode")) - mock_task_service = MagicMock() - mock_task_service.fire_and_forget_task = AsyncMock() - - with ( - patch("langflow.api.v2.workflow.Graph.from_payload", return_value=graph) as mock_from_payload, - patch("langflow.api.v2.workflow.get_job_service", return_value=mock_job_service), - patch("langflow.api.v2.workflow.get_task_service", return_value=mock_task_service), - ): - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - assert result["globals"] == body_globals - assert mock_from_payload.call_args.kwargs["context"] == {"request_variables": body_globals} - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_honors_legacy_global_variable_headers_with_deprecation( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """V2 workflows still read X-LANGFLOW-GLOBAL-VAR-* headers for one release. - - Legacy headers are honored so existing callers don't silently lose their - global variables, but a deprecation warning is emitted. Body globals - win over header globals on key conflicts. - """ - flow_id = uuid4() - body_globals = {"FILENAME": "body-wins.pdf"} - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Legacy Header Globals Flow", - description="Flow for header globals testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": {"ChatInput-abc.input_value": "what is 2+2"}, - "globals": body_globals, - } - graph = _mock_empty_graph() - mock_job_service = MagicMock() - mock_job_service.create_job = AsyncMock() - mock_job_service.execute_with_status = AsyncMock(return_value=([], "session-header")) - mock_task_service = MagicMock() - mock_task_service.fire_and_forget_task = AsyncMock() - - with ( - patch("langflow.api.v2.workflow.Graph.from_payload", return_value=graph) as mock_from_payload, - patch("langflow.api.v2.workflow.get_job_service", return_value=mock_job_service), - patch("langflow.api.v2.workflow.get_task_service", return_value=mock_task_service), - patch("langflow.api.v2.workflow.logger.warning") as mock_warning, - ): - headers = { - "x-api-key": created_api_key.api_key, - "X-LANGFLOW-GLOBAL-VAR-FILENAME": "header-loses.pdf", - "X-LANGFLOW-GLOBAL-VAR-OWNER_NAME": "header-only", - } - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - expected_globals = {"FILENAME": "body-wins.pdf", "OWNER_NAME": "header-only"} - assert result["globals"] == expected_globals - assert mock_from_payload.call_args.kwargs["context"] == {"request_variables": expected_globals} - mock_warning.assert_called_once() - assert "deprecated" in mock_warning.call_args.args[0].lower() - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_rejects_oversized_globals_value( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Body globals values are bounded; oversized values are rejected at validation.""" - from lfx.schema.workflow import GLOBAL_VALUE_MAX_LEN - - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Oversized Globals Flow", - description="Flow for oversized globals testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "globals": {"BIG_VAR": "x" * (GLOBAL_VALUE_MAX_LEN + 1)}, - } - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - assert response.status_code == 422 - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_with_llm_output( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test sync execution with LLM component output including model metadata.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="LLM Flow", - description="Flow with LLM component", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": { - "ChatInput-abc.input_value": "Explain quantum computing", - "OpenAIModel-def.temperature": 0.7, - }, - } - - # Mock LLM execution with model metadata - mock_result_data = MagicMock() - mock_result_data.component_id = "OpenAIModel-def" - mock_result_data.outputs = { - "model_output": { - "message": { - "message": "Quantum computing uses quantum mechanics...", - "model_name": "gpt-4", - "type": "text", - } - } - } - mock_result_data.metadata = {"tokens_used": 150} - - # Wrap ResultData in RunOutputs - mock_run_output = MagicMock() - mock_run_output.outputs = [mock_result_data] - - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - # run_graph_internal returns tuple[list[RunOutputs], str] - mock_run.return_value = ([mock_run_output], "session-789") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - assert "outputs" in result - # Note: Detailed content validation requires proper graph/vertex mocking - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_with_file_save_output( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test sync execution with SaveToFile component.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="File Save Flow", - description="Flow with file save component", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": { - "TextInput-abc.text": "Content to save", - "SaveToFile-xyz.file_path": "/tmp/output.txt", # noqa: S108 - }, - } - - # Mock SaveToFile execution - mock_result_data = MagicMock() - mock_result_data.component_id = "SaveToFile-xyz" - mock_result_data.outputs = { - "message": {"message": "File saved successfully to /tmp/output.txt", "type": "text"} - } - mock_result_data.metadata = {"bytes_written": 1024} - - # Wrap ResultData in RunOutputs - mock_run_output = MagicMock() - mock_run_output.outputs = [mock_result_data] - - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - # run_graph_internal returns tuple[list[RunOutputs], str] - mock_run.return_value = ([mock_run_output], "session-101") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - assert "outputs" in result - # Note: Detailed content validation requires proper graph/vertex mocking - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_with_multiple_terminal_nodes( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test sync execution with multiple terminal nodes (outputs).""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Multi-Output Flow", - description="Flow with multiple terminal nodes", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": False, - "stream": False, - "inputs": {"ChatInput-abc.input_value": "Process this"}, - } - - # Mock execution with multiple outputs - mock_chat_output = MagicMock() - mock_chat_output.component_id = "ChatOutput-aaa" - mock_chat_output.outputs = {"message": {"message": "Chat response", "type": "text"}} - - mock_file_output = MagicMock() - mock_file_output.component_id = "SaveToFile-bbb" - mock_file_output.outputs = {"message": {"message": "File saved successfully", "type": "text"}} - - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - # run_graph_internal returns tuple[list[RunOutputs], str] - mock_run.return_value = ([mock_chat_output, mock_file_output], "session-202") - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - assert result["flow_id"] == str(flow_id) - assert "job_id" in result - assert "outputs" in result - # Note: Detailed content validation requires proper graph/vertex mocking - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_response_structure_validation( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test that sync execution response has correct WorkflowExecutionResponse structure.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Test Flow", - description="Flow for response validation", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = {"flow_id": str(flow_id), "background": False, "stream": False, "inputs": None} - - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - # Verify WorkflowExecutionResponse structure - assert "flow_id" in result - assert isinstance(result["flow_id"], str) - assert result["flow_id"] == str(flow_id) - - assert "job_id" in result - assert isinstance(result["job_id"], str) - - # session_id is optional - only present if provided in inputs - if "session_id" in result: - assert isinstance(result["session_id"], str) - - assert "object" in result - assert result["object"] == "response" - - assert "created_timestamp" in result - assert isinstance(result["created_timestamp"], str) - - assert "status" in result - assert result["status"] in ["completed", "failed", "running", "queued"] - - assert "errors" in result - assert isinstance(result["errors"], list) - - assert "inputs" in result - assert isinstance(result["inputs"], dict) - - assert "outputs" in result - assert isinstance(result["outputs"], dict) - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - # Test POST /workflow/stop without API key - response = await client.post( - "api/v2/workflows/stop", - json={"job_id": "550e8400-e29b-41d4-a716-446655440001"}, - ) - assert response.status_code == 403 - assert "API key must be passed" in response.json()["detail"] - - -class TestWorkflowBackgroundQueueing: - """Test background workflow execution and queueing behavior.""" - - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - - async def test_background_execution_flow( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test the full background job submission flow.""" - flow_id = uuid4() - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Background Flow", - description="Flow for background testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": True, - "inputs": {"test.input": "data"}, - } - - headers = {"x-api-key": created_api_key.api_key} - - # Mock uuid4 to return a predictable job_id - mock_job_id = "550e8400-e29b-41d4-a716-446655440001" - with ( - patch("langflow.api.v2.workflow.get_task_service") as mock_get_task_service, - patch("langflow.api.v2.workflow.uuid4", return_value=UUID(mock_job_id)), - ): - mock_task_service = MagicMock() - # fire_and_forget_task is now awaited but its return value is not used for the job_id in response - # as it uses graph.run_id. However, we still need it to be an awaitable if it's awaited. - mock_task_service.fire_and_forget_task.return_value = asyncio.Future() - mock_task_service.fire_and_forget_task.return_value.set_result(mock_job_id) - mock_get_task_service.return_value = mock_task_service - - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - - assert result["job_id"] == mock_job_id - assert result["flow_id"] == str(flow_id) - assert result["object"] == "job" - assert result["status"] == "queued" - assert "links" in result - assert "status" in result["links"] - assert mock_job_id in result["links"]["status"] - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_background_execution_uses_body_globals_with_unicode( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Background V2 workflow submissions pass body globals to graph context.""" - flow_id = uuid4() - body_globals = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"} - - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Background Unicode Globals Flow", - description="Flow for background body globals testing", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - await session.refresh(flow) - - try: - request_data = { - "flow_id": str(flow_id), - "background": True, - "inputs": {"ChatInput-abc.input_value": "what is 2+2"}, - "globals": body_globals, - } - headers = {"x-api-key": created_api_key.api_key} - graph = _mock_empty_graph() - mock_job_service = MagicMock() - mock_job_service.create_job = AsyncMock() - mock_job_service.execute_with_status = AsyncMock() - mock_task_service = MagicMock() - mock_task_service.fire_and_forget_task = AsyncMock() - - with ( - patch("langflow.api.v2.workflow.Graph.from_payload", return_value=graph) as mock_from_payload, - patch("langflow.api.v2.workflow.get_job_service", return_value=mock_job_service), - patch("langflow.api.v2.workflow.get_task_service", return_value=mock_task_service), - ): - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - assert result["globals"] == body_globals - assert result["status"] == "queued" - assert mock_from_payload.call_args.kwargs["context"] == {"request_variables": body_globals} - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_background_execution_invalid_flow( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test background execution with a non-existent flow ID.""" - request_data = { - "flow_id": str(uuid4()), - "background": True, - } - headers = {"x-api-key": created_api_key.api_key} - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - assert response.status_code == 404 - detail = response.json()["detail"] - message = detail["message"] if isinstance(detail, dict) else detail - assert "does not exist" in message.lower() - - async def test_background_execution_queue_exception( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test handling of exceptions during task queueing.""" - flow_id = uuid4() - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Fail Flow", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - - try: - request_data = {"flow_id": str(flow_id), "background": True} - headers = {"x-api-key": created_api_key.api_key} - - with patch("langflow.api.v2.workflow.get_task_service") as mock_get_task_service: - mock_task_service = MagicMock() - mock_task_service.fire_and_forget_task.side_effect = Exception("Queueing failed") - mock_get_task_service.return_value = mock_task_service - - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - assert response.status_code == 500 - detail = response.json()["detail"] - message = detail["message"] if isinstance(detail, dict) else detail - assert "Queueing failed" in message - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - - async def test_sync_execution_error_handling( - self, - client: AsyncClient, - created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 - ): - """Test error handling during synchronous execution.""" - flow_id = uuid4() - async with session_scope() as session: - flow = Flow( - id=flow_id, - name="Error Flow", - data={"nodes": [], "edges": []}, - user_id=created_api_key.user_id, - ) - session.add(flow) - await session.flush() - - try: - request_data = {"flow_id": str(flow_id), "background": False} - headers = {"x-api-key": created_api_key.api_key} - - with patch("langflow.api.v2.workflow.run_graph_internal") as mock_run: - mock_run.side_effect = Exception("Internal execution engine failure") - response = await client.post("api/v2/workflows", json=request_data, headers=headers) - - assert response.status_code == 200 - result = response.json() - assert "errors" in result - assert "Internal execution engine failure" in str(result["errors"]) - - finally: - async with session_scope() as session: - flow = await session.get(Flow, flow_id) - if flow: - await session.delete(flow) - class TestWorkflowStatus: """Test workflow status retrieval endpoints.""" - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - async def test_get_status_queued( self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow returns 200 for a queued job.""" job_id = uuid4() @@ -1594,7 +148,6 @@ class TestWorkflowStatus: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow returns 404 for a non-existent job.""" job_id = uuid4() @@ -1615,7 +168,6 @@ class TestWorkflowStatus: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow returns 500 for a failed job.""" job_id = uuid4() @@ -1643,7 +195,6 @@ class TestWorkflowStatus: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow returns reconstructed response for a completed job.""" job_id = uuid4() @@ -1683,7 +234,6 @@ class TestWorkflowStatus: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test GET /workflow returns 408 for a timed out job.""" job_id = uuid4() @@ -1715,22 +265,10 @@ class TestWorkflowStatus: class TestWorkflowStop: """Test workflow stop endpoints.""" - @pytest.fixture - def mock_settings_dev_api_enabled(self): - """Mock settings with developer API enabled.""" - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings_service: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings_service.return_value = mock_service - yield mock_settings - async def test_stop_workflow_success( self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test POST /workflow/stop cancels a running job.""" job_id = str(uuid4()) @@ -1768,7 +306,6 @@ class TestWorkflowStop: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test POST /workflow/stop returns 404 for non-existent job.""" job_id = str(uuid4()) @@ -1789,7 +326,6 @@ class TestWorkflowStop: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """Test POST /workflow/stop handles already cancelled jobs.""" job_id = str(uuid4()) @@ -1822,23 +358,12 @@ class TestWorkflowIDORProtection: cancellable cross-user unless an ownership check is enforced. """ - @pytest.fixture - def mock_settings_dev_api_enabled(self): - with patch("langflow.api.v2.workflow.get_settings_service") as mock_get_settings: - mock_service = MagicMock() - mock_settings = MagicMock() - mock_settings.developer_api_enabled = True - mock_service.settings = mock_settings - mock_get_settings.return_value = mock_service - yield mock_settings - @pytest.mark.security async def test_get_workflow_status_forbidden_for_other_user_job( self, client: AsyncClient, created_api_key, created_user_two_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """GET /api/v2/workflows returns 404 when the job belongs to a different user. @@ -1866,7 +391,7 @@ class TestWorkflowIDORProtection: assert response.status_code == 404 result = response.json() assert result["detail"]["code"] == "JOB_NOT_FOUND" - assert str(job_id) in result["detail"]["job_id"] + assert result["detail"]["job_id"] == str(job_id) finally: async with session_scope() as session: db_job = await session.get(Job, job_id) @@ -1878,7 +403,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """GET /api/v2/workflows returns 200 when the job belongs to the requesting user.""" job_id = uuid4() @@ -1901,7 +425,7 @@ class TestWorkflowIDORProtection: assert response.status_code == 200 result = response.json() - assert str(job_id) in result["job_id"] + assert result["job_id"] == str(job_id) finally: async with session_scope() as session: db_job = await session.get(Job, job_id) @@ -1914,7 +438,6 @@ class TestWorkflowIDORProtection: client: AsyncClient, created_api_key, created_user_two_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """POST /api/v2/workflows/stop returns 404 when the job belongs to a different user. @@ -1946,7 +469,7 @@ class TestWorkflowIDORProtection: assert response.status_code == 404 result = response.json() assert result["detail"]["code"] == "JOB_NOT_FOUND" - assert str(job_id) in result["detail"]["job_id"] + assert result["detail"]["job_id"] == str(job_id) finally: async with session_scope() as session: db_job = await session.get(Job, job_id) @@ -1958,7 +481,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """POST /api/v2/workflows/stop succeeds when the job belongs to the requesting user.""" job_id = uuid4() @@ -1995,7 +517,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """GET /api/v2/workflows does NOT block legacy jobs where user_id is NULL. @@ -2032,7 +553,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """POST /api/v2/workflows/stop does NOT block legacy jobs where user_id is NULL. @@ -2074,7 +594,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """POST /api/v2/workflows/stop returns 404 for non-WORKFLOW job types. @@ -2115,7 +634,6 @@ class TestWorkflowIDORProtection: self, client: AsyncClient, created_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """GET /api/v2/workflows returns 404 for non-WORKFLOW job types. @@ -2153,7 +671,6 @@ class TestWorkflowIDORProtection: client: AsyncClient, created_api_key, created_user_two_api_key, - mock_settings_dev_api_enabled, # noqa: ARG002 ): """End-to-end: POST /workflows (background=true) stores user_id and blocks cross-user GET. @@ -2190,9 +707,8 @@ class TestWorkflowIDORProtection: "api/v2/workflows", json={ "flow_id": str(flow_id), - "background": True, - "stream": False, - "inputs": {}, + "mode": "background", + "session_id": "idor-thread", }, headers=alice_headers, ) diff --git a/src/backend/tests/unit/api/v2/test_workflow_agui.py b/src/backend/tests/unit/api/v2/test_workflow_agui.py new file mode 100644 index 0000000000..3a9a2abb23 --- /dev/null +++ b/src/backend/tests/unit/api/v2/test_workflow_agui.py @@ -0,0 +1,1475 @@ +"""V2 Workflow endpoint tests covering the native ``WorkflowRunRequest`` body. + +The v2 ``POST /workflows`` endpoint accepts a Langflow-native body shape and +dispatches the stream protocol from ``stream_protocol`` (``langflow`` default, +``agui`` opt-in). Tests in this module pin the ``agui`` protocol so the +AG-UI-shaped assertions still apply end-to-end. + +Execution mode (``mode`` field): + - ``sync`` (default): run inline, return the aggregated WorkflowExecutionResponse. + - ``background``: queue a job, return a WorkflowJobResponse. + - ``stream``: SSE in the chosen ``stream_protocol``. +""" + +import json +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from langflow.services.database.models.flow.model import Flow +from lfx.services.deps import session_scope + + +def _agui_body(flow_id, *, message: str = "hello", mode: str = "sync", tweaks: dict | None = None) -> dict: + """Build a ``WorkflowRunRequest`` JSON body pinned to the ``agui`` protocol. + + Pinning the protocol keeps the AG-UI assertions in this module valid after + the cutover from the ``RunAgentInput`` body shape. + """ + body: dict = { + "flow_id": str(flow_id), + "input_value": message, + "mode": mode, + "stream_protocol": "agui", + "session_id": "thread-1", + } + if tweaks: + body["tweaks"] = tweaks + return body + + +@pytest.fixture +async def empty_flow(created_api_key): + """Create a real empty flow owned by the API-key user; clean it up after.""" + flow_id = uuid4() + async with session_scope() as session: + flow = Flow( + id=flow_id, + name="AG-UI Test Flow", + description="Empty flow for AG-UI endpoint tests", + data={"nodes": [], "edges": []}, + user_id=created_api_key.user_id, + ) + session.add(flow) + await session.flush() + await session.refresh(flow) + yield flow_id + async with session_scope() as session: + flow = await session.get(Flow, flow_id) + if flow: + await session.delete(flow) + + +@pytest.fixture +async def chatbot_flow(created_api_key, json_memory_chatbot_no_llm): + """Create a real no-LLM chatbot flow (ChatInput -> Prompt/Memory -> ChatOutput).""" + raw = json.loads(json_memory_chatbot_no_llm) + flow_id = uuid4() + async with session_scope() as session: + flow = Flow( + id=flow_id, + name="AG-UI Chatbot Flow", + description="No-LLM chatbot flow for AG-UI endpoint tests", + data=raw.get("data", raw), + user_id=created_api_key.user_id, + ) + session.add(flow) + await session.flush() + yield flow_id + async with session_scope() as session: + flow = await session.get(Flow, flow_id) + if flow: + await session.delete(flow) + + +class TestAGUIRequestContract: + """The endpoint accepts the AG-UI RunAgentInput body shape.""" + + async def test_sync_mode_with_real_flow_returns_200( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """A RunAgentInput body with mode=sync runs the flow inline and returns 200.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(empty_flow, mode="sync"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["flow_id"] == str(empty_flow) + assert "job_id" in result + assert isinstance(result["outputs"], dict) + + async def test_unknown_flow_returns_404( + self, + client: AsyncClient, + created_api_key, + ): + """A body whose flow_id does not exist returns 404.""" + missing = "550e8400-e29b-41d4-a716-446655440000" + response = await client.post( + "api/v2/workflows", + json=_agui_body(missing, mode="sync"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "FLOW_NOT_FOUND" + + async def test_missing_flow_id_returns_422( + self, + client: AsyncClient, + created_api_key, + ): + """A body without ``flow_id`` fails Pydantic validation up front.""" + response = await client.post( + "api/v2/workflows", + json={"input_value": "hi", "mode": "sync"}, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 422 + + async def test_requires_authentication( + self, + client: AsyncClient, + empty_flow, + ): + """An AG-UI request with no API key and no session token is rejected.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(empty_flow, mode="sync"), + ) + + assert response.status_code == 403 + + async def test_accepts_session_token_auth( + self, + client: AsyncClient, + logged_in_headers, + ): + """The endpoint accepts a session token, not only an API key.""" + missing = "550e8400-e29b-41d4-a716-446655440000" + response = await client.post( + "api/v2/workflows", + json=_agui_body(missing, mode="sync"), + headers=logged_in_headers, + ) + + # Auth passes via the session token; 404 only because the flow does not exist. + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "FLOW_NOT_FOUND" + + async def test_rejects_non_agui_body( + self, + client: AsyncClient, + created_api_key, + ): + """The old flat {flow_id, background, stream, inputs} body is no longer valid.""" + response = await client.post( + "api/v2/workflows", + json={"flow_id": str(uuid4()), "background": False, "stream": False, "inputs": None}, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 422 + + +class TestAGUIModeDispatch: + """``mode`` field selects the execution path.""" + + async def test_background_mode_returns_job_response( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """mode=background queues a job and returns a job id.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(empty_flow, mode="background"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["flow_id"] == str(empty_flow) + assert result["job_id"] + assert result["status"] in {"queued", "in_progress", "completed"} + + async def test_sync_mode_is_default( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """Omitting mode defaults to sync (curl-DX: one POST returns one JSON).""" + body = _agui_body(empty_flow) + body.pop("mode") + response = await client.post( + "api/v2/workflows", + json=body, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + result = response.json() + assert result["status"] == "completed" + + +class TestAGUIStreaming: + """mode=stream returns an AG-UI server-sent event stream.""" + + async def test_stream_emits_run_lifecycle_events( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """A streamed run brackets its events with RUN_STARTED and RUN_FINISHED.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(empty_flow, mode="stream"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + body = response.text + assert "RUN_STARTED" in body + assert "RUN_FINISHED" in body + + async def test_stream_unknown_flow_returns_404( + self, + client: AsyncClient, + created_api_key, + ): + """A stream-mode request for a missing flow fails before streaming starts.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body("550e8400-e29b-41d4-a716-446655440000", mode="stream"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "FLOW_NOT_FOUND" + + async def test_stream_real_flow_runs_without_error( + self, + client: AsyncClient, + created_api_key, + json_memory_chatbot_no_llm, + ): + """Streaming a real no-LLM chatbot flow runs the graph end-to-end with no RUN_ERROR.""" + raw = json.loads(json_memory_chatbot_no_llm) + flow_data = raw.get("data", raw) + flow_id = uuid4() + async with session_scope() as session: + flow = Flow( + id=flow_id, + name="AG-UI Memory Chatbot Flow", + data=flow_data, + user_id=created_api_key.user_id, + ) + session.add(flow) + await session.flush() + + try: + response = await client.post( + "api/v2/workflows", + json=_agui_body(flow_id, message="hello from agui", mode="stream"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + body = response.text + assert "RUN_STARTED" in body + assert "RUN_FINISHED" in body + assert "RUN_ERROR" not in body + # The ChatOutput component's message reached the stream as AG-UI + # text-message events, proving the real event pipeline works. + assert "TEXT_MESSAGE_START" in body + assert "TEXT_MESSAGE_CONTENT" in body + # Per-vertex events flow through the translator (v1 build-vertex + # loop emits end_vertex per node) so the canvas can color nodes. + assert "STEP_FINISHED" in body + assert "STATE_DELTA" in body + finally: + async with session_scope() as session: + flow = await session.get(Flow, flow_id) + if flow: + await session.delete(flow) + + +class TestAGUISyncExecution: + """mode=sync runs the flow inline and folds outputs into the response.""" + + async def test_sync_real_flow_returns_completed_with_outputs( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A sync run of a real chatbot flow completes with the terminal outputs.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, message="hello from agui", mode="sync"), + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["status"] == "completed" + assert result["errors"] == [] + assert isinstance(result["outputs"], dict) + # The chatbot flow produced at least one terminal output. + assert result["outputs"] + + +class TestAGUICancellation: + """A run can be stopped by job id, and a streaming client can disconnect.""" + + async def test_background_run_can_be_stopped( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """POST /workflows/stop cancels a background run by its job id.""" + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + stop = await client.post("api/v2/workflows/stop", json={"job_id": job_id}, headers=headers) + + assert stop.status_code == 200 + assert "cancelled" in stop.json()["message"].lower() + + async def test_streaming_client_can_disconnect_early( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """Closing the SSE stream after the first event does not error the server.""" + headers = {"x-api-key": created_api_key.api_key} + seen: list[str] = [] + async with client.stream( + "POST", + "api/v2/workflows", + json=_agui_body(chatbot_flow, message="hi", mode="stream"), + headers=headers, + ) as response: + assert response.status_code == 200 + async for line in response.aiter_lines(): + if line.startswith("data:"): + seen.append(line) + break # disconnect mid-stream + + # The stream delivered at least one event before the client disconnected. + assert seen + + +class TestAGUIBackgroundReattach: + """A background run buffers its AG-UI events so a client can re-attach.""" + + async def test_background_run_events_can_be_reattached( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """After starting a background run, GET /workflows/{job_id}/events replays its stream.""" + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + response = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + body = response.text + assert "RUN_STARTED" in body + assert "RUN_FINISHED" in body + + async def test_reattach_replays_after_last_event_id( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """Re-attaching with a Last-Event-ID header skips already-delivered events.""" + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers=headers, + ) + job_id = start.json()["job_id"] + + response = await client.get( + f"api/v2/workflows/{job_id}/events", + headers={**headers, "Last-Event-ID": "0"}, + ) + + assert response.status_code == 200 + body = response.text + # Event 0 is RUN_STARTED; with Last-Event-ID=0 it must not be replayed. + # Split per line so CRLF endings don't sneak the event through the + # substring check. + event_ids = [line.removeprefix("id:").strip() for line in body.splitlines() if line.startswith("id:")] + assert "0" not in event_ids + assert "RUN_FINISHED" in body + + async def test_reattach_unknown_job_returns_404( + self, + client: AsyncClient, + created_api_key, + ): + """Re-attaching to a job id with no background run returns 404.""" + response = await client.get( + "api/v2/workflows/550e8400-e29b-41d4-a716-446655440000/events", + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 404 + + async def test_reattach_forbidden_for_other_user( + self, + client: AsyncClient, + created_api_key, + created_user_two_api_key, + chatbot_flow, + ): + """A background run's event stream is not readable by another user.""" + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers={"x-api-key": created_api_key.api_key}, + ) + job_id = start.json()["job_id"] + + response = await client.get( + f"api/v2/workflows/{job_id}/events", + headers={"x-api-key": created_user_two_api_key.api_key}, + ) + + assert response.status_code == 404 + + +class TestAGUIBackgroundJobStatus: + """Background job status comes from the translator's terminal event. + + A substring scan over serialized SSE frames would false-positive on any + payload that happened to contain the literal byte sequence ``"RUN_ERROR"``. + """ + + async def test_message_text_containing_run_error_literal_does_not_fail_job( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A user message JSON-encoded as ``"RUN_ERROR"`` must not fail the job. + + That string is exactly what the translator emits for ``RunErrorEvent``, + so a byte-substring detector would false-positive on it. + """ + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + # The chat input is the bare literal ``RUN_ERROR``; once JSON-encoded + # into the side-channel CustomEvent it appears as ``"RUN_ERROR"``. + json=_agui_body(chatbot_flow, message="RUN_ERROR", mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + # Drain the SSE buffer; the background task finalizes job status in finally. + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + assert "RUN_FINISHED" in events.text + + # ``update_job_status`` runs after the events stream returns; poll the + # Job row directly so this test exercises the status-finalization path + # without depending on vertex-build reconstruction. + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + final_status = None + for _ in range(100): + async with session_scope() as session: + row = await session.get(_Job, _UUID(job_id)) + final_status = row.status if row is not None else None + if final_status is not None and final_status.value in ("completed", "failed"): + break + await _asyncio.sleep(0.1) + assert final_status is not None, "job row was never created" + assert final_status.value == "completed", ( + f"Background job was marked {final_status.value!r}; the message text contained " + "the literal 'RUN_ERROR' so the byte-substring detector would false-positive" + ) + + async def test_message_with_json_shaped_run_error_payload_does_not_fail_job( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A chat message that mimics a serialized AG-UI RunErrorEvent must not fail the job. + + Even when the user's text echoes the exact JSON the translator would emit + for a real RUN_ERROR (``{"type":"RUN_ERROR", ...}``), the structural detector + keys off the translator's event type, not bytes inside a CustomEvent payload. + """ + headers = {"x-api-key": created_api_key.api_key} + echo = '{"type":"RUN_ERROR","message":"fake","timestamp":1}' + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, message=echo, mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + assert "RUN_FINISHED" in events.text + # The user's text appears inside a CustomEvent (side-channel) whose type + # attribute is CUSTOM. The substring scan would false-positive on + # ``"type":"RUN_ERROR"`` inside that payload; the structural check ignores it. + + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + final_status = None + for _ in range(100): + async with session_scope() as session: + row = await session.get(_Job, _UUID(job_id)) + final_status = row.status if row is not None else None + if final_status is not None and final_status.value in ("completed", "failed"): + break + await _asyncio.sleep(0.1) + assert final_status is not None, "job row was never created" + assert final_status.value == "completed", ( + f"Background job was marked {final_status.value!r}; the message echoed a " + "serialized RUN_ERROR payload, which a substring scan would have flagged" + ) + + async def test_genuine_flow_failure_marks_job_as_failed( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A run that actually errors must still be marked FAILED by the structural detector. + + The fix replaced a byte-substring scan with a check on the translator's + event type. Confirm a real RunErrorEvent (type=RUN_ERROR) still flips + the job to FAILED -- otherwise the fix would have a hole that hides + every error. + + We force a failure by overriding the flow's data with an edge that + references a vertex id with no matching node. The graph build raises + ``Vertex a not found``; drive() catches it, dispatches an ``error`` + side-channel event, and the translator emits a real RunErrorEvent. + """ + headers = {"x-api-key": created_api_key.api_key} + body = _agui_body(chatbot_flow, message="hi", mode="background") + body["data"] = { + "nodes": [], + "edges": [{"source": "a", "target": "b"}], + } + + start = await client.post("api/v2/workflows", json=body, headers=headers) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + # The stream contains a real RUN_ERROR event from the translator. + assert "RUN_ERROR" in events.text, ( + "Expected a real RunErrorEvent in the stream; if absent, the test " + "did not actually force a failure and the FAILED assertion below is meaningless" + ) + + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + final_status = None + for _ in range(100): + async with session_scope() as session: + row = await session.get(_Job, _UUID(job_id)) + final_status = row.status if row is not None else None + if final_status is not None and final_status.value in ("completed", "failed"): + break + await _asyncio.sleep(0.1) + assert final_status is not None, "job row was never created" + assert final_status.value == "failed", ( + f"Background job was marked {final_status.value!r}; a real RunErrorEvent " + "was emitted so the structural detector should have flipped it to FAILED. " + "If this fails, the fix has a hole: real errors are no longer detected." + ) + + async def test_side_channel_error_event_marks_job_as_failed( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A side-channel ``error`` event must still flip the job to FAILED. + + The translator handles ``error`` by emitting [CustomEvent, RunErrorEvent]. + The CustomEvent wrapper has type=CUSTOM (must not trip the detector); + the RunErrorEvent has type=RUN_ERROR (must trip it). Net effect: FAILED. + This is the same fault path used by drive()'s exception handler, exercised + here through the bogus start_component_id. + """ + # Reuse the same failure-trigger as the previous test; this asserts the + # combined dispatch order (CustomEvent first, then RunErrorEvent) yields + # FAILED, proving the wrapper's CUSTOM type is correctly ignored. + headers = {"x-api-key": created_api_key.api_key} + body = _agui_body(chatbot_flow, message="hi", mode="background") + body["data"] = { + "nodes": [], + "edges": [{"source": "a", "target": "b"}], + } + + start = await client.post("api/v2/workflows", json=body, headers=headers) + job_id = start.json()["job_id"] + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + + # The stream carries both the CustomEvent wrapper (type=CUSTOM, name=langflow.event, + # value contains event_type="error") AND the RunErrorEvent (type=RUN_ERROR). + body_text = events.text + assert "RUN_ERROR" in body_text + assert '"event_type":"error"' in body_text or '"event_type": "error"' in body_text, ( + "Expected the side-channel error wrapper to appear as a CustomEvent" + ) + + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + final_status = None + for _ in range(100): + async with session_scope() as session: + row = await session.get(_Job, _UUID(job_id)) + final_status = row.status if row is not None else None + if final_status is not None and final_status.value in ("completed", "failed"): + break + await _asyncio.sleep(0.1) + assert final_status is not None + assert final_status.value == "failed" + + +class TestAGUISyncStillWorksAfterTupleYieldRefactor: + """The tuple-yield refactor changed _agui_event_frames' return type. + + The sync path no longer goes through _buffer_background_run; it goes through + _execute_streaming_workflow's _frames_only wrapper. Confirm an end-to-end + sync run still produces a normal completion. + """ + + async def test_sync_mode_still_completes_after_refactor( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A sync run end-to-end should return 200 with status=completed.""" + response = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, message="hi from sync", mode="sync"), + headers={"x-api-key": created_api_key.api_key}, + ) + assert response.status_code == 200 + result = response.json() + assert result["status"] == "completed" + assert result["errors"] == [] + + async def test_stream_mode_still_unwraps_tuples_to_bytes( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """The streaming consumer's _frames_only wrapper must unpack (frame, type) to bytes. + + If the wrapper accidentally yielded the tuple, EventSourceResponse would + choke on the non-bytes value. Run a real flow and confirm we get clean + SSE frames terminated by RUN_FINISHED. + """ + response = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, message="hi from stream", mode="stream"), + headers={"x-api-key": created_api_key.api_key}, + ) + assert response.status_code == 200 + body = response.text + assert "RUN_STARTED" in body + assert "RUN_FINISHED" in body + # Every SSE frame should be `data: ...` lines, not Python tuple repr. + assert "data: " in body + assert "(b'" not in body, "Tuple leaked into the SSE stream" + + +class TestAGUIBackgroundTasksLifecycle: + """Tasks the v1 build path queues via ``BackgroundTasks`` must fire. + + ``generate_flow_events`` registers telemetry, tracing teardown, and other + callbacks on the ``BackgroundTasks`` instance it is handed. In FastAPI's + request lifecycle those run after the response is sent. The background-mode + code path has no response carrying that container, so the buffer task must + drain the queue explicitly or every queued callback is silently dropped. + """ + + async def test_background_run_drains_queued_background_tasks( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + monkeypatch: pytest.MonkeyPatch, + ): + """After a background run ends, the BackgroundTasks queue must be invoked.""" + from langflow.api.v2 import workflow as _workflow_module + from starlette.background import BackgroundTasks as _Real + + instances: list[_Real] = [] + + class RecordingBackgroundTasks(_Real): + """Observe ``__call__`` so we can verify the queue was drained.""" + + def __init__(self) -> None: + super().__init__() + self.called = False + instances.append(self) + + async def __call__(self, *args, **kwargs): + self.called = True + return await super().__call__(*args, **kwargs) + + monkeypatch.setattr(_workflow_module, "BackgroundTasks", RecordingBackgroundTasks) + + 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 _buffer_background_run reaches its finally block. + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + assert "RUN_FINISHED" in events.text + + # Wait for the buffer task to finalize. + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + 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 instances, "No RecordingBackgroundTasks instance was constructed" + bg = instances[0] + assert bg.tasks, ( + "generate_flow_events queued no background tasks for the chatbot flow; " + "this test is vacuous without at least one queued callback" + ) + assert bg.called, ( + "fresh BackgroundTasks queue was never drained; telemetry, tracing teardown, " + "and every other callback queued by the v1 build path is silently dropped" + ) + + +class TestMemoryBaseHookBackgroundMode: + """The memory-base ``on_flow_output`` hook must fire after a background run. + + Sync mode wires the hook directly inside ``execute_workflow_sync`` and the + v1 build pipeline wires it after ``end_all_traces`` in ``api/build.py``. + The v2 background mode buffers frames in ``_buffer_background_run`` and + must dispatch the same hook in its ``finally`` block on successful + completion. Without it, MemoryBase auto-capture silently misses every + background run. + """ + + async def test_background_run_fires_memory_base_hook_on_success( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + monkeypatch: pytest.MonkeyPatch, + ): + """Background run schedules ``on_flow_output``. + + ``flow_id``, ``session_id``, and ``job_id`` must reach the hook. + """ + from langflow.api.v2 import workflow as _workflow_module + + captured: list[dict] = [] + + class _RecordingMemoryBaseService: + async def on_flow_output(self, **kwargs): + captured.append(kwargs) + + monkeypatch.setattr( + _workflow_module, + "get_memory_base_service", + lambda: _RecordingMemoryBaseService(), + ) + + 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"] + + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + assert "RUN_FINISHED" in events.text + + # Wait for the buffer task to finalize the job row. + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + + 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) + + # The hook is fired via ``fire_and_forget_task`` so allow the loop a + # tick to drain the scheduled coroutine. + for _ in range(20): + if captured: + break + await _asyncio.sleep(0.05) + + assert captured, ( + "Memory-base on_flow_output was not called after a successful " + "background run. The hook is silently dropped for every " + "background-mode workflow run." + ) + assert len(captured) == 1 + call = captured[0] + assert call["flow_id"] == chatbot_flow + assert call["session_id"] == "thread-1" # matches _agui_body session_id + assert call["job_id"] == _UUID(job_id) + + +class TestBackgroundFinalizationGuards: + """Cancellation state + cleanup guarantees for the background path. + + ``_buffer_background_run`` and ``execute_workflow_background`` must + preserve cancellation state and clean up after scheduling failures. + """ + + async def test_finalize_does_not_overwrite_cancelled_status( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """A run cancelled mid-flight must stay CANCELLED after the buffer ends. + + Race: ``stop_workflow`` sets the job to CANCELLED. The buffer task's + ``finally`` block runs shortly after and previously wrote + COMPLETED/FAILED unconditionally, silently overwriting the user's + stop intent. Guarded by ``_finalize_job_status``. + """ + import asyncio as _asyncio + from uuid import UUID as _UUID + + from langflow.services.database.models.jobs.model import Job as _Job + from langflow.services.database.models.jobs.model import JobStatus as _JobStatus + + 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"] + job_uuid = _UUID(job_id) + + # Stop the run before it gets a chance to complete on its own. The + # /stop endpoint flips the row to CANCELLED. + stop = await client.post( + "api/v2/workflows/stop", + json={"job_id": job_id}, + headers=headers, + ) + assert stop.status_code == 200 + + # Give the buffer task time to reach its finally block and call + # _finalize_job_status. Poll the row for stability. + for _ in range(60): + async with session_scope() as session: + row = await session.get(_Job, job_uuid) + if row is not None and row.status in (_JobStatus.COMPLETED, _JobStatus.FAILED): + break + await _asyncio.sleep(0.1) + + async with session_scope() as session: + row = await session.get(_Job, job_uuid) + assert row is not None + assert row.status == _JobStatus.CANCELLED, ( + f"Buffer task overwrote the user's cancellation: got {row.status} " + f"(expected CANCELLED). The finally block in _buffer_background_run " + f"is racing with stop_workflow." + ) + + +class TestBackgroundModeStreamProtocol: + """Background mode must honor ``stream_protocol`` end-to-end. + + Pre-step-5 the background buffer hardcoded the ``agui`` adapter, so any + caller-requested protocol was silently ignored. These tests pin both the + happy paths (``langflow``, ``agui``) and the unknown-protocol contract + (422 with ``available`` in the body) for ``mode=background`` and + ``mode=stream``. + """ + + async def test_background_mode_with_langflow_protocol_buffers_langflow_frames( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """mode=background + stream_protocol=langflow buffers raw EventManager payloads. + + The langflow adapter is a passthrough: each frame is + ``{"event": "", "data": {...}}``. AG-UI specifics + (RUN_STARTED, RUN_FINISHED, TEXT_MESSAGE_START) must NOT appear. + """ + headers = {"x-api-key": created_api_key.api_key} + body: dict = { + "flow_id": str(chatbot_flow), + "input_value": "hello", + "mode": "background", + "stream_protocol": "langflow", + "session_id": "thread-1", + } + start = await client.post("api/v2/workflows", json=body, headers=headers) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + text = events.text + # Langflow passthrough shape: `{"event": "...", "data": {...}}`. + assert '"event":' in text + assert '"data":' in text + # AG-UI lifecycle types must be absent: the wire is langflow, not agui. + assert "RUN_STARTED" not in text + assert "RUN_FINISHED" not in text + assert "TEXT_MESSAGE_START" not in text + + async def test_background_mode_with_unknown_protocol_returns_422( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """mode=background + an unregistered protocol returns 422 with the available list.""" + body: dict = { + "flow_id": str(empty_flow), + "input_value": "hi", + "mode": "background", + "stream_protocol": "definitely-not-a-real-protocol", + } + response = await client.post( + "api/v2/workflows", + json=body, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "UNKNOWN_STREAM_PROTOCOL" + assert "available" in detail + # The two built-in adapters must always be listed. + assert "agui" in detail["available"] + assert "langflow" in detail["available"] + + async def test_streaming_mode_with_unknown_protocol_returns_422( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """mode=stream + an unregistered protocol returns 422 with the available list. + + Pinned alongside the background test so both branches share the same + 422 contract; refactoring the helper must not break either branch. + """ + body: dict = { + "flow_id": str(empty_flow), + "input_value": "hi", + "mode": "stream", + "stream_protocol": "definitely-not-a-real-protocol", + } + response = await client.post( + "api/v2/workflows", + json=body, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "UNKNOWN_STREAM_PROTOCOL" + assert "available" in detail + assert "agui" in detail["available"] + assert "langflow" in detail["available"] + + async def test_sync_mode_with_unknown_protocol_returns_422( + self, + client: AsyncClient, + created_api_key, + empty_flow, + ): + """mode=sync + an unregistered protocol must also return 422. + + ``stream_protocol`` is part of the request contract regardless of mode: + an invalid value in any mode should fail with the same 422 body. + """ + body: dict = { + "flow_id": str(empty_flow), + "input_value": "hi", + "mode": "sync", + "stream_protocol": "definitely-not-a-real-protocol", + } + response = await client.post( + "api/v2/workflows", + json=body, + headers={"x-api-key": created_api_key.api_key}, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "UNKNOWN_STREAM_PROTOCOL" + assert "available" in detail + assert "agui" in detail["available"] + assert "langflow" in detail["available"] + + async def test_background_mode_with_agui_protocol_still_buffers_agui_frames( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + """Regression: mode=background + stream_protocol=agui keeps the AG-UI shape. + + The pre-step-5 path was always AG-UI; this test pins that path stays + valid after the buffer becomes adapter-aware. + """ + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers) + assert events.status_code == 200 + text = events.text + assert "RUN_STARTED" in text + assert "RUN_FINISHED" in text + + +class TestBackgroundRunsRegistryEviction: + """``_register_background_run`` must not evict still-running buffers. + + The registry is bounded by ``_MAX_BACKGROUND_RUNS``. The naive policy of + popping the oldest entry by insertion order drops the re-attach handle for + a long-running first job the moment the limit is hit, so the buffer task + keeps appending into an orphaned ``_BackgroundRun`` while re-attach + returns 404. Eviction must prefer completed entries first; falling back + 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): + from langflow.api.v2 import workflow as workflow_module + + monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 3) + monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + + 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) + + # 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) + + # 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) + + 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 + + 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. + """ + from langflow.api.v2 import workflow as workflow_module + + monkeypatch.setattr(workflow_module, "_MAX_BACKGROUND_RUNS", 2) + monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + + for job_id in ("a", "b"): + run = workflow_module._BackgroundRun(user_id="u") + workflow_module._register_background_run(job_id, run) + + # All running; adding a third must evict the oldest (a). + third = workflow_module._BackgroundRun(user_id="u") + 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 + + +class TestClearBackgroundRun: + """``_clear_background_run`` releases a stopped run's buffer and wakes waiters. + + Without this, ``stop_workflow`` revokes the buffer task but leaves the + ``_BackgroundRun`` registered. Re-attach readers can hang on + ``_cond.wait()`` indefinitely, and up to ``_MAX_BACKGROUND_RUNS`` cancelled + buffers occupy memory. + """ + + async def test_clear_pops_registry_entry_and_finishes_buffer(self, monkeypatch): + from langflow.api.v2 import workflow as workflow_module + + monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + + bg_run = workflow_module._BackgroundRun(user_id="u") + workflow_module._register_background_run("job-1", bg_run) + assert bg_run.done is False + + await workflow_module._clear_background_run("job-1") + + assert "job-1" not in workflow_module._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 + + monkeypatch.setattr(workflow_module, "_BACKGROUND_RUNS", {}) + + # Must not raise even when nothing is registered. + await workflow_module._clear_background_run("nope") + + +class TestBackgroundRunReplayConcurrentReaders: + """``_BackgroundRun.replay`` must serve multiple concurrent re-attach readers. + + The buffer's contract is that any number of clients can attach mid-run and + each one sees every frame from their ``start_index`` through ``finish``. + The asyncio.Condition wakeup is broadcast (``notify_all``) so every waiter + advances on each ``append``. This pins that contract at the unit level; + Playwright exercises it indirectly. + """ + + async def test_two_concurrent_readers_each_receive_all_frames(self): + import asyncio as _asyncio + + from langflow.api.v2 import workflow as workflow_module + + bg_run = workflow_module._BackgroundRun(user_id="u") + + async def consume() -> list[bytes]: + return [frame async for frame in bg_run.replay(start_index=0)] + + reader_a = _asyncio.create_task(consume()) + reader_b = _asyncio.create_task(consume()) + + # Give both readers two ticks to enter ``_cond.wait()``. One tick is + # enough on CPython (asyncio schedules pending tasks FIFO on yield), + # but a second pass is cheap defensive insurance against future + # scheduler tweaks. + await _asyncio.sleep(0) + await _asyncio.sleep(0) + + await bg_run.append(b"frame1") + await bg_run.append(b"frame2") + await bg_run.finish() + + a_frames, b_frames = await _asyncio.wait_for( + _asyncio.gather(reader_a, reader_b), + timeout=5.0, + ) + assert a_frames == [b"frame1", b"frame2"] + assert b_frames == [b"frame1", b"frame2"] + + async def test_late_reader_replays_buffered_frames_then_tails(self): + """A reader attaching after some frames have been buffered still sees them all. + + The first batch is drained from the snapshot; the reader then re-enters + ``_cond.wait()`` and picks up subsequent ``append`` calls before + ``finish`` releases it. + """ + import asyncio as _asyncio + + from langflow.api.v2 import workflow as workflow_module + + bg_run = workflow_module._BackgroundRun(user_id="u") + + # Buffer two frames before any reader attaches. + await bg_run.append(b"early-1") + await bg_run.append(b"early-2") + + async def consume() -> list[bytes]: + return [frame async for frame in bg_run.replay(start_index=0)] + + reader = _asyncio.create_task(consume()) + await _asyncio.sleep(0) # let the reader yield the early batch and re-enter wait + + await bg_run.append(b"late-1") + await bg_run.finish() + + frames = await _asyncio.wait_for(reader, timeout=5.0) + assert frames == [b"early-1", b"early-2", b"late-1"] + + async def test_replay_with_start_index_skips_earlier_frames(self): + """``start_index`` honors the Last-Event-ID hand-off semantics.""" + import asyncio as _asyncio + + from langflow.api.v2 import workflow as workflow_module + + bg_run = workflow_module._BackgroundRun(user_id="u") + await bg_run.append(b"f0") + await bg_run.append(b"f1") + await bg_run.append(b"f2") + await bg_run.finish() + + collected = [frame async for frame in bg_run.replay(start_index=1)] + assert collected == [b"f1", b"f2"] + + # A start_index past the end yields nothing and returns cleanly. + nothing = [frame async for frame in bg_run.replay(start_index=99)] + assert nothing == [] + + # Calling replay after finish from start_index=0 still replays the buffer. + async def _drain() -> list[bytes]: + return [frame async for frame in bg_run.replay(start_index=0)] + + replayed = await _asyncio.wait_for(_drain(), timeout=5.0) + assert replayed == [b"f0", b"f1", b"f2"] + + +class TestBufferBackgroundRunUnknownProtocolGuard: + """``_buffer_background_run`` flips the job to FAILED if the adapter registry was mutated. + + The route validates ``stream_protocol`` up front, but the buffer task + re-resolves the adapter inside the fire-and-forget coroutine. If a + registration was dropped between the route's check and the coroutine's + start, ``get_stream_adapter`` raises ``UnknownStreamProtocolError`` and the + coroutine must: + - mark the buffer done (waking any re-attach reader) + - update the job row to ``FAILED`` with a finished timestamp + - return cleanly without raising (it is fire-and-forget) + """ + + async def test_missing_protocol_marks_bg_run_done_and_fails_job( + self, + created_api_key, + empty_flow, + ): + """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.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 + + # Real Job row so update_job_status can flip it. + job_id = _uuid4() + await get_job_service().create_job( + job_id=job_id, + flow_id=empty_flow, + user_id=created_api_key.user_id, + ) + + async with session_scope() as session: + flow_row = await session.get(Flow, empty_flow) + flow = FlowRead.model_validate(flow_row, from_attributes=True) + 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)) + + # ``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 + # shared dict in place and restore it afterwards. + snapshot = dict(_REGISTRY) + _REGISTRY.clear() + try: + await workflow_module._buffer_background_run( + bg_run=bg_run, + flow=flow, + parsed=ParsedWorkflowRun(flow_id=str(empty_flow), mode="background"), + job_id=str(job_id), + current_user=user, + stream_protocol="agui", # registered at import-time; cleared above + ) + finally: + _REGISTRY.update(snapshot) + + assert bg_run.done is True + + async with session_scope() as session: + row = await session.get(Job, job_id) + assert row is not None + assert row.status == JobStatus.FAILED + assert row.finished_timestamp is not None + + async def test_missing_protocol_does_not_raise(self, created_api_key, empty_flow): + """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.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 + + job_id = _uuid4() + await get_job_service().create_job( + job_id=job_id, + flow_id=empty_flow, + user_id=created_api_key.user_id, + ) + + async with session_scope() as session: + flow_row = await session.get(Flow, empty_flow) + flow = FlowRead.model_validate(flow_row, from_attributes=True) + 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)) + + snapshot = dict(_REGISTRY) + _REGISTRY.clear() + try: + # Must not raise, even though the protocol is unknown. + await workflow_module._buffer_background_run( + bg_run=bg_run, + flow=flow, + parsed=ParsedWorkflowRun(flow_id=str(empty_flow), mode="background"), + job_id=str(job_id), + current_user=user, + stream_protocol="langflow", + ) + finally: + _REGISTRY.update(snapshot) + + +class TestStopWorkflowEndToEnd: + """The full ``POST /workflows/stop`` HTTP flow clears the in-memory buffer too. + + ``test_background_run_can_be_stopped`` covers the 200 response. This class + pins the side-effects: the in-process ``_BACKGROUND_RUNS`` entry is popped, + the ``_BackgroundRun.done`` flag flips so re-attach readers unblock, and + the Job row is marked ``CANCELLED``. + """ + + async def test_stop_clears_in_memory_registry_and_marks_job_cancelled( + self, + client: AsyncClient, + created_api_key, + chatbot_flow, + ): + from uuid import UUID as _UUID + + from langflow.api.v2 import workflow as workflow_module + from langflow.services.database.models.jobs.model import Job, JobStatus + + headers = {"x-api-key": created_api_key.api_key} + start = await client.post( + "api/v2/workflows", + json=_agui_body(chatbot_flow, mode="background"), + headers=headers, + ) + assert start.status_code == 200 + job_id = start.json()["job_id"] + + # The registry holds the buffer keyed by job_id until /stop or finish. + assert job_id in workflow_module._BACKGROUND_RUNS, ( + "Background run was not registered; the stop assertions below would be vacuous" + ) + bg_run = workflow_module._BACKGROUND_RUNS[job_id] + + stop = await client.post( + "api/v2/workflows/stop", + json={"job_id": job_id}, + headers=headers, + ) + assert stop.status_code == 200 + + # Side-effects: registry popped, buffer finished, job row CANCELLED. + assert job_id not in workflow_module._BACKGROUND_RUNS + assert bg_run.done is True + + async with session_scope() as session: + row = await session.get(Job, _UUID(job_id)) + assert row is not None + assert row.status == JobStatus.CANCELLED diff --git a/src/backend/tests/unit/api/v2/test_workflow_public.py b/src/backend/tests/unit/api/v2/test_workflow_public.py new file mode 100644 index 0000000000..67128fd1e3 --- /dev/null +++ b/src/backend/tests/unit/api/v2/test_workflow_public.py @@ -0,0 +1,381 @@ +"""Security tests for ``POST /api/v2/workflows/public``. + +Mirrors the v1 ``build_public_tmp`` security suite. Each test pins one of +the mitigations the v2 public endpoint is supposed to inherit from v1: + +- access_type == PUBLIC gate (403 for private flows). +- per-visitor virtual_flow_id propagated to the graph builder. +- session id namespaced under the visitor's virtual flow id + (CVE-2026-33017). +- file-path validation (GHSA-rcjh-r59h-gq37). +- ``data`` / ``tweaks`` rejected by the wire schema (visitors must never + override the stored flow definition). +- AUTO_LOGIN parity: authenticated_user_id is ignored when AUTO_LOGIN is + on so the backend's virtual_flow_id matches the frontend's. +- Owner impersonation: the flow runs under the flow owner's user, not + the visitor's. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from httpx import AsyncClient, codes +from langflow.services.database.models.flow.model import Flow +from lfx.services.deps import session_scope +from sqlmodel import select + +if TYPE_CHECKING: + from uuid import UUID + + +def _stub_generate_flow_events(monkeypatch, captured: dict) -> None: + """Capture kwargs that would reach ``generate_flow_events`` without running anything. + + The v2 public endpoint reaches ``generate_flow_events`` via + ``_stream_event_frames``; intercepting at the build entry point lets + us assert on flow_id translation, session scoping, owner + impersonation, and source_flow_id propagation without needing the + full streaming pipeline. + """ + import langflow.api.v2.workflow as workflow_module + + async def _fake_generate_flow_events(**kwargs: Any) -> None: + captured.update(kwargs) + # Make sure the stream loop's queue terminates cleanly. + import time + + await kwargs["event_manager"].queue.put((None, None, time.time())) + + monkeypatch.setattr(workflow_module, "generate_flow_events", _fake_generate_flow_events) + + +def _send_unauthenticated(client: AsyncClient, client_id: str) -> None: + """Drop login cookies and set the public ``client_id`` cookie. + + The shared client persists access-token cookies from + ``logged_in_headers``; clearing them is the only way to land in the + AUTO_LOGIN=true unauthenticated namespace path the CVE targets. + """ + client.cookies.clear() + client.cookies.set("client_id", client_id) + + +async def _make_flow_public(client: AsyncClient, flow_id: UUID, headers: dict) -> None: + response = await client.patch( + f"api/v1/flows/{flow_id}", + json={"access_type": "PUBLIC"}, + headers=headers, + ) + assert response.status_code == codes.OK + + +async def _read_stream(response) -> None: + """Drain the SSE response so the generator runs and the stub fires.""" + async for _ in response.aiter_bytes(): + pass + + +@pytest.fixture +async def public_flow_id(client: AsyncClient, json_memory_chatbot_no_llm, logged_in_headers): + from tests.unit.build_utils import create_flow + + flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers) + await _make_flow_public(client, flow_id, logged_in_headers) + return flow_id + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_non_public_flow( + client: AsyncClient, json_memory_chatbot_no_llm, logged_in_headers +): + """Private flows return 403 before any policy validation runs (info-leak guard).""" + from tests.unit.build_utils import create_flow + + flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers) + # Note: we deliberately do NOT mark it public. + + _send_unauthenticated(client, "private-test-client") + response = await client.post( + "api/v2/workflows/public", + json={"flow_id": str(flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.FORBIDDEN + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_data_field(client: AsyncClient, public_flow_id): + """``data`` is forbidden by the wire schema — visitors cannot override stored flow data.""" + _send_unauthenticated(client, "data-rejection-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "data": {"nodes": [], "edges": []}, + }, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.UNPROCESSABLE_ENTITY + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_tweaks_field(client: AsyncClient, public_flow_id): + """``tweaks`` is forbidden by the wire schema — visitors cannot override component params.""" + _send_unauthenticated(client, "tweaks-rejection-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "tweaks": {"node-id": {"input_value": "override"}}, + }, + 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): + """``mode`` must be ``stream`` — sync/background widen the public attack surface.""" + _send_unauthenticated(client, "mode-rejection-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "mode": "sync", + }, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.UNPROCESSABLE_ENTITY + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_malicious_files(client: AsyncClient, public_flow_id): + """Regression for GHSA-rcjh-r59h-gq37 — file paths must be ``{flow_id}/{basename}``.""" + _send_unauthenticated(client, "files-rejection-client") + response = await client.post( + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "files": ["../../../etc/passwd"], + }, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.BAD_REQUEST + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_namespaces_caller_session(client: AsyncClient, public_flow_id, monkeypatch): + """Caller-supplied ``session_id`` is wrapped under the (client_id, flow_id) namespace. + + Pins CVE-2026-33017 for the v2 public endpoint: an unauthenticated + visitor must not be able to address a session that lives outside + their own namespace through a Memory component. + """ + from langflow.api.utils.flow_utils import compute_virtual_flow_id + + captured: dict = {} + _stub_generate_flow_events(monkeypatch, captured) + + client_id = "ns-test-client-v2" + _send_unauthenticated(client, client_id) + victim_session = str(public_flow_id) + + async with client.stream( + "POST", + "api/v2/workflows/public", + json={ + "flow_id": str(public_flow_id), + "input_value": "Hi", + "session_id": victim_session, + }, + headers={"Content-Type": "application/json"}, + ) as response: + assert response.status_code == codes.OK + await _read_stream(response) + + expected_namespace = str(compute_virtual_flow_id(client_id, public_flow_id)) + sent_inputs = captured["inputs"] + assert sent_inputs is not None + assert sent_inputs.session == f"{expected_namespace}:{victim_session}" + assert sent_inputs.session != victim_session + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_uses_virtual_flow_id_for_storage(client: AsyncClient, public_flow_id, monkeypatch): + """``generate_flow_events`` is called with ``flow_id=virtual``, ``source_flow_id=real``. + + The graph loads from the real flow id (the DB row) but tags messages + with the virtual flow id so the popup's ``useGetFlowId``-keyed + filter actually matches. + """ + from langflow.api.utils.flow_utils import compute_virtual_flow_id + + captured: dict = {} + _stub_generate_flow_events(monkeypatch, captured) + + client_id = "virtual-id-test-client" + _send_unauthenticated(client, client_id) + + async with client.stream( + "POST", + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) as response: + assert response.status_code == codes.OK + await _read_stream(response) + + expected_virtual = compute_virtual_flow_id(client_id, public_flow_id) + assert captured["flow_id"] == expected_virtual + assert captured["source_flow_id"] == public_flow_id + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_isolates_disjoint_clients(client: AsyncClient, public_flow_id, monkeypatch): + """Two client_ids submitting the same session string land in disjoint namespaces.""" + shared_session = "shared-session-name" + + captured: dict = {} + _stub_generate_flow_events(monkeypatch, captured) + + _send_unauthenticated(client, "iso-client-A") + async with client.stream( + "POST", + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi", "session_id": shared_session}, + headers={"Content-Type": "application/json"}, + ) as response: + assert response.status_code == codes.OK + await _read_stream(response) + session_a = captured["inputs"].session + + captured.clear() + _send_unauthenticated(client, "iso-client-B") + async with client.stream( + "POST", + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi", "session_id": shared_session}, + headers={"Content-Type": "application/json"}, + ) as response: + assert response.status_code == codes.OK + await _read_stream(response) + session_b = captured["inputs"].session + + assert session_a != session_b + assert session_a.endswith(f":{shared_session}") + assert session_b.endswith(f":{shared_session}") + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_runs_as_flow_owner(client: AsyncClient, public_flow_id, monkeypatch): + """Owner impersonation: ``current_user`` passed to the build is the flow's owner.""" + captured: dict = {} + _stub_generate_flow_events(monkeypatch, captured) + + _send_unauthenticated(client, "owner-test-client") + async with client.stream( + "POST", + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) as response: + assert response.status_code == codes.OK + await _read_stream(response) + + # Look up the flow owner directly to compare. + async with session_scope() as session: + flow = (await session.exec(select(Flow).where(Flow.id == public_flow_id))).first() + assert flow is not None + owner_id = flow.user_id + + assert captured["current_user"].id == owner_id + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_rejects_missing_client_id(client: AsyncClient, public_flow_id): + """Without a ``client_id`` cookie or authenticated user, the request is rejected.""" + client.cookies.clear() # no client_id, no auth + response = await client.post( + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == codes.BAD_REQUEST + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_sanitizes_component_validation_error(client: AsyncClient, public_flow_id, monkeypatch): + """``CustomComponentValidationError`` must not leak blocked class names to anonymous visitors. + + Mirrors v1 ``build_public_tmp``: the raw error message embeds the + disabled component class names, which is enumeration of the owner's + flow internals through a public surface. Surface a sanitized 400. + """ + from lfx.utils.flow_validation import CustomComponentValidationError + + raw_message = "Flow build blocked: custom components are not allowed: SecretInternalComponent" + + def _raise(*_args, **_kwargs): + raise CustomComponentValidationError(raw_message) + + import langflow.api.v2.workflow_public as workflow_public_module + + monkeypatch.setattr(workflow_public_module, "validate_flow_for_current_settings", _raise) + + _send_unauthenticated(client, "component-validation-client") + response = await client.post( + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == codes.BAD_REQUEST + detail = response.json().get("detail", "") + assert detail == "This flow cannot be executed." + assert "SecretInternalComponent" not in detail + assert raw_message not in response.text + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_public_endpoint_surfaces_value_error_as_400(client: AsyncClient, public_flow_id, monkeypatch): + """Other ``ValueError``s from the gate sequence become 400 with the message preserved. + + Mirrors v1 ``build_public_tmp``'s ``except ValueError -> HTTP 400``. + Without the wrapper the same path returns a 500 with a stack trace. + """ + import langflow.api.v2.workflow_public as workflow_public_module + + gate_error_message = "custom gate failure" + + def _raise(*_args, **_kwargs): + raise ValueError(gate_error_message) + + monkeypatch.setattr(workflow_public_module, "validate_flow_for_current_settings", _raise) + + _send_unauthenticated(client, "value-error-client") + response = await client.post( + "api/v2/workflows/public", + json={"flow_id": str(public_flow_id), "input_value": "Hi"}, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == codes.BAD_REQUEST + assert response.json().get("detail") == "custom gate failure" diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index bb99ff3992..2bfb64f1d2 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "langflow", "version": "1.10.0", "dependencies": { + "@ag-ui/client": "0.0.53", "@chakra-ui/number-input": "^2.1.2", "@chakra-ui/system": "^2.6.2", "@emotion/react": "^11.14.0", @@ -154,6 +155,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@ag-ui/client": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz", + "integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==", + "dependencies": { + "@ag-ui/core": "0.0.53", + "@ag-ui/encoder": "0.0.53", + "@ag-ui/proto": "0.0.53", + "@types/uuid": "^10.0.0", + "compare-versions": "^6.1.1", + "fast-json-patch": "^3.1.1", + "rxjs": "7.8.1", + "untruncate-json": "^0.0.1", + "uuid": "^11.1.0", + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/client/node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@ag-ui/client/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@ag-ui/core": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz", + "integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==", + "dependencies": { + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/encoder": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz", + "integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==", + "dependencies": { + "@ag-ui/core": "0.0.53", + "@ag-ui/proto": "0.0.53" + } + }, + "node_modules/@ag-ui/proto": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz", + "integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==", + "dependencies": { + "@ag-ui/core": "0.0.53", + "@bufbuild/protobuf": "^2.2.5", + "@protobuf-ts/protoc": "^2.11.1" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -883,6 +947,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@chakra-ui/anatomy": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@chakra-ui/anatomy/-/anatomy-2.2.2.tgz", @@ -3624,6 +3694,15 @@ "node": ">=18" } }, + "node_modules/@protobuf-ts/protoc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz", + "integrity": "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==", + "license": "Apache-2.0", + "bin": { + "protoc": "protoc.js" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -9157,6 +9236,12 @@ "dev": true, "license": "MIT" }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -10368,6 +10453,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -17584,6 +17675,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -19430,6 +19530,12 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/untruncate-json": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/untruncate-json/-/untruncate-json-0.0.1.tgz", + "integrity": "sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index f33d22a304..5cfd9d8add 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -52,6 +52,7 @@ "@tabler/icons-react": "^3.6.0", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/line-clamp": "^0.4.4", + "@ag-ui/client": "0.0.53", "@tanstack/react-query": "^5.49.2", "@types/axios": "^0.14.0", "@types/mustache": "^4.2.6", diff --git a/src/frontend/src/controllers/API/agui/__tests__/apply-state-delta.test.ts b/src/frontend/src/controllers/API/agui/__tests__/apply-state-delta.test.ts new file mode 100644 index 0000000000..dc7a471903 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/__tests__/apply-state-delta.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for `applyStateDelta` — the JSON-Patch parser the bridge runs on + * every `STATE_DELTA` event. The regex (`^\/nodes\/([^/]+)$`), the + * add/replace gating, and the BUILDING fallback for unknown statuses are + * the bits a backend wire-shape change would silently break, so we pin + * them directly against the real flowStore singleton. + */ + +import { BuildStatus } from "@/constants/enums"; +import { applyStateDelta } from "@/controllers/API/agui/run-flow-bridge"; +import useFlowStore from "@/stores/flowStore"; + +beforeEach(() => { + // Zustand singleton — only blow away the slices this helper touches so the + // rest of the store's action surface (updateBuildStatus, addDataToFlowPool) + // keeps working as written. + useFlowStore.setState({ flowBuildStatus: {}, flowPool: {} }); +}); + +describe("applyStateDelta", () => { + it("adds the vertex build entry on op:add with success + output", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { + status: "success", + output: { results: { text: "hi" } }, + }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT); + expect(state.flowPool["node-a"]).toHaveLength(1); + expect(state.flowPool["node-a"][0]).toMatchObject({ + id: "node-a", + run_id: "run-1", + valid: true, + data: { results: { text: "hi" } }, + }); + expect(touched.has("node-a")).toBe(true); + }); + + it("treats op:replace the same as op:add", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "replace", + path: "/nodes/node-a", + value: { + status: "success", + output: { results: { text: "hi" } }, + }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT); + expect(state.flowPool["node-a"]).toHaveLength(1); + expect(touched.has("node-a")).toBe(true); + }); + + it("ignores op:remove", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "remove", + path: "/nodes/node-a", + value: { status: "success", output: {} }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]).toBeUndefined(); + expect(state.flowPool["node-a"]).toBeUndefined(); + expect(touched.size).toBe(0); + }); + + it("ignores nested paths beyond /nodes/", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a/extra/path", + value: { status: "success", output: {} }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]).toBeUndefined(); + expect(state.flowPool["node-a"]).toBeUndefined(); + expect(touched.size).toBe(0); + }); + + it("ignores ops with null value", () => { + const touched = new Set(); + + applyStateDelta( + [{ op: "add", path: "/nodes/node-a", value: null }], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]).toBeUndefined(); + expect(state.flowPool["node-a"]).toBeUndefined(); + expect(touched.size).toBe(0); + }); + + it("ignores ops with non-object value", () => { + const touched = new Set(); + + applyStateDelta( + [{ op: "add", path: "/nodes/node-a", value: "string" }], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]).toBeUndefined(); + expect(state.flowPool["node-a"]).toBeUndefined(); + expect(touched.size).toBe(0); + }); + + it("updates build status to BUILDING for running with null output and skips the flow pool", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "running", output: null }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILDING); + expect(state.flowPool["node-a"]).toBeUndefined(); + expect(touched.has("node-a")).toBe(true); + }); + + it("falls back to BuildStatus.BUILDING when the status is unknown", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "unknown_status", output: null }, + }, + ], + "run-1", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILDING); + expect(touched.has("node-a")).toBe(true); + }); + + it("stamps the runId on each flow-pool entry", () => { + const touched = new Set(); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "success", output: { results: {} } }, + }, + { + op: "add", + path: "/nodes/node-b", + value: { status: "error", output: { results: {} } }, + }, + ], + "run-xyz", + touched, + ); + + const state = useFlowStore.getState(); + expect(state.flowPool["node-a"][0].run_id).toBe("run-xyz"); + expect(state.flowPool["node-b"][0].run_id).toBe("run-xyz"); + // `valid` mirrors `status === "success"`. + expect(state.flowPool["node-a"][0].valid).toBe(true); + expect(state.flowPool["node-b"][0].valid).toBe(false); + }); + + describe("edge animation tracks node status", () => { + /** + * The v1 build path drove edge animation through onBuildStart/onBuildEnd. + * Without an explicit toggle here the AG-UI path used to leave edges + * static until ``finish()`` cleared them. Pin the contract: running flips + * edges on, success/error flips them off (per-node, no global resets). + */ + it("flips edges on when a node enters running status", () => { + const touched = new Set(); + const calls: Array<{ ids: string[]; running: boolean }> = []; + const original = useFlowStore.getState().updateEdgesRunningByNodes; + useFlowStore.setState({ + updateEdgesRunningByNodes: (ids: string[], running: boolean) => { + calls.push({ ids, running }); + }, + }); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "running", output: null }, + }, + ], + "run-1", + touched, + ); + + useFlowStore.setState({ updateEdgesRunningByNodes: original }); + + expect(calls).toEqual([{ ids: ["node-a"], running: true }]); + }); + + it("flips edges off when a node completes (success or error)", () => { + const touched = new Set(); + const calls: Array<{ ids: string[]; running: boolean }> = []; + const original = useFlowStore.getState().updateEdgesRunningByNodes; + useFlowStore.setState({ + updateEdgesRunningByNodes: (ids: string[], running: boolean) => { + calls.push({ ids, running }); + }, + }); + + applyStateDelta( + [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "success", output: { results: {} } }, + }, + { + op: "add", + path: "/nodes/node-b", + value: { status: "error", output: null }, + }, + ], + "run-1", + touched, + ); + + useFlowStore.setState({ updateEdgesRunningByNodes: original }); + + expect(calls).toEqual([ + { ids: ["node-a"], running: false }, + { ids: ["node-b"], running: false }, + ]); + }); + }); +}); diff --git a/src/frontend/src/controllers/API/agui/__tests__/run-agent.test.ts b/src/frontend/src/controllers/API/agui/__tests__/run-agent.test.ts new file mode 100644 index 0000000000..0540aefbb7 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/__tests__/run-agent.test.ts @@ -0,0 +1,395 @@ +import { + buildWorkflowRunRequest, + createWorkflowAgent, + WORKFLOWS_ENDPOINT, + WORKFLOWS_PUBLIC_ENDPOINT, +} from "../run-agent"; + +describe("buildWorkflowRunRequest", () => { + it("returns a native WorkflowRunRequest with input_value mapped from message", () => { + const body = buildWorkflowRunRequest({ + flowId: "flow-1", + message: "hello", + }); + + expect(body).toEqual({ + flow_id: "flow-1", + input_value: "hello", + mode: "stream", + stream_protocol: "agui", + }); + }); + + it("defaults input_value to empty string when no message is provided", () => { + const body = buildWorkflowRunRequest({ flowId: "flow-1" }); + + expect(body.input_value).toBe(""); + }); + + it("maps threadId to session_id only when explicitly provided", () => { + const withThread = buildWorkflowRunRequest({ + flowId: "flow-1", + threadId: "t-9", + }); + const without = buildWorkflowRunRequest({ flowId: "flow-1" }); + + expect(withThread.session_id).toBe("t-9"); + expect(without).not.toHaveProperty("session_id"); + }); + + it("drops an empty-string threadId (falsy guard pins current behavior)", () => { + // Semantic gap: the builder uses `if (opts.threadId)`, so an explicit + // `""` is indistinguishable from an unset value and session_id is + // omitted. This test pins that behavior; if we ever switch to + // `!== undefined` semantics, this assertion flips and the change is + // a deliberate one. + const body = buildWorkflowRunRequest({ flowId: "flow-1", threadId: "" }); + + expect(body).not.toHaveProperty("session_id"); + }); + + it('forwards an explicit mode="stream" and pins stream_protocol', () => { + const body = buildWorkflowRunRequest({ + flowId: "flow-1", + mode: "stream", + }); + + expect(body.mode).toBe("stream"); + expect(body.stream_protocol).toBe("agui"); + }); + + it("maps tweaks, partial-run ids, flowData and files into native keys", () => { + const body = buildWorkflowRunRequest({ + flowId: "flow-1", + tweaks: { "ChatInput-abc": { input_value: "x" } }, + startComponentId: "c1", + stopComponentId: "c2", + flowData: { nodes: [{ id: "n1" }], edges: [] }, + files: ["a.txt"], + }); + + expect(body).toMatchObject({ + tweaks: { "ChatInput-abc": { input_value: "x" } }, + start_component_id: "c1", + stop_component_id: "c2", + data: { nodes: [{ id: "n1" }], edges: [] }, + files: ["a.txt"], + }); + }); + + it('rejects mode="sync" because the SSE decoder can\'t read a JSON response', () => { + expect(() => + buildWorkflowRunRequest({ flowId: "flow-1", mode: "sync" }), + ).toThrow(/only supports mode="stream"/); + }); + + it('rejects mode="background" because the response is a job JSON, not SSE', () => { + expect(() => + buildWorkflowRunRequest({ flowId: "flow-1", mode: "background" }), + ).toThrow(/only supports mode="stream"/); + }); + + it("omits optional native fields when the caller did not set them", () => { + const body = buildWorkflowRunRequest({ flowId: "flow-1" }); + + expect(body).not.toHaveProperty("tweaks"); + expect(body).not.toHaveProperty("start_component_id"); + expect(body).not.toHaveProperty("stop_component_id"); + expect(body).not.toHaveProperty("data"); + expect(body).not.toHaveProperty("files"); + }); + + it("never emits AG-UI RunAgentInput keys on the request body", () => { + const body = buildWorkflowRunRequest({ + flowId: "flow-1", + message: "hi", + threadId: "t-1", + tweaks: { x: { y: 1 } }, + startComponentId: "s", + stopComponentId: "e", + flowData: { nodes: [], edges: [] }, + files: ["f"], + }); + + for (const forbidden of [ + "threadId", + "runId", + "forwardedProps", + "state", + "messages", + "tools", + "context", + ]) { + expect(body).not.toHaveProperty(forbidden); + } + }); +}); + +describe("createWorkflowAgent", () => { + it("defaults to the v2 workflows endpoint", () => { + const agent = createWorkflowAgent({ + body: buildWorkflowRunRequest({ flowId: "flow-1" }), + }); + + expect(agent.url).toBe(WORKFLOWS_ENDPOINT); + }); + + it("uses a caller-provided url", () => { + const agent = createWorkflowAgent({ + url: "/api/v2/workflows-test", + body: buildWorkflowRunRequest({ flowId: "flow-1" }), + }); + + expect(agent.url).toBe("/api/v2/workflows-test"); + }); + + it("passes custom headers through to the underlying HttpAgent", () => { + const agent = createWorkflowAgent({ + headers: { "X-Foo": "bar" }, + body: buildWorkflowRunRequest({ flowId: "flow-1" }), + }); + + expect(agent.headers).toMatchObject({ "X-Foo": "bar" }); + }); +}); + +describe("createWorkflowAgent wire body", () => { + // jsdom does not ship `fetch`, `TextEncoder`, or `TextDecoder`; pull + // them from Node's `util` so we can spy on fetch and the AG-UI SSE + // decoder has the codecs it expects. + beforeAll(() => { + const g = global as { + fetch?: unknown; + TextEncoder?: unknown; + TextDecoder?: unknown; + }; + const util = require("util") as { + TextEncoder: typeof TextEncoder; + TextDecoder: typeof TextDecoder; + }; + if (typeof g.TextEncoder !== "function") g.TextEncoder = util.TextEncoder; + if (typeof g.TextDecoder !== "function") g.TextDecoder = util.TextDecoder; + if (typeof g.fetch !== "function") { + g.fetch = () => Promise.reject(new Error("fetch not stubbed")); + } + }); + + // AG-UI's `runHttpRequest` consumes `.ok`, `.status`, `.headers.get()`, + // and `.body.getReader()`; this fake response is the minimum surface + // that satisfies that contract so the SSE decoder runs for real. + function sseResponse(events: object[]): unknown { + const encoder = new ( + globalThis as { TextEncoder: typeof TextEncoder } + ).TextEncoder(); + const payload = encoder.encode( + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), + ); + let delivered = false; + return { + ok: true, + status: 200, + headers: { + get(key: string) { + return key.toLowerCase() === "content-type" + ? "text/event-stream" + : null; + }, + }, + body: { + getReader() { + return { + async read() { + if (delivered) return { done: true, value: undefined }; + delivered = true; + return { done: false, value: payload }; + }, + async cancel() { + /* no-op */ + }, + }; + }, + }, + text: async () => "", + }; + } + + it("posts the native WorkflowRunRequest body (not RunAgentInput) to the endpoint", async () => { + const captured: { url: string; init: RequestInit }[] = []; + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => { + captured.push({ + url: + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url, + init: init ?? {}, + }); + return sseResponse([ + { type: "RUN_STARTED", threadId: "t", runId: "r", timestamp: 0 }, + { type: "RUN_FINISHED", threadId: "t", runId: "r", timestamp: 0 }, + ]); + }; + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation(fetchImpl as unknown as typeof fetch); + + try { + const body = buildWorkflowRunRequest({ + flowId: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + message: "hello", + threadId: "session-abc", + }); + const agent = createWorkflowAgent({ body }); + + await new Promise((resolve, reject) => { + const sub = agent + .run({ + threadId: "session-abc", + runId: "", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: {}, + }) + .subscribe({ + error: (err) => { + sub.unsubscribe(); + reject(err); + }, + complete: () => { + sub.unsubscribe(); + resolve(); + }, + }); + }); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe(WORKFLOWS_ENDPOINT); + expect(captured[0].init.method).toBe("POST"); + const wireBody = JSON.parse(captured[0].init.body as string); + expect(wireBody).toEqual({ + flow_id: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + input_value: "hello", + mode: "stream", + stream_protocol: "agui", + session_id: "session-abc", + }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("decodes typed AG-UI events from the SSE response", async () => { + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation((async () => + sseResponse([ + { type: "RUN_STARTED", threadId: "t", runId: "r", timestamp: 0 }, + { + type: "CUSTOM", + name: "langflow.event", + value: { event_type: "token", data: { chunk: "hi" } }, + timestamp: 0, + rawEvent: {}, + }, + { + type: "RUN_FINISHED", + threadId: "t", + runId: "r", + timestamp: 0, + }, + ])) as unknown as typeof fetch); + + try { + const agent = createWorkflowAgent({ + body: buildWorkflowRunRequest({ + flowId: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + message: "hi", + }), + }); + + const seenTypes: string[] = []; + await new Promise((resolve, reject) => { + const sub = agent + .run({ + threadId: "client-thread", + runId: "client-run", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: {}, + }) + .subscribe({ + next: (e: { type: string }) => { + seenTypes.push(e.type); + }, + error: (err) => { + sub.unsubscribe(); + reject(err); + }, + complete: () => { + sub.unsubscribe(); + resolve(); + }, + }); + }); + + expect(seenTypes).toEqual( + expect.arrayContaining(["RUN_STARTED", "CUSTOM", "RUN_FINISHED"]), + ); + } finally { + fetchSpy.mockRestore(); + } + }); +}); + +describe("buildWorkflowRunRequest — public endpoint shape", () => { + // The public-flow endpoint's Pydantic schema has ``extra="forbid"`` + // and explicitly omits ``data`` and ``tweaks``. Sending either would + // 422 the request. Pin the dropping behaviour here so a regression in + // the bridge surfaces in the unit suite, not as a CI playwright fail. + + it("drops tweaks when usePublicEndpoint is true", () => { + const body = buildWorkflowRunRequest({ + flowId: "11111111-1111-1111-1111-111111111111", + message: "hi", + tweaks: { "node-id": { input_value: "x" } }, + usePublicEndpoint: true, + }); + + expect(body.tweaks).toBeUndefined(); + }); + + it("drops flowData when usePublicEndpoint is true", () => { + const body = buildWorkflowRunRequest({ + flowId: "11111111-1111-1111-1111-111111111111", + message: "hi", + flowData: { nodes: [], edges: [] }, + usePublicEndpoint: true, + }); + + expect(body.data).toBeUndefined(); + }); + + it("still includes tweaks/data when usePublicEndpoint is false (canvas path)", () => { + const body = buildWorkflowRunRequest({ + flowId: "11111111-1111-1111-1111-111111111111", + message: "hi", + tweaks: { "node-id": { input_value: "x" } }, + flowData: { nodes: [], edges: [] }, + usePublicEndpoint: false, + }); + + expect(body.tweaks).toEqual({ "node-id": { input_value: "x" } }); + expect(body.data).toEqual({ nodes: [], edges: [] }); + }); +}); + +describe("WORKFLOWS_PUBLIC_ENDPOINT", () => { + it("is the canonical /api/v2/workflows/public path", () => { + expect(WORKFLOWS_PUBLIC_ENDPOINT).toBe("/api/v2/workflows/public"); + }); +}); diff --git a/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge-e2e.test.ts b/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge-e2e.test.ts new file mode 100644 index 0000000000..529cc8616b --- /dev/null +++ b/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge-e2e.test.ts @@ -0,0 +1,333 @@ +/** + * End-to-end tests for the AG-UI bridge: drive `runFlowAGUI` against a + * synthetic SSE stream and assert side effects on the real `flowStore` + + * `alertStore` singletons. The unit tests in `run-flow-bridge.test.ts` + * only cover the event-dispatch return-value contract, so this file + * pins the integration: a realistic event sequence has to leave the + * canvas in the expected resolved state (or surface the error to the + * alert store on RUN_ERROR). + */ + +import { BuildStatus } from "@/constants/enums"; +import { runFlowAGUI } from "@/controllers/API/agui/run-flow-bridge"; +import useAlertStore from "@/stores/alertStore"; +import useFlowStore from "@/stores/flowStore"; +import { useMessagesStore } from "@/stores/messagesStore"; + +beforeAll(() => { + // jsdom lacks the codecs the AG-UI SSE decoder uses; pull them from + // node's `util` exactly as `run-agent.test.ts` does. + const g = global as { + fetch?: unknown; + TextEncoder?: unknown; + TextDecoder?: unknown; + }; + const util = require("util") as { + TextEncoder: typeof TextEncoder; + TextDecoder: typeof TextDecoder; + }; + if (typeof g.TextEncoder !== "function") g.TextEncoder = util.TextEncoder; + if (typeof g.TextDecoder !== "function") g.TextDecoder = util.TextDecoder; + if (typeof g.fetch !== "function") { + g.fetch = () => Promise.reject(new Error("fetch not stubbed")); + } +}); + +beforeEach(() => { + // Reset only the slices the bridge writes to. Touching the full state + // would clobber action implementations (Zustand stores actions in the + // same state object). + useFlowStore.setState({ + flowBuildStatus: {}, + flowPool: {}, + isBuilding: true, + buildInfo: null, + }); + useAlertStore.setState({ + errorData: { title: "", list: [] }, + notificationList: [], + tempNotificationList: [], + }); + // The CUSTOM(langflow.event, add_message) branch routes through + // ``handleMessageEvent``, which appends to ``useMessagesStore``. Clearing + // here keeps the assertion below honest across reruns. + useMessagesStore.setState({ messages: [] }); +}); + +// Minimal fake Response sufficient for `runHttpRequest`'s SSE decoder. +// Lifted from the pattern in `run-agent.test.ts` so the bridge sees +// exactly what the wire produces. +function sseResponse(events: object[]): unknown { + const encoder = new ( + globalThis as { TextEncoder: typeof TextEncoder } + ).TextEncoder(); + const payload = encoder.encode( + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), + ); + let delivered = false; + return { + ok: true, + status: 200, + headers: { + get(key: string) { + return key.toLowerCase() === "content-type" + ? "text/event-stream" + : null; + }, + }, + body: { + getReader() { + return { + async read() { + if (delivered) return { done: true, value: undefined }; + delivered = true; + return { done: false, value: payload }; + }, + async cancel() { + /* no-op */ + }, + }; + }, + }, + text: async () => "", + }; +} + +describe("runFlowAGUI end-to-end", () => { + it("folds a full RUN_STARTED → STATE_DELTA → RUN_FINISHED stream into flowStore", async () => { + const events = [ + { + type: "RUN_STARTED", + threadId: "thread-1", + runId: "run-1", + timestamp: 0, + }, + { + type: "STATE_SNAPSHOT", + snapshot: { nodes: {} }, + timestamp: 0, + }, + { + type: "STATE_DELTA", + delta: [ + { + op: "add", + path: "/nodes/node-a", + value: { status: "running", output: null }, + }, + ], + timestamp: 0, + }, + { + type: "STATE_DELTA", + delta: [ + { + op: "replace", + path: "/nodes/node-a", + value: { + status: "success", + output: { results: { text: "hello" } }, + }, + }, + ], + timestamp: 0, + }, + { + type: "CUSTOM", + name: "langflow.event", + value: { + event_type: "add_message", + data: { + id: "msg-1", + text: "hello from agent", + sender: "AI", + sender_name: "agent", + session_id: "thread-1", + }, + }, + timestamp: 0, + rawEvent: {}, + }, + { + type: "RUN_FINISHED", + threadId: "thread-1", + runId: "run-1", + timestamp: 0, + }, + ]; + + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation((async () => + sseResponse(events)) as unknown as typeof fetch); + + try { + await runFlowAGUI({ + flowId: "flow-1", + message: "hi", + threadId: "thread-1", + }); + + const flow = useFlowStore.getState(); + expect(flow.isBuilding).toBe(false); + expect(flow.buildInfo).toEqual({ success: true }); + // Final delta carries a real output, so the flow pool gets the entry + // stamped with the runId server announced via RUN_STARTED. + expect(flow.flowPool["node-a"]).toHaveLength(1); + expect(flow.flowPool["node-a"][0]).toMatchObject({ + id: "node-a", + run_id: "run-1", + valid: true, + }); + // `revertBuiltStatusFromBuilding` is called after the run, but the + // final status here came in as `success` already so it stays BUILT. + expect(flow.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT); + // The CUSTOM(langflow.event) frame was routed through + // ``handleMessageEvent``, which appended to ``useMessagesStore``. + // Pinning the side effect proves the bridge dispatch actually wires + // a realistic ``event_type`` through to the chat-view handler, not + // just an inert pass-through. + const messages = useMessagesStore.getState().messages; + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ id: "msg-1", sender: "AI" }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("surfaces RUN_ERROR to alertStore and clears isBuilding", async () => { + const events = [ + { + type: "RUN_STARTED", + threadId: "thread-1", + runId: "run-1", + timestamp: 0, + }, + { + type: "RUN_ERROR", + message: "graph blew up", + timestamp: 0, + }, + ]; + + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation((async () => + sseResponse(events)) as unknown as typeof fetch); + + try { + await runFlowAGUI({ flowId: "flow-1", message: "boom" }); + + const flow = useFlowStore.getState(); + const alert = useAlertStore.getState(); + expect(flow.isBuilding).toBe(false); + expect(flow.buildInfo).toEqual({ + error: ["graph blew up"], + success: false, + }); + expect(alert.errorData.title).toBe("Workflow run failed"); + expect(alert.errorData.list).toEqual(["graph blew up"]); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("records a failure when the stream closes without RUN_FINISHED or RUN_ERROR", async () => { + /** + * Server-side crash, truncated proxy response, or a buggy stream can + * close the SSE without delivering a terminal event. ``buildInfo`` must + * surface that as an error so the caller's analytics doesn't record a + * silent close as success. + */ + const events = [ + { + type: "RUN_STARTED", + threadId: "thread-1", + runId: "run-1", + timestamp: 0, + }, + // No RUN_FINISHED or RUN_ERROR. + ]; + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation((async () => + sseResponse(events)) as unknown as typeof fetch); + + try { + await runFlowAGUI({ flowId: "flow-1", message: "silent" }); + + const flow = useFlowStore.getState(); + expect(flow.isBuilding).toBe(false); + expect(flow.buildInfo).toEqual({ + error: ["Workflow run ended unexpectedly"], + success: false, + }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("aborts the in-flight SSE when the caller's AbortSignal fires", async () => { + /** + * ``flowStore.stopBuilding`` aborts its build controller. The signal is + * passed to ``runFlowAGUI`` so the underlying fetch is cancelled too; + * without the plumbing, Stop would update local state but the SSE + * stream would keep running on the wire. + */ + const signals: AbortSignal[] = []; + const fetchSpy = jest + .spyOn(global as { fetch: typeof fetch }, "fetch") + .mockImplementation(((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.signal) signals.push(init.signal); + return Promise.resolve({ + ok: true, + status: 200, + headers: { + get(key: string) { + return key.toLowerCase() === "content-type" + ? "text/event-stream" + : null; + }, + }, + body: { + getReader() { + return { + read: () => new Promise(() => {}), + cancel: async () => {}, + }; + }, + }, + text: async () => "", + } as unknown as Response); + }) as unknown as typeof fetch); + + try { + const controller = new AbortController(); + const runPromise = runFlowAGUI({ + flowId: "flow-1", + message: "stop-me", + signal: controller.signal, + }); + // Yield so fetch + subscribe wire up. + await Promise.resolve(); + await Promise.resolve(); + + expect(signals.length).toBe(1); + expect(signals[0].aborted).toBe(false); + + controller.abort(); + await runPromise; + + expect(signals[0].aborted).toBe(true); + const flow = useFlowStore.getState(); + expect(flow.isBuilding).toBe(false); + // ``buildInfo`` is recorded as a failure (so trackFlowBuild sees the + // cancellation as an error) but without an ``error`` string: the + // caller's stopBuilding already shows the user-facing "Build stopped" + // alert and a duplicate inside the canvas footer would trip locators. + expect(flow.buildInfo).toEqual({ success: false }); + } finally { + fetchSpy.mockRestore(); + } + }); +}); diff --git a/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge.test.ts b/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge.test.ts new file mode 100644 index 0000000000..f61cd9f432 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/__tests__/run-flow-bridge.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for the AG-UI bridge event dispatcher. + * + * The handler is extracted so the terminal-event contract is unit-testable + * without standing up a real flowStore or a fake long-lived SSE stream: + * RUN_FINISHED and RUN_ERROR must return ``true`` so the subscribe wrapper + * tears the subscription down. STATE_DELTA, RUN_STARTED, and CUSTOM must + * return ``false`` so the run keeps streaming. + */ + +import { type BaseEvent, EventType } from "@ag-ui/client"; +import { + type BridgeContext, + handleAGUIEvent, +} from "@/controllers/API/agui/run-flow-bridge"; + +function makeRecordingContext() { + const calls: string[] = []; + const ctx: BridgeContext = { + setRunId: (runId) => calls.push(`setRunId:${runId}`), + applyDelta: (ops) => calls.push(`applyDelta:${ops.length}`), + handleCustomEvent: (eventType) => calls.push(`custom:${eventType}`), + onFinished: () => calls.push("finished"), + onError: (message) => calls.push(`error:${message}`), + }; + return { ctx, calls }; +} + +describe("handleAGUIEvent terminal contract", () => { + it("returns true for RUN_FINISHED and invokes onFinished", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { type: EventType.RUN_FINISHED } as BaseEvent, + ctx, + ); + + expect(terminal).toBe(true); + expect(calls).toEqual(["finished"]); + }); + + it("returns true for RUN_ERROR and surfaces the error message", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { type: EventType.RUN_ERROR, message: "boom" } as unknown as BaseEvent, + ctx, + ); + + expect(terminal).toBe(true); + expect(calls).toEqual(["error:boom"]); + }); + + it("returns true for RUN_ERROR with no message, falling back to a default", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { type: EventType.RUN_ERROR } as BaseEvent, + ctx, + ); + + expect(terminal).toBe(true); + expect(calls).toEqual(["error:Unknown run error"]); + }); +}); + +describe("handleAGUIEvent non-terminal contract", () => { + it("returns false for RUN_STARTED and propagates the runId", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { type: EventType.RUN_STARTED, runId: "r1" } as unknown as BaseEvent, + ctx, + ); + + expect(terminal).toBe(false); + expect(calls).toEqual(["setRunId:r1"]); + }); + + it("returns false for RUN_STARTED with no runId, leaving setRunId untouched", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { type: EventType.RUN_STARTED } as BaseEvent, + ctx, + ); + + expect(terminal).toBe(false); + expect(calls).toEqual([]); + }); + + it("returns false for STATE_DELTA and forwards the patch ops", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { + type: EventType.STATE_DELTA, + delta: [ + { op: "add", path: "/nodes/a", value: {} }, + { op: "add", path: "/nodes/b", value: {} }, + ], + } as unknown as BaseEvent, + ctx, + ); + + expect(terminal).toBe(false); + expect(calls).toEqual(["applyDelta:2"]); + }); + + it("returns false for CUSTOM(langflow.event) and forwards the event type", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { + type: EventType.CUSTOM, + name: "langflow.event", + value: { event_type: "add_message", data: { id: "m1" } }, + } as unknown as BaseEvent, + ctx, + ); + + expect(terminal).toBe(false); + expect(calls).toEqual(["custom:add_message"]); + }); + + it("returns false for CUSTOM with a foreign name and skips the handler", () => { + const { ctx, calls } = makeRecordingContext(); + + const terminal = handleAGUIEvent( + { + type: EventType.CUSTOM, + name: "some.other", + value: { event_type: "add_message", data: {} }, + } as unknown as BaseEvent, + ctx, + ); + + expect(terminal).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/src/frontend/src/controllers/API/agui/run-agent.ts b/src/frontend/src/controllers/API/agui/run-agent.ts new file mode 100644 index 0000000000..0f6e573053 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/run-agent.ts @@ -0,0 +1,191 @@ +/** + * v2 workflows run service. + * + * Builds the native `WorkflowRunRequest` body that + * `POST /api/v2/workflows` accepts and runs it through a thin `HttpAgent` + * subclass so the frontend keeps consuming the typed AG-UI event stream + * (`stream_protocol: "agui"`) emitted by the server. + * + * The endpoint Pydantic model has `extra="forbid"`, so this builder must + * never leak AG-UI `RunAgentInput` keys (`threadId`, `runId`, + * `forwardedProps`, `state`, `messages`, `tools`, `context`) onto the wire. + */ + +import { + HttpAgent, + type HttpAgentConfig, + type RunAgentInput, +} from "@ag-ui/client"; + +/** Execution mode accepted by the v2 workflows endpoint. */ +export type WorkflowMode = "sync" | "stream" | "background"; + +/** Streaming protocol selector (server-side adapter registry). */ +export type StreamProtocol = "agui" | "langflow"; + +/** Options describing one Langflow workflow run. */ +export interface WorkflowRunOptions { + /** The Langflow flow id to run. Required by the v2 endpoint. */ + flowId: string; + /** User chat input (last user message) — maps to `input_value`. */ + message?: string; + /** Component-keyed parameter overrides, e.g. `{ChatInput-abc: {input_value: "..."}}`. */ + tweaks?: Record>; + /** Execution mode; defaults to `stream`. */ + mode?: WorkflowMode; + /** Chat-session id — maps to the endpoint's `session_id`. Server derives `run_id`/`thread_id`. */ + threadId?: string; + /** Optional partial-run start vertex id. */ + startComponentId?: string; + /** Optional partial-run stop vertex id. */ + stopComponentId?: string; + /** Current flow data (nodes + edges) to run; falls back to the DB copy if omitted. */ + flowData?: { nodes: unknown[]; edges: unknown[] }; + /** Runtime file references the graph build needs (e.g. uploaded file paths). */ + files?: string[]; + /** + * When true, route the request to the public-flow endpoint + * (``/api/v2/workflows/public``) instead of the authenticated one. + * + * Set by the shareable-playground popup so the backend can apply the + * public-access mitigations (per-visitor virtual_flow_id, session + * namespacing, file-path validation, owner impersonation) that mirror + * v1's ``build_public_tmp``. + * + * When enabled the request body is narrowed too: ``tweaks`` and + * ``flowData`` are silently dropped because the public schema forbids + * them (visitors must never override the stored flow definition). + */ + usePublicEndpoint?: boolean; +} + +/** The v2 workflows endpoint path. */ +export const WORKFLOWS_ENDPOINT = "/api/v2/workflows"; + +/** The v2 public workflows endpoint path (shareable playground). */ +export const WORKFLOWS_PUBLIC_ENDPOINT = "/api/v2/workflows/public"; + +/** + * Wire shape for `POST /api/v2/workflows`. Mirrors + * `lfx.schema.workflow.WorkflowRunRequest`; the Pydantic model rejects + * extra fields, so this type intentionally has no escape hatch. + */ +export interface WorkflowRunRequestBody { + flow_id: string; + input_value: string; + mode: WorkflowMode; + stream_protocol: StreamProtocol; + tweaks?: Record>; + session_id?: string; + data?: { nodes: unknown[]; edges: unknown[] }; + files?: string[]; + start_component_id?: string; + stop_component_id?: string; +} + +/** + * Build a native `WorkflowRunRequest` body for the v2 workflows endpoint. + * + * The frontend pins `stream_protocol: "agui"` so the SSE response is a + * typed AG-UI event stream the existing consumers already understand. + * `run_id` and `thread_id` are derived server-side from `session_id` and + * `flow_id`; we only forward `session_id` when the caller actually has one. + */ +export function buildWorkflowRunRequest( + opts: WorkflowRunOptions, +): WorkflowRunRequestBody { + const mode = opts.mode ?? "stream"; + // ``createWorkflowAgent`` always patches ``Accept: text/event-stream`` and + // ``HttpAgent.run`` decodes the response as SSE. ``mode=sync`` returns + // ``application/json`` and ``mode=background`` returns a job JSON, so + // either of those modes through this builder would mis-decode at runtime. + // The only in-tree caller (``runFlowAGUI``) sends ``stream``; this guard + // catches accidental misuse early instead of silently producing a broken + // run. Sync / background callers should go through a JSON fetch path. + if (mode !== "stream") { + throw new Error( + `createWorkflowAgent only supports mode="stream"; got "${mode}". ` + + `Use a JSON fetch against ${WORKFLOWS_ENDPOINT} for sync / background.`, + ); + } + const body: WorkflowRunRequestBody = { + flow_id: opts.flowId, + input_value: opts.message ?? "", + mode, + stream_protocol: "agui", + }; + if (opts.threadId) body.session_id = opts.threadId; + // The public endpoint's schema (extra="forbid") rejects tweaks/data: + // visitors must never override the stored flow definition. Drop them + // here instead of letting the request fail with a 422. + const isPublic = !!opts.usePublicEndpoint; + if (!isPublic && opts.tweaks) body.tweaks = opts.tweaks; + if (opts.startComponentId) body.start_component_id = opts.startComponentId; + if (opts.stopComponentId) body.stop_component_id = opts.stopComponentId; + if (!isPublic && opts.flowData) body.data = opts.flowData; + if (opts.files && opts.files.length > 0) body.files = opts.files; + return body; +} + +/** Construction options for the workflow agent. */ +export interface WorkflowAgentOptions { + /** Native body to POST. Built via `buildWorkflowRunRequest`. */ + body: WorkflowRunRequestBody; + /** Override the endpoint URL (defaults to `/api/v2/workflows`). */ + url?: string; + /** Extra headers; cookies and `fetch-intercept`'d headers are sent automatically. */ + headers?: Record; +} + +/** + * `HttpAgent` instance with `requestInit` swapped to POST a native + * `WorkflowRunRequest` body instead of the AG-UI `RunAgentInput`. + * + * `HttpAgent` exposes `requestInit` as the documented override surface + * ("Override this to customize the request"). We patch it on the instance + * rather than subclassing because the project's ts-jest targets `es5`, + * which can't extend the CJS-compiled class. Patching keeps us in sync + * with future AG-UI updates (SSE framing, abort, headers) instead of + * forking the whole client. + */ +export type WorkflowHttpAgent = HttpAgent & { + workflowBody: WorkflowRunRequestBody; +}; + +/** + * Create a workflow agent preconfigured for the v2 workflows endpoint. + * + * Auth is carried by the browser: same-origin cookies are sent automatically, + * and Langflow's `fetch-intercept` registration adds any custom headers. + */ +export function createWorkflowAgent( + opts: WorkflowAgentOptions, +): WorkflowHttpAgent { + const config: HttpAgentConfig = { + url: opts.url ?? WORKFLOWS_ENDPOINT, + headers: opts.headers, + }; + const agent = new HttpAgent(config) as WorkflowHttpAgent; + agent.workflowBody = opts.body; + // `requestInit` is the documented override surface on `HttpAgent`. + // Swap it on the instance so the wire body is the native + // `WorkflowRunRequest`; the AG-UI `RunAgentInput` passed to `run()` + // is used only for client-side correlation. + ( + agent as unknown as { + requestInit: (input: RunAgentInput) => RequestInit; + } + ).requestInit = function requestInit(_input: RunAgentInput): RequestInit { + return { + method: "POST", + headers: { + ...agent.headers, + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify(agent.workflowBody), + signal: agent.abortController.signal, + }; + }; + return agent; +} diff --git a/src/frontend/src/controllers/API/agui/run-flow-bridge.ts b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts new file mode 100644 index 0000000000..74ed69b1f9 --- /dev/null +++ b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts @@ -0,0 +1,323 @@ +/** + * Bridge: run a workflow through the AG-UI service and update flowStore. + * + * ``flowStore.buildFlow`` calls this function to drive a run through the + * v2 workflows endpoint. It starts the AG-UI HttpAgent and folds each + * event into the existing flow-store methods (``updateBuildStatus``, + * ``addDataToFlowPool``, ``setBuildInfo``, ``setIsBuilding``) so the + * canvas and "built successfully" toast keep working. + */ + +import { type BaseEvent, EventType } from "@ag-ui/client"; +import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-view/utils/message-event-handler"; +import { BuildStatus } from "@/constants/enums"; +import useAlertStore from "@/stores/alertStore"; +import useFlowStore from "@/stores/flowStore"; +import type { + ChatInputType, + ChatOutputType, + VertexBuildTypeAPI, + VertexDataTypeAPI, +} from "@/types/api"; +import { + buildWorkflowRunRequest, + createWorkflowAgent, + WORKFLOWS_PUBLIC_ENDPOINT, + type WorkflowRunOptions, +} from "./run-agent"; + +const AGUI_STATUS_TO_BUILD_STATUS: Record = { + pending: BuildStatus.TO_BUILD, + running: BuildStatus.BUILDING, + success: BuildStatus.BUILT, + error: BuildStatus.ERROR, +}; + +interface JsonPatchOp { + op: string; + path: string; + value?: unknown; +} + +interface AGUINodeState { + status: string; + output: VertexDataTypeAPI | null; +} + +/** + * Hooks the bridge exposes to {@link handleAGUIEvent}. Injecting them keeps + * the event-dispatch logic testable without importing the real flow / alert + * stores or the chat-view message handler. + */ +export interface BridgeContext { + setRunId: (runId: string) => void; + applyDelta: (ops: JsonPatchOp[]) => void; + handleCustomEvent: (eventType: string, data: unknown) => void; + onFinished: () => void; + onError: (message: string) => void; +} + +/** + * Dispatch a single AG-UI event into the bridge context. + * + * Returns ``true`` when the event is terminal (``RUN_FINISHED`` or + * ``RUN_ERROR``); the caller must then tear down the subscription and + * resolve the run. Pulling this out of the inline subscribe handler keeps + * the terminal-event contract unit-testable. + */ +export function handleAGUIEvent(event: BaseEvent, ctx: BridgeContext): boolean { + if (event.type === EventType.RUN_STARTED) { + const started = event as unknown as { runId?: string }; + if (started.runId) ctx.setRunId(started.runId); + return false; + } + if (event.type === EventType.STATE_DELTA) { + const ops = (event as unknown as { delta?: JsonPatchOp[] }).delta ?? []; + ctx.applyDelta(ops); + return false; + } + if (event.type === EventType.CUSTOM) { + const custom = event as unknown as { + name?: string; + value?: { event_type?: string; data?: unknown }; + }; + if (custom.name === "langflow.event" && custom.value?.event_type) { + ctx.handleCustomEvent(custom.value.event_type, custom.value.data); + } + return false; + } + if (event.type === EventType.RUN_FINISHED) { + ctx.onFinished(); + return true; + } + if (event.type === EventType.RUN_ERROR) { + const message = + (event as unknown as { message?: string }).message ?? "Unknown run error"; + ctx.onError(message); + return true; + } + return false; +} + +/** + * @internal + * + * Exported only so the JSON-Patch parsing + status fallback can be pinned + * directly. `runFlowAGUI` is the sole production caller; tests live in + * `__tests__/apply-state-delta.test.ts`. The JSDoc `@internal` tag is what + * TypeScript's `--stripInternal` recognizes, so this stays out of generated + * `.d.ts` if the project ever turns that flag on. + */ +export function applyStateDelta( + ops: JsonPatchOp[], + runId: string, + nodeIds: Set, +): void { + const flowStore = useFlowStore.getState(); + for (const op of ops) { + const match = /^\/nodes\/([^/]+)$/.exec(op.path); + if (!match) continue; + if (op.op !== "add" && op.op !== "replace") continue; + const nodeId = match[1]; + const value = op.value as AGUINodeState | undefined; + if (!value || typeof value !== "object") continue; + + const buildStatus = + AGUI_STATUS_TO_BUILD_STATUS[value.status] ?? BuildStatus.BUILDING; + flowStore.updateBuildStatus([nodeId], buildStatus); + nodeIds.add(nodeId); + + // Drive the canvas edge animation in step with build status so the AG-UI + // path matches the v1 build callbacks: edges from a node light up while + // it runs and stop when it finishes (either way). Without this only the + // bridge's final ``finish()`` clears edges, and they never animate during + // the run. + if (value.status === "running") { + flowStore.updateEdgesRunningByNodes([nodeId], true); + } else if (value.status === "success" || value.status === "error") { + flowStore.updateEdgesRunningByNodes([nodeId], false); + } + + // Only the final per-vertex emission carries the result data; running + // states have ``output: null`` and contribute no flow-pool entry. + if (value.output) { + const entry: VertexBuildTypeAPI = { + id: nodeId, + inactivated_vertices: null, + next_vertices_ids: [], + top_level_vertices: [], + run_id: runId, + valid: value.status === "success", + data: value.output, + timestamp: new Date().toISOString(), + params: null, + messages: [] as ChatOutputType[] | ChatInputType[], + artifacts: null, + }; + flowStore.addDataToFlowPool(entry, nodeId); + } + } +} + +/** + * Run a workflow through the AG-UI service and update flowStore as events + * arrive. Resolves when the run ends (RUN_FINISHED or RUN_ERROR), the + * underlying observable errors, or ``signal`` aborts. + * + * Passing the caller's ``AbortSignal`` (typically + * ``flowStore.buildController.signal``) lets ``flowStore.stopBuilding`` cancel + * the in-flight SSE request, not just the local build state. Without it the + * agent's internal AbortController would keep streaming after Stop. + */ +export async function runFlowAGUI( + opts: WorkflowRunOptions & { signal?: AbortSignal }, +): Promise { + // The shareable-playground popup posts on behalf of a visitor that + // does not own the flow. Route those runs to the public endpoint so + // the backend applies the public-access mitigations (virtual_flow_id, + // session namespacing, file-path validation, owner impersonation). + // The canvas's regular runs keep going to ``/api/v2/workflows``. + const usePublicEndpoint = + opts.usePublicEndpoint ?? useFlowStore.getState().playgroundPage; + const body = buildWorkflowRunRequest({ ...opts, usePublicEndpoint }); + const agent = createWorkflowAgent({ + body, + url: usePublicEndpoint ? WORKFLOWS_PUBLIC_ENDPOINT : undefined, + }); + // Replace the agent's internal AbortController with one tied to the + // caller's signal so an upstream stop also aborts the SSE fetch. Without + // this, the agent owns its own controller and the stream keeps running + // even after ``stopBuilding`` aborts the caller's controller. + if (opts.signal) { + const linkedController = new AbortController(); + if (opts.signal.aborted) { + linkedController.abort(); + } else { + opts.signal.addEventListener("abort", () => linkedController.abort(), { + once: true, + }); + } + (agent as { abortController: AbortController }).abortController = + linkedController; + } + const flowStore = useFlowStore.getState(); + const setErrorData = useAlertStore.getState().setErrorData; + const touchedNodeIds = new Set(); + // Server derives run_id from session_id/flow_id and announces it via + // RUN_STARTED. Until that arrives we have no id to stamp on flow-pool + // entries, so we fall back to an empty string (matches the legacy + // contract for an unresolved run). + let runId = ""; + // ``buildInfo`` must be set on every terminal path (RUN_FINISHED, + // RUN_ERROR, observable error, complete-without-terminal-event, abort) + // so the caller's analytics ``trackFlowBuild`` can read success vs + // failure. Without this, a silent ``complete:`` would leave ``buildInfo`` + // ``null`` and ``trackFlowBuild`` would mis-record the run as success. + let terminalEventSeen = false; + + // `agent.run` still needs a `RunAgentInput` for the client-side apply + // pipeline (subscriber correlation); the actual wire body is the native + // `WorkflowRunRequest` set on the agent. These ids stay local. + const runInput = { + threadId: opts.threadId ?? "", + runId: "", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: {}, + }; + + // Side-channel: the backend mirrors message-shaped events (add_message, + // token, remove_message, error) as a `langflow.event` CustomEvent so + // the playground's chat-view utilities can consume them in their v1 + // shape. A follow-up can retire this once chat-view consumes the + // AG-UI `TEXT_MESSAGE_*` lifecycle directly. + const ctx: BridgeContext = { + setRunId: (r) => { + runId = r; + }, + applyDelta: (ops) => applyStateDelta(ops, runId, touchedNodeIds), + handleCustomEvent: (eventType, data) => handleMessageEvent(eventType, data), + onFinished: () => { + terminalEventSeen = true; + flowStore.setBuildInfo({ success: true }); + }, + onError: (message) => { + terminalEventSeen = true; + flowStore.setBuildInfo({ error: [message], success: false }); + setErrorData({ title: "Workflow run failed", list: [message] }); + }, + }; + + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + flowStore.updateEdgesRunningByNodes([...touchedNodeIds], false); + flowStore.setIsBuilding(false); + flowStore.revertBuiltStatusFromBuilding(); + resolve(); + }; + + const abortHandler = () => { + // ``buildController.abort`` was called from ``flowStore.stopBuilding``. + // Mark the run as a failure so analytics records the cancellation and + // tear the subscription down (the agent's internal AbortController is + // already linked to this signal, so the SSE fetch is already aborting). + // Do NOT write an ``error`` string here: ``stopBuilding`` already + // surfaces the user-facing "Build stopped" alert. A second message in + // ``buildInfo.error`` would render the same text inside the canvas + // footer and trip strict locators ("build stopped" resolved twice). + if (!terminalEventSeen) { + flowStore.setBuildInfo({ success: false }); + } + subscription.unsubscribe(); + finish(); + }; + if (opts.signal) { + if (opts.signal.aborted) { + // Already aborted before we even started. Skip the subscribe. + flowStore.setBuildInfo({ success: false }); + finish(); + return; + } + opts.signal.addEventListener("abort", abortHandler, { once: true }); + } + + const subscription = agent.run(runInput).subscribe({ + next: (event: BaseEvent) => { + if (handleAGUIEvent(event, ctx)) { + // Tear the subscription down on the terminal event instead of + // waiting for `complete:` from the SSE stream. A server-side + // keepalive or buffered chunk after the terminal event can leave + // the observable open, which would leave the canvas stuck on + // ``isBuilding=true`` indefinitely. + subscription.unsubscribe(); + finish(); + } + }, + error: (err: Error) => { + flowStore.setBuildInfo({ error: [err.message], success: false }); + setErrorData({ title: "Workflow run failed", list: [err.message] }); + subscription.unsubscribe(); + finish(); + }, + complete: () => { + // ``complete`` without a terminal event means the SSE stream closed + // cleanly but never delivered RUN_FINISHED/RUN_ERROR (server crash, + // truncated response, proxy timeout). Record an error so analytics + // doesn't treat a silent close as success. + if (!terminalEventSeen) { + flowStore.setBuildInfo({ + error: ["Workflow run ended unexpectedly"], + success: false, + }); + } + subscription.unsubscribe(); + finish(); + }, + }); + }); +} diff --git a/src/frontend/src/stores/__tests__/flowStore.test.ts b/src/frontend/src/stores/__tests__/flowStore.test.ts index d0406ee4b0..809ec66545 100644 --- a/src/frontend/src/stores/__tests__/flowStore.test.ts +++ b/src/frontend/src/stores/__tests__/flowStore.test.ts @@ -50,15 +50,24 @@ jest.mock("../darkStore", () => ({ }, })); -jest.mock("../flowsManagerStore", () => ({ - __esModule: true, - default: { - getState: () => ({ - setCurrentFlow: jest.fn(), - takeSnapshot: jest.fn(), - }), - }, -})); +jest.mock("../flowsManagerStore", () => { + const state: { currentFlow: { id: string; name: string } | undefined } = { + currentFlow: undefined, + }; + return { + __esModule: true, + default: { + getState: () => ({ + ...state, + setCurrentFlow: jest.fn(), + takeSnapshot: jest.fn(), + }), + __setCurrentFlow: (flow: { id: string; name: string } | undefined) => { + state.currentFlow = flow; + }, + }, + }; +}); jest.mock("../globalVariablesStore/globalVariables", () => ({ useGlobalVariablesStore: { @@ -90,10 +99,30 @@ jest.mock("@/utils/utils", () => ({ brokenEdgeMessage: jest.fn(), })); +// runFlowAGUI is exercised end-to-end in its own bridge tests; here we just +// need a controllable replacement so the buildFlow integration can be tested +// without touching the network. +jest.mock("@/controllers/API/agui/run-flow-bridge", () => ({ + runFlowAGUI: jest.fn(), +})); + +// Keep reactflowUtils' real behaviour for the rest of the suite; only flip +// validateNodes / validateEdge to no-ops so the buildFlow analytics tests can +// reach the runFlowAGUI call with an empty (test-fixture) graph. +jest.mock("../../utils/reactflowUtils", () => { + const actual = jest.requireActual("../../utils/reactflowUtils"); + return { + ...actual, + validateNodes: jest.fn(() => []), + validateEdge: jest.fn(() => []), + }; +}); + // Note: Some utility modules may not exist in test environment // The store should handle missing utilities gracefully import { checkCodeValidity } from "@/CustomNodes/helpers/check-code-validity"; +import type { LogsLogType, VertexBuildTypeAPI } from "@/types/api"; import type { AllNodeType, EdgeType } from "@/types/flow"; import useFlowStore, { completeNodeUpdate, @@ -867,9 +896,8 @@ describe("useFlowStore", () => { const createEdge = ( id: string, sourceHandleId: string, - // biome-ignore lint/suspicious/noExplicitAny: legacy - overrides: Partial = {}, - ) => + overrides: Partial = {}, + ): EdgeType => ({ id, source: `src-${id}`, @@ -878,8 +906,7 @@ describe("useFlowStore", () => { className: "", data: { sourceHandle: { id: sourceHandleId } }, ...overrides, - // biome-ignore lint/suspicious/noExplicitAny: legacy - }) as any; + }) as unknown as EdgeType; it("should clear all edge animations when no nextIds provided", () => { const { result } = renderHook(() => useFlowStore()); @@ -988,15 +1015,13 @@ describe("useFlowStore", () => { id: "node-1", data: { results: {} }, valid: true, - // biome-ignore lint/suspicious/noExplicitAny: legacy - } as any; + } as unknown as VertexBuildTypeAPI; const mockVertexData2 = { id: "node-1", data: { results: { other: true } }, valid: true, - // biome-ignore lint/suspicious/noExplicitAny: legacy - } as any; + } as unknown as VertexBuildTypeAPI; it("should add data to new nodeId entry", () => { const { result } = renderHook(() => useFlowStore()); @@ -1043,14 +1068,16 @@ describe("useFlowStore", () => { }); describe("appendLogToFlowPool", () => { - // biome-ignore lint/suspicious/noExplicitAny: legacy - const mockLog = { name: "Test Log", message: "hello", type: "info" } as any; + const mockLog = { + name: "Test Log", + message: "hello", + type: "info", + } as unknown as LogsLogType; const mockLog2 = { name: "Second Log", message: "world", type: "info", - // biome-ignore lint/suspicious/noExplicitAny: legacy - } as any; + } as unknown as LogsLogType; it("creates a new pool entry with the log when no entry exists for nodeId", () => { const { result } = renderHook(() => useFlowStore()); @@ -1108,4 +1135,82 @@ describe("useFlowStore", () => { expect(latest["output_b"]).toEqual([mockLog2]); }); }); + + describe("buildFlow analytics — trackFlowBuild integration", () => { + let mockedRunFlow: jest.Mock; + let trackFlowBuildMock: jest.Mock; + + beforeAll(() => { + // Cast through unknown so the test does not depend on the bridge module's + // public type — we only care about controlling the side effect. + const bridge = jest.requireMock( + "@/controllers/API/agui/run-flow-bridge", + ) as { runFlowAGUI: jest.Mock }; + mockedRunFlow = bridge.runFlowAGUI; + + const analytics = jest.requireMock("@/customization/utils/analytics") as { + trackFlowBuild: jest.Mock; + }; + trackFlowBuildMock = analytics.trackFlowBuild; + + // Make `currentFlow` resolvable inside buildFlow. + const flowsManager = jest.requireMock("../flowsManagerStore") as { + default: { + __setCurrentFlow: ( + flow: { id: string; name: string } | undefined, + ) => void; + }; + }; + flowsManager.default.__setCurrentFlow({ + id: "flow-abc", + name: "Test Flow", + }); + }); + + beforeEach(() => { + mockedRunFlow.mockReset(); + trackFlowBuildMock.mockReset(); + act(() => { + useFlowStore.setState({ + nodes: [], + edges: [], + buildInfo: null, + flowBuildStatus: {}, + isBuilding: false, + componentsToUpdate: [], + }); + }); + }); + + it("fires trackFlowBuild with isError=false after a successful run", async () => { + mockedRunFlow.mockImplementation(async () => { + // The bridge writes success into the store on `RUN_FINISHED`. + useFlowStore.setState({ buildInfo: { success: true } }); + }); + + await useFlowStore.getState().buildFlow({}); + + expect(mockedRunFlow).toHaveBeenCalledTimes(1); + expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", false, { + flowId: "flow-abc", + }); + }); + + it("fires trackFlowBuild with isError=true and the error list after a failure", async () => { + const errorList = ["boom"]; + mockedRunFlow.mockImplementation(async () => { + useFlowStore.setState({ + buildInfo: { success: false, error: errorList }, + }); + }); + + await useFlowStore.getState().buildFlow({}); + + expect(mockedRunFlow).toHaveBeenCalledTimes(1); + expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", true, { + flowId: "flow-abc", + error: errorList, + }); + }); + }); }); diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts index a0a6aa6c67..117730376e 100644 --- a/src/frontend/src/stores/flowStore.ts +++ b/src/frontend/src/stores/flowStore.ts @@ -6,19 +6,13 @@ import { type Node, type NodeChange, } from "@xyflow/react"; -import { cloneDeep, zip } from "lodash"; +import { cloneDeep } from "lodash"; import { create } from "zustand"; import { checkCodeValidity } from "@/CustomNodes/helpers/check-code-validity"; import { queryClient } from "@/contexts"; -import { - ENABLE_DATASTAX_LANGFLOW, - ENABLE_INSPECTION_PANEL, -} from "@/customization/feature-flags"; -import { - track, - trackDataLoaded, - trackFlowBuild, -} from "@/customization/utils/analytics"; +import { runFlowAGUI } from "@/controllers/API/agui/run-flow-bridge"; +import { ENABLE_INSPECTION_PANEL } from "@/customization/feature-flags"; +import { track, trackFlowBuild } from "@/customization/utils/analytics"; import { brokenEdgeMessage } from "@/utils/utils"; import { BuildStatus, EventDeliveryType } from "../constants/enums"; import i18n from "../i18n"; @@ -36,7 +30,6 @@ import type { FlowStoreType, VertexLayerElementType, } from "../types/zustand/flow"; -import { buildFlowVerticesWithFallback } from "../utils/buildUtils"; import { buildPositionDictionary, checkChatInput, @@ -190,8 +183,7 @@ const useFlowStore = create((set, get) => ({ set({ isBuilding: false }); get().revertBuiltStatusFromBuilding(); useAlertStore.getState().setErrorData({ - // biome-ignore lint/suspicious/noExplicitAny: legacy - title: (i18n as any).t("alerts.buildStopped"), + title: i18n.t("alerts.buildStopped"), }); }, isPending: true, @@ -819,7 +811,6 @@ const useFlowStore = create((set, get) => ({ }, buildInfo: null, }); - const playgroundPage = get().playgroundPage; get().setIsBuilding(true); set({ flowBuildStatus: {} }); const currentFlow = useFlowsManagerStore.getState().currentFlow; @@ -926,201 +917,48 @@ const useFlowStore = create((set, get) => ({ ); } - function validateSubgraph() {} - function handleBuildUpdate( - vertexBuildData: VertexBuildTypeAPI, - status: BuildStatus, - runId: string, - ) { - if (vertexBuildData && vertexBuildData.inactivated_vertices) { - get().removeFromVerticesBuild(vertexBuildData.inactivated_vertices); - if (vertexBuildData.inactivated_vertices.length > 0) { - get().updateBuildStatus( - vertexBuildData.inactivated_vertices, - BuildStatus.INACTIVE, - ); - } - } + // Each build gets its own AbortController so ``stopBuilding`` cancels + // only the in-flight run (re-using a controller across runs would leave + // it in the aborted state for the next call). The signal is passed to + // ``runFlowAGUI`` so Stop aborts the actual SSE request, not just the + // local build state. + const buildController = new AbortController(); + get().setBuildController(buildController); - if (vertexBuildData.next_vertices_ids) { - // next_vertices_ids is a list of vertices that are going to be built next - // verticesLayers is a list of list of vertices ids, where each list is a layer of vertices - // we want to add a new layer (next_vertices_ids) to the list of layers (verticesLayers) - // and the values of next_vertices_ids to the list of vertices ids (verticesIds) - - // const nextVertices will be the zip of vertexBuildData.next_vertices_ids and - // vertexBuildData.top_level_vertices - // the VertexLayerElementType as {id: next_vertices_id, layer: top_level_vertex} - - // next_vertices_ids should be next_vertices_ids without the inactivated vertices - const next_vertices_ids = vertexBuildData.next_vertices_ids.filter( - (id) => !vertexBuildData.inactivated_vertices?.includes(id), - ); - const top_level_vertices = vertexBuildData.top_level_vertices.filter( - (vertex) => !vertexBuildData.inactivated_vertices?.includes(vertex), - ); - let nextVertices: VertexLayerElementType[] = zip( - next_vertices_ids, - top_level_vertices, - ).map(([id, reference]) => ({ id: id!, reference })); - - // Now we filter nextVertices to remove any vertices that are in verticesLayers - // because they are already being built - // each layer is a list of vertexlayerelementtypes - const lastLayer = - get().verticesBuild!.verticesLayers[ - get().verticesBuild!.verticesLayers.length - 1 - ]; - - nextVertices = nextVertices.filter( - (vertexElement) => - !lastLayer.some( - (layerElement) => - layerElement.id === vertexElement.id && - layerElement.reference === vertexElement.reference, - ), - ); - const newLayers = [ - ...get().verticesBuild!.verticesLayers, - nextVertices, - ]; - const newIds = [ - ...get().verticesBuild!.verticesIds, - ...next_vertices_ids, - ]; - if ( - ENABLE_DATASTAX_LANGFLOW && - vertexBuildData?.id?.includes("AstraDB") - ) { - const search_results: LogsLogType[] = Object.values( - vertexBuildData?.data?.logs?.search_results, - ); - search_results.forEach((log) => { - if ( - log.message.includes("Adding") && - log.message.includes("documents") && - log.message.includes("Vector Store") - ) { - trackDataLoaded( - get().currentFlow?.id, - get().currentFlow?.name, - "AstraDB Vector Store", - vertexBuildData?.id, - ); - } - }); - } - get().updateVerticesBuild({ - verticesIds: newIds, - verticesLayers: newLayers, - runId: runId, - verticesToRun: get().verticesBuild!.verticesToRun, - }); - - get().updateBuildStatus(top_level_vertices, BuildStatus.TO_BUILD); - } - - get().addDataToFlowPool( - { ...vertexBuildData, run_id: runId }, - vertexBuildData.id, - ); - if (status !== BuildStatus.ERROR) { - get().updateBuildStatus([vertexBuildData.id], status); - } - } - - await buildFlowVerticesWithFallback({ - session, - input_value, - files, + // Always run through the v2 workflows endpoint. Current frontend nodes + // + edges are sent so unsaved tweaks (dropdowns, text inputs) run as + // the user sees them. + await runFlowAGUI({ flowId: currentFlow!.id, - startNodeId, - stopNodeId, - onGetOrderSuccess: () => {}, - onBuildComplete: (allNodesValid) => { - if (!silent) { - if (allNodesValid) { - get().setBuildInfo({ success: true }); - } - } - get().updateEdgesRunningByNodes( - get().nodes.map((n) => n.id), - false, - ); - get().setIsBuilding(false); - // Invalidate KB-related caches so any KnowledgeIngestion node - // that ran inside this build surfaces its updated stats / runs - // the next time the user opens the assets/knowledge-bases tab. - // Cheap when no subscribers are mounted; the queries only - // refetch if a component is actively reading them. - queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBases"] }); - queryClient.invalidateQueries({ queryKey: ["useGetIngestionRuns"] }); - queryClient.invalidateQueries({ - queryKey: ["useGetKnowledgeBaseChunks"], - }); - trackFlowBuild(get().currentFlow?.name ?? "Unknown", false, { - flowId: get().currentFlow?.id, - }); - }, - onBuildUpdate: handleBuildUpdate, - onBuildError: (title: string, list: string[], elementList) => { - const idList = - (elementList - ?.map((element) => element.id) - .filter(Boolean) as string[]) ?? get().nodes.map((n) => n.id); - useFlowStore.getState().updateBuildStatus(idList, BuildStatus.ERROR); - const isCustomComponentBlocked = list.some((msg) => - msg.toLowerCase().includes("custom components are not allowed"), - ); - if (!isCustomComponentBlocked && get().componentsToUpdate.length > 0) - setErrorData({ - title: i18n.t("errors.blockedComponents"), - }); - get().updateEdgesRunningByNodes( - get().nodes.map((n) => n.id), - false, - ); - get().setBuildInfo({ error: list, success: false }); - useAlertStore.getState().addNotificationToHistory({ - title: title, - type: "error", - list: list, - }); - get().setIsBuilding(false); - get().buildController.abort(); - trackFlowBuild(get().currentFlow?.name ?? "Unknown", true, { - flowId: get().currentFlow?.id, - error: list, - }); - }, - onBuildStart: (elementList) => { - const idList = elementList - // reference is the id of the vertex or the id of the parent in a group node - .map((element) => element.reference) - .filter(Boolean) as string[]; - get().updateBuildStatus(idList, BuildStatus.BUILDING); - - const edges = get().edges; - const newEdges = edges.map((edge) => { - if ( - edge.data?.targetHandle && - idList.includes(edge.data.targetHandle.id ?? "") - ) { - edge.className = "ran"; - } - return edge; - }); - set({ edges: newEdges }); - }, - onValidateNodes: validateSubgraph, - nodes: get().nodes || undefined, - edges: get().edges || undefined, - logBuilds: get().onFlowPage, - playgroundPage, - eventDelivery, + message: input_value, + threadId: session, + startComponentId: startNodeId, + stopComponentId: stopNodeId, + flowData: { nodes: get().nodes, edges: get().edges }, + files, + signal: buildController.signal, + }); + + // Invalidate KB-related caches so any KnowledgeIngestion node that ran + // inside this build surfaces its updated stats / runs the next time the + // user opens the assets/knowledge-bases tab. Cheap when no subscribers are + // mounted; the queries only refetch if a component is actively reading them. + queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBases"] }); + queryClient.invalidateQueries({ queryKey: ["useGetIngestionRuns"] }); + queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBaseChunks"] }); + + // Mirror the v1 build callbacks' analytics: every actual build attempt + // logs a flow-build event with success/error and (on failure) the error + // list. `runFlowAGUI` always resolves and writes the outcome into + // `buildInfo`, so reading it back is the cleanest success/error signal. + const finalBuildInfo = get().buildInfo; + const hasError = finalBuildInfo?.success === false; + trackFlowBuild(currentFlow?.name ?? "Unknown", hasError, { + flowId: currentFlow?.id, + ...(hasError && finalBuildInfo?.error + ? { error: finalBuildInfo.error } + : {}), }); - get().setIsBuilding(false); - get().revertBuiltStatusFromBuilding(); }, getFlow: () => { return { diff --git a/src/frontend/tests/utils/withEventDeliveryModes.ts b/src/frontend/tests/utils/withEventDeliveryModes.ts index 2566e9f31d..c5ee3599d9 100644 --- a/src/frontend/tests/utils/withEventDeliveryModes.ts +++ b/src/frontend/tests/utils/withEventDeliveryModes.ts @@ -5,8 +5,12 @@ type TestFunction = (args: { page: Page }) => Promise; type TestConfig = Parameters[1]; /** - * Wraps a test function to run it with both streaming and polling event delivery modes. - * Adds a 3-second delay between test runs to ensure proper separation. + * Shim that historically expanded a spec into one test per v1 + * ``event_delivery`` mode (streaming / polling / direct). The v2 workflows + * endpoint replaced the three modes with a single AG-UI SSE path, so the + * wrapper now just registers the test once. Kept as a no-op so existing + * spec call sites don't all have to change in this PR; a follow-up can + * delete the wrapper and inline ``test`` at every call site. * * @param title The test title * @param config The test configuration (tags, etc) @@ -17,20 +21,7 @@ export function withEventDeliveryModes( config: TestConfig, testFn: TestFunction, ) { - const eventDeliveryModes = ["streaming", "polling", "direct"] as const; - - for (const eventDelivery of eventDeliveryModes) { - test(`${title} - ${eventDelivery}`, config, async ({ page }) => { - // Intercept the config request and modify the event_delivery setting - await page.route("**/api/v1/config", async (route) => { - const response = await route.fetch(); - const json = await response.json(); - json.event_delivery = eventDelivery; - await route.fulfill({ response, json }); - }); - - // Run the original test function - await testFn({ page }); - }); - } + test(title, config, async ({ page }) => { + await testFn({ page }); + }); } diff --git a/src/lfx/src/lfx/schema/workflow.py b/src/lfx/src/lfx/schema/workflow.py index e8962721d8..1286e6984f 100644 --- a/src/lfx/src/lfx/schema/workflow.py +++ b/src/lfx/src/lfx/schema/workflow.py @@ -126,6 +126,187 @@ class WorkflowExecutionRequest(BaseModel): ) +class WorkflowMode(str, Enum): + """Execution mode for a v2 workflow run.""" + + SYNC = "sync" + STREAM = "stream" + BACKGROUND = "background" + + +class WorkflowRunRequest(BaseModel): + """Request schema for ``POST /api/v2/workflows`` (v2 native body). + + First-class fields for everything callers actually configure when running a + flow. Streaming protocol is selected by ``stream_protocol``; the endpoint + validates it against the live adapter registry and returns 422 with the + available list when unknown. + """ + + flow_id: str = Field(..., description="UUID of the flow to run.") + input_value: str = Field("", description="Chat-style input value.") + tweaks: dict[str, Any] = Field( + default_factory=dict, + description="Per-component parameter overrides keyed by component id.", + ) + session_id: str | None = Field( + None, + description="When set, message memory and chat history scope to this session.", + ) + mode: WorkflowMode = Field( + WorkflowMode.SYNC, + description=( + "Execution mode. ``sync`` runs inline and returns the aggregated " + "response; ``stream`` returns SSE; ``background`` queues a job." + ), + ) + stream_protocol: str = Field( + "langflow", + description=( + "Wire protocol for streaming events. Defaults to ``langflow`` " + "(raw EventManager payloads). ``agui`` emits AG-UI events. Unknown " + "values return 422 with the available list. Ignored when mode=sync." + ), + ) + data: dict[str, Any] | None = Field( + None, + description=( + "Optional live-canvas override of the flow's nodes/edges; takes priority over the saved flow data." + ), + ) + files: list[str] | None = Field( + None, + description="Optional list of pre-uploaded file paths to attach to the run.", + ) + start_component_id: str | None = Field(None, description="Partial-run start component id.") + stop_component_id: str | None = Field(None, description="Partial-run stop component id.") + globals: dict[GlobalVarKey, GlobalVarValue] = Field( + default_factory=dict, + description=( + "Request-level global variables made available to workflow components. " + "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." + ), + ) + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={ + "examples": [ + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Hello, how can you help me today?", + }, + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Stream this conversation", + "mode": "stream", + }, + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Drive the canvas", + "mode": "stream", + "stream_protocol": "agui", + "session_id": "session-123", + }, + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Process in the background", + "mode": "background", + }, + ], + }, + ) + + @model_validator(mode="after") + def validate_flow_id(self) -> WorkflowRunRequest: + """Reject non-UUID ``flow_id`` early so the endpoint can trust it.""" + uuid_validator(self.flow_id, message="Invalid flow_id, must be a UUID") + return self + + +class PublicWorkflowRunRequest(BaseModel): + """Request schema for ``POST /api/v2/workflows/public``. + + Narrower than ``WorkflowRunRequest`` so the public-flow surface stays + locked down. Notably absent vs the regular body: + + - ``data`` — visitors must never override the stored flow definition. + - ``tweaks`` — visitors must never override component parameters. + + The endpoint enforces the additional CVE mitigations that the regular + endpoint does not need: + + - ``access_type == PUBLIC`` gate (others 403). + - ``virtual_flow_id = uuid5(identifier, flow_id)`` so messages stay + isolated per visitor. + - Session string namespaced under the virtual flow id + (CVE-2026-33017). + - File-path validation (GHSA-rcjh-r59h-gq37). + - Owner impersonation: the run executes under the flow owner's + permissions, never the visitor's. + """ + + 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( + None, + description=("Optional caller session. Always namespaced under the visitor's virtual flow id by the endpoint."), + ) + mode: Literal[WorkflowMode.STREAM] = Field( + WorkflowMode.STREAM, + description=( + "Always ``stream``. Sync/background modes would widen the public " + "attack surface (job polling, owner impersonation persists across " + "queue boundaries) so the schema rejects them at the wire." + ), + ) + stream_protocol: str = Field( + "langflow", + description=( + "Wire protocol for streaming events. Defaults to ``langflow`` " + "(raw EventManager payloads). ``agui`` emits AG-UI events. Unknown " + "values return 422 with the available list." + ), + ) + files: list[str] | None = Field( + None, + description=( + "Optional list of pre-uploaded file paths. Each path must be " + "scoped to this flow's own storage namespace; the endpoint " + "rejects path traversal or cross-flow references." + ), + ) + start_component_id: str | None = Field(None, description="Partial-run start component id.") + stop_component_id: str | None = Field(None, description="Partial-run stop component id.") + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={ + "examples": [ + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Hello from the shareable playground", + }, + { + "flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6", + "input_value": "Stream the response", + "stream_protocol": "agui", + "session_id": "thread-A", + }, + ], + }, + ) + + @model_validator(mode="after") + def validate_flow_id(self) -> PublicWorkflowRunRequest: + """Reject non-UUID ``flow_id`` early so the endpoint can trust it.""" + uuid_validator(self.flow_id, message="Invalid flow_id, must be a UUID") + return self + + class WorkflowExecutionResponse(BaseModel): """Synchronous workflow execution response.""" diff --git a/src/lfx/src/lfx/services/settings/base.py b/src/lfx/src/lfx/services/settings/base.py index 8770fb0a4d..f517cc0fe9 100644 --- a/src/lfx/src/lfx/services/settings/base.py +++ b/src/lfx/src/lfx/services/settings/base.py @@ -564,7 +564,15 @@ class Settings(BaseSettings): @field_validator("cors_origins", mode="before") @classmethod def validate_cors_origins(cls, value): - """Convert comma-separated string to list if needed.""" + """Convert comma-separated string to list if needed. + + Pydantic-settings on Python 3.14 parses the env var "*" into ["*"] + before this validator runs (the union list[str] | str resolves + differently). Collapse that back to the bare-string wildcard so + downstream consumers see the same shape on every Python version. + """ + if isinstance(value, list) and value == ["*"]: + return "*" if isinstance(value, str) and value != "*": if "," in value: # Convert comma-separated string to list diff --git a/src/lfx/tests/unit/schema/test_workflow_run_request.py b/src/lfx/tests/unit/schema/test_workflow_run_request.py new file mode 100644 index 0000000000..ddb305d729 --- /dev/null +++ b/src/lfx/tests/unit/schema/test_workflow_run_request.py @@ -0,0 +1,97 @@ +"""Tests for the v2 ``WorkflowRunRequest`` body schema.""" + +from __future__ import annotations + +import pytest +from lfx.schema.workflow import WorkflowMode, WorkflowRunRequest +from pydantic import ValidationError + +_VALID_UUID = "67ccd2be-17f0-8190-81ff-3bb2cf6508e6" + + +class TestDefaults: + """A minimal body should auto-populate the awesome-DX defaults.""" + + def test_minimal_body_defaults_to_sync_langflow(self): + req = WorkflowRunRequest(flow_id=_VALID_UUID) + assert req.flow_id == _VALID_UUID + assert req.input_value == "" + assert req.tweaks == {} + assert req.session_id is None + assert req.mode is WorkflowMode.SYNC + assert req.stream_protocol == "langflow" + assert req.data is None + assert req.files is None + assert req.start_component_id is None + assert req.stop_component_id is None + + def test_mode_accepts_string_value(self): + req = WorkflowRunRequest(flow_id=_VALID_UUID, mode="stream") + assert req.mode is WorkflowMode.STREAM + + def test_mode_rejects_unknown_value(self): + with pytest.raises(ValidationError) as exc: + WorkflowRunRequest(flow_id=_VALID_UUID, mode="async") + assert "mode" in str(exc.value) + + def test_stream_protocol_accepts_any_string_for_registry_lookup(self): + """The schema doesn't constrain the protocol so adapters can register at runtime. + + Endpoint-level dispatch is responsible for the 422 with the available list. + """ + req = WorkflowRunRequest( + flow_id=_VALID_UUID, + mode="stream", + stream_protocol="something-not-registered-yet", + ) + assert req.stream_protocol == "something-not-registered-yet" + + +class TestFlowIdValidation: + """``flow_id`` must be a UUID-formatted string.""" + + def test_missing_flow_id_raises(self): + with pytest.raises(ValidationError) as exc: + WorkflowRunRequest() + assert "flow_id" in str(exc.value) + + def test_non_uuid_flow_id_raises(self): + with pytest.raises(ValidationError) as exc: + WorkflowRunRequest(flow_id="not-a-uuid") + assert "flow_id" in str(exc.value).lower() or "uuid" in str(exc.value).lower() + + def test_uppercase_uuid_accepted(self): + upper = _VALID_UUID.upper() + req = WorkflowRunRequest(flow_id=upper) + assert req.flow_id == upper + + +class TestExtraForbid: + """Unknown body keys must be rejected so typos surface as 422 instead of silent drops.""" + + def test_unknown_field_rejected(self): + with pytest.raises(ValidationError) as exc: + WorkflowRunRequest(flow_id=_VALID_UUID, mod="sync") + assert "mod" in str(exc.value) + + +class TestRoundTripsWithRichBody: + """A fully populated body round-trips through ``model_dump`` / ``model_validate``.""" + + def test_full_body_round_trip(self): + body = { + "flow_id": _VALID_UUID, + "input_value": "Hello!", + "tweaks": {"ChatInput-abc": {"some_param": "value"}}, + "session_id": "session-123", + "mode": "stream", + "stream_protocol": "agui", + "data": {"nodes": [], "edges": []}, + "files": ["/tmp/a.txt", "/tmp/b.png"], + "start_component_id": "ChatInput-abc", + "stop_component_id": "ChatOutput-xyz", + "globals": {"API_TOKEN": "secret-123"}, + } + req = WorkflowRunRequest.model_validate(body) + dumped = req.model_dump(mode="json") + assert dumped == body diff --git a/uv.lock b/uv.lock index 0a55024dc2..0a1858a2e7 100644 --- a/uv.lock +++ b/uv.lock @@ -7603,6 +7603,7 @@ name = "langflow-base" version = "0.10.0" source = { editable = "src/backend/base" } dependencies = [ + { name = "ag-ui-protocol" }, { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "aiofiles" }, @@ -8303,6 +8304,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "ag-ui-protocol", specifier = "==0.1.18" }, { name = "ag2", marker = "extra == 'ag2'", specifier = ">=0.1.0" }, { name = "agent-lifecycle-toolkit", marker = "(python_full_version < '3.14' and platform_machine != 'x86_64' and extra == 'altk') or (python_full_version < '3.14' and sys_platform != 'darwin' and extra == 'altk')", specifier = ">=0.10.1,<1.0" }, { name = "aioboto3", marker = "extra == 'aioboto3'", specifier = ">=15.2.0,<16.0.0" },