diff --git a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx index 8aa7cbbc..c493e123 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx @@ -24,7 +24,12 @@ import { } from "@/components/ui/table"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; -import { Pencil2Icon, PlayIcon } from "@radix-ui/react-icons"; +import { + MagnifyingGlassIcon, + MixerHorizontalIcon, + Pencil2Icon, + PlayIcon, +} from "@radix-ui/react-icons"; import { useEffect, useState } from "react"; import { Link, @@ -35,6 +40,15 @@ import { import { useWorkflowQuery } from "./hooks/useWorkflowQuery"; import { useWorkflowRunsQuery } from "./hooks/useWorkflowRunsQuery"; import { WorkflowActions } from "./WorkflowActions"; +import { Input } from "@/components/ui/input"; +import { useDebounce } from "use-debounce"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { RunParametersDialog } from "./workflowRun/RunParametersDialog"; function WorkflowPage() { const { workflowPermanentId } = useParams(); @@ -44,11 +58,15 @@ function WorkflowPage() { const navigate = useNavigate(); const PAGE_SIZE = 10; + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebounce(search, 500); + const [openRunParams, setOpenRunParams] = useState(null); const { data: workflowRuns, isLoading } = useWorkflowRunsQuery({ workflowPermanentId, statusFilters, page, + search: debouncedSearch, refetchOnMount: "always", }); @@ -106,13 +124,31 @@ function WorkflowPage() {
-
+

Past Runs

+
+
+
+
+ +
+ { + setSearch(e.target.value); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + }} + placeholder="Search runs by parameter..." + className="w-48 pl-9 lg:w-72" + /> +
-
+
@@ -120,6 +156,7 @@ function WorkflowPage() { ID Status Created At + @@ -173,12 +210,43 @@ function WorkflowPage() { > {basicLocalTimeFormat(workflowRun.created_at)} + +
+ + + + + + View Parameters + + +
+
); }) )}
+ { + if (!open) setOpenRunParams(null); + }} + workflowPermanentId={workflowPermanentId} + workflowRunId={openRunParams} + /> diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 2dfb94d8..bf8b9a9b 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -29,6 +29,7 @@ import { cn } from "@/util/utils"; import { LightningBoltIcon, MagnifyingGlassIcon, + MixerHorizontalIcon, Pencil2Icon, PlayIcon, PlusIcon, @@ -39,6 +40,7 @@ import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useDebounce } from "use-debounce"; import { NarrativeCard } from "./components/header/NarrativeCard"; +import { WorkflowParametersDialog } from "./components/WorkflowParametersDialog"; import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation"; import { ImportWorkflowButton } from "./ImportWorkflowButton"; import { WorkflowApiResponse } from "./types/workflowTypes"; @@ -63,6 +65,7 @@ function Workflows() { const createWorkflowMutation = useCreateWorkflowMutation(); const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); + const [openWorkflowId, setOpenWorkflowId] = useState(null); const [debouncedSearch] = useDebounce(search, 500); const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const itemsPerPage = searchParams.get("page_size") @@ -79,7 +82,9 @@ function Workflows() { params.append("page", String(page)); params.append("page_size", String(itemsPerPage)); params.append("only_workflows", "true"); - params.append("title", debouncedSearch); + if (debouncedSearch) { + params.append("search_key", debouncedSearch); + } return client .get(`/workflows`, { params, @@ -96,7 +101,9 @@ function Workflows() { params.append("page", String(page + 1)); params.append("page_size", String(itemsPerPage)); params.append("only_workflows", "true"); - params.append("title", debouncedSearch); + if (debouncedSearch) { + params.append("search_key", debouncedSearch); + } return client .get(`/workflows`, { params, @@ -198,7 +205,7 @@ function Workflows() { setSearch(event.target.value); setParamPatch({ page: "1" }); }} - placeholder="Search by title..." + placeholder="Search by title or parameter..." className="w-48 pl-9 lg:w-72" />
@@ -312,6 +319,29 @@ function Workflows() { + + + + + + View Parameters + + @@ -320,6 +350,14 @@ function Workflows() { )} + { + if (!open) setOpenWorkflowId(null); + }} + workflowId={openWorkflowId} + workflows={workflows} + />
Items per page diff --git a/skyvern-frontend/src/routes/workflows/components/ParametersDialogBase.tsx b/skyvern-frontend/src/routes/workflows/components/ParametersDialogBase.tsx new file mode 100644 index 00000000..bdd12eb1 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/ParametersDialogBase.tsx @@ -0,0 +1,121 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { EyeClosedIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; + +type ParameterItem = { + id: string; + key: string; + description?: string | null; + type?: string | null; + value?: string | null; // safe display value only; never raw secrets +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + sectionLabel?: string; + items: Array; +}; + +export function ParametersDialogBase({ + open, + onOpenChange, + title = "Parameters", + sectionLabel = "Parameters", + items, +}: Props) { + const [revealedIds, setRevealedIds] = useState>(new Set()); + + function toggleReveal(id: string) { + setRevealedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function renderRow(item: ParameterItem) { + const revealed = + item.value !== undefined && + item.value !== null && + item.value !== "" && + revealedIds.has(item.id); + const isRevealable = + item.value !== undefined && item.value !== null && item.value !== ""; + return ( +
+
+
+
+
{item.key}
+ {item.description ? ( +
+ — {item.description} +
+ ) : null} +
+
+
+ {item.type ? {item.type} : null} + {isRevealable ? ( + + ) : null} +
+
+ {isRevealable ? ( +
+
+ {revealed ? item.value : "••••••"} +
+
+ ) : null} +
+ ); + } + + return ( + + + + {title} + + {items.length === 0 ? ( +
No parameters.
+ ) : ( +
+ + + +
+ {items.map((it) => renderRow(it))} +
+
+
+
+ )} +
+
+ ); +} diff --git a/skyvern-frontend/src/routes/workflows/components/WorkflowParametersDialog.tsx b/skyvern-frontend/src/routes/workflows/components/WorkflowParametersDialog.tsx new file mode 100644 index 00000000..c800c121 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/WorkflowParametersDialog.tsx @@ -0,0 +1,179 @@ +import { useMemo } from "react"; +import { ParametersDialogBase } from "./ParametersDialogBase"; +import { + WorkflowApiResponse, + WorkflowParameter, + WorkflowParameterTypes, + Parameter, + CredentialParameter, + AWSSecretParameter, + OnePasswordCredentialParameter, + AzureVaultCredentialParameter, + BitwardenLoginCredentialParameter, + BitwardenSensitiveInformationParameter, + BitwardenCreditCardDataParameter, + ContextParameter, +} from "../types/workflowTypes"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + workflowId: string | null; + workflows: Array; +}; + +function getParameterId(param: Parameter): string { + if ("workflow_parameter_id" in param && param.workflow_parameter_id) + return param.workflow_parameter_id; + if ("credential_parameter_id" in param && param.credential_parameter_id) + return param.credential_parameter_id; + if ("aws_secret_parameter_id" in param && param.aws_secret_parameter_id) + return param.aws_secret_parameter_id; + if ( + "onepassword_credential_parameter_id" in param && + param.onepassword_credential_parameter_id + ) + return param.onepassword_credential_parameter_id; + if ( + "azure_vault_credential_parameter_id" in param && + param.azure_vault_credential_parameter_id + ) + return param.azure_vault_credential_parameter_id; + if ( + "bitwarden_login_credential_parameter_id" in param && + param.bitwarden_login_credential_parameter_id + ) + return param.bitwarden_login_credential_parameter_id; + if ( + "bitwarden_sensitive_information_parameter_id" in param && + param.bitwarden_sensitive_information_parameter_id + ) + return param.bitwarden_sensitive_information_parameter_id; + if ( + "bitwarden_credit_card_data_parameter_id" in param && + param.bitwarden_credit_card_data_parameter_id + ) + return param.bitwarden_credit_card_data_parameter_id; + if ("output_parameter_id" in param && param.output_parameter_id) + return param.output_parameter_id; + return param.key; +} + +function getParameterDisplayType(param: Parameter): string { + return param.parameter_type; +} + +function getParameterDisplayValue(param: Parameter): string | null { + switch (param.parameter_type) { + case "workflow": { + const p = param as WorkflowParameter; + const value = p.default_value; + try { + return value === null || value === undefined + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value); + } catch { + return String(value); + } + } + case "credential": { + // Show referenced credential id; do not reveal secrets + return "credential_id" in param + ? String((param as CredentialParameter).credential_id) + : null; + } + case "aws_secret": { + // Show the AWS secret key reference only + return "aws_key" in param + ? String((param as AWSSecretParameter).aws_key) + : null; + } + case "onepassword": { + const p = param as OnePasswordCredentialParameter; + if (p.vault_id && p.item_id) return `${p.vault_id} / ${p.item_id}`; + return null; + } + case "azure_vault_credential": { + const p = param as AzureVaultCredentialParameter; + return p.vault_name ? `${p.vault_name}` : null; + } + case "bitwarden_login_credential": { + const p = param as BitwardenLoginCredentialParameter; + return p.bitwarden_item_id ?? p.bitwarden_collection_id ?? null; + } + case "bitwarden_sensitive_information": { + const p = param as BitwardenSensitiveInformationParameter; + return p.bitwarden_identity_key ?? null; + } + case "bitwarden_credit_card_data": { + const p = param as BitwardenCreditCardDataParameter; + return p.bitwarden_item_id ?? null; + } + case "context": { + const p = param as ContextParameter; + if ("value" in p && p.value !== undefined) { + try { + return typeof p.value === "string" + ? p.value + : JSON.stringify(p.value); + } catch { + return String(p.value); + } + } + return null; + } + default: + return null; + } +} + +// Row rendering moved inside component to access local reveal state + +export function WorkflowParametersDialog({ + open, + onOpenChange, + workflowId, + workflows, +}: Props) { + const workflow = useMemo( + () => workflows?.find((w) => w.workflow_permanent_id === workflowId), + [workflows, workflowId], + ); + + const items = useMemo(() => { + const params = workflow + ? (workflow.workflow_definition.parameters.filter( + (p) => + p.parameter_type === WorkflowParameterTypes.Workflow || + p.parameter_type === "credential" || + p.parameter_type === "aws_secret" || + p.parameter_type === "onepassword" || + p.parameter_type === "azure_vault_credential" || + p.parameter_type === "bitwarden_login_credential" || + p.parameter_type === "bitwarden_sensitive_information" || + p.parameter_type === "bitwarden_credit_card_data" || + p.parameter_type === "context", + ) as Parameter[]) + : ([] as Parameter[]); + return params.map((param) => ({ + id: getParameterId(param), + key: param.key, + description: + "description" in param ? param.description ?? undefined : undefined, + type: getParameterDisplayType(param), + value: getParameterDisplayValue(param), + })); + }, [workflow]); + + return ( + + ); +} diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts index e03095cf..d6d274e9 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts @@ -14,19 +14,27 @@ type Props = { workflowPermanentId?: string; statusFilters?: Array; page: number; + search?: string; } & UseQueryOptions; function useWorkflowRunsQuery({ workflowPermanentId, statusFilters, page, + search, ...queryOptions }: Props) { const { data: globalWorkflows } = useGlobalWorkflowsQuery(); const credentialGetter = useCredentialGetter(); return useQuery>({ - queryKey: ["workflowRuns", { statusFilters }, workflowPermanentId, page], + queryKey: [ + "workflowRuns", + { statusFilters }, + workflowPermanentId, + page, + search, + ], queryFn: async () => { const client = await getClient(credentialGetter); const params = new URLSearchParams(); @@ -42,6 +50,9 @@ function useWorkflowRunsQuery({ params.append("status", status); }); } + if (search) { + params.append("search_key", search); + } return client .get(`/workflows/${workflowPermanentId}/runs`, { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/RunParametersDialog.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/RunParametersDialog.tsx new file mode 100644 index 00000000..593d57d5 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/RunParametersDialog.tsx @@ -0,0 +1,82 @@ +import { ParametersDialogBase } from "../components/ParametersDialogBase"; +import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { getClient } from "@/api/AxiosClient"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; +import { WorkflowRunStatusApiResponse } from "@/api/types"; +import { Parameter } from "../types/workflowTypes"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + workflowPermanentId?: string; + workflowRunId: string | null; +}; + +export function RunParametersDialog({ + open, + onOpenChange, + workflowPermanentId, + workflowRunId, +}: Props) { + const { data: globalWorkflows } = useGlobalWorkflowsQuery(); + const credentialGetter = useCredentialGetter(); + const { data: workflow } = useWorkflowQuery({ workflowPermanentId }); + const { data: run } = useQuery({ + queryKey: ["workflowRun", workflowPermanentId, workflowRunId, "dialog"], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + const isGlobalWorkflow = globalWorkflows?.some( + (workflow) => workflow.workflow_permanent_id === workflowPermanentId, + ); + if (isGlobalWorkflow) { + params.set("template", "true"); + } + return client + .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}`, { + params, + }) + .then((r) => r.data); + }, + enabled: !!workflowPermanentId && !!workflowRunId && !!globalWorkflows, + }); + + const defByKey = new Map( + (workflow?.workflow_definition.parameters ?? []).map((p: Parameter) => [ + p.key, + p, + ]), + ); + + const items = Object.entries(run?.parameters ?? {}).map(([key, value]) => { + const def = defByKey.get(key); + const description = + def && "description" in def ? def.description ?? undefined : undefined; + const type = def ? def.parameter_type ?? undefined : undefined; + const displayValue = + value === null || value === undefined + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value); + return { + id: key, + key, + description, + type, + value: displayValue, + }; + }); + + return ( + + ); +} diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index ccf8162e..60a937e4 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from typing import Any, List, Literal, Sequence, overload import structlog -from sqlalchemy import and_, asc, case, delete, distinct, func, or_, pool, select, tuple_, update +from sqlalchemy import and_, asc, case, delete, distinct, exists, func, or_, pool, select, tuple_, update from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine @@ -1574,11 +1574,17 @@ class AgentDB: page_size: int = 10, only_saved_tasks: bool = False, only_workflows: bool = False, - title: str = "", + search_key: str | None = None, statuses: list[WorkflowStatus] | None = None, ) -> list[Workflow]: """ Get all workflows with the latest version for the organization. + + Search semantics: + - If `search_key` is provided, its value is used as a unified search term for both + `workflows.title` and workflow parameter metadata (key, description, and default_value). + - If `search_key` is not provided, no search filtering is applied. + - Parameter metadata search excludes soft-deleted parameter rows across parameter tables. """ if page < 1: raise ValueError(f"Page must be greater than 0, got {page}") @@ -1609,10 +1615,133 @@ class AgentDB: main_query = main_query.where(WorkflowModel.is_saved_task.is_(True)) elif only_workflows: main_query = main_query.where(WorkflowModel.is_saved_task.is_(False)) - if title: - main_query = main_query.where(WorkflowModel.title.ilike(f"%{title}%")) if statuses: main_query = main_query.where(WorkflowModel.status.in_(statuses)) + if search_key: + search_like = f"%{search_key}%" + title_like = WorkflowModel.title.ilike(search_like) + + parameter_filters = [ + # WorkflowParameterModel + exists( + select(1) + .select_from(WorkflowParameterModel) + .where(WorkflowParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(WorkflowParameterModel.deleted_at.is_(None)) + .where( + or_( + WorkflowParameterModel.key.ilike(search_like), + WorkflowParameterModel.description.ilike(search_like), + WorkflowParameterModel.default_value.ilike(search_like), + ) + ) + ), + # OutputParameterModel + exists( + select(1) + .select_from(OutputParameterModel) + .where(OutputParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(OutputParameterModel.deleted_at.is_(None)) + .where( + or_( + OutputParameterModel.key.ilike(search_like), + OutputParameterModel.description.ilike(search_like), + ) + ) + ), + # AWSSecretParameterModel + exists( + select(1) + .select_from(AWSSecretParameterModel) + .where(AWSSecretParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(AWSSecretParameterModel.deleted_at.is_(None)) + .where( + or_( + AWSSecretParameterModel.key.ilike(search_like), + AWSSecretParameterModel.description.ilike(search_like), + ) + ) + ), + # BitwardenLoginCredentialParameterModel + exists( + select(1) + .select_from(BitwardenLoginCredentialParameterModel) + .where(BitwardenLoginCredentialParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(BitwardenLoginCredentialParameterModel.deleted_at.is_(None)) + .where( + or_( + BitwardenLoginCredentialParameterModel.key.ilike(search_like), + BitwardenLoginCredentialParameterModel.description.ilike(search_like), + ) + ) + ), + # BitwardenSensitiveInformationParameterModel + exists( + select(1) + .select_from(BitwardenSensitiveInformationParameterModel) + .where(BitwardenSensitiveInformationParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(BitwardenSensitiveInformationParameterModel.deleted_at.is_(None)) + .where( + or_( + BitwardenSensitiveInformationParameterModel.key.ilike(search_like), + BitwardenSensitiveInformationParameterModel.description.ilike(search_like), + ) + ) + ), + # BitwardenCreditCardDataParameterModel + exists( + select(1) + .select_from(BitwardenCreditCardDataParameterModel) + .where(BitwardenCreditCardDataParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(BitwardenCreditCardDataParameterModel.deleted_at.is_(None)) + .where( + or_( + BitwardenCreditCardDataParameterModel.key.ilike(search_like), + BitwardenCreditCardDataParameterModel.description.ilike(search_like), + ) + ) + ), + # OnePasswordCredentialParameterModel + exists( + select(1) + .select_from(OnePasswordCredentialParameterModel) + .where(OnePasswordCredentialParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(OnePasswordCredentialParameterModel.deleted_at.is_(None)) + .where( + or_( + OnePasswordCredentialParameterModel.key.ilike(search_like), + OnePasswordCredentialParameterModel.description.ilike(search_like), + ) + ) + ), + # AzureVaultCredentialParameterModel + exists( + select(1) + .select_from(AzureVaultCredentialParameterModel) + .where(AzureVaultCredentialParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(AzureVaultCredentialParameterModel.deleted_at.is_(None)) + .where( + or_( + AzureVaultCredentialParameterModel.key.ilike(search_like), + AzureVaultCredentialParameterModel.description.ilike(search_like), + ) + ) + ), + # CredentialParameterModel + exists( + select(1) + .select_from(CredentialParameterModel) + .where(CredentialParameterModel.workflow_id == WorkflowModel.workflow_id) + .where(CredentialParameterModel.deleted_at.is_(None)) + .where( + or_( + CredentialParameterModel.key.ilike(search_like), + CredentialParameterModel.description.ilike(search_like), + ) + ) + ), + ] + main_query = main_query.where(or_(title_like, or_(*parameter_filters))) main_query = ( main_query.order_by(WorkflowModel.created_at.desc()).limit(page_size).offset(db_page * page_size) ) @@ -1987,7 +2116,11 @@ class AgentDB: page: int = 1, page_size: int = 10, status: list[WorkflowRunStatus] | None = None, + search_key: str | None = None, ) -> list[WorkflowRun]: + """ + Get runs for a workflow, with optional `search_key` on parameter key/description/value. + """ try: async with self.Session() as session: db_page = page - 1 # offset logic is 0 based @@ -1997,6 +2130,29 @@ class AgentDB: .filter(WorkflowRunModel.workflow_permanent_id == workflow_permanent_id) .filter(WorkflowRunModel.organization_id == organization_id) ) + if search_key: + key_like = f"%{search_key}%" + # Filter runs where any run parameter matches by key/description/value + # Use EXISTS to avoid duplicate rows and to keep pagination correct + param_exists = exists( + select(1) + .select_from(WorkflowRunParameterModel) + .join( + WorkflowParameterModel, + WorkflowParameterModel.workflow_parameter_id + == WorkflowRunParameterModel.workflow_parameter_id, + ) + .where(WorkflowRunParameterModel.workflow_run_id == WorkflowRunModel.workflow_run_id) + .where(WorkflowParameterModel.deleted_at.is_(None)) + .where( + or_( + WorkflowParameterModel.key.ilike(key_like), + WorkflowParameterModel.description.ilike(key_like), + WorkflowRunParameterModel.value.ilike(key_like), + ) + ) + ) + query = query.where(param_exists) if status: query = query.filter(WorkflowRunModel.status.in_(status)) query = query.order_by(WorkflowRunModel.created_at.desc()).limit(page_size).offset(db_page * page_size) diff --git a/skyvern/forge/sdk/routes/agent_protocol.py b/skyvern/forge/sdk/routes/agent_protocol.py index 0d3fb7b2..457376f3 100644 --- a/skyvern/forge/sdk/routes/agent_protocol.py +++ b/skyvern/forge/sdk/routes/agent_protocol.py @@ -1679,8 +1679,15 @@ async def get_workflow_runs_by_id( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1), status: Annotated[list[WorkflowRunStatus] | None, Query()] = None, + search_key: str | None = Query( + None, + description="Search runs by parameter key, parameter description, or run parameter value.", + ), current_org: Organization = Depends(org_auth_service.get_current_org), ) -> list[WorkflowRun]: + """ + Get workflow runs for a specific workflow permanent id. + """ analytics.capture("skyvern-oss-agent-workflow-runs-get") return await app.WORKFLOW_SERVICE.get_workflow_runs_for_workflow_permanent_id( workflow_permanent_id=workflow_id, @@ -1688,6 +1695,7 @@ async def get_workflow_runs_by_id( page=page, page_size=page_size, status=status, + search_key=search_key, ) @@ -1800,15 +1808,29 @@ async def get_workflows( page_size: int = Query(10, ge=1), only_saved_tasks: bool = Query(False), only_workflows: bool = Query(False), - title: str = Query(""), + search_key: str | None = Query( + None, + description="Unified search across workflow title and parameter metadata (key, description, default_value).", + ), + title: str = Query("", deprecated=True, description="Deprecated: use search_key instead."), current_org: Organization = Depends(org_auth_service.get_current_org), template: bool = Query(False), ) -> list[Workflow]: """ Get all workflows with the latest version for the organization. + + Search semantics: + - If `search_key` is provided, its value is used as a unified search term for both + `workflows.title` and workflow parameter metadata (key, description, and default_value for + `WorkflowParameterModel`). + - Falls back to deprecated `title` (title-only search) if `search_key` is not provided. + - Parameter metadata search excludes soft-deleted parameter rows across all parameter tables. """ analytics.capture("skyvern-oss-agent-workflows-get") + # Determine the effective search term: prioritize search_key, fallback to title + effective_search = search_key or (title if title else None) + if template: global_workflows_permanent_ids = await app.STORAGE.retrieve_global_workflows() if not global_workflows_permanent_ids: @@ -1817,7 +1839,7 @@ async def get_workflows( workflow_permanent_ids=global_workflows_permanent_ids, page=page, page_size=page_size, - title=title, + search_key=effective_search or "", statuses=[WorkflowStatus.published, WorkflowStatus.draft], ) return workflows @@ -1834,7 +1856,7 @@ async def get_workflows( page_size=page_size, only_saved_tasks=only_saved_tasks, only_workflows=only_workflows, - title=title, + search_key=effective_search, statuses=[WorkflowStatus.published, WorkflowStatus.draft], ) diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index 0196a7f2..92db2214 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -1137,7 +1137,7 @@ class WorkflowService: organization_id: str | None = None, page: int = 1, page_size: int = 10, - title: str = "", + search_key: str = "", statuses: list[WorkflowStatus] | None = None, ) -> list[Workflow]: return await app.DATABASE.get_workflows_by_permanent_ids( @@ -1145,7 +1145,7 @@ class WorkflowService: organization_id=organization_id, page=page, page_size=page_size, - title=title, + title=search_key, statuses=statuses, ) @@ -1156,11 +1156,14 @@ class WorkflowService: page_size: int = 10, only_saved_tasks: bool = False, only_workflows: bool = False, - title: str = "", + search_key: str | None = None, statuses: list[WorkflowStatus] | None = None, ) -> list[Workflow]: """ Get all workflows with the latest version for the organization. + + Args: + search_key: Unified search term for title and parameter metadata (replaces title/parameter). """ return await app.DATABASE.get_workflows_by_organization_id( organization_id=organization_id, @@ -1168,7 +1171,7 @@ class WorkflowService: page_size=page_size, only_saved_tasks=only_saved_tasks, only_workflows=only_workflows, - title=title, + search_key=search_key, statuses=statuses, ) @@ -1327,6 +1330,7 @@ class WorkflowService: page: int = 1, page_size: int = 10, status: list[WorkflowRunStatus] | None = None, + search_key: str | None = None, ) -> list[WorkflowRun]: return await app.DATABASE.get_workflow_runs_for_workflow_permanent_id( workflow_permanent_id=workflow_permanent_id, @@ -1334,6 +1338,7 @@ class WorkflowService: page=page, page_size=page_size, status=status, + search_key=search_key, ) async def create_workflow_run(