mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
156 lines
6.5 KiB
Python
156 lines
6.5 KiB
Python
"""Select one browser to translate a committed draft change into CRDT operations."""
|
|
|
|
import base64
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import UTC
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
from socketio import Server # type: ignore[reportMissingTypeStubs]
|
|
from socketio.exceptions import TimeoutError as SocketIOTimeoutError # type: ignore[reportMissingTypeStubs]
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from configs import dify_config
|
|
from extensions.ext_socketio import sio
|
|
from models.workflow import Workflow
|
|
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
|
from repositories.workflow_draft_sync_repository import ServerDraftChange, WorkflowDraftSyncRepository
|
|
from services.workflow_collaboration_service import WorkflowCollaborationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
DRAFT_PROPOSAL_TIMEOUT_SECONDS = 5
|
|
|
|
|
|
class DraftGraphProposal(BaseModel):
|
|
revision: str
|
|
update: bytes
|
|
|
|
|
|
def _change_from_workflow(workflow: Workflow, previous_graph: dict[str, object] | None = None) -> ServerDraftChange:
|
|
updated_at = workflow.updated_at.replace(tzinfo=UTC)
|
|
return ServerDraftChange(
|
|
revision=f"{updated_at.isoformat(timespec='microseconds')}:{workflow.unique_hash}",
|
|
hash=workflow.unique_hash,
|
|
updated_at=int(updated_at.timestamp()),
|
|
graph=dict(workflow.graph_dict),
|
|
previous_graph=previous_graph,
|
|
)
|
|
|
|
|
|
def notify_workflow_draft_changed(workflow: Workflow, *, previous_graph: dict[str, object] | None = None) -> None:
|
|
"""Call after commit; a notification failure must not undo a successful write.
|
|
|
|
An explicit canvas refresh also requests the latest committed draft, allowing
|
|
the browser to recover when this notification or its delivery fails.
|
|
"""
|
|
if not dify_config.ENABLE_COLLABORATION_MODE:
|
|
return
|
|
try:
|
|
if not WorkflowCollaborationRepository().get_session_sids(workflow.app_id):
|
|
return
|
|
change = _change_from_workflow(workflow, previous_graph)
|
|
if WorkflowDraftSyncRepository().announce(workflow.app_id, change):
|
|
sio.emit(
|
|
"server_draft_changed",
|
|
{"revision": change.revision},
|
|
room=workflow.app_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to notify collaborators about draft change: app_id=%s", workflow.app_id)
|
|
|
|
|
|
class WorkflowDraftSyncService:
|
|
def __init__(
|
|
self,
|
|
repository: WorkflowDraftSyncRepository,
|
|
collaboration_repository: WorkflowCollaborationRepository,
|
|
collaboration: WorkflowCollaborationService,
|
|
socketio: Server,
|
|
) -> None:
|
|
self._repository = repository
|
|
self._collaboration_repository = collaboration_repository
|
|
self._collaboration = collaboration
|
|
self._socketio = socketio
|
|
|
|
def sync(self, sid: str, *, session: Session) -> tuple[dict[str, object], int]:
|
|
mapping = self._collaboration_repository.get_sid_mapping(sid)
|
|
if not mapping:
|
|
return {"msg": "unauthorized"}, 401
|
|
identity = self._socketio.get_session(sid)
|
|
if not identity.get("tenant_id"):
|
|
return {"msg": "unauthorized"}, 401
|
|
app_id = mapping["workflow_id"]
|
|
# End the database transaction before waiting for a browser response.
|
|
with session.begin():
|
|
workflow = session.scalar(
|
|
select(Workflow).where(
|
|
Workflow.app_id == app_id,
|
|
Workflow.tenant_id == identity["tenant_id"],
|
|
Workflow.version == Workflow.VERSION_DRAFT,
|
|
)
|
|
)
|
|
if workflow is None:
|
|
return {"msg": "draft_not_found"}, 404
|
|
latest = _change_from_workflow(workflow)
|
|
|
|
if self._repository.announce(app_id, latest):
|
|
self._socketio.emit("server_draft_changed", {"revision": latest.revision}, room=app_id)
|
|
change = self._repository.get(app_id)
|
|
if change is None:
|
|
return {"msg": "draft_sync_expired"}, 409
|
|
if change.update is not None:
|
|
return self._result(change), 200
|
|
|
|
token = uuid.uuid4().hex
|
|
if not self._repository.claim(app_id, token):
|
|
return {"msg": "draft_sync_pending"}, 202
|
|
try:
|
|
leader = self._collaboration_repository.get_current_leader(app_id)
|
|
candidates = self._collaboration_repository.get_session_sids(app_id)
|
|
if leader in candidates:
|
|
candidates.remove(leader)
|
|
candidates.insert(0, leader)
|
|
active = [candidate for candidate in candidates if self._collaboration.is_session_active(app_id, candidate)]
|
|
# Keep retries inside the lease, but skip unready browsers without
|
|
# preventing a later candidate from responding. A timed-out browser
|
|
# has only edited a fork, so its late response is harmless.
|
|
deadline = time.monotonic() + 2 * DRAFT_PROPOSAL_TIMEOUT_SECONDS
|
|
for target in active:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
try:
|
|
response = self._socketio.call(
|
|
"server_draft_request",
|
|
change.model_dump(exclude={"update"}),
|
|
to=target,
|
|
timeout=min(DRAFT_PROPOSAL_TIMEOUT_SECONDS, remaining),
|
|
)
|
|
proposal = DraftGraphProposal.model_validate(response, strict=True)
|
|
except (SocketIOTimeoutError, ValidationError):
|
|
continue
|
|
if proposal.revision != change.revision:
|
|
continue
|
|
encoded_update = base64.b64encode(proposal.update).decode("ascii")
|
|
if not self._repository.accept(app_id, token, change.revision, encoded_update):
|
|
return {"msg": "draft_sync_superseded"}, 409
|
|
change.update = encoded_update
|
|
result = self._result(change)
|
|
self._socketio.emit("server_draft_update", result, room=app_id)
|
|
return result, 200
|
|
return {"msg": "no_ready_draft_writer"}, 503
|
|
finally:
|
|
self._repository.release(app_id, token)
|
|
|
|
@staticmethod
|
|
def _result(change: ServerDraftChange) -> dict[str, object]:
|
|
assert change.update is not None
|
|
return {
|
|
"revision": change.revision,
|
|
"hash": change.hash,
|
|
"updated_at": change.updated_at,
|
|
"update": base64.b64decode(change.update),
|
|
}
|