Skip to content

Commit 080bf11

Browse files
committed
feat: integrate memory-base + trackFlowBuild with v2 workflows
Two follow-up integrations: memory-base on_flow_output hook: Release-1.10.0 wired the memory-base auto-capture hook into the v1 build pipeline (api/build.py) so MemoryBases watching a flow get notified after every completed run. Wire the same hook into the v2 endpoint's sync and background paths so MemoryBase auto-capture works for v2 workflows too. Background mode fires the hook on successful runs only, inside the buffered runner's finally block. fire_and_forget_task because we are already running in a background context and the hook must not block job-status finalization. trackFlowBuild analytics: Release-1.10.0 wired trackFlowBuild into the v1 build callbacks (success -> isError=false, error -> isError=true + error list). Those callbacks were deleted with the v1 fallback. Read the bridge's final buildInfo after runFlowAGUI resolves and fire trackFlowBuild with the same signature as the v1 path: - (name, false, {flowId}) on success - (name, true, {flowId, error}) on failure Tests: - Background-mode memory-base hook fires on a real chatbot run end-to-end (TestMemoryBaseHookBackgroundMode in test_workflow_agui). - buildFlow analytics tests stub runFlowAGUI and assert trackFlowBuild's args on success + failure.
1 parent 59c016b commit 080bf11

4 files changed

Lines changed: 384 additions & 12 deletions

File tree

src/backend/base/langflow/api/v2/workflow.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
from langflow.services.database.models.flow.model import FlowRead
8585
from langflow.services.database.models.jobs.model import JobType
8686
from langflow.services.database.models.user.model import UserRead
87-
from langflow.services.deps import get_job_service, get_queue_service, get_task_service
87+
from langflow.services.deps import get_job_service, get_memory_base_service, get_queue_service, get_task_service
8888

8989
# Configuration constants
9090
EXECUTION_TIMEOUT = 300 # 5 minutes default timeout for sync execution
@@ -429,6 +429,18 @@ async def execute_sync_workflow(
429429
stream=False,
430430
)
431431

432+
# Fire memory-base auto-capture hook — non-blocking background effect.
433+
try:
434+
_run_id_uuid = UUID(graph.run_id) if graph.run_id else None # type-cast only; same run_id set on graph
435+
await get_task_service().fire_and_forget_task(
436+
get_memory_base_service().on_flow_output,
437+
flow_id=flow.id,
438+
session_id=execution_session_id,
439+
job_id=_run_id_uuid,
440+
)
441+
except (RuntimeError, ValueError, OSError):
442+
await logger.awarning("Memory base hook scheduling failed for flow %s", flow.id, exc_info=True)
443+
432444
# Build RunResponse
433445
run_response = RunResponse(outputs=task_result, session_id=execution_session_id)
434446
# Convert to WorkflowExecutionResponse
@@ -810,6 +822,20 @@ async def _buffer_background_run(
810822
JobStatus.FAILED if errored else JobStatus.COMPLETED,
811823
finished_timestamp=True,
812824
)
825+
# Fire memory-base auto-capture hook on successful runs only. Matches
826+
# the sync mode wiring above and the v1 build-pipeline wiring in
827+
# ``api/build.py``. ``fire_and_forget_task`` because we are already a
828+
# background coroutine and the hook must not block job finalization.
829+
if not errored:
830+
try:
831+
await get_task_service().fire_and_forget_task(
832+
get_memory_base_service().on_flow_output,
833+
flow_id=flow.id,
834+
session_id=parsed.session_id or str(flow.id),
835+
job_id=job_uuid,
836+
)
837+
except (RuntimeError, ValueError, OSError):
838+
await logger.awarning("Memory base hook scheduling failed for flow %s", flow.id, exc_info=True)
813839

814840

815841
async def execute_workflow_background(

src/backend/tests/unit/api/v2/test_workflow_agui.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,87 @@ async def __call__(self, *args, **kwargs):
811811
)
812812

813813

