mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 00:39:35 +08:00
feat(collaboration): reconcile presence via periodic snapshots
Reuse the collaboration poll loop to purge expired SQLite connections every 30s and broadcast presence.snapshot per active flow, so clients recover from missed presence.left events. Batch list_users across flow IDs, key connections by connection_id with non-null selected_at, and fold selection into presence.snapshot on the wire and frontend.
This commit is contained in:
@ -23,7 +23,6 @@ from langflow.api.utils.collab.operations import (
|
||||
apply_flow_operation_batch,
|
||||
)
|
||||
from langflow.api.v1.collaboration_manager import (
|
||||
WORKER_ID,
|
||||
CollaborationManager,
|
||||
ensure_collaboration_poll_loop,
|
||||
get_collaboration_manager,
|
||||
@ -40,7 +39,6 @@ from langflow.api.v1.schemas.flow_collaboration import (
|
||||
)
|
||||
from langflow.services.collaboration_events.schemas import (
|
||||
CollaborationPresenceChange,
|
||||
CollaborationSelectionTarget,
|
||||
)
|
||||
from langflow.services.database.models.user.model import UserRead
|
||||
from langflow.services.deps import get_collaboration_events_service, session_scope
|
||||
@ -146,7 +144,7 @@ class FlowCollaborationConnection:
|
||||
username=self.current_user.username,
|
||||
profile_image=self.current_user.profile_image,
|
||||
)
|
||||
snapshot = self._event_service.list_users(self.flow_id)
|
||||
snapshot = self._event_service.list_users([self.flow_id])[self.flow_id]
|
||||
|
||||
await self.websocket.send_json(
|
||||
CollaborationSessionReadyMessage(
|
||||
@ -156,7 +154,6 @@ class FlowCollaborationConnection:
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
await self.websocket.send_json(self.manager.presence_snapshot_message(snapshot))
|
||||
await self.websocket.send_json(self.manager.selection_snapshot_message(snapshot))
|
||||
|
||||
await self._emit_presence_change(presence_change, exclude_connection_id=self.connection_id)
|
||||
|
||||
@ -337,28 +334,12 @@ class FlowCollaborationConnection:
|
||||
*,
|
||||
exclude_connection_id: str | None = None,
|
||||
) -> None:
|
||||
if change is None:
|
||||
return
|
||||
|
||||
if change.joined:
|
||||
message = self.manager.presence_joined_message(
|
||||
user_id=change.joined.user_id,
|
||||
username=change.joined.username,
|
||||
profile_image=change.joined.profile_image,
|
||||
)
|
||||
await self.manager.broadcast_json(
|
||||
self.flow_id,
|
||||
message,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
self._publish_presence_joined(change.joined)
|
||||
|
||||
if change.left_user_id:
|
||||
message = self.manager.presence_left_message(change.left_user_id)
|
||||
await self.manager.broadcast_json(self.flow_id, message)
|
||||
self._publish_presence_left(change.left_user_id)
|
||||
|
||||
await self._emit_selection_change(change)
|
||||
await self.manager.emit_presence_change(
|
||||
self.flow_id,
|
||||
change,
|
||||
self._event_service,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
|
||||
async def _emit_selection_change(
|
||||
self,
|
||||
@ -366,47 +347,9 @@ class FlowCollaborationConnection:
|
||||
*,
|
||||
exclude_connection_id: str | None = None,
|
||||
) -> None:
|
||||
if change is None or change.selection_updated is None:
|
||||
return
|
||||
|
||||
selected = change.selection_updated.selected
|
||||
message = self.manager.selection_updated_message(change.selection_updated.user_id, selected)
|
||||
await self.manager.broadcast_json(
|
||||
await self.manager.emit_selection_change(
|
||||
self.flow_id,
|
||||
message,
|
||||
change,
|
||||
self._event_service,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
self._publish_selection_updated(change.selection_updated.user_id, selected)
|
||||
|
||||
def _publish_presence_joined(self, joined: object) -> None:
|
||||
self._event_service.publish(
|
||||
self.flow_id,
|
||||
"presence.joined",
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"user": {
|
||||
"user_id": str(joined.user_id),
|
||||
"username": joined.username,
|
||||
"profile_image": joined.profile_image,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _publish_presence_left(self, user_id: UUID) -> None:
|
||||
self._event_service.publish(
|
||||
self.flow_id,
|
||||
"presence.left",
|
||||
{"worker_id": WORKER_ID, "user_id": str(user_id)},
|
||||
)
|
||||
|
||||
def _publish_selection_updated(
|
||||
self,
|
||||
user_id: UUID,
|
||||
selected: CollaborationSelectionTarget | None,
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"worker_id": WORKER_ID,
|
||||
"user_id": str(user_id),
|
||||
"selected": {"kind": selected.kind, "id": selected.id} if selected is not None else None,
|
||||
}
|
||||
self._event_service.publish(self.flow_id, "selection.updated", payload)
|
||||
|
||||
@ -23,14 +23,17 @@ from langflow.api.v1.schemas.flow_collaboration import (
|
||||
CollaborationPresenceLeftMessage,
|
||||
CollaborationPresenceSnapshotMessage,
|
||||
CollaborationPresenceUser,
|
||||
CollaborationSelectionSnapshotMessage,
|
||||
CollaborationSelectionUpdatedBackplaneEvent,
|
||||
CollaborationSelectionUpdatedMessage,
|
||||
CollaborationUserSelection,
|
||||
UnsupportedCollaborationBackplaneEventError,
|
||||
parse_collaboration_backplane_event,
|
||||
)
|
||||
from langflow.services.collaboration_events.schemas import CollaborationPresenceSnapshot, CollaborationSelectionTarget
|
||||
from langflow.services.collaboration_events import CollaborationEventService
|
||||
from langflow.services.collaboration_events.schemas import (
|
||||
CollaborationPresenceChange,
|
||||
CollaborationPresenceSnapshot,
|
||||
CollaborationSelectionTarget,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.websockets import WebSocket
|
||||
@ -42,6 +45,7 @@ BackplaneEventType = Literal["operation.accepted", "presence.joined", "presence.
|
||||
|
||||
WORKER_ID = str(uuid.uuid4())
|
||||
FANNED_REVISION_TTL_SECONDS = 120.0
|
||||
PRESENCE_RECONCILE_INTERVAL_SECONDS = 30.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -117,6 +121,7 @@ class CollaborationManager:
|
||||
user_id=user.user_id,
|
||||
username=user.username,
|
||||
profile_image=user.profile_image,
|
||||
selected=user.selected,
|
||||
)
|
||||
for user in snapshot.users
|
||||
]
|
||||
@ -135,14 +140,6 @@ class CollaborationManager:
|
||||
def presence_left_message(self, user_id: UUID) -> dict[str, Any]:
|
||||
return CollaborationPresenceLeftMessage(user_id=user_id).model_dump(mode="json")
|
||||
|
||||
def selection_snapshot_message(self, snapshot: CollaborationPresenceSnapshot) -> dict[str, Any]:
|
||||
selections = [
|
||||
CollaborationUserSelection(user_id=user.user_id, selected=user.selected)
|
||||
for user in snapshot.users
|
||||
if user.selected is not None
|
||||
]
|
||||
return CollaborationSelectionSnapshotMessage(selections=selections).model_dump(mode="json")
|
||||
|
||||
def selection_updated_message(
|
||||
self,
|
||||
user_id: UUID,
|
||||
@ -191,6 +188,82 @@ class CollaborationManager:
|
||||
return
|
||||
await handler(flow_id, backplane_event)
|
||||
|
||||
async def emit_presence_change(
|
||||
self,
|
||||
flow_id: UUID,
|
||||
change: CollaborationPresenceChange | None,
|
||||
event_service: CollaborationEventService,
|
||||
*,
|
||||
exclude_connection_id: str | None = None,
|
||||
) -> None:
|
||||
if change is None:
|
||||
return
|
||||
|
||||
if change.joined:
|
||||
message = self.presence_joined_message(
|
||||
user_id=change.joined.user_id,
|
||||
username=change.joined.username,
|
||||
profile_image=change.joined.profile_image,
|
||||
)
|
||||
await self.broadcast_json(
|
||||
flow_id,
|
||||
message,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
event_service.publish(
|
||||
flow_id,
|
||||
"presence.joined",
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"user": {
|
||||
"user_id": str(change.joined.user_id),
|
||||
"username": change.joined.username,
|
||||
"profile_image": change.joined.profile_image,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if change.left_user_id:
|
||||
message = self.presence_left_message(change.left_user_id)
|
||||
await self.broadcast_json(flow_id, message)
|
||||
event_service.publish(
|
||||
flow_id,
|
||||
"presence.left",
|
||||
{"worker_id": WORKER_ID, "user_id": str(change.left_user_id)},
|
||||
)
|
||||
|
||||
await self.emit_selection_change(
|
||||
flow_id,
|
||||
change,
|
||||
event_service,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
|
||||
async def emit_selection_change(
|
||||
self,
|
||||
flow_id: UUID,
|
||||
change: CollaborationPresenceChange | None,
|
||||
event_service: CollaborationEventService,
|
||||
*,
|
||||
exclude_connection_id: str | None = None,
|
||||
) -> None:
|
||||
if change is None or change.selection_updated is None:
|
||||
return
|
||||
|
||||
selected = change.selection_updated.selected
|
||||
message = self.selection_updated_message(change.selection_updated.user_id, selected)
|
||||
await self.broadcast_json(
|
||||
flow_id,
|
||||
message,
|
||||
exclude_connection_id=exclude_connection_id,
|
||||
)
|
||||
payload: dict[str, object] = {
|
||||
"worker_id": WORKER_ID,
|
||||
"user_id": str(change.selection_updated.user_id),
|
||||
"selected": {"kind": selected.kind, "id": selected.id} if selected is not None else None,
|
||||
}
|
||||
event_service.publish(flow_id, "selection.updated", payload)
|
||||
|
||||
async def _handle_operation_accepted_backplane_event(
|
||||
self,
|
||||
flow_id: UUID,
|
||||
@ -299,8 +372,9 @@ async def _poll_collaboration_events() -> None:
|
||||
from langflow.services.deps import get_collaboration_events_service
|
||||
|
||||
manager = get_collaboration_manager()
|
||||
event_service = get_collaboration_events_service()
|
||||
event_service: CollaborationEventService = get_collaboration_events_service()
|
||||
cursors: dict[UUID, CollaborationPollCursor] = {}
|
||||
next_presence_reconcile_at = time.monotonic()
|
||||
|
||||
while True:
|
||||
flow_ids = manager.active_flow_ids()
|
||||
@ -308,6 +382,15 @@ async def _poll_collaboration_events() -> None:
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
|
||||
now = time.monotonic()
|
||||
should_reconcile_presence = now >= next_presence_reconcile_at
|
||||
if should_reconcile_presence:
|
||||
next_presence_reconcile_at = now + PRESENCE_RECONCILE_INTERVAL_SECONDS
|
||||
|
||||
snapshots = event_service.list_users(list(flow_ids))
|
||||
for flow_id, snapshot in snapshots.items():
|
||||
await manager.broadcast_json(flow_id, manager.presence_snapshot_message(snapshot))
|
||||
|
||||
for flow_id in flow_ids:
|
||||
cursor = cursors.get(flow_id)
|
||||
events, new_cursor = event_service.poll(flow_id, cursor=cursor)
|
||||
|
||||
@ -16,6 +16,7 @@ class CollaborationPresenceUser(BaseModel):
|
||||
user_id: UUID
|
||||
username: str
|
||||
profile_image: str | None = None
|
||||
selected: CollaborationSelectionTarget | None = None
|
||||
|
||||
|
||||
class CollaborationSessionStartMessage(BaseModel):
|
||||
@ -82,11 +83,6 @@ class CollaborationSelectionUpdateMessage(BaseModel):
|
||||
selected: CollaborationSelectionTarget | None = None
|
||||
|
||||
|
||||
class CollaborationSelectionSnapshotMessage(BaseModel):
|
||||
type: Literal["selection.snapshot"] = "selection.snapshot"
|
||||
selections: list[CollaborationUserSelection]
|
||||
|
||||
|
||||
class CollaborationSelectionUpdatedMessage(BaseModel):
|
||||
type: Literal["selection.updated"] = "selection.updated"
|
||||
user_id: UUID
|
||||
|
||||
@ -79,5 +79,5 @@ class CollaborationEventService(Service, ABC):
|
||||
"""Remove one active connection row."""
|
||||
|
||||
@abstractmethod
|
||||
def list_users(self, flow_id: UUID) -> CollaborationPresenceSnapshot:
|
||||
"""Return active deduped users plus effective per-user selections."""
|
||||
def list_users(self, flow_ids: list[UUID]) -> dict[UUID, CollaborationPresenceSnapshot]:
|
||||
"""Return active deduped users plus effective per-user selections keyed by flow id."""
|
||||
|
||||
@ -37,14 +37,13 @@ CREATE INDEX IF NOT EXISTS idx_events_expires ON events(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
flow_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
profile_image TEXT,
|
||||
selected_kind TEXT,
|
||||
selected_id TEXT,
|
||||
selected_at REAL,
|
||||
expires_at REAL NOT NULL,
|
||||
PRIMARY KEY (flow_id, user_id, connection_id)
|
||||
selected_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_flow_expires
|
||||
ON connections(flow_id, expires_at);
|
||||
@ -113,7 +112,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
payload=event_payload,
|
||||
)
|
||||
expires_at = now + self.TTL_SECONDS
|
||||
self._purge_expired_locked(now)
|
||||
self._purge_expired_events_locked(now)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO events
|
||||
@ -148,7 +147,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
cursor = cursor or CollaborationPollCursor()
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
self._purge_expired_locked(now)
|
||||
self._purge_expired_events_locked(now)
|
||||
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
@ -219,7 +218,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
now = time.time()
|
||||
self._purge_expired_locked(now)
|
||||
self._purge_expired_events_locked(now)
|
||||
before_user = self._effective_user_locked(flow_id_key, user_id_key, now)
|
||||
expires_at = now + self.PRESENCE_TTL_SECONDS
|
||||
self._conn.execute(
|
||||
@ -227,10 +226,15 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
INSERT INTO connections (
|
||||
flow_id, user_id, connection_id, username, profile_image,
|
||||
selected_kind, selected_id, selected_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?)
|
||||
ON CONFLICT(flow_id, user_id, connection_id) DO UPDATE SET
|
||||
) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?)
|
||||
ON CONFLICT(connection_id) DO UPDATE SET
|
||||
flow_id = excluded.flow_id,
|
||||
user_id = excluded.user_id,
|
||||
username = excluded.username,
|
||||
profile_image = excluded.profile_image,
|
||||
selected_kind = NULL,
|
||||
selected_id = NULL,
|
||||
selected_at = excluded.selected_at,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(
|
||||
@ -239,6 +243,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
connection_id,
|
||||
username,
|
||||
profile_image,
|
||||
now,
|
||||
expires_at,
|
||||
),
|
||||
)
|
||||
@ -269,7 +274,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
now = time.time()
|
||||
self._purge_expired_locked(now)
|
||||
self._purge_expired_events_locked(now)
|
||||
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
@ -351,7 +356,7 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
now = time.time()
|
||||
self._purge_expired_locked(now)
|
||||
self._purge_expired_events_locked(now)
|
||||
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
@ -386,21 +391,47 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
|
||||
return change
|
||||
|
||||
def list_users(self, flow_id: UUID) -> CollaborationPresenceSnapshot:
|
||||
"""Return a snapshot of all active, deduplicated users and their effective selections."""
|
||||
flow_id_key = str(flow_id)
|
||||
def list_users(self, flow_ids: list[UUID]) -> dict[UUID, CollaborationPresenceSnapshot]:
|
||||
"""Return snapshots of active, deduplicated users and their effective selections, grouped by flow id."""
|
||||
if not flow_ids:
|
||||
msg = "flow_ids must not be empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
flow_id_keys = [str(flow_id) for flow_id in flow_ids]
|
||||
flow_ids_json = json.dumps(flow_id_keys)
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
self._purge_expired_locked(now)
|
||||
users = self._effective_users_locked(flow_id_key, now)
|
||||
return CollaborationPresenceSnapshot(users=list(users.values()))
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
now = time.time()
|
||||
self._purge_expired_events_locked(now)
|
||||
self._conn.execute(
|
||||
"""
|
||||
DELETE FROM connections
|
||||
WHERE flow_id IN (SELECT value FROM json_each(?))
|
||||
AND expires_at < ?
|
||||
""",
|
||||
(flow_ids_json, now),
|
||||
)
|
||||
snapshots = self._presence_snapshots_for_flows_locked(flow_id_keys, flow_ids_json, now)
|
||||
self._conn.execute("COMMIT")
|
||||
except Exception:
|
||||
self._conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
return {flow_id: snapshots.get(flow_id, CollaborationPresenceSnapshot(users=[])) for flow_id in flow_ids}
|
||||
|
||||
async def teardown(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
|
||||
def _purge_expired_locked(self, now: float) -> None:
|
||||
self._purge_expired_events_locked(now)
|
||||
self._purge_expired_connections_locked(now)
|
||||
|
||||
def _purge_expired_events_locked(self, now: float) -> None:
|
||||
self._conn.execute("DELETE FROM events WHERE expires_at < ?", (now,))
|
||||
|
||||
def _purge_expired_connections_locked(self, now: float) -> None:
|
||||
self._conn.execute("DELETE FROM connections WHERE expires_at < ?", (now,))
|
||||
|
||||
def _enforce_per_flow_cap_locked(self, flow_id: str) -> None:
|
||||
@ -417,63 +448,50 @@ class SQLiteCollaborationEventService(CollaborationEventService):
|
||||
(flow_id, self.MAX_EVENTS_PER_FLOW),
|
||||
)
|
||||
|
||||
def _effective_users_locked(
|
||||
def _presence_snapshots_for_flows_locked(
|
||||
self,
|
||||
flow_id_key: str,
|
||||
flow_id_keys: list[str],
|
||||
flow_ids_json: str,
|
||||
now: float,
|
||||
) -> dict[UUID, CollaborationPresenceConnectionUser]:
|
||||
"""Compute the effective deduplicated user presence state.
|
||||
|
||||
Groups all non-expired connections by user_id. The effective selection
|
||||
for each user is taken from the connection that most recently updated
|
||||
its selection.
|
||||
"""
|
||||
) -> dict[UUID, CollaborationPresenceSnapshot]:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT user_id, connection_id, username, profile_image,
|
||||
selected_kind, selected_id, selected_at
|
||||
FROM connections
|
||||
WHERE flow_id = ?
|
||||
SELECT
|
||||
flow_id,
|
||||
user_id,
|
||||
username,
|
||||
profile_image,
|
||||
selected_kind,
|
||||
selected_id
|
||||
FROM connections AS c
|
||||
WHERE flow_id IN (SELECT value FROM json_each(?))
|
||||
AND expires_at >= ?
|
||||
ORDER BY user_id ASC, selected_at DESC
|
||||
ORDER BY flow_id ASC, user_id ASC, selected_at DESC, connection_id ASC
|
||||
""",
|
||||
(flow_id_key, now),
|
||||
(flow_ids_json, now),
|
||||
).fetchall()
|
||||
|
||||
users: dict[UUID, CollaborationPresenceConnectionUser] = {}
|
||||
best_selection: dict[UUID, tuple[float, CollaborationSelectionTarget | None]] = {}
|
||||
|
||||
snapshots = {UUID(flow_id_key): CollaborationPresenceSnapshot(users=[]) for flow_id_key in flow_id_keys}
|
||||
seen_users: set[tuple[UUID, UUID]] = set()
|
||||
for row in rows:
|
||||
user_id = UUID(row[0])
|
||||
username = row[2]
|
||||
profile_image = row[3]
|
||||
selected_kind = row[4]
|
||||
selected_id = row[5]
|
||||
selected_at = row[6]
|
||||
flow_id = UUID(row[0])
|
||||
user_id = UUID(row[1])
|
||||
user_key = (flow_id, user_id)
|
||||
if user_key in seen_users:
|
||||
continue
|
||||
seen_users.add(user_key)
|
||||
|
||||
if user_id not in users:
|
||||
users[user_id] = CollaborationPresenceConnectionUser(
|
||||
selected = self._selection_target(row[4], row[5])
|
||||
snapshots[flow_id].users.append(
|
||||
CollaborationPresenceConnectionUser(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
profile_image=profile_image,
|
||||
username=row[2],
|
||||
profile_image=row[3],
|
||||
selected=selected,
|
||||
)
|
||||
|
||||
if selected_at is not None:
|
||||
selected = self._selection_target(selected_kind, selected_id)
|
||||
current = best_selection.get(user_id)
|
||||
if current is None or selected_at > current[0]:
|
||||
best_selection[user_id] = (selected_at, selected)
|
||||
|
||||
for user_id, (_, selected) in best_selection.items():
|
||||
user = users[user_id]
|
||||
users[user_id] = CollaborationPresenceConnectionUser(
|
||||
user_id=user.user_id,
|
||||
username=user.username,
|
||||
profile_image=user.profile_image,
|
||||
selected=selected,
|
||||
)
|
||||
|
||||
return users
|
||||
return snapshots
|
||||
|
||||
def _effective_user_locked(
|
||||
self,
|
||||
|
||||
@ -99,11 +99,10 @@ def _receive_message_type(ws, expected_type: str) -> dict:
|
||||
return message
|
||||
|
||||
|
||||
def _receive_session_bootstrap(ws) -> tuple[dict, dict, dict]:
|
||||
def _receive_session_bootstrap(ws) -> tuple[dict, dict]:
|
||||
ready = _receive_message_type(ws, "session.ready")
|
||||
presence = _receive_message_type(ws, "presence.snapshot")
|
||||
selection = _receive_message_type(ws, "selection.snapshot")
|
||||
return ready, presence, selection
|
||||
return ready, presence
|
||||
|
||||
|
||||
def _close_websocket_cleanly(ws) -> None:
|
||||
@ -262,7 +261,7 @@ async def test_collab_session_ready(client: AsyncClient, logged_in_headers):
|
||||
|
||||
def _assert(ws) -> None:
|
||||
ws.send_json({"type": "session.start"})
|
||||
ready, presence, selection = _receive_session_bootstrap(ws)
|
||||
ready, presence = _receive_session_bootstrap(ws)
|
||||
assert ready["type"] == "session.ready"
|
||||
assert ready["current_revision"] == 0
|
||||
assert ready["flow_id"] == str(flow_id)
|
||||
@ -270,8 +269,6 @@ async def test_collab_session_ready(client: AsyncClient, logged_in_headers):
|
||||
assert presence["type"] == "presence.snapshot"
|
||||
assert len(presence["users"]) == 1
|
||||
assert presence["users"][0]["username"] == "activeuser"
|
||||
assert selection["type"] == "selection.snapshot"
|
||||
assert selection["selections"] == []
|
||||
|
||||
await _run_websocket_test(app, flow_id, token, _assert)
|
||||
|
||||
@ -283,7 +280,7 @@ async def test_operation_submit_accepted_increments_revision(client: AsyncClient
|
||||
|
||||
def _submit(ws) -> None:
|
||||
ws.send_json({"type": "session.start"})
|
||||
ready, _, _ = _receive_session_bootstrap(ws)
|
||||
ready, _ = _receive_session_bootstrap(ws)
|
||||
updated = copy.deepcopy(NODE_A)
|
||||
updated["position"] = {"x": 50, "y": 50}
|
||||
ws.send_json(
|
||||
@ -362,7 +359,7 @@ async def test_stale_revision_rejected(client: AsyncClient, logged_in_headers):
|
||||
|
||||
def _submit(ws) -> None:
|
||||
ws.send_json({"type": "session.start"})
|
||||
ready, _, _ = _receive_session_bootstrap(ws)
|
||||
ready, _ = _receive_session_bootstrap(ws)
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "operation.submit",
|
||||
@ -425,7 +422,7 @@ async def test_invalid_edge_rejected_without_revision_change(client: AsyncClient
|
||||
|
||||
def _submit(ws) -> None:
|
||||
ws.send_json({"type": "session.start"})
|
||||
ready, _, _ = _receive_session_bootstrap(ws)
|
||||
ready, _ = _receive_session_bootstrap(ws)
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "operation.submit",
|
||||
@ -458,7 +455,7 @@ async def test_operation_broadcast_to_peer_socket(client: AsyncClient, logged_in
|
||||
|
||||
def _peers(ws_a, ws_b) -> None:
|
||||
ws_a.send_json({"type": "session.start"})
|
||||
ready_a, _, _ = _receive_session_bootstrap(ws_a)
|
||||
ready_a, _ = _receive_session_bootstrap(ws_a)
|
||||
ws_b.send_json({"type": "session.start"})
|
||||
_receive_session_bootstrap(ws_b)
|
||||
|
||||
@ -490,12 +487,12 @@ async def test_presence_shows_each_user_once_with_two_tabs(client: AsyncClient,
|
||||
|
||||
def _peers(ws_a, ws_b) -> None:
|
||||
ws_a.send_json({"type": "session.start"})
|
||||
_, presence_a, _ = _receive_session_bootstrap(ws_a)
|
||||
_, presence_a = _receive_session_bootstrap(ws_a)
|
||||
assert len(presence_a["users"]) == 1
|
||||
assert presence_a["users"][0]["username"] == "activeuser"
|
||||
|
||||
ws_b.send_json({"type": "session.start"})
|
||||
_, presence_b, _ = _receive_session_bootstrap(ws_b)
|
||||
_, presence_b = _receive_session_bootstrap(ws_b)
|
||||
assert len(presence_b["users"]) == 1
|
||||
|
||||
await _run_dual_websocket_test(app, flow_id, token, _peers)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@ -10,6 +10,7 @@ from langflow.api.v1 import collaboration_manager as collaboration_manager_modul
|
||||
from langflow.api.v1.collaboration_manager import CollaborationManager
|
||||
from langflow.services.collaboration_events.schemas import (
|
||||
CollaborationEvent,
|
||||
CollaborationPresenceChange,
|
||||
CollaborationPresenceConnectionUser,
|
||||
CollaborationPresenceSnapshot,
|
||||
)
|
||||
@ -166,7 +167,7 @@ async def test_handle_backplane_event_ignores_unknown_type(manager, flow_id, use
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_presence_snapshot_and_selection_snapshot_messages(manager, user_a):
|
||||
async def test_presence_snapshot_includes_selection(manager, user_a):
|
||||
snapshot = _snapshot(
|
||||
CollaborationPresenceConnectionUser(
|
||||
user_id=user_a,
|
||||
@ -177,12 +178,36 @@ async def test_presence_snapshot_and_selection_snapshot_messages(manager, user_a
|
||||
)
|
||||
|
||||
presence = manager.presence_snapshot_message(snapshot)
|
||||
selection = manager.selection_snapshot_message(snapshot)
|
||||
|
||||
assert presence["type"] == "presence.snapshot"
|
||||
assert len(presence["users"]) == 1
|
||||
assert selection["type"] == "selection.snapshot"
|
||||
assert selection["selections"] == [{"user_id": str(user_a), "selected": {"kind": "node", "id": "node-1"}}]
|
||||
assert presence["users"] == [
|
||||
{
|
||||
"user_id": str(user_a),
|
||||
"username": "alice",
|
||||
"profile_image": None,
|
||||
"selected": {"kind": "node", "id": "node-1"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_presence_change_broadcasts_and_publishes_left(manager, flow_id, user_a, user_b):
|
||||
await _register(manager, flow_id, user_a, "alice")
|
||||
peer_id = await _register(manager, flow_id, user_b, "bob")
|
||||
peer_ws = manager._rooms[flow_id][peer_id].websocket
|
||||
event_service = Mock()
|
||||
|
||||
await manager.emit_presence_change(flow_id, CollaborationPresenceChange(left_user_id=user_a), event_service)
|
||||
|
||||
peer_ws.send_json.assert_called_once()
|
||||
payload = peer_ws.send_json.call_args.args[0]
|
||||
assert payload["type"] == "presence.left"
|
||||
assert payload["user_id"] == str(user_a)
|
||||
event_service.publish.assert_called_once_with(
|
||||
flow_id,
|
||||
"presence.left",
|
||||
{"worker_id": collaboration_manager_module.WORKER_ID, "user_id": str(user_a)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -180,6 +180,11 @@ def test_presence_change_fields_are_mutually_exclusive():
|
||||
CollaborationPresenceChange(left_user_id=user_id, selection_updated=selection)
|
||||
|
||||
|
||||
def test_list_users_requires_flow_ids(svc: SQLiteCollaborationEventService):
|
||||
with pytest.raises(ValueError, match="flow_ids"):
|
||||
svc.list_users([])
|
||||
|
||||
|
||||
def test_add_update_remove_and_list_presence(svc: SQLiteCollaborationEventService, flow_id: UUID):
|
||||
user_id = uuid4()
|
||||
conn_a = "conn-a"
|
||||
@ -196,7 +201,7 @@ def test_add_update_remove_and_list_presence(svc: SQLiteCollaborationEventServic
|
||||
assert joined.joined is not None
|
||||
assert joined.joined.username == "alice"
|
||||
|
||||
snapshot = svc.list_users(flow_id)
|
||||
snapshot = svc.list_users([flow_id])[flow_id]
|
||||
assert len(snapshot.users) == 1
|
||||
|
||||
second = svc.add_connection(
|
||||
@ -207,7 +212,7 @@ def test_add_update_remove_and_list_presence(svc: SQLiteCollaborationEventServic
|
||||
profile_image=None,
|
||||
)
|
||||
assert second is None
|
||||
assert len(svc.list_users(flow_id).users) == 1
|
||||
assert len(svc.list_users([flow_id])[flow_id].users) == 1
|
||||
|
||||
change = svc.update_connection(
|
||||
flow_id=flow_id,
|
||||
@ -218,20 +223,20 @@ def test_add_update_remove_and_list_presence(svc: SQLiteCollaborationEventServic
|
||||
assert change.selection_updated is not None
|
||||
assert change.selection_updated.selected == CollaborationSelectionTarget(kind="node", id="node-1")
|
||||
|
||||
snapshot = svc.list_users(flow_id)
|
||||
snapshot = svc.list_users([flow_id])[flow_id]
|
||||
assert snapshot.users[0].selected == CollaborationSelectionTarget(kind="node", id="node-1")
|
||||
|
||||
left = svc.remove_connection(flow_id=flow_id, connection_id=conn_a)
|
||||
assert left is not None
|
||||
assert left.left_user_id is None
|
||||
assert left.selection_updated == CollaborationUserSelection(user_id=user_id, selected=None)
|
||||
assert len(svc.list_users(flow_id).users) == 1
|
||||
assert len(svc.list_users([flow_id])[flow_id].users) == 1
|
||||
|
||||
final = svc.remove_connection(flow_id=flow_id, connection_id=conn_b)
|
||||
assert final is not None
|
||||
assert final.left_user_id == user_id
|
||||
assert final.selection_updated is None
|
||||
assert svc.list_users(flow_id).users == []
|
||||
assert svc.list_users([flow_id])[flow_id].users == []
|
||||
|
||||
|
||||
def test_presence_ttl_cleanup(svc: SQLiteCollaborationEventService, flow_id: UUID):
|
||||
@ -244,12 +249,84 @@ def test_presence_ttl_cleanup(svc: SQLiteCollaborationEventService, flow_id: UUI
|
||||
username="alice",
|
||||
profile_image=None,
|
||||
)
|
||||
assert len(svc.list_users(flow_id).users) == 1
|
||||
assert len(svc.list_users([flow_id])[flow_id].users) == 1
|
||||
|
||||
time.sleep(0.15)
|
||||
svc.cleanup()
|
||||
|
||||
assert svc.list_users(flow_id).users == []
|
||||
assert svc.list_users([flow_id])[flow_id].users == []
|
||||
|
||||
|
||||
def test_list_users_removes_expired_connections(svc: SQLiteCollaborationEventService, flow_id: UUID):
|
||||
svc.PRESENCE_TTL_SECONDS = 0.1
|
||||
user_id = uuid4()
|
||||
svc.add_connection(
|
||||
flow_id=flow_id,
|
||||
user_id=user_id,
|
||||
connection_id="conn-1",
|
||||
username="alice",
|
||||
profile_image=None,
|
||||
)
|
||||
|
||||
time.sleep(0.15)
|
||||
|
||||
snapshots = svc.list_users([flow_id])
|
||||
assert snapshots[flow_id].users == []
|
||||
assert svc.list_users([flow_id])[flow_id].users == []
|
||||
|
||||
|
||||
def test_poll_does_not_consume_expired_presence_before_list_users(
|
||||
svc: SQLiteCollaborationEventService,
|
||||
flow_id: UUID,
|
||||
):
|
||||
svc.PRESENCE_TTL_SECONDS = 0.1
|
||||
user_id = uuid4()
|
||||
svc.add_connection(
|
||||
flow_id=flow_id,
|
||||
user_id=user_id,
|
||||
connection_id="conn-1",
|
||||
username="alice",
|
||||
profile_image=None,
|
||||
)
|
||||
|
||||
time.sleep(0.15)
|
||||
svc.publish(flow_id, "operation.accepted", {"revision": 1})
|
||||
svc.poll(flow_id)
|
||||
|
||||
snapshots = svc.list_users([flow_id])
|
||||
assert snapshots[flow_id].users == []
|
||||
|
||||
|
||||
def test_list_users_batches_flow_snapshots(
|
||||
svc: SQLiteCollaborationEventService,
|
||||
flow_id: UUID,
|
||||
other_flow_id: UUID,
|
||||
):
|
||||
svc.PRESENCE_TTL_SECONDS = 0.1
|
||||
expired_user_id = uuid4()
|
||||
active_user_id = uuid4()
|
||||
svc.add_connection(
|
||||
flow_id=flow_id,
|
||||
user_id=expired_user_id,
|
||||
connection_id="expired-conn",
|
||||
username="expired",
|
||||
profile_image=None,
|
||||
)
|
||||
svc.PRESENCE_TTL_SECONDS = 30.0
|
||||
svc.add_connection(
|
||||
flow_id=other_flow_id,
|
||||
user_id=active_user_id,
|
||||
connection_id="active-conn",
|
||||
username="active",
|
||||
profile_image=None,
|
||||
)
|
||||
|
||||
time.sleep(0.15)
|
||||
|
||||
snapshots = svc.list_users([flow_id, other_flow_id])
|
||||
assert snapshots[flow_id].users == []
|
||||
assert len(snapshots[other_flow_id].users) == 1
|
||||
assert snapshots[other_flow_id].users[0].user_id == active_user_id
|
||||
|
||||
|
||||
def test_effective_selection_uses_latest_connection(svc: SQLiteCollaborationEventService, flow_id: UUID):
|
||||
@ -280,10 +357,37 @@ def test_effective_selection_uses_latest_connection(svc: SQLiteCollaborationEven
|
||||
selected=CollaborationSelectionTarget(kind="edge", id="new"),
|
||||
)
|
||||
|
||||
snapshot = svc.list_users(flow_id)
|
||||
snapshot = svc.list_users([flow_id])[flow_id]
|
||||
assert snapshot.users[0].selected == CollaborationSelectionTarget(kind="edge", id="new")
|
||||
|
||||
|
||||
def test_initial_connection_counts_as_currently_unselected(svc: SQLiteCollaborationEventService, flow_id: UUID):
|
||||
user_id = uuid4()
|
||||
svc.add_connection(
|
||||
flow_id=flow_id,
|
||||
user_id=user_id,
|
||||
connection_id="conn-a",
|
||||
username="alice",
|
||||
profile_image=None,
|
||||
)
|
||||
svc.update_connection(
|
||||
flow_id=flow_id,
|
||||
connection_id="conn-a",
|
||||
selected=CollaborationSelectionTarget(kind="node", id="old"),
|
||||
)
|
||||
time.sleep(0.01)
|
||||
svc.add_connection(
|
||||
flow_id=flow_id,
|
||||
user_id=user_id,
|
||||
connection_id="conn-b",
|
||||
username="alice",
|
||||
profile_image=None,
|
||||
)
|
||||
|
||||
snapshot = svc.list_users([flow_id])[flow_id]
|
||||
assert snapshot.users[0].selected is None
|
||||
|
||||
|
||||
def test_cross_worker_presence_visibility(tmp_path, flow_id: UUID):
|
||||
shared = tmp_path / "shared"
|
||||
user_id = uuid4()
|
||||
@ -299,6 +403,6 @@ def test_cross_worker_presence_visibility(tmp_path, flow_id: UUID):
|
||||
profile_image=None,
|
||||
)
|
||||
|
||||
snapshot = worker_b.list_users(flow_id)
|
||||
snapshot = worker_b.list_users([flow_id])[flow_id]
|
||||
assert len(snapshot.users) == 1
|
||||
assert snapshot.users[0].username == "bob"
|
||||
|
||||
@ -2,8 +2,8 @@ import {
|
||||
applyPresenceJoined,
|
||||
applyPresenceLeft,
|
||||
applyPresenceSnapshot,
|
||||
applySelectionSnapshot,
|
||||
applySelectionUpdated,
|
||||
selectionsFromPresenceSnapshot,
|
||||
} from "@/hooks/flows/flow-collaboration-state";
|
||||
|
||||
describe("flow-collaboration-state", () => {
|
||||
@ -11,7 +11,13 @@ describe("flow-collaboration-state", () => {
|
||||
expect(
|
||||
applyPresenceSnapshot(
|
||||
[{ user_id: "old", username: "old-user" }],
|
||||
[{ user_id: "new", username: "new-user" }],
|
||||
[
|
||||
{
|
||||
user_id: "new",
|
||||
username: "new-user",
|
||||
selected: { kind: "node", id: "n1" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([{ user_id: "new", username: "new-user" }]);
|
||||
});
|
||||
@ -51,12 +57,16 @@ describe("flow-collaboration-state", () => {
|
||||
).toEqual([{ user_id: "user-2", username: "bob" }]);
|
||||
});
|
||||
|
||||
it("should replace selections on selection.snapshot", () => {
|
||||
it("should derive selections from presence.snapshot users", () => {
|
||||
expect(
|
||||
applySelectionSnapshot(
|
||||
[{ user_id: "user-1", selected: { kind: "node", id: "n1" } }],
|
||||
[{ user_id: "user-2", selected: { kind: "edge", id: "e1" } }],
|
||||
),
|
||||
selectionsFromPresenceSnapshot([
|
||||
{ user_id: "user-1", username: "ana", selected: null },
|
||||
{
|
||||
user_id: "user-2",
|
||||
username: "bob",
|
||||
selected: { kind: "edge", id: "e1" },
|
||||
},
|
||||
]),
|
||||
).toEqual([{ user_id: "user-2", selected: { kind: "edge", id: "e1" } }]);
|
||||
});
|
||||
|
||||
|
||||
@ -140,13 +140,10 @@ async function connectSession(currentRevision = 0) {
|
||||
user_id: "user-1",
|
||||
username: "ana",
|
||||
profile_image: "Space/046-rocket.svg",
|
||||
selected: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
socket.triggerMessage({
|
||||
type: "selection.snapshot",
|
||||
selections: [],
|
||||
});
|
||||
});
|
||||
|
||||
return socket;
|
||||
@ -371,6 +368,26 @@ describe("useFlowCollaboration", () => {
|
||||
const { result } = await mountHook({ flowId: "flow-1" });
|
||||
await connectSession(0);
|
||||
|
||||
await act(async () => {
|
||||
latestSocket().triggerMessage({
|
||||
type: "presence.snapshot",
|
||||
users: [
|
||||
{
|
||||
user_id: "user-1",
|
||||
username: "ana",
|
||||
selected: { kind: "edge", id: "edge-1" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([
|
||||
{ user_id: "user-1", username: "ana" },
|
||||
]);
|
||||
expect(result.current.selections).toEqual([
|
||||
{ user_id: "user-1", selected: { kind: "edge", id: "edge-1" } },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
latestSocket().triggerMessage({
|
||||
type: "selection.updated",
|
||||
@ -380,6 +397,7 @@ describe("useFlowCollaboration", () => {
|
||||
});
|
||||
|
||||
expect(result.current.selections).toEqual([
|
||||
{ user_id: "user-1", selected: { kind: "edge", id: "edge-1" } },
|
||||
{ user_id: "user-2", selected: { kind: "node", id: "node-1" } },
|
||||
]);
|
||||
|
||||
@ -391,7 +409,9 @@ describe("useFlowCollaboration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.selections).toEqual([]);
|
||||
expect(result.current.selections).toEqual([
|
||||
{ user_id: "user-1", selected: { kind: "edge", id: "edge-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should send selection.update when sendSelectionUpdate is called", async () => {
|
||||
|
||||
@ -8,22 +8,23 @@ export function applyPresenceSnapshot(
|
||||
_currentUsers: CollaborationPresenceUser[],
|
||||
users: CollaborationPresenceUser[],
|
||||
): CollaborationPresenceUser[] {
|
||||
return users.map((user) => ({ ...user }));
|
||||
return users.map(({ selected: _selected, ...user }) => ({ ...user }));
|
||||
}
|
||||
|
||||
export function applyPresenceJoined(
|
||||
currentUsers: CollaborationPresenceUser[],
|
||||
user: CollaborationPresenceUser,
|
||||
): CollaborationPresenceUser[] {
|
||||
const { selected: _selected, ...presenceUser } = user;
|
||||
const existingIndex = currentUsers.findIndex(
|
||||
(entry) => entry.user_id === user.user_id,
|
||||
(entry) => entry.user_id === presenceUser.user_id,
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
return [...currentUsers, { ...user }];
|
||||
return [...currentUsers, { ...presenceUser }];
|
||||
}
|
||||
|
||||
const nextUsers = [...currentUsers];
|
||||
nextUsers[existingIndex] = { ...user };
|
||||
nextUsers[existingIndex] = { ...presenceUser };
|
||||
return nextUsers;
|
||||
}
|
||||
|
||||
@ -34,14 +35,15 @@ export function applyPresenceLeft(
|
||||
return currentUsers.filter((user) => user.user_id !== userId);
|
||||
}
|
||||
|
||||
export function applySelectionSnapshot(
|
||||
_currentSelections: CollaborationUserSelection[],
|
||||
selections: CollaborationUserSelection[],
|
||||
export function selectionsFromPresenceSnapshot(
|
||||
users: CollaborationPresenceUser[],
|
||||
): CollaborationUserSelection[] {
|
||||
return selections.map((selection) => ({
|
||||
user_id: selection.user_id,
|
||||
selected: selection.selected ? { ...selection.selected } : null,
|
||||
}));
|
||||
return users
|
||||
.filter((user) => user.selected != null)
|
||||
.map((user) => ({
|
||||
user_id: user.user_id,
|
||||
selected: user.selected ? { ...user.selected } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applySelectionUpdated(
|
||||
|
||||
@ -3,8 +3,8 @@ import {
|
||||
applyPresenceJoined,
|
||||
applyPresenceLeft,
|
||||
applyPresenceSnapshot,
|
||||
applySelectionSnapshot,
|
||||
applySelectionUpdated,
|
||||
selectionsFromPresenceSnapshot,
|
||||
} from "@/hooks/flows/flow-collaboration-state";
|
||||
import { buildFlowCollaborationWebSocketUrl } from "@/hooks/flows/flow-collaboration-url";
|
||||
import type {
|
||||
@ -203,6 +203,7 @@ export function useFlowCollaboration({
|
||||
setUsers((currentUsers) =>
|
||||
applyPresenceSnapshot(currentUsers, message.users),
|
||||
);
|
||||
setSelections(selectionsFromPresenceSnapshot(message.users));
|
||||
return;
|
||||
}
|
||||
case "presence.joined": {
|
||||
@ -222,12 +223,6 @@ export function useFlowCollaboration({
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "selection.snapshot": {
|
||||
setSelections((currentSelections) =>
|
||||
applySelectionSnapshot(currentSelections, message.selections),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "selection.updated": {
|
||||
setSelections((currentSelections) =>
|
||||
applySelectionUpdated(
|
||||
|
||||
@ -8,6 +8,7 @@ export type CollaborationPresenceUser = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
profile_image?: string | null;
|
||||
selected?: CollaborationSelectionTarget | null;
|
||||
};
|
||||
|
||||
export type CollaborationSessionStartMessage = {
|
||||
@ -86,11 +87,6 @@ export type CollaborationSelectionUpdateMessage = {
|
||||
selected: CollaborationSelectionTarget | null;
|
||||
};
|
||||
|
||||
export type CollaborationSelectionSnapshotMessage = {
|
||||
type: "selection.snapshot";
|
||||
selections: CollaborationUserSelection[];
|
||||
};
|
||||
|
||||
export type CollaborationSelectionUpdatedMessage = {
|
||||
type: "selection.updated";
|
||||
user_id: string;
|
||||
@ -111,7 +107,6 @@ export type CollaborationServerMessage =
|
||||
| CollaborationPresenceSnapshotMessage
|
||||
| CollaborationPresenceJoinedMessage
|
||||
| CollaborationPresenceLeftMessage
|
||||
| CollaborationSelectionSnapshotMessage
|
||||
| CollaborationSelectionUpdatedMessage
|
||||
| CollaborationMessageErrorMessage;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user