diff --git a/skyvern-frontend/src/api/AxiosClient.ts b/skyvern-frontend/src/api/AxiosClient.ts index 44ec83e5..be05d207 100644 --- a/skyvern-frontend/src/api/AxiosClient.ts +++ b/skyvern-frontend/src/api/AxiosClient.ts @@ -49,38 +49,46 @@ const artifactApiClient = axios.create({ baseURL: artifactApiBaseUrl, }); +const clients = [client, v2Client, clientSansApiV1] as const; + +function setHeaderForAllClients(header: string, value: string) { + clients.forEach((instance) => { + instance.defaults.headers.common[header] = value; + }); +} + +function removeHeaderForAllClients(header: string) { + clients.forEach((instance) => { + // Axios stores headers at both `common` (shared across methods) and the + // top-level defaults object (can be set by initial config spread). Delete + // from both locations to ensure the header is fully removed. + delete instance.defaults.headers.common[header]; + delete (instance.defaults.headers as Record)[header]; + }); +} + export function setAuthorizationHeader(token: string) { - client.defaults.headers.common["Authorization"] = `Bearer ${token}`; - v2Client.defaults.headers.common["Authorization"] = `Bearer ${token}`; - clientSansApiV1.defaults.headers.common["Authorization"] = `Bearer ${token}`; + setHeaderForAllClients("Authorization", `Bearer ${token}`); } export function removeAuthorizationHeader() { - if (client.defaults.headers.common["Authorization"]) { - delete client.defaults.headers.common["Authorization"]; - delete v2Client.defaults.headers.common["Authorization"]; - delete clientSansApiV1.defaults.headers.common["Authorization"]; - } + removeHeaderForAllClients("Authorization"); } export function setApiKeyHeader(apiKey: string) { persistRuntimeApiKey(apiKey); - client.defaults.headers.common["X-API-Key"] = apiKey; - v2Client.defaults.headers.common["X-API-Key"] = apiKey; - clientSansApiV1.defaults.headers.common["X-API-Key"] = apiKey; + setHeaderForAllClients("X-API-Key", apiKey); } -export function removeApiKeyHeader() { - clearRuntimeApiKey(); - if (client.defaults.headers.common["X-API-Key"]) { - delete client.defaults.headers.common["X-API-Key"]; - } - if (v2Client.defaults.headers.common["X-API-Key"]) { - delete v2Client.defaults.headers.common["X-API-Key"]; - } - if (clientSansApiV1.defaults.headers.common["X-API-Key"]) { - delete clientSansApiV1.defaults.headers.common["X-API-Key"]; +export function removeApiKeyHeader({ + clearStoredKey = true, +}: { + clearStoredKey?: boolean; +} = {}) { + if (clearStoredKey) { + clearRuntimeApiKey(); } + removeHeaderForAllClients("X-API-Key"); } async function getClient( @@ -101,17 +109,24 @@ async function getClient( } }; - if (credentialGetter) { - removeApiKeyHeader(); - - const credential = await credentialGetter(); - - if (!credential) { - console.warn("No credential found"); - return get(); - } - + // Both auth headers are sent intentionally: Authorization (Bearer token from + // the credential getter, e.g. Clerk) is used for user-session auth, while + // X-API-Key is used for org-level API key auth. The backend accepts either + // and gives precedence to the API key when both are present. Sending both + // ensures requests succeed regardless of which auth method the org uses. + const credential = credentialGetter ? await credentialGetter() : null; + if (credential) { setAuthorizationHeader(credential); + } else { + removeAuthorizationHeader(); + } + + const apiKey = getRuntimeApiKey(); + if (apiKey) { + setHeaderForAllClients("X-API-Key", apiKey); + } else { + // Avoid mutating persisted keys here - just control request headers. + removeApiKeyHeader({ clearStoredKey: false }); } return get(); diff --git a/skyvern-frontend/src/api/sse.ts b/skyvern-frontend/src/api/sse.ts index 531c4156..2081a705 100644 --- a/skyvern-frontend/src/api/sse.ts +++ b/skyvern-frontend/src/api/sse.ts @@ -114,11 +114,11 @@ export async function getSseClient( if (authToken) { requestHeaders.Authorization = `Bearer ${authToken}`; - } else { - const apiKey = getRuntimeApiKey(); - if (apiKey) { - requestHeaders["X-API-Key"] = apiKey; - } + } + + const apiKey = getRuntimeApiKey(); + if (apiKey) { + requestHeaders["X-API-Key"] = apiKey; } return { diff --git a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx index eeb4dc69..31001543 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx @@ -1,23 +1,23 @@ import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { statusIsFinalized } from "@/routes/tasks/types"; +import { cn } from "@/util/utils"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; import { isBlockItem, isObserverThought, - isTaskVariantBlockItem, isThoughtItem, ObserverThought, WorkflowRunBlock, } from "../types/workflowRunTypes"; +import { countActions } from "../utils"; import { ThoughtCard } from "@/routes/workflows/workflowRun/ThoughtCard"; import { ActionItem, WorkflowRunOverviewActiveElement, } from "@/routes/workflows/workflowRun/WorkflowRunOverview"; import { WorkflowRunTimelineBlockItem } from "@/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem"; -import { cn } from "@/util/utils"; type Props = { activeItem: WorkflowRunOverviewActiveElement; @@ -48,12 +48,7 @@ function DebuggerRunTimeline({ const workflowRunIsFinalized = statusIsFinalized(workflowRun); - const numberOfActions = workflowRunTimeline.reduce((total, current) => { - if (isTaskVariantBlockItem(current)) { - return total + current.block!.actions!.length; - } - return total + 0; - }, 0); + const numberOfActions = countActions(workflowRunTimeline); const firstActionOrThoughtIsPending = !workflowRunIsFinalized && workflowRunTimeline.length === 0; diff --git a/skyvern-frontend/src/routes/workflows/utils.ts b/skyvern-frontend/src/routes/workflows/utils.ts index ff68d9ca..e7c6f74a 100644 --- a/skyvern-frontend/src/routes/workflows/utils.ts +++ b/skyvern-frontend/src/routes/workflows/utils.ts @@ -1,6 +1,11 @@ import { useLocation } from "react-router-dom"; import type { WorkflowParameter } from "./types/workflowTypes"; import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; +import { + isBlockItem, + isTaskVariantBlockItem, + WorkflowRunTimelineItem, +} from "./types/workflowRunTypes"; type Location = ReturnType; @@ -73,6 +78,20 @@ export const formatDuration = (duration: Duration): string => { } }; +export function countActions(items: Array): number { + return items.reduce((total, item) => { + if (!isBlockItem(item)) { + return total; + } + + const blockActionCount = isTaskVariantBlockItem(item) + ? item.block.actions?.length ?? 0 + : 0; + + return total + blockActionCount + countActions(item.children); + }, 0); +} + export const getOrderedBlockLabels = (workflow?: WorkflowApiResponse) => { if (!workflow) { return []; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx index a0d8d121..18abf4a2 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx @@ -8,11 +8,11 @@ import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuer import { isBlockItem, isObserverThought, - isTaskVariantBlockItem, isThoughtItem, ObserverThought, WorkflowRunBlock, } from "../types/workflowRunTypes"; +import { countActions } from "../utils"; import { ThoughtCard } from "./ThoughtCard"; import { ActionItem, @@ -56,12 +56,7 @@ function WorkflowRunTimeline({ const finallyBlockLabel = workflowRun.workflow?.workflow_definition?.finally_block_label ?? null; - const numberOfActions = workflowRunTimeline.reduce((total, current) => { - if (isTaskVariantBlockItem(current)) { - return total + current.block!.actions!.length; - } - return total + 0; - }, 0); + const numberOfActions = countActions(workflowRunTimeline); return (
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index 600dead4..a41f08c3 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -1,51 +1,190 @@ import { CheckCircledIcon, + ChevronDownIcon, + ChevronRightIcon, CrossCircledIcon, CubeIcon, ExternalLinkIcon, } from "@radix-ui/react-icons"; -import { useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { Status } from "@/api/types"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { formatDuration, toDuration } from "@/routes/workflows/utils"; import { cn } from "@/util/utils"; import { workflowBlockTitle } from "../editor/nodes/types"; import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; import { + hasEvaluations, isAction, isBlockItem, isObserverThought, isThoughtItem, isWorkflowRunBlock, - hasEvaluations, + ObserverThought, WorkflowRunBlock, WorkflowRunTimelineItem, } from "../types/workflowRunTypes"; +import { isTaskVariantBlock } from "../types/workflowTypes"; import { ActionCard } from "./ActionCard"; +import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction"; import { ActionItem, WorkflowRunOverviewActiveElement, } from "./WorkflowRunOverview"; import { ThoughtCard } from "./ThoughtCard"; -import { ObserverThought } from "../types/workflowRunTypes"; -import { isTaskVariantBlock } from "../types/workflowTypes"; -import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction"; type Props = { activeItem: WorkflowRunOverviewActiveElement; block: WorkflowRunBlock; subItems: Array; + depth?: number; onBlockItemClick: (block: WorkflowRunBlock) => void; onActionClick: (action: ActionItem) => void; onThoughtCardClick: (thought: ObserverThought) => void; finallyBlockLabel?: string | null; }; +type LoopIterationGroup = { + index: number | null; + currentValue: string | null; + items: Array; +}; + +function stringifyTimelineValue(value: unknown): string { + if (value === null || value === undefined) { + return "null"; + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function truncateValue(value: string, maxLength = 120): string { + const collapsed = value.replace(/\s+/g, " ").trim(); + if (collapsed.length <= maxLength) { + return collapsed; + } + return `${collapsed.slice(0, maxLength - 3)}...`; +} + +function getLoopIterationGroups( + items: Array, +): Array { + const groupsByKey = new Map(); + + items.forEach((item) => { + const currentIndex = isBlockItem(item) ? item.block.current_index : null; + const currentValue = isBlockItem(item) ? item.block.current_value : null; + const groupKey = + currentIndex === null ? "unknown" : `index-${currentIndex}`; + + if (!groupsByKey.has(groupKey)) { + groupsByKey.set(groupKey, { + index: currentIndex, + currentValue, + items: [], + }); + } + + const group = groupsByKey.get(groupKey)!; + if (!group.currentValue && currentValue) { + group.currentValue = currentValue; + } + group.items.push(item); + }); + + return Array.from(groupsByKey.values()).sort((left, right) => { + if (left.index === null && right.index === null) { + return 0; + } + if (left.index === null) { + return 1; + } + if (right.index === null) { + return -1; + } + return right.index - left.index; + }); +} + +type TimelineSubItemsProps = { + items: Array; + activeItem: WorkflowRunOverviewActiveElement; + depth: number; + onBlockItemClick: (block: WorkflowRunBlock) => void; + onActionClick: (action: ActionItem) => void; + onThoughtCardClick: (thought: ObserverThought) => void; + finallyBlockLabel?: string | null; +}; + +function TimelineSubItems({ + items, + activeItem, + depth, + onBlockItemClick, + onActionClick, + onThoughtCardClick, + finallyBlockLabel, +}: TimelineSubItemsProps) { + return ( +
+ {items.map((item) => { + if (isBlockItem(item)) { + return ( + + ); + } + + if (isThoughtItem(item)) { + return ( + + ); + } + })} +
+ ); +} + function WorkflowRunTimelineBlockItem({ activeItem, block, subItems, + depth = 0, onBlockItemClick, onActionClick, onThoughtCardClick, @@ -98,241 +237,352 @@ function WorkflowRunTimelineBlockItem({ // NOTE(jdo): want to put this back; await for now const showDuration = false as const; + const hasNestedChildren = subItems.length > 0; + const isLoopBlock = block.block_type === "for_loop"; + const isConditionalBlock = block.block_type === "conditional"; + const [childrenOpen, setChildrenOpen] = useState(!isConditionalBlock); + + const loopIterationGroups = useMemo( + () => getLoopIterationGroups(subItems), + [subItems], + ); + + const loopValues = Array.isArray(block.loop_values) ? block.loop_values : []; return ( -
{ - event.stopPropagation(); - onBlockItemClick(block); - }} - ref={refCallback} - > -
-
-
-
- -
+
0 })}> +
{ + event.stopPropagation(); + onBlockItemClick(block); + }} + ref={refCallback} + > +
+
+
+
+ +
-
- - {workflowBlockTitle[block.block_type]} - - - {block.label} - - {isFinallyBlock && ( - - Execute on any outcome +
+ + {workflowBlockTitle[block.block_type]} - )} -
-
-
- {showFailureIndicator && ( -
- -
- )} - {showSuccessIndicator && ( -
- -
- )} -
-
- {showDiagnosticLink ? ( - event.stopPropagation()} - > -
- - Diagnostics -
- - ) : ( - <> - - Block - + + {block.label} + + {isFinallyBlock && ( + + Execute on any outcome + )}
- {duration && showDuration && ( -
- {duration} +
+
+ {showFailureIndicator && ( +
+ +
+ )} + {showSuccessIndicator && ( +
+ +
+ )} +
+
+ {showDiagnosticLink ? ( + event.stopPropagation()} + > +
+ + Diagnostics +
+ + ) : ( + <> + + Block + + )} +
+ {duration && showDuration && ( +
+ {duration} +
+ )} +
+
+
+ {block.description ? ( +
{block.description}
+ ) : null} + {isLoopBlock && ( +
+
+ Iterable values:{" "} + + {loopValues.length} + +
+ {loopValues.length > 0 && ( +
+ {loopValues.map((value, index) => ( +
+ [{index}] + + {truncateValue(stringifyTimelineValue(value), 120)} + +
+ ))}
)}
-
-
- {block.description ? ( -
{block.description}
- ) : null} - {block.block_type === "conditional" && block.executed_branch_id && ( -
- {hasEvaluations(block.output) && block.output.evaluations ? ( - // New format: show all branch evaluations -
- {block.output.evaluations.map((evaluation, index) => ( -
- {evaluation.is_default ? ( -
- Default branch - {evaluation.is_matched && ( - ✓ Matched - )} -
- ) : ( -
-
- - {evaluation.original_expression} - -
- {evaluation.rendered_expression && - evaluation.rendered_expression !== - evaluation.original_expression && ( -
- → rendered to{" "} - - {evaluation.rendered_expression} - -
- )} -
- evaluated to - - {evaluation.result ? "True" : "False"} - + )} + {block.block_type === "conditional" && block.executed_branch_id && ( +
+ {hasEvaluations(block.output) && block.output.evaluations ? ( + // New format: show all branch evaluations +
+ {block.output.evaluations.map((evaluation, index) => ( +
+ {evaluation.is_default ? ( +
+ Default branch {evaluation.is_matched && ( - ✓ Matched + ✓ Matched )}
-
- )} - {evaluation.is_matched && evaluation.next_block_label && ( -
- → Executing next block:{" "} - - {evaluation.next_block_label} - -
- )} + ) : ( +
+
+ + {evaluation.original_expression} + +
+ {evaluation.rendered_expression && + evaluation.rendered_expression !== + evaluation.original_expression && ( +
+ -> rendered to{" "} + + {evaluation.rendered_expression} + +
+ )} +
+ evaluated to + + {evaluation.result ? "True" : "False"} + + {evaluation.is_matched && ( + ✓ Matched + )} +
+
+ )} + {evaluation.is_matched && evaluation.next_block_label && ( +
+ -> Executing next block:{" "} + + {evaluation.next_block_label} + +
+ )} +
+ ))} +
+ ) : ( + // Fallback: old format without evaluations array + <> + {block.executed_branch_expression !== null && + block.executed_branch_expression !== undefined ? ( +
+ Condition{" "} + + {block.executed_branch_expression} + {" "} + evaluated to{" "} + True +
+ ) : ( +
+ No conditions matched, executing default branch +
+ )} + {block.executed_branch_next_block && ( +
+ -> Executing next block:{" "} + + {block.executed_branch_next_block} + +
+ )} + + )} +
+ )} +
+ + {block.block_type === "human_interaction" && ( + + )} + + {actions.map((action, index) => { + return ( + { + event.stopPropagation(); + const actionItem: ActionItem = { + block, + action, + }; + onActionClick(actionItem); + }} + /> + ); + })} + + {hasNestedChildren && isLoopBlock && ( +
+ {loopIterationGroups.map((group, groupIndex) => { + const loopValueFromIterable = + group.index !== null ? loopValues[group.index] ?? null : null; + const iterationNumber = + group.index !== null + ? group.index + 1 + : loopIterationGroups.length - groupIndex; + const currentValuePreview = truncateValue( + stringifyTimelineValue( + loopValueFromIterable ?? group.currentValue, + ), + 140, + ); + + return ( + +
+ + +
- ))} -
- ) : ( - // Fallback: old format without evaluations array - <> - {block.executed_branch_expression !== null && - block.executed_branch_expression !== undefined ? ( -
- Condition{" "} - - {block.executed_branch_expression} - {" "} - evaluated to{" "} - True -
- ) : ( -
- No conditions matched, executing default branch -
- )} - {block.executed_branch_next_block && ( -
- → Executing next block:{" "} - - {block.executed_branch_next_block} - -
- )} - - )} + + + + + ); + })}
)} -
- {block.block_type === "human_interaction" && ( - - )} + {hasNestedChildren && isConditionalBlock && ( + +
+ + + +
+ + + +
+ )} - {actions.map((action, index) => { - return ( - { - event.stopPropagation(); - const actionItem: ActionItem = { - block, - action, - }; - onActionClick(actionItem); - }} + {hasNestedChildren && !isLoopBlock && !isConditionalBlock && ( + - ); - })} - {subItems.map((item) => { - if (isBlockItem(item)) { - return ( - - ); - } - if (isThoughtItem(item)) { - return ( - - ); - } - })} + )} +
); } diff --git a/skyvern/forge/sdk/db/agent_db.py b/skyvern/forge/sdk/db/agent_db.py index a87f6246..c2fe2f15 100644 --- a/skyvern/forge/sdk/db/agent_db.py +++ b/skyvern/forge/sdk/db/agent_db.py @@ -4440,11 +4440,15 @@ class AgentDB(BaseAlchemyDB): workflow_run_block.task_id = task_id if failure_reason: workflow_run_block.failure_reason = failure_reason - if loop_values: + # Use `is not None` instead of truthiness checks so that falsy + # values like current_index=0, empty loop_values=[], or + # current_value="" are correctly persisted. Without this, + # the first loop iteration (index 0) loses its metadata. + if loop_values is not None: workflow_run_block.loop_values = loop_values - if current_value: + if current_value is not None: workflow_run_block.current_value = current_value - if current_index: + if current_index is not None: workflow_run_block.current_index = current_index if recipients: workflow_run_block.recipients = recipients