814+
class TestMemoryBaseHookBackgroundMode:
815+
"""The memory-base ``on_flow_output`` hook must fire after a background run.
816+
817+
Sync mode wires the hook directly inside ``execute_workflow_sync`` and the
818+
v1 build pipeline wires it after ``end_all_traces`` in ``api/build.py``.
819+
The v2 background mode buffers frames in ``_buffer_background_run`` and
820+
must dispatch the same hook in its ``finally`` block on successful
821+
completion. Without it, MemoryBase auto-capture silently misses every
822+
background run.
823+
"""
824+
825+
async def test_background_run_fires_memory_base_hook_on_success(
826+
self,
827+
client: AsyncClient,
828+
created_api_key,
829+
chatbot_flow,
830+
monkeypatch: pytest.MonkeyPatch,
831+
):
832+
"""Background run schedules ``on_flow_output``.
833+
834+
``flow_id``, ``session_id``, and ``job_id`` must reach the hook.
835+
"""
836+
from langflow.api.v2 import workflow as _workflow_module
837+
838+
captured: list[dict] = []
839+
840+
class _RecordingMemoryBaseService:
841+
async def on_flow_output(self, **kwargs):
842+
captured.append(kwargs)
843+
844+
monkeypatch.setattr(
845+
_workflow_module,
846+
"get_memory_base_service",
847+
lambda: _RecordingMemoryBaseService(),
848+
)
849+
850+
headers = {"x-api-key": created_api_key.api_key}
851+
start = await client.post(
852+
"api/v2/workflows",
853+
json=_agui_body(chatbot_flow, message="hi", mode="background"),
854+
headers=headers,
855+
)
856+
assert start.status_code == 200
857+
job_id = start.json()["job_id"]
858+
859+
events = await client.get(f"api/v2/workflows/{job_id}/events", headers=headers)
860+
assert events.status_code == 200
861+
assert "RUN_FINISHED" in events.text
862+
863+
# Wait for the buffer task to finalize the job row.
864+
import asyncio as _asyncio
865+
from uuid import UUID as _UUID
866+
867+
from langflow.services.database.models.jobs.model import Job as _Job
868+
869+
for _ in range(100):
870+
async with session_scope() as session:
871+
row = await session.get(_Job, _UUID(job_id))
872+
if row is not None and row.status.value in ("completed", "failed"):
873+
break
874+
await _asyncio.sleep(0.1)
875+
876+
# The hook is fired via ``fire_and_forget_task`` so allow the loop a
877+
# tick to drain the scheduled coroutine.
878+
for _ in range(20):
879+
if captured:
880+
break
881+
await _asyncio.sleep(0.05)
882+
883+
assert captured, (
884+
"Memory-base on_flow_output was not called after a successful "
885+
"background run. The hook is silently dropped for every "
886+
"background-mode workflow run."
887+
)
888+
assert len(captured) == 1
889+
call = captured[0]
890+
assert call["flow_id"] == chatbot_flow
891+
assert call["session_id"] == "thread-1" # matches _agui_body session_id
892+
assert call["job_id"] == _UUID(job_id)
893+
894+
814895
class TestBackgroundModeStreamProtocol:
815896
"""Background mode must honor ``stream_protocol`` end-to-end.
816897

src/frontend/src/stores/__tests__/flowStore.test.ts

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,24 @@ jest.mock("../darkStore", () => ({
5050
},
5151
}));
5252

53-
jest.mock("../flowsManagerStore", () => ({
54-
__esModule: true,
55-
default: {
56-
getState: () => ({
57-
setCurrentFlow: jest.fn(),
58-
takeSnapshot: jest.fn(),
59-
}),
60-
},
61-
}));
53+
jest.mock("../flowsManagerStore", () => {
54+
const state: { currentFlow: { id: string; name: string } | undefined } = {
55+
currentFlow: undefined,
56+
};
57+
return {
58+
__esModule: true,
59+
default: {
60+
getState: () => ({
61+
...state,
62+
setCurrentFlow: jest.fn(),
63+
takeSnapshot: jest.fn(),
64+
}),
65+
__setCurrentFlow: (flow: { id: string; name: string } | undefined) => {
66+
state.currentFlow = flow;
67+
},
68+
},
69+
};
70+
});
6271

6372
jest.mock("../globalVariablesStore/globalVariables", () => ({
6473
useGlobalVariablesStore: {
@@ -90,6 +99,25 @@ jest.mock("@/utils/utils", () => ({
9099
brokenEdgeMessage: jest.fn(),
91100
}));
92101

102+
// runFlowAGUI is exercised end-to-end in its own bridge tests; here we just
103+
// need a controllable replacement so the buildFlow integration can be tested
104+
// without touching the network.
105+
jest.mock("@/controllers/API/agui/run-flow-bridge", () => ({
106+
runFlowAGUI: jest.fn(),
107+
}));
108+
109+
// Keep reactflowUtils' real behaviour for the rest of the suite; only flip
110+
// validateNodes / validateEdge to no-ops so the buildFlow analytics tests can
111+
// reach the runFlowAGUI call with an empty (test-fixture) graph.
112+
jest.mock("../../utils/reactflowUtils", () => {
113+
const actual = jest.requireActual("../../utils/reactflowUtils");
114+
return {
115+
...actual,
116+
validateNodes: jest.fn(() => []),
117+
validateEdge: jest.fn(() => []),
118+
};
119+
});
120+
93121
// Note: Some utility modules may not exist in test environment
94122
// The store should handle missing utilities gracefully
95123

@@ -1107,4 +1135,82 @@ describe("useFlowStore", () => {
11071135
expect(latest["output_b"]).toEqual([mockLog2]);
11081136
});
11091137
});
1138+
1139+
describe("buildFlow analytics — trackFlowBuild integration", () => {
1140+
let mockedRunFlow: jest.Mock;
1141+
let trackFlowBuildMock: jest.Mock;
1142+
1143+
beforeAll(() => {
1144+
// Cast through unknown so the test does not depend on the bridge module's
1145+
// public type — we only care about controlling the side effect.
1146+
const bridge = jest.requireMock(
1147+
"@/controllers/API/agui/run-flow-bridge",
1148+
) as { runFlowAGUI: jest.Mock };
1149+
mockedRunFlow = bridge.runFlowAGUI;
1150+
1151+
const analytics = jest.requireMock("@/customization/utils/analytics") as {
1152+
trackFlowBuild: jest.Mock;
1153+
};
1154+
trackFlowBuildMock = analytics.trackFlowBuild;
1155+
1156+
// Make `currentFlow` resolvable inside buildFlow.
1157+
const flowsManager = jest.requireMock("../flowsManagerStore") as {
1158+
default: {
1159+
__setCurrentFlow: (
1160+
flow: { id: string; name: string } | undefined,
1161+
) => void;
1162+
};
1163+
};
1164+
flowsManager.default.__setCurrentFlow({
1165+
id: "flow-abc",
1166+
name: "Test Flow",
1167+
});
1168+
});
1169+
1170+
beforeEach(() => {
1171+
mockedRunFlow.mockReset();
1172+
trackFlowBuildMock.mockReset();
1173+
act(() => {
1174+
useFlowStore.setState({
1175+
nodes: [],
1176+
edges: [],
1177+
buildInfo: null,
1178+
flowBuildStatus: {},
1179+
isBuilding: false,
1180+
componentsToUpdate: [],
1181+
});
1182+
});
1183+
});
1184+
1185+
it("fires trackFlowBuild with isError=false after a successful run", async () => {
1186+
mockedRunFlow.mockImplementation(async () => {
1187+
// The bridge writes success into the store on `RUN_FINISHED`.
1188+
useFlowStore.setState({ buildInfo: { success: true } });
1189+
});
1190+
1191+
await useFlowStore.getState().buildFlow({});
1192+
1193+
expect(mockedRunFlow).toHaveBeenCalledTimes(1);
1194+
expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", false, {
1195+
flowId: "flow-abc",
1196+
});
1197+
});
1198+
1199+
it("fires trackFlowBuild with isError=true and the error list after a failure", async () => {
1200+
const errorList = ["boom"];
1201+
mockedRunFlow.mockImplementation(async () => {
1202+
useFlowStore.setState({
1203+
buildInfo: { success: false, error: errorList },
1204+
});
1205+
});
1206+
1207+
await useFlowStore.getState().buildFlow({});
1208+
1209+
expect(mockedRunFlow).toHaveBeenCalledTimes(1);
1210+
expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", true, {
1211+
flowId: "flow-abc",
1212+
error: errorList,
1213+
});
1214+
});
1215+
});
11101216
});

0 commit comments

Comments
 (0)