mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
Resume within the same workflow, account, and page so the old connection stops receiving snapshot requests. Recover from the saved draft when the replacement becomes leader.
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
import logging
|
|
from collections.abc import Callable
|
|
from typing import Annotated, cast
|
|
|
|
from flask import Request as FlaskRequest
|
|
from pydantic import UUID4, BaseModel, Field, ValidationError
|
|
|
|
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,
|
|
WorkflowPageResume,
|
|
)
|
|
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)
|
|
|
|
|
|
class WorkflowPageResumePayload(BaseModel):
|
|
page_id: UUID4
|
|
sequence: Annotated[int, Field(strict=True, ge=1)]
|
|
|
|
|
|
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: str, data: object):
|
|
"""Admit a new page or resume one after validating the optional page credential."""
|
|
if not isinstance(data, dict) or not isinstance(workflow_id := data.get("workflow_id"), str) or not workflow_id:
|
|
return {"msg": "workflow_id is required"}, 400
|
|
|
|
resume = None
|
|
if (raw_resume := data.get("resume")) is not None:
|
|
try:
|
|
payload = WorkflowPageResumePayload.model_validate(raw_resume)
|
|
except ValidationError:
|
|
return {"msg": "invalid resume"}, 400
|
|
resume = WorkflowPageResume(payload.page_id.hex, payload.sequence)
|
|
|
|
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, resume=resume
|
|
)
|
|
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)
|