Files
dify/api/controllers/console/socketio/workflow.py
T
twwu d9bd071d1d Merge branch 'main' into feat/dify-builder
# Conflicts:
#	AGENTS.md
#	api/controllers/console/socketio/workflow.py
#	api/services/workflow_runtime/entry.py
#	api/tasks/app_generate/workflow_execute_task.py
#	api/tests/unit_tests/services/test_app_service.py
#	web/app/components/apps/app-list-catalog.tsx
#	web/app/components/apps/list.tsx
#	web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx
#	web/app/components/workflow-app/components/workflow-main.tsx
#	web/app/components/workflow-app/draft/use-draft-save.ts
#	web/app/components/workflow/collaboration/core/collaboration-manager.ts
#	web/app/components/workflow/collaboration/hooks/use-collaboration.ts
#	web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx
#	web/app/components/workflow/operator/zoom-in-out.tsx
2026-11-05 17:17:16 +08:00

145 lines
5.5 KiB
Python

import logging
from collections.abc import Callable
from typing import cast
from flask import Request as FlaskRequest
from core.db.session_factory import session_factory
from extensions.ext_application_services import application_services
from extensions.ext_socketio import sio
from extensions.otel.workflow_collaboration import observe_workflow_event
from libs.passport import PassportService
from libs.token import extract_access_token
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from repositories.workflow_draft_sync_repository import WorkflowDraftSyncRepository
from services.errors.billing import BillingUpstreamError
from services.workflow_collaboration_service import WorkflowCollaborationNotEntitledError, WorkflowCollaborationService
from services.workflow_draft_sync_service import WorkflowDraftSyncService
repository = WorkflowCollaborationRepository()
collaboration_service = WorkflowCollaborationService(repository, sio)
draft_sync_repository = WorkflowDraftSyncRepository()
draft_sync_service = WorkflowDraftSyncService(draft_sync_repository, repository, collaboration_service, sio)
def _sio_on(event: str) -> Callable[[Callable[..., object]], Callable[..., object]]:
return cast(Callable[[Callable[..., object]], Callable[..., object]], sio.on(event))
@_sio_on("connect")
@observe_workflow_event("connect", sio)
def socket_connect(sid, environ, auth=None):
"""
Authenticate a connect with optional Socket.IO auth data; preserve token-based admission.
"""
try:
request_environ = FlaskRequest(environ)
token = extract_access_token(request_environ)
except Exception:
logging.exception("Failed to extract token")
token = None
if not token:
logging.warning("Socket connect rejected: missing token (sid=%s)", sid)
return False
try:
decoded = PassportService().verify(token)
user_id = decoded.get("user_id")
if not user_id:
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
return False
with sio.app.app_context():
user = application_services().accounts.identity.load_user(user_id)
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False
if not user.has_edit_permission:
logging.warning("Socket connect rejected: no edit permission (user_id=%s, sid=%s)", user_id, sid)
return False
collaboration_service.save_socket_identity(sid, user)
return True
except Exception:
logging.exception("Socket authentication failed")
return False
@_sio_on("user_connect")
@observe_workflow_event("user_connect", sio)
def handle_user_connect(sid, data):
"""
Handle user connect event. Each session (tab) is treated as an independent collaborator.
"""
workflow_id = data.get("workflow_id")
if not workflow_id:
return {"msg": "workflow_id is required"}, 400
try:
with sio.app.app_context(), session_factory.create_session() as session:
result = collaboration_service.authorize_and_join_workflow_room(workflow_id, sid, session=session)
except BillingUpstreamError:
logging.exception("Failed to check collaboration entitlement (workflow_id=%s)", workflow_id)
return {"msg": "collaboration_unavailable"}, 503
except WorkflowCollaborationNotEntitledError:
return {"msg": "not_entitled"}, 403
if not result:
return {"msg": "unauthorized"}, 401
user_id, is_leader = result
pending_draft = draft_sync_repository.get(workflow_id)
if pending_draft is not None and pending_draft.update is None:
sio.emit("server_draft_changed", {"revision": pending_draft.revision}, to=sid)
return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader}
@observe_workflow_event("disconnect", sio)
def _handle_disconnect(sid):
"""Remove the session from online users and observe the result."""
workflow_id = collaboration_service.disconnect_session(sid)
if workflow_id and not repository.get_session_sids(workflow_id):
draft_sync_repository.clear(workflow_id)
@_sio_on("server_draft_sync")
def handle_server_draft_sync(sid, _data):
"""Return one accepted CRDT update for the latest committed server draft."""
with sio.app.app_context(), session_factory.create_session() as session:
return draft_sync_service.sync(sid, session=session)
@_sio_on("disconnect")
def handle_disconnect(sid):
"""Let Socket.IO discard its reason before starting observable cleanup.
A cleanup TypeError must propagate without Socket.IO retrying the cleanup.
"""
_handle_disconnect(sid)
@_sio_on("collaboration_event")
@observe_workflow_event("collaboration_event", sio)
def handle_collaboration_event(sid, data):
"""
Handle general collaboration events, include:
1. mouse_move
2. vars_and_features_update
3. sync_request (ask leader to update graph)
4. workflow_update
5. comments_update
6. node_panel_presence
7. graph_view_state (session reports tab visibility; drives leader election)
"""
return collaboration_service.relay_collaboration_event(sid, data)
@_sio_on("graph_event")
@observe_workflow_event("graph_event", sio)
def handle_graph_event(sid, data):
"""
Handle graph events - simple broadcast relay.
"""
return collaboration_service.relay_graph_event(sid, data)