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 { CopyText } from "@/routes/workflows/editor/Workspace";
import { basicTimeFormat } from "@/util/timeFormat";
import { cn, formatMs } 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;
}
import { cn, formatMs, toDate } from "@/util/utils";
function sessionIsOpen(browserSession: BrowserSession): boolean {
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,
} from "@/store/WorkflowHasChangesStore";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns";
import { cn } from "@/util/utils";
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
@@ -1245,7 +1245,7 @@ function Workspace({
split={{ left: workflowWidth }}
onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
>
{/* code and infinite canvas */}
{/* code, infinite canvas, and block runs */}
<div className="relative h-full w-full">
<div
className={cn(
@@ -1305,6 +1305,10 @@ function Workspace({
/>
</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 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,
};
};
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;
}