import asyncio import base64 from datetime import datetime import structlog from fastapi import WebSocket, WebSocketDisconnect from pydantic import ValidationError from websockets.exceptions import ConnectionClosedOK from skyvern.forge import app from skyvern.forge.sdk.routes.routers import legacy_base_router from skyvern.forge.sdk.schemas.tasks import TaskStatus from skyvern.forge.sdk.services.org_auth_service import get_current_org from skyvern.forge.sdk.workflow.models.workflow import WorkflowRunStatus LOG = structlog.get_logger() STREAMING_TIMEOUT = 300 @legacy_base_router.websocket("/stream/tasks/{task_id}") async def task_stream( websocket: WebSocket, task_id: str, apikey: str | None = None, token: str | None = None, ) -> None: try: await websocket.accept() if not token and not apikey: await websocket.send_text("No valid credential provided") return except ConnectionClosedOK: LOG.info("ConnectionClosedOK error. Streaming won't start") return try: organization = await get_current_org(x_api_key=apikey, authorization=token) organization_id = organization.organization_id except Exception: LOG.exception("Error while getting organization", task_id=task_id) try: await websocket.send_text("Invalid credential provided") except ConnectionClosedOK: LOG.info("ConnectionClosedOK error while sending invalid credential message") return LOG.info("Started task streaming", task_id=task_id, organization_id=organization_id) # timestamp last time when streaming activity happens last_activity_timestamp = datetime.utcnow() try: while True: # if no activity for 5 minutes, close the connection if (datetime.utcnow() - last_activity_timestamp).total_seconds() > STREAMING_TIMEOUT: LOG.info( "No activity for 5 minutes. Closing connection", task_id=task_id, organization_id=organization_id ) await websocket.send_json( { "task_id": task_id, "status": "timeout", } ) return task = await app.DATABASE.get_task(task_id=task_id, organization_id=organization_id) if not task: LOG.info("Task not found. Closing connection", task_id=task_id, organization_id=organization_id) await websocket.send_json( { "task_id": task_id, "status": "not_found", } ) return if task.status.is_final(): LOG.info( "Task is in a final state. Closing connection", task_status=task.status, task_id=task_id, organization_id=organization_id, ) await websocket.send_json( { "task_id": task_id, "status": task.status, } ) return if task.status == TaskStatus.running: file_name = f"{task_id}.png" if task.workflow_run_id: file_name = f"{task.workflow_run_id}.png" screenshot = await app.STORAGE.get_streaming_file(organization_id, file_name) if screenshot: encoded_screenshot = base64.b64encode(screenshot).decode("utf-8") await websocket.send_json( { "task_id": task_id, "status": task.status, "screenshot": encoded_screenshot, } ) last_activity_timestamp = datetime.utcnow() await asyncio.sleep(2) except ValidationError as e: await websocket.send_text(f"Invalid data: {e}") except WebSocketDisconnect: LOG.info("WebSocket connection closed", task_id=task_id, organization_id=organization_id) except ConnectionClosedOK: LOG.info("ConnectionClosedOK error while streaming", task_id=task_id, organization_id=organization_id) return except Exception: LOG.warning("Error while streaming", task_id=task_id, organization_id=organization_id, exc_info=True) return LOG.info("WebSocket connection closed successfully", task_id=task_id, organization_id=organization_id) return @legacy_base_router.websocket("/stream/workflow_runs/{workflow_run_id}") async def workflow_run_streaming( websocket: WebSocket, workflow_run_id: str, apikey: str | None = None, token: str | None = None, ) -> None: try: await websocket.accept() if not token and not apikey: await websocket.send_text("No valid credential provided") return except ConnectionClosedOK: LOG.info("WofklowRun Streaming: ConnectionClosedOK error. Streaming won't start") return try: organization = await get_current_org(x_api_key=apikey, authorization=token) organization_id = organization.organization_id except Exception: LOG.exception("WofklowRun Streaming: Error while getting organization", workflow_run_id=workflow_run_id) try: await websocket.send_text("Invalid credential provided") except ConnectionClosedOK: LOG.info("WofklowRun Streaming: ConnectionClosedOK error while sending invalid credential message") return LOG.info( "WofklowRun Streaming: Started workflow run streaming", workflow_run_id=workflow_run_id, organization_id=organization_id, ) # timestamp last time when streaming activity happens last_activity_timestamp = datetime.utcnow() try: while True: # if no activity for 5 minutes, close the connection if (datetime.utcnow() - last_activity_timestamp).total_seconds() > STREAMING_TIMEOUT: LOG.info( "WofklowRun Streaming: No activity for 5 minutes. Closing connection", workflow_run_id=workflow_run_id, organization_id=organization_id, ) await websocket.send_json( { "workflow_run_id": workflow_run_id, "status": "timeout", } ) return workflow_run = await app.DATABASE.get_workflow_run( workflow_run_id=workflow_run_id, organization_id=organization_id, ) if not workflow_run or workflow_run.organization_id != organization_id: LOG.info( "WofklowRun Streaming: Workflow not found", workflow_run_id=workflow_run_id, organization_id=organization_id, ) await websocket.send_json( { "workflow_run_id": workflow_run_id, "status": "not_found", } ) return if workflow_run.status in [ WorkflowRunStatus.completed, WorkflowRunStatus.failed, WorkflowRunStatus.terminated, ]: LOG.info( "Workflow run is in a final state. Closing connection", workflow_run_status=workflow_run.status, workflow_run_id=workflow_run_id, organization_id=organization_id, ) await websocket.send_json( { "workflow_run_id": workflow_run_id, "status": workflow_run.status, } ) return if workflow_run.status == WorkflowRunStatus.running: file_name = f"{workflow_run_id}.png" screenshot = await app.STORAGE.get_streaming_file(organization_id, file_name) if screenshot: encoded_screenshot = base64.b64encode(screenshot).decode("utf-8") await websocket.send_json( { "workflow_run_id": workflow_run_id, "status": workflow_run.status, "screenshot": encoded_screenshot, } ) last_activity_timestamp = datetime.utcnow() await asyncio.sleep(2) except ValidationError as e: await websocket.send_text(f"Invalid data: {e}") except WebSocketDisconnect: LOG.info( "WofklowRun Streaming: WebSocket connection closed", workflow_run_id=workflow_run_id, organization_id=organization_id, ) except ConnectionClosedOK: LOG.info( "WofklowRun Streaming: ConnectionClosedOK error while streaming", workflow_run_id=workflow_run_id, organization_id=organization_id, ) return except Exception: LOG.warning( "WofklowRun Streaming: Error while streaming", workflow_run_id=workflow_run_id, organization_id=organization_id, exc_info=True, ) return LOG.info( "WofklowRun Streaming: WebSocket connection closed successfully", workflow_run_id=workflow_run_id, organization_id=organization_id, ) return