Skip to content

Commit 1e61ac4

Browse files
committed
Merge remote-tracking branch 'origin/release-1.8.4'
# Conflicts: # src/backend/base/langflow/api/v1/chat.py # src/backend/tests/unit/test_chat_endpoint.py
2 parents 7f44894 + 6cb87cc commit 1e61ac4

14 files changed

Lines changed: 916 additions & 419 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "langflow"
3-
version = "1.8.3"
3+
version = "1.8.4"
44
description = "A Python package with a built-in web application"
55
requires-python = ">=3.10,<3.14"
66
license = "MIT"
@@ -17,7 +17,7 @@ maintainers = [
1717
]
1818
# Define your main dependencies here
1919
dependencies = [
20-
"langflow-base[complete]~=0.8.3",
20+
"langflow-base[complete]~=0.8.4",
2121
]
2222

2323

src/backend/base/langflow/api/build.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,26 @@ async def start_flow_build(
6969
current_user: CurrentActiveUser,
7070
queue_service: JobQueueService,
7171
flow_name: str | None = None,
72+
source_flow_id: uuid.UUID | None = None,
7273
) -> str:
7374
"""Start the flow build process by setting up the queue and starting the build task.
7475
76+
Args:
77+
flow_id: The flow ID used for tracking, sessions, and messages.
78+
background_tasks: FastAPI background tasks handler.
79+
inputs: Optional input values for the flow.
80+
data: Optional flow data request.
81+
files: Optional list of file paths.
82+
stop_component_id: Optional component ID to stop the build at.
83+
start_component_id: Optional component ID to start the build from.
84+
log_builds: Whether to log builds.
85+
current_user: The current authenticated user.
86+
queue_service: The job queue service.
87+
flow_name: Optional flow name override.
88+
source_flow_id: If provided, the actual flow ID to load from DB.
89+
Used by public flows where flow_id is a virtual UUID for session isolation
90+
but the flow data must be loaded from the original flow in the database.
91+
7592
Returns:
7693
the job_id.
7794
"""
@@ -90,6 +107,7 @@ async def start_flow_build(
90107
log_builds=log_builds,
91108
current_user=current_user,
92109
flow_name=flow_name,
110+
source_flow_id=source_flow_id,
93111
)
94112
queue_service.start_job(job_id, task_coro)
95113
except Exception as e:
@@ -209,6 +227,7 @@ async def generate_flow_events(
209227
log_builds: bool,
210228
current_user: CurrentActiveUser,
211229
flow_name: str | None = None,
230+
source_flow_id: uuid.UUID | None = None,
212231
) -> None:
213232
"""Generate events for flow building process.
214233
@@ -283,13 +302,19 @@ async def create_graph(fresh_session, flow_id_str: str, flow_name: str | None) -
283302
effective_session_id = flow_id_str
284303

285304
if not data:
286-
return await build_graph_from_db(
287-
flow_id=flow_id,
305+
# For public flows, source_flow_id is the real DB ID, flow_id is virtual.
306+
# Load from DB using the real ID, then override graph.flow_id with virtual.
307+
db_flow_id = source_flow_id if source_flow_id is not None else flow_id
308+
graph = await build_graph_from_db(
309+
flow_id=db_flow_id,
288310
session=fresh_session,
289311
chat_service=chat_service,
290312
user_id=str(current_user.id),
291313
session_id=effective_session_id,
292314
)
315+
if source_flow_id is not None:
316+
graph.flow_id = str(flow_id)
317+
return graph
293318

294319
if not flow_name:
295320
result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id))

src/backend/base/langflow/api/v1/chat.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ async def build_public_tmp(
583583
background_tasks: LimitVertexBuildBackgroundTasks,
584584
flow_id: uuid.UUID,
585585
inputs: Annotated[InputValueRequest | None, Body(embed=True)] = None,
586+
data: Annotated[FlowDataRequest | None, Body(embed=True)] = None,
586587
files: list[str] | None = None,
587588
stop_component_id: str | None = None,
588589
start_component_id: str | None = None,
@@ -597,16 +598,10 @@ async def build_public_tmp(
597598
This endpoint is specifically for public flows that don't require authentication.
598599
It uses a client_id cookie to create a deterministic flow ID for tracking purposes.
599600
600-
Security Note:
601-
- The 'data' parameter is NOT accepted to prevent flow definition tampering
602-
- Public flows must execute the stored flow definition only
603-
- The flow definition is always loaded from the database
604-
605601
The endpoint:
606602
1. Verifies the requested flow is marked as public in the database
607603
2. Creates a deterministic UUID based on client_id and flow_id
608604
3. Uses the flow owner's permissions to build the flow
609-
4. Always loads the flow definition from the database
610605
611606
Requirements:
612607
- The flow must be marked as PUBLIC in the database
@@ -616,6 +611,7 @@ async def build_public_tmp(
616611
flow_id: UUID of the public flow to build
617612
background_tasks: Background tasks manager
618613
inputs: Optional input values for the flow
614+
data: Optional flow data
619615
files: Optional files to include
620616
stop_component_id: Optional ID of component to stop at
621617
start_component_id: Optional ID of component to start from
@@ -633,13 +629,14 @@ async def build_public_tmp(
633629
client_id = request.cookies.get("client_id")
634630
owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id)
635631

636-
# Start the flow build using the new flow ID
637-
# data is always None for public flows - they load from database only
632+
# flow_id=new_flow_id for tracking/sessions/messages (virtual, per-user isolation).
633+
# source_flow_id=flow_id to load the actual flow data from the database.
638634
job_id = await start_flow_build(
639635
flow_id=new_flow_id,
636+
source_flow_id=flow_id,
640637
background_tasks=background_tasks,
641638
inputs=inputs,
642-
data=None, # Always None - public flows load from database only
639+
data=data,
643640
files=files,
644641
stop_component_id=stop_component_id,
645642
start_component_id=start_component_id,
@@ -660,3 +657,54 @@ async def build_public_tmp(
660657
queue_service=queue_service,
661658
event_delivery=event_delivery,
662659
)
660+
661+
662+
@router.get("/build_public_tmp/{job_id}/events")
663+
async def get_build_events_public(
664+
job_id: str,
665+
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
666+
*,
667+
event_delivery: EventDeliveryType = EventDeliveryType.STREAMING,
668+
):
669+
"""Get events for a public flow build job.
670+
671+
This endpoint does not require authentication, matching the public build endpoint.
672+
It is used by the shareable playground to consume build events.
673+
"""
674+
return await get_flow_events_response(
675+
job_id=job_id,
676+
queue_service=queue_service,
677+
event_delivery=event_delivery,
678+
)
679+
680+
681+
@router.post(
682+
"/build_public_tmp/{job_id}/cancel",
683+
response_model=CancelFlowResponse,
684+
)
685+
async def cancel_build_public(
686+
job_id: str,
687+
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
688+
):
689+
"""Cancel a public flow build job.
690+
691+
This endpoint does not require authentication, matching the public build endpoint.
692+
It is used by the shareable playground to cancel builds.
693+
"""
694+
try:
695+
cancellation_success = await cancel_flow_build(job_id=job_id, queue_service=queue_service)
696+
697+
if cancellation_success:
698+
return CancelFlowResponse(success=True, message="Flow build cancelled successfully")
699+
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
700+
except asyncio.CancelledError:
701+
await logger.aerror(f"Failed to cancel public flow build for job_id {job_id} (CancelledError caught)")
702+
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
703+
except ValueError as exc:
704+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
705+
except JobQueueNotFoundError as exc:
706+
await logger.aerror(f"Public job not found: {job_id}. Error: {exc!s}")
707+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Job not found: {exc!s}") from exc
708+
except Exception as exc:
709+
await logger.aexception(f"Error cancelling public flow build for job_id {job_id}: {exc}")
710+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc

src/backend/base/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "langflow-base"
3-
version = "0.8.3"
3+
version = "0.8.4"
44
description = "A Python package with a built-in web application"
55
requires-python = ">=3.10,<3.14"
66
license = "MIT"
@@ -17,7 +17,7 @@ maintainers = [
1717
]
1818

1919
dependencies = [
20-
"lfx~=0.3.3",
20+
"lfx~=0.3.4",
2121
"fastapi>=0.135.0,<1.0.0",
2222
"httpx[http2]>=0.27,<1.0.0",
2323
"aiofile>=3.9.0,<4.0.0",

src/backend/tests/unit/test_chat_endpoint.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,54 @@ async def mock_cancel_flow_build_with_cancelled_error(*_args, **_kwargs):
434434
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
435435

436436

437+
@pytest.mark.benchmark
438+
async def test_should_have_public_events_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
439+
"""Test that public events endpoint exists and is accessible without authentication.
440+
441+
Bug: After sending a message in the Shareable Playground, the chat input resets
442+
but no response is rendered. The root cause is that the events endpoint
443+
(/build/{job_id}/events) requires authentication, which the unauthenticated
444+
shareable playground user does not have.
445+
446+
This test proves:
447+
1. The PUBLIC events endpoint exists and responds without auth (404 = route exists, job not found)
448+
2. The AUTHENTICATED events endpoint rejects unauthenticated requests (403)
449+
"""
450+
fake_job_id = str(uuid.uuid4())
451+
452+
# Assert 1 — the PUBLIC events endpoint is accessible without auth
453+
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
454+
events_response = await client.get(
455+
f"api/v1/build_public_tmp/{fake_job_id}/events?event_delivery=polling",
456+
headers={"Accept": "application/x-ndjson"},
457+
)
458+
assert events_response.status_code == codes.NOT_FOUND
459+
460+
# The key proof: the public endpoint responded with 404 (route exists, job not found)
461+
# rather than 401/403 (authentication required). Before the fix, this endpoint
462+
# didn't exist at all and would return 404 for the route, not the job.
463+
assert "Job not found" in events_response.json()["detail"]
464+
465+
466+
@pytest.mark.benchmark
467+
async def test_should_have_public_cancel_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
468+
"""Test that public cancel endpoint exists and is accessible without authentication.
469+
470+
Same root cause as the events bug: the cancel endpoint requires auth
471+
but the shareable playground user is unauthenticated.
472+
"""
473+
fake_job_id = str(uuid.uuid4())
474+
475+
# The PUBLIC cancel endpoint is accessible without auth
476+
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
477+
cancel_response = await client.post(
478+
f"api/v1/build_public_tmp/{fake_job_id}/cancel",
479+
headers={"Content-Type": "application/json"},
480+
)
481+
assert cancel_response.status_code == codes.NOT_FOUND
482+
assert "Job not found" in cancel_response.json()["detail"]
483+
484+
437485
@pytest.mark.benchmark
438486
async def test_build_public_tmp_ignores_data_parameter(client, json_memory_chatbot_no_llm, logged_in_headers):
439487
"""Test that build_public_tmp endpoint silently ignores data parameter for security.

