expose block runs for debug sessions (#3740)

This commit is contained in:
Jonathan Dobson
2025-10-16 08:24:05 -04:00
committed by GitHub
parent 881396389e
commit 427e674299
10 changed files with 610 additions and 273 deletions

View File

@@ -37,26 +37,7 @@ import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/
import { type BrowserSession } from "@/routes/workflows/types/browserSessionTypes"; import { type BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
import { CopyText } from "@/routes/workflows/editor/Workspace"; import { CopyText } from "@/routes/workflows/editor/Workspace";
import { basicTimeFormat } from "@/util/timeFormat"; import { basicTimeFormat } from "@/util/timeFormat";
import { cn, formatMs } from "@/util/utils"; import { cn, formatMs, toDate } from "@/util/utils";
function toDate(
time: string,
defaultDate: Date | null = new Date(0),
): Date | null {
time = time.replace(/\.(\d{3})\d*/, ".$1");
if (!time.endsWith("Z")) {
time += "Z";
}
const date = new Date(time);
if (isNaN(date.getTime())) {
return defaultDate;
}
return date;
}
function sessionIsOpen(browserSession: BrowserSession): boolean { function sessionIsOpen(browserSession: BrowserSession): boolean {
return ( return (

View File

@@ -0,0 +1,128 @@
/**
* THe debugger has an underlying debug_session_id. Block runs that occur within
* same debug session are grouped together. We will show them with this component.
*/
import { useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Tip } from "@/components/Tip";
import { statusIsFinalized } from "@/routes/tasks/types";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { cn, formatMs, toDate } from "@/util/utils";
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import {
useDebugSessionRunsQuery,
type DebugSessionRun,
} from "../hooks/useDebugSessionRunsQuery";
import { toast } from "@/components/ui/use-toast";
function DebuggerBlockRuns() {
const { workflowPermanentId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { data: workflowRun } = useWorkflowRunQuery();
const { data: workflow } = useWorkflowQuery({
workflowPermanentId,
});
const { data: debugSession } = useDebugSessionQuery({
workflowPermanentId,
});
const { data: debugSessionRuns } = useDebugSessionRunsQuery({
debugSessionId: debugSession?.debug_session_id,
});
const numRuns = debugSessionRuns?.runs.length ?? 0;
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
const isRunning = isFinalized !== null && !isFinalized;
const handleClick = (run: DebugSessionRun) => {
if (isRunning) {
return;
}
const blockLabel = run.block_label;
const workflowDefinition = workflow?.workflow_definition;
const blocks = workflowDefinition?.blocks ?? [];
const block = blocks.find((b) => b.label === blockLabel);
if (!block) {
toast({
variant: "destructive",
title: "Block not found",
description: `The block with label '${blockLabel}' is no longer found in the workflow.`,
});
return;
}
navigate(
`/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/${blockLabel}/debug`,
);
};
useEffect(() => {
queryClient.invalidateQueries({
queryKey: ["debug-session-runs"],
});
// We only want to run this when the workflowRun changes, not on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowRun]);
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [debugSessionRuns]);
if (numRuns <= 1) {
return null;
}
return (
<div className="relative flex w-full items-center justify-center gap-2 opacity-80 hover:opacity-100">
<div
ref={scrollContainerRef}
className="flex max-w-[7rem] gap-2 overflow-x-auto rounded-full bg-[#020617] p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...(debugSessionRuns?.runs ?? [])].reverse().map((run) => {
const dt = toDate(run.created_at ?? "", null);
const ago = dt ? formatMs(Date.now() - dt.getTime()).ago : null;
return (
<Tip
key={run.workflow_run_id}
content={
ago
? `${run.block_label} [${run.status}] (${ago})`
: `${run.block_label} [${run.status}]`
}
>
<div
className={cn(
"h-[1rem] w-[1rem] flex-shrink-0 rounded-full border border-white/50 hover:border-white/80",
{
"cursor-pointer": !isRunning,
},
{
"animate-spin border-dashed [animation-duration:_2s]":
run.status === "running" && isRunning,
"border-[red] opacity-50 hover:border-[red] hover:opacity-100":
run.status === "failed",
},
)}
onClick={() => handleClick(run)}
/>
</Tip>
);
})}
</div>
</div>
);
}
export { DebuggerBlockRuns };

View File

@@ -62,7 +62,7 @@ import {
useWorkflowSave, useWorkflowSave,
} from "@/store/WorkflowHasChangesStore"; } from "@/store/WorkflowHasChangesStore";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils"; import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer"; import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
@@ -1245,7 +1245,7 @@ function Workspace({
split={{ left: workflowWidth }} split={{ left: workflowWidth }}
onResize={() => setContainerResizeTrigger((prev) => prev + 1)} onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
> >
{/* code and infinite canvas */} {/* code, infinite canvas, and block runs */}
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<div <div
className={cn( className={cn(
@@ -1305,6 +1305,10 @@ function Workspace({
/> />
</div> </div>
</div> </div>
{/* block runs history for current debug session id*/}
<div className="absolute bottom-[0.5rem] left-[0.75rem] flex w-full items-start justify-center">
<DebuggerBlockRuns />
</div>
</div> </div>
<div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6"> <div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6">

View File

@@ -0,0 +1,70 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
type Props = {
debugSessionId?: string;
};
const debugSessionStatuses = ["created", "completed"] as const;
type DebugSessionStatus = (typeof debugSessionStatuses)[number];
interface DebugSession {
debug_session_id: string;
browser_session_id: string;
vnc_streaming_supported: boolean | null;
workflow_permanent_id: string | null;
created_at: string;
modified_at: string;
deleted_at: string | null;
status: DebugSessionStatus;
}
interface DebugSessionRun {
ai_fallback: boolean | null;
block_label: string;
browser_session_id: string;
code_gen: boolean | null;
debug_session_id: string;
failure_reason: string | null;
output_parameter_id: string;
run_with: string | null;
script_run_id: string | null;
status: string;
workflow_id: string;
workflow_permanent_id: string;
workflow_run_id: string;
created_at: string;
queued_at: string | null;
started_at: string | null;
finished_at: string | null;
}
interface DebugSessionRuns {
debug_session: DebugSession;
runs: DebugSessionRun[];
}
function useDebugSessionRunsQuery({ debugSessionId }: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<DebugSessionRuns>({
queryKey: ["debug-session-runs", debugSessionId],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
const result = await client
.get(`/debug-session/${debugSessionId}/runs`)
.then((response) => response.data);
return result;
},
enabled: !!debugSessionId,
});
}
export {
useDebugSessionRunsQuery,
type DebugSession,
type DebugSessionRun,
type DebugSessionStatus,
};

View File

@@ -36,3 +36,22 @@ export const formatMs = (elapsed: number) => {
day: days, day: days,
}; };
}; };
export function toDate(
time: string,
defaultDate: Date | null = new Date(0),
): Date | null {
time = time.replace(/\.(\d{3})\d*/, ".$1");
if (!time.endsWith("Z")) {
time += "Z";
}
const date = new Date(time);
if (isNaN(date.getTime())) {
return defaultDate;
}
return date;
}

View File

@@ -78,7 +78,7 @@ from skyvern.forge.sdk.log_artifacts import save_workflow_run_logs
from skyvern.forge.sdk.models import Step, StepStatus from skyvern.forge.sdk.models import Step, StepStatus
from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion
from skyvern.forge.sdk.schemas.credentials import Credential, CredentialType, CredentialVaultType from skyvern.forge.sdk.schemas.credentials import Credential, CredentialType, CredentialVaultType
from skyvern.forge.sdk.schemas.debug_sessions import BlockRun, DebugSession from skyvern.forge.sdk.schemas.debug_sessions import BlockRun, DebugSession, DebugSessionRun
from skyvern.forge.sdk.schemas.organization_bitwarden_collections import OrganizationBitwardenCollection from skyvern.forge.sdk.schemas.organization_bitwarden_collections import OrganizationBitwardenCollection
from skyvern.forge.sdk.schemas.organizations import ( from skyvern.forge.sdk.schemas.organizations import (
AzureClientSecretCredential, AzureClientSecretCredential,
@@ -3908,6 +3908,65 @@ class AgentDB:
return DebugSession.model_validate(model) if model else None return DebugSession.model_validate(model) if model else None
async def get_debug_session_by_id(
self,
debug_session_id: str,
organization_id: str,
) -> DebugSession | None:
async with self.Session() as session:
query = (
select(DebugSessionModel)
.filter_by(organization_id=organization_id)
.filter_by(deleted_at=None)
.filter_by(debug_session_id=debug_session_id)
)
model = (await session.scalars(query)).first()
return DebugSession.model_validate(model) if model else None
async def get_workflow_runs_by_debug_session_id(
self,
debug_session_id: str,
organization_id: str,
) -> list[DebugSessionRun]:
async with self.Session() as session:
query = (
select(WorkflowRunModel, BlockRunModel)
.join(BlockRunModel, BlockRunModel.workflow_run_id == WorkflowRunModel.workflow_run_id)
.filter(WorkflowRunModel.organization_id == organization_id)
.filter(WorkflowRunModel.debug_session_id == debug_session_id)
.order_by(WorkflowRunModel.created_at.desc())
)
results = (await session.execute(query)).all()
debug_session_runs = []
for workflow_run, block_run in results:
debug_session_runs.append(
DebugSessionRun(
ai_fallback=workflow_run.ai_fallback,
block_label=block_run.block_label,
browser_session_id=workflow_run.browser_session_id,
code_gen=workflow_run.code_gen,
debug_session_id=workflow_run.debug_session_id,
failure_reason=workflow_run.failure_reason,
output_parameter_id=block_run.output_parameter_id,
run_with=workflow_run.run_with,
script_run_id=workflow_run.script_run.get("script_run_id") if workflow_run.script_run else None,
status=workflow_run.status,
workflow_id=workflow_run.workflow_id,
workflow_permanent_id=workflow_run.workflow_permanent_id,
workflow_run_id=workflow_run.workflow_run_id,
created_at=workflow_run.created_at,
queued_at=workflow_run.queued_at,
started_at=workflow_run.started_at,
finished_at=workflow_run.finished_at,
)
)
return debug_session_runs
async def complete_debug_sessions( async def complete_debug_sessions(
self, self,
*, *,

View File

@@ -1,6 +1,7 @@
from skyvern.forge.sdk.routes import agent_protocol # noqa: F401 from skyvern.forge.sdk.routes import agent_protocol # noqa: F401
from skyvern.forge.sdk.routes import browser_sessions # noqa: F401 from skyvern.forge.sdk.routes import browser_sessions # noqa: F401
from skyvern.forge.sdk.routes import credentials # noqa: F401 from skyvern.forge.sdk.routes import credentials # noqa: F401
from skyvern.forge.sdk.routes import debug_sessions # noqa: F401
from skyvern.forge.sdk.routes import pylon # noqa: F401 from skyvern.forge.sdk.routes import pylon # noqa: F401
from skyvern.forge.sdk.routes import run_blocks # noqa: F401 from skyvern.forge.sdk.routes import run_blocks # noqa: F401
from skyvern.forge.sdk.routes import scripts # noqa: F401 from skyvern.forge.sdk.routes import scripts # noqa: F401

View File

@@ -1,7 +1,4 @@
import asyncio
from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
from functools import partial
from typing import Annotated, Any from typing import Annotated, Any
import structlog import structlog
@@ -12,11 +9,7 @@ from fastapi.responses import ORJSONResponse
from skyvern import analytics from skyvern import analytics
from skyvern._version import __version__ from skyvern._version import __version__
from skyvern.config import settings from skyvern.config import settings
from skyvern.exceptions import ( from skyvern.exceptions import CannotUpdateWorkflowDueToCodeCache, MissingBrowserAddressError
BrowserSessionNotRenewable,
CannotUpdateWorkflowDueToCodeCache,
MissingBrowserAddressError,
)
from skyvern.forge import app from skyvern.forge import app
from skyvern.forge.prompts import prompt_engine from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.api.llm.exceptions import LLMProviderError from skyvern.forge.sdk.api.llm.exceptions import LLMProviderError
@@ -26,7 +19,6 @@ from skyvern.forge.sdk.core.curl_converter import curl_to_http_request_block_par
from skyvern.forge.sdk.core.permissions.permission_checker_factory import PermissionCheckerFactory from skyvern.forge.sdk.core.permissions.permission_checker_factory import PermissionCheckerFactory
from skyvern.forge.sdk.core.security import generate_skyvern_signature from skyvern.forge.sdk.core.security import generate_skyvern_signature
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
from skyvern.forge.sdk.db.exceptions import NotFoundError
from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory
from skyvern.forge.sdk.models import Step from skyvern.forge.sdk.models import Step
from skyvern.forge.sdk.routes.code_samples import ( from skyvern.forge.sdk.routes.code_samples import (
@@ -45,14 +37,12 @@ from skyvern.forge.sdk.routes.code_samples import (
) )
from skyvern.forge.sdk.routes.routers import base_router, legacy_base_router, legacy_v2_router from skyvern.forge.sdk.routes.routers import base_router, legacy_base_router, legacy_v2_router
from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestionBase, AISuggestionRequest from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestionBase, AISuggestionRequest
from skyvern.forge.sdk.schemas.debug_sessions import DebugSession
from skyvern.forge.sdk.schemas.organizations import ( from skyvern.forge.sdk.schemas.organizations import (
GetOrganizationAPIKeysResponse, GetOrganizationAPIKeysResponse,
GetOrganizationsResponse, GetOrganizationsResponse,
Organization, Organization,
OrganizationUpdate, OrganizationUpdate,
) )
from skyvern.forge.sdk.schemas.persistent_browser_sessions import PersistentBrowserSession
from skyvern.forge.sdk.schemas.prompts import CreateFromPromptRequest from skyvern.forge.sdk.schemas.prompts import CreateFromPromptRequest
from skyvern.forge.sdk.schemas.task_generations import GenerateTaskRequest, TaskGeneration from skyvern.forge.sdk.schemas.task_generations import GenerateTaskRequest, TaskGeneration
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Request from skyvern.forge.sdk.schemas.task_v2 import TaskV2Request
@@ -87,7 +77,6 @@ from skyvern.schemas.runs import (
CUA_ENGINES, CUA_ENGINES,
BlockRunRequest, BlockRunRequest,
BlockRunResponse, BlockRunResponse,
ProxyLocation,
RunEngine, RunEngine,
RunResponse, RunResponse,
RunType, RunType,
@@ -2231,241 +2220,3 @@ async def _flatten_workflow_run_timeline(organization_id: str, workflow_run_id:
final_workflow_run_block_timeline.extend(thought_timeline) final_workflow_run_block_timeline.extend(thought_timeline)
final_workflow_run_block_timeline.sort(key=lambda x: x.created_at, reverse=True) final_workflow_run_block_timeline.sort(key=lambda x: x.created_at, reverse=True)
return final_workflow_run_block_timeline return final_workflow_run_block_timeline
@base_router.get(
"/debug-session/{workflow_permanent_id}",
include_in_schema=False,
)
async def get_or_create_debug_session_by_user_and_workflow_permanent_id(
workflow_permanent_id: str,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> DebugSession:
"""
`current_user_id` is a unique identifier for a user, but does not map to an
entity in the database (at time of writing)
If the debug session does not exist, a new one will be created.
In addition, the timeout for the debug session's browser session will be
extended to 4 hours from the time of the request. If the browser session
cannot be renewed, a new one will be created and assigned to the debug
session. The browser_session that could not be renewed will be closed.
"""
debug_session = await app.DATABASE.get_debug_session(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if not debug_session:
LOG.info(
"Existing debug session not found, created a new one, along with a new browser session",
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return await new_debug_session(
workflow_permanent_id,
current_org,
current_user_id,
)
LOG.info(
"Existing debug session found",
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
try:
await app.PERSISTENT_SESSIONS_MANAGER.renew_or_close_session(
debug_session.browser_session_id,
current_org.organization_id,
)
return debug_session
except BrowserSessionNotRenewable as ex:
LOG.info(
"Browser session was non-renewable; creating a new debug session",
ex=str(ex),
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
workflow_permanent_id=workflow_permanent_id,
user_id=current_user_id,
)
return await new_debug_session(
workflow_permanent_id,
current_org,
current_user_id,
)
@base_router.post(
"/debug-session/{workflow_permanent_id}/new",
include_in_schema=False,
)
async def new_debug_session(
workflow_permanent_id: str,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> DebugSession:
"""
Create a new debug session, along with a new browser session. If any
existing debug sessions are found, "complete" them. Then close the browser
sessions associated with those completed debug sessions.
Return the new debug session.
CAVEAT: if an existing debug session for this user is <30s old, then we
return that instead. This is to curtail damage from browser session
spamming.
"""
if current_user_id:
debug_session = await app.DATABASE.get_latest_debug_session_for_user(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if debug_session:
now = datetime.now(timezone.utc)
created_at_utc = (
debug_session.created_at.replace(tzinfo=timezone.utc)
if debug_session.created_at.tzinfo is None
else debug_session.created_at
)
if now - created_at_utc < timedelta(seconds=30):
LOG.info(
"Existing debug session is less than 30s old, returning it",
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return debug_session
completed_debug_sessions = await app.DATABASE.complete_debug_sessions(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
LOG.info(
f"Completed {len(completed_debug_sessions)} pre-existing debug session(s)",
num_completed_debug_sessions=len(completed_debug_sessions),
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if completed_debug_sessions:
closeable_browser_sessions: list[PersistentBrowserSession] = []
for debug_session in completed_debug_sessions:
try:
browser_session = await app.DATABASE.get_persistent_browser_session(
debug_session.browser_session_id,
current_org.organization_id,
)
except NotFoundError:
browser_session = None
if browser_session and browser_session.completed_at is None:
closeable_browser_sessions.append(browser_session)
LOG.info(
f"Closing browser {len(closeable_browser_sessions)} browser session(s)",
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
def handle_close_browser_session_error(
browser_session_id: str,
organization_id: str,
task: asyncio.Task,
) -> None:
if task.exception():
LOG.error(
f"Failed to close session: {task.exception()}",
browser_session_id=browser_session_id,
organization_id=organization_id,
)
for browser_session in closeable_browser_sessions:
LOG.info(
"Closing existing browser session for debug session",
browser_session_id=browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
)
# NOTE(jdo): these may fail to actually close on infra, but the user
# wants (and should get) a new session regardless - so we will just
# log the error and continue
task = asyncio.create_task(
app.PERSISTENT_SESSIONS_MANAGER.close_session(
current_org.organization_id,
browser_session.persistent_browser_session_id,
)
)
task.add_done_callback(
partial(
handle_close_browser_session_error,
browser_session.persistent_browser_session_id,
current_org.organization_id,
)
)
new_browser_session = await app.PERSISTENT_SESSIONS_MANAGER.create_session(
organization_id=current_org.organization_id,
timeout_minutes=settings.DEBUG_SESSION_TIMEOUT_MINUTES,
proxy_location=ProxyLocation.RESIDENTIAL,
)
debug_session = await app.DATABASE.create_debug_session(
browser_session_id=new_browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
vnc_streaming_supported=True if new_browser_session.ip_address else False,
)
LOG.info(
"Created new debug session",
debug_session_id=debug_session.debug_session_id,
browser_session_id=new_browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return debug_session
@base_router.get(
"/debug-session/{workflow_permanent_id}/block-outputs",
response_model=dict[str, dict[str, Any]],
include_in_schema=False,
)
async def get_block_outputs_for_debug_session(
workflow_permanent_id: str,
version: int | None = None,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> dict[str, dict[str, Any]]:
return await app.WORKFLOW_SERVICE.get_block_outputs_for_debug_session(
workflow_permanent_id=workflow_permanent_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
version=version,
)

View File

@@ -0,0 +1,294 @@
import asyncio
import typing as t
from datetime import datetime, timedelta, timezone
from functools import partial
import structlog
from fastapi import Depends, HTTPException
from skyvern.config import settings
from skyvern.exceptions import BrowserSessionNotRenewable
from skyvern.forge import app
from skyvern.forge.sdk.db.exceptions import NotFoundError
from skyvern.forge.sdk.routes.routers import base_router
from skyvern.forge.sdk.schemas.debug_sessions import DebugSession, DebugSessionRuns
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.persistent_browser_sessions import PersistentBrowserSession
from skyvern.forge.sdk.services import org_auth_service
from skyvern.schemas.runs import ProxyLocation
LOG = structlog.get_logger()
@base_router.get(
"/debug-session/{workflow_permanent_id}",
include_in_schema=False,
)
async def get_or_create_debug_session_by_user_and_workflow_permanent_id(
workflow_permanent_id: str,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> DebugSession:
"""
`current_user_id` is a unique identifier for a user, but does not map to an
entity in the database (at time of writing)
If the debug session does not exist, a new one will be created.
In addition, the timeout for the debug session's browser session will be
extended to 4 hours from the time of the request. If the browser session
cannot be renewed, a new one will be created and assigned to the debug
session. The browser_session that could not be renewed will be closed.
"""
debug_session = await app.DATABASE.get_debug_session(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if not debug_session:
LOG.info(
"Existing debug session not found, created a new one, along with a new browser session",
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return await new_debug_session(
workflow_permanent_id,
current_org,
current_user_id,
)
LOG.info(
"Existing debug session found",
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
try:
await app.PERSISTENT_SESSIONS_MANAGER.renew_or_close_session(
debug_session.browser_session_id,
current_org.organization_id,
)
return debug_session
except BrowserSessionNotRenewable as ex:
LOG.info(
"Browser session was non-renewable; creating a new debug session",
ex=str(ex),
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
workflow_permanent_id=workflow_permanent_id,
user_id=current_user_id,
)
return await new_debug_session(
workflow_permanent_id,
current_org,
current_user_id,
)
@base_router.post(
"/debug-session/{workflow_permanent_id}/new",
include_in_schema=False,
)
async def new_debug_session(
workflow_permanent_id: str,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> DebugSession:
"""
Create a new debug session, along with a new browser session. If any
existing debug sessions are found, "complete" them. Then close the browser
sessions associated with those completed debug sessions.
Return the new debug session.
CAVEAT: if an existing debug session for this user is <30s old, then we
return that instead. This is to curtail damage from browser session
spamming.
"""
if current_user_id:
debug_session = await app.DATABASE.get_latest_debug_session_for_user(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if debug_session:
now = datetime.now(timezone.utc)
created_at_utc = (
debug_session.created_at.replace(tzinfo=timezone.utc)
if debug_session.created_at.tzinfo is None
else debug_session.created_at
)
if now - created_at_utc < timedelta(seconds=30):
LOG.info(
"Existing debug session is less than 30s old, returning it",
debug_session_id=debug_session.debug_session_id,
browser_session_id=debug_session.browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return debug_session
completed_debug_sessions = await app.DATABASE.complete_debug_sessions(
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
LOG.info(
f"Completed {len(completed_debug_sessions)} pre-existing debug session(s)",
num_completed_debug_sessions=len(completed_debug_sessions),
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
if completed_debug_sessions:
closeable_browser_sessions: list[PersistentBrowserSession] = []
for debug_session in completed_debug_sessions:
try:
browser_session = await app.DATABASE.get_persistent_browser_session(
debug_session.browser_session_id,
current_org.organization_id,
)
except NotFoundError:
browser_session = None
if browser_session and browser_session.completed_at is None:
closeable_browser_sessions.append(browser_session)
LOG.info(
f"Closing browser {len(closeable_browser_sessions)} browser session(s)",
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
def handle_close_browser_session_error(
browser_session_id: str,
organization_id: str,
task: asyncio.Task,
) -> None:
if task.exception():
LOG.error(
f"Failed to close session: {task.exception()}",
browser_session_id=browser_session_id,
organization_id=organization_id,
)
for browser_session in closeable_browser_sessions:
LOG.info(
"Closing existing browser session for debug session",
browser_session_id=browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
)
# NOTE(jdo): these may fail to actually close on infra, but the user
# wants (and should get) a new session regardless - so we will just
# log the error and continue
task = asyncio.create_task(
app.PERSISTENT_SESSIONS_MANAGER.close_session(
current_org.organization_id,
browser_session.persistent_browser_session_id,
)
)
task.add_done_callback(
partial(
handle_close_browser_session_error,
browser_session.persistent_browser_session_id,
current_org.organization_id,
)
)
new_browser_session = await app.PERSISTENT_SESSIONS_MANAGER.create_session(
organization_id=current_org.organization_id,
timeout_minutes=settings.DEBUG_SESSION_TIMEOUT_MINUTES,
proxy_location=ProxyLocation.RESIDENTIAL,
)
debug_session = await app.DATABASE.create_debug_session(
browser_session_id=new_browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
vnc_streaming_supported=True if new_browser_session.ip_address else False,
)
LOG.info(
"Created new debug session",
debug_session_id=debug_session.debug_session_id,
browser_session_id=new_browser_session.persistent_browser_session_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
workflow_permanent_id=workflow_permanent_id,
)
return debug_session
@base_router.get(
"/debug-session/{workflow_permanent_id}/block-outputs",
response_model=dict[str, dict[str, t.Any]],
include_in_schema=False,
)
async def get_block_outputs_for_debug_session(
workflow_permanent_id: str,
version: int | None = None,
current_org: Organization = Depends(org_auth_service.get_current_org),
current_user_id: str = Depends(org_auth_service.get_current_user_id),
) -> dict[str, dict[str, t.Any]]:
return await app.WORKFLOW_SERVICE.get_block_outputs_for_debug_session(
workflow_permanent_id=workflow_permanent_id,
organization_id=current_org.organization_id,
user_id=current_user_id,
version=version,
)
@base_router.get(
"/debug-session/{debug_session_id}/runs",
include_in_schema=False,
)
@base_router.get(
"/debug-session/{debug_session_id}/runs/",
include_in_schema=False,
)
async def get_debug_session_runs(
current_org: Organization = Depends(org_auth_service.get_current_org),
debug_session_id: str = "",
) -> DebugSessionRuns:
"""Get all debug session runs for the debug_session_id"""
LOG.critical(
"Fetching runs for debugger",
debug_session_id=debug_session_id,
organization_id=current_org.organization_id,
)
debug_session = await app.DATABASE.get_debug_session_by_id(
debug_session_id=debug_session_id,
organization_id=current_org.organization_id,
)
if not debug_session:
raise HTTPException(status_code=404, detail="Debug session not found")
runs = await app.DATABASE.get_workflow_runs_by_debug_session_id(
debug_session_id=debug_session.debug_session_id,
organization_id=current_org.organization_id,
)
return DebugSessionRuns(debug_session=debug_session, runs=runs)

View File

@@ -26,3 +26,33 @@ class DebugSession(BaseModel):
modified_at: datetime modified_at: datetime
deleted_at: datetime | None = None deleted_at: datetime | None = None
status: DebugSessionStatus status: DebugSessionStatus
class DebugSessionRun(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_fallback: bool | None = None
block_label: str
browser_session_id: str
code_gen: bool | None = None
debug_session_id: str
failure_reason: str | None = None
output_parameter_id: str
run_with: str | None = None
script_run_id: str | None = None
status: str
workflow_id: str
workflow_permanent_id: str
workflow_run_id: str
# --
created_at: datetime
queued_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class DebugSessionRuns(BaseModel):
model_config = ConfigDict(from_attributes=True)
debug_session: DebugSession
runs: list[DebugSessionRun]