src/frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
22
"name": "langflow",
3-
"version": "1.8.3",
3+
"version": "1.8.4",
44
"private": true,
55
"engines": {
66
"node": ">=20.19.0"
77
},
88
"overrides": {
99
"tar": "^7.5.7",
10+
"tar-fs": ">=2.1.4",
1011
"glob": "^11.1.0",
1112
"test-exclude": "^7.0.0"
1213
},

src/frontend/src/components/core/playgroundComponent/chat-view/utils/message-event-handler.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,60 @@
1+
import { useMessagesStore } from "@/stores/messagesStore";
12
import type { Message } from "@/types/messages";
23
import { removeMessages, updateMessage } from "./message-utils";
34

45
/**
56
* Handles message-related events from the build process.
6-
* This keeps all chat message logic within the chat-view scope.
7+
* Updates both React Query cache (used by the internal playground)
8+
* and useMessagesStore (used by the shareable playground / IOModal).
79
*/
810
export const handleMessageEvent = (
911
eventType: string,
1012
data: unknown,
1113
): boolean => {
1214
switch (eventType) {
1315
case "add_message": {
14-
// Add/update message in React Query cache (replaces placeholder if exists)
16+
// Update React Query cache (internal playground)
1517
updateMessage(data as Message);
18+
// Update Zustand store (shareable playground / IOModal)
19+
useMessagesStore.getState().addMessage(data as Message);
1620
return true;
1721
}
1822
case "token": {
19-
// Update message text in React Query cache for streaming
20-
updateMessage({
21-
id: data.id,
22-
flow_id: data.flow_id || "",
23-
session_id: data.session_id || "",
24-
text: data.chunk || "",
25-
sender: data.sender || "Machine",
26-
sender_name: data.sender_name || "AI",
27-
timestamp: data.timestamp || new Date().toISOString(),
28-
files: data.files || [],
29-
edit: data.edit || false,
30-
background_color: data.background_color || "",
31-
text_color: data.text_color || "",
32-
properties: { ...data.properties, state: "partial" },
33-
} as Message);
23+
const d = data as Record<string, unknown>;
24+
const tokenMessage = {
25+
id: d.id,
26+
flow_id: d.flow_id || "",
27+
session_id: d.session_id || "",
28+
text: d.chunk || "",
29+
sender: d.sender || "Machine",
30+
sender_name: d.sender_name || "AI",
31+
timestamp: d.timestamp || new Date().toISOString(),
32+
files: d.files || [],
33+
edit: d.edit || false,
34+
background_color: d.background_color || "",
35+
text_color: d.text_color || "",
36+
properties: { ...(d.properties as object), state: "partial" },
37+
} as Message;
38+
// Update React Query cache (internal playground)
39+
updateMessage(tokenMessage);
40+
// Update Zustand store (shareable playground / IOModal)
41+
useMessagesStore.getState().addMessage(tokenMessage);
3442
return true;
3543
}
3644
case "remove_message": {
37-
// Remove message from React Query cache
38-
removeMessages([data.id], data.session_id || "", data.flow_id || "");
45+
const rm = data as Record<string, string>;
46+
// Remove from React Query cache
47+
removeMessages([rm.id], rm.session_id || "", rm.flow_id || "");
48+
// Remove from Zustand store
49+
useMessagesStore.getState().removeMessage(data as Message);
3950
return true;
4051
}
4152
case "error": {
42-
if (data?.category === "error") {
43-
// Add error message to React Query cache
53+
if ((data as Record<string, unknown>)?.category === "error") {
54+
// Update React Query cache
4455
updateMessage(data as Message);
56+
// Update Zustand store
57+
useMessagesStore.getState().addMessage(data as Message);
4558
}
4659
return true;
4760
}

src/frontend/src/customization/utils/custom-buildUtils.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ export const customBuildUrl = (flowId: string, playgroundPage?: boolean) => {
44
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${flowId}/flow`;
55
};
66

7-
export const customCancelBuildUrl = (jobId: string) => {
8-
return `${getBaseUrl()}build/${jobId}/cancel`;
7+
export const customCancelBuildUrl = (
8+
jobId: string,
9+
playgroundPage?: boolean,
10+
) => {
11+
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/cancel`;
912
};
1013

11-
export const customEventsUrl = (jobId: string) => {
12-
return `${getBaseUrl()}build/${jobId}/events`;
14+
export const customEventsUrl = (jobId: string, playgroundPage?: boolean) => {
15+
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/events`;
1316
};

0 commit comments

Comments
 (0)