mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-11767): rotate login credentials across a pool per workflow run (#7053)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com> Co-authored-by: AronPerez <aperez0295@gmail.com>
This commit is contained in:
parent
b8e789d665
commit
234ebcba02
53 changed files with 2577 additions and 243 deletions
|
|
@ -0,0 +1,49 @@
|
|||
"""add workflow run credential selections
|
||||
|
||||
Revision ID: aff1632dc377
|
||||
Revises: 2ac47bc1c075
|
||||
Create Date: 2026-07-03T18:53:23.324057+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "aff1632dc377"
|
||||
down_revision: Union[str, None] = "2ac47bc1c075"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("credential_parameters", sa.Column("credential_ids", sa.JSON(), nullable=True))
|
||||
op.add_column("credential_parameters", sa.Column("selection_strategy", sa.String(), nullable=True))
|
||||
op.create_table(
|
||||
"workflow_run_credential_selections",
|
||||
sa.Column("selection_id", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_run_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_permanent_id", sa.String(), nullable=False),
|
||||
sa.Column("parameter_key", sa.String(), nullable=False),
|
||||
sa.Column("credential_id", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("selection_id"),
|
||||
sa.UniqueConstraint("workflow_run_id", "parameter_key", name="uq_wrcs_workflow_run_parameter_key"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_wrcs_lru_lookup",
|
||||
"workflow_run_credential_selections",
|
||||
["organization_id", "workflow_permanent_id", "parameter_key", "credential_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_wrcs_lru_lookup", table_name="workflow_run_credential_selections")
|
||||
op.drop_table("workflow_run_credential_selections")
|
||||
op.drop_column("credential_parameters", "selection_strategy")
|
||||
op.drop_column("credential_parameters", "credential_ids")
|
||||
|
|
@ -5809,6 +5809,44 @@
|
|||
},
|
||||
"description": "Exact-match filter on the error_code field inside each task's errors JSON array. A run matches if any of its tasks contains an error with a matching error_code. Error codes are user-defined strings set during workflow execution."
|
||||
},
|
||||
{
|
||||
"name": "created_at_start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Only include runs created at or after this UTC timestamp (ISO 8601).",
|
||||
"title": "Created At Start"
|
||||
},
|
||||
"description": "Only include runs created at or after this UTC timestamp (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "created_at_end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Only include runs created strictly before this UTC timestamp (ISO 8601).",
|
||||
"title": "Created At End"
|
||||
},
|
||||
"description": "Only include runs created strictly before this UTC timestamp (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
|
|
|
|||
49
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts
Normal file
49
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// @vitest-environment jsdom
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnabled } = vi.hoisted(() => ({ mockEnabled: vi.fn() }));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: () => mockEnabled(),
|
||||
}));
|
||||
|
||||
import { useAnalyticsDashboardFlag } from "./useAnalyticsDashboardFlag";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("useAnalyticsDashboardFlag", () => {
|
||||
it("passes through the posthog flag value when mock analytics is off", () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
mockEnabled.mockReturnValue(undefined);
|
||||
expect(
|
||||
renderHook(() => useAnalyticsDashboardFlag()).result.current,
|
||||
).toBeUndefined();
|
||||
|
||||
mockEnabled.mockReturnValue(true);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("forces true under VITE_MOCK_ANALYTICS=1, even when the posthog flag is false or undefined", () => {
|
||||
vi.stubEnv("VITE_MOCK_ANALYTICS", "1");
|
||||
|
||||
mockEnabled.mockReturnValue(false);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
mockEnabled.mockReturnValue(undefined);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
13
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts
Normal file
13
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||
import { ANALYTICS_DASHBOARD_FLAG } from "@/util/featureFlags";
|
||||
|
||||
// VITE_MOCK_ANALYTICS fakes API responses (see cloud/dev/mockAnalyticsServer.ts)
|
||||
// but can't reach real PostHog, so every ANALYTICS_DASHBOARD-gated surface
|
||||
// would stay hidden in local dev without this override.
|
||||
export function useAnalyticsDashboardFlag(): boolean | undefined {
|
||||
const enabled = useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_MOCK_ANALYTICS === "1") {
|
||||
return true;
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import CloudContext from "@/store/CloudContext";
|
||||
import { PageSlotsProvider, type PageSlots } from "@/store/PageSlots";
|
||||
import { WorkflowPage } from "./WorkflowPage";
|
||||
|
||||
const { mockFeatureFlagEnabled } = vi.hoisted(() => ({
|
||||
|
|
@ -114,24 +115,28 @@ afterEach(() => {
|
|||
type RenderOptions = {
|
||||
isCloud?: boolean;
|
||||
analyticsFlagEnabled?: boolean;
|
||||
pageSlots?: PageSlots;
|
||||
};
|
||||
|
||||
function renderWorkflowPage({
|
||||
isCloud = true,
|
||||
analyticsFlagEnabled = true,
|
||||
pageSlots = {},
|
||||
}: RenderOptions = {}) {
|
||||
mockFeatureFlagEnabled.mockReturnValue(analyticsFlagEnabled);
|
||||
|
||||
return render(
|
||||
<CloudContext.Provider value={isCloud}>
|
||||
<MemoryRouter initialEntries={["/workflows/wpid_abc123"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workflows/:workflowPermanentId"
|
||||
element={<WorkflowPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<PageSlotsProvider value={pageSlots}>
|
||||
<MemoryRouter initialEntries={["/workflows/wpid_abc123"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workflows/:workflowPermanentId"
|
||||
element={<WorkflowPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PageSlotsProvider>
|
||||
</CloudContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
|
@ -158,4 +163,15 @@ describe("WorkflowPage analytics button", () => {
|
|||
|
||||
expect(screen.queryByRole("link", { name: /analytics/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the injected workflow analytics panel above Past Runs", () => {
|
||||
const PanelStub = () => <div data-testid="analytics-panel-stub" />;
|
||||
const { container } = renderWorkflowPage({
|
||||
pageSlots: { workflowAnalyticsPanel: PanelStub },
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="analytics-panel-stub"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
} from "@/util/timeFormat";
|
||||
import { cn } from "@/util/utils";
|
||||
import CloudContext from "@/store/CloudContext";
|
||||
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
|
|
@ -78,18 +78,15 @@ import { useParameterExpansion } from "./hooks/useParameterExpansion";
|
|||
import { ParameterDisplayInline } from "./components/ParameterDisplayInline";
|
||||
import { getOrderedRunParameters } from "./utils";
|
||||
import { buildWorkflowAnalyticsPath } from "./workflowAnalyticsPath";
|
||||
import {
|
||||
useFeatureFlagEnabled,
|
||||
useFeatureFlagVariantKey,
|
||||
} from "posthog-js/react";
|
||||
import { useFeatureFlagVariantKey } from "posthog-js/react";
|
||||
import { EXPERIMENT, isABVariant } from "@/util/onboarding/experimentConfig";
|
||||
import {
|
||||
ANALYTICS_DASHBOARD_FLAG,
|
||||
WORKFLOW_TAGGING_FLAG,
|
||||
} from "@/util/featureFlags";
|
||||
import { WORKFLOW_TAGGING_FLAG } from "@/util/featureFlags";
|
||||
import { useAnalyticsDashboardFlag } from "@/hooks/useAnalyticsDashboardFlag";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
import { useOnboardingStateOptional } from "@/store/onboarding/useOnboardingState";
|
||||
import { OnboardingEmptyState } from "@/components/onboarding/OnboardingEmptyState";
|
||||
import { usePageSlots } from "@/store/PageSlots";
|
||||
import { resolveRunWindow } from "./resolveRunWindow";
|
||||
|
||||
function WorkflowPage() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
|
|
@ -98,9 +95,15 @@ function WorkflowPage() {
|
|||
const isNewUser = onboarding?.isNewUser ?? false;
|
||||
const onboardingState = onboarding?.state ?? null;
|
||||
const onboardingFlag = useFeatureFlagVariantKey(EXPERIMENT.flagKey);
|
||||
const analyticsEnabled =
|
||||
useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG) === true;
|
||||
const analyticsEnabled = useAnalyticsDashboardFlag() === true;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Snapped once on mount so the window stays stable across unrelated
|
||||
// searchParams changes (page flips, search) instead of re-sampling `now`.
|
||||
const runWindowNow = useRef(new Date());
|
||||
const runWindow = useMemo(
|
||||
() => resolveRunWindow(searchParams, runWindowNow.current),
|
||||
[searchParams],
|
||||
);
|
||||
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
|
||||
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -121,6 +124,8 @@ function WorkflowPage() {
|
|||
page,
|
||||
pageSize,
|
||||
search: debouncedSearch,
|
||||
createdAtStart: runWindow.createdAtStart,
|
||||
createdAtEnd: runWindow.createdAtEnd,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
|
|
@ -158,76 +163,85 @@ function WorkflowPage() {
|
|||
);
|
||||
const { data: tagColors } = useTagValuesQuery({ enabled: taggingEnabled });
|
||||
|
||||
const { workflowAnalyticsPanel: WorkflowAnalyticsPanel } = usePageSlots();
|
||||
|
||||
if (!workflowPermanentId) {
|
||||
return null; // this should never happen
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex justify-between">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{workflowIsLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-7 w-56" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-lg font-semibold">{workflow?.title}</h1>
|
||||
<h2 className="text-sm">{workflowPermanentId}</h2>
|
||||
</>
|
||||
)}
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{workflowIsLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-7 w-56" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-lg font-semibold">{workflow?.title}</h1>
|
||||
<h2 className="text-sm text-muted-foreground">
|
||||
{workflowPermanentId}
|
||||
</h2>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{taggingEnabled &&
|
||||
!workflowIsLoading &&
|
||||
workflowTags &&
|
||||
workflowTags.length > 0 ? (
|
||||
<TagChipList
|
||||
tags={workflowTags}
|
||||
descriptions={tagDescriptions}
|
||||
colors={tagColors}
|
||||
maxVisible={6}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{taggingEnabled &&
|
||||
!workflowIsLoading &&
|
||||
workflowTags &&
|
||||
workflowTags.length > 0 ? (
|
||||
<TagChipList
|
||||
tags={workflowTags}
|
||||
descriptions={tagDescriptions}
|
||||
colors={tagColors}
|
||||
maxVisible={6}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{workflow && (
|
||||
<WorkflowActions
|
||||
workflow={workflow}
|
||||
onSuccessfullyDeleted={() => navigate("/agents")}
|
||||
/>
|
||||
)}
|
||||
{isCloud && analyticsEnabled ? (
|
||||
<div className="flex gap-2">
|
||||
{workflow && (
|
||||
<WorkflowActions
|
||||
workflow={workflow}
|
||||
onSuccessfullyDeleted={() => navigate("/agents")}
|
||||
/>
|
||||
)}
|
||||
{isCloud && analyticsEnabled ? (
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={buildWorkflowAnalyticsPath(workflowPermanentId)}>
|
||||
<BarChartIcon className="mr-2 size-4" />
|
||||
Analytics
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={buildWorkflowAnalyticsPath(workflowPermanentId)}>
|
||||
<BarChartIcon className="mr-2 size-4" />
|
||||
Analytics
|
||||
<Link to={`/agents/${workflowPermanentId}/scripts`}>
|
||||
<CodeIcon className="mr-2 size-4" />
|
||||
Scripts
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={`/agents/${workflowPermanentId}/scripts`}>
|
||||
<CodeIcon className="mr-2 size-4" />
|
||||
Scripts
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary">
|
||||
<Link
|
||||
to={workflowEditorPath(workflowPermanentId, studioEnabled)}
|
||||
data-testid="workflow-open-editor-link"
|
||||
>
|
||||
<Pencil2Icon className="mr-2 size-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to={`/agents/${workflowPermanentId}/run`}>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Run
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary">
|
||||
<Link
|
||||
to={workflowEditorPath(workflowPermanentId, studioEnabled)}
|
||||
data-testid="workflow-open-editor-link"
|
||||
>
|
||||
<Pencil2Icon className="mr-2 size-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to={`/agents/${workflowPermanentId}/run`}>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Run
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{WorkflowAnalyticsPanel ? (
|
||||
<WorkflowAnalyticsPanel workflowPermanentId={workflowPermanentId} />
|
||||
) : null}
|
||||
</header>
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
|
|
|
|||
|
|
@ -308,10 +308,26 @@ function convertToParametersYAML(
|
|||
BITWARDEN_MASTER_PASSWORD_AWS_SECRET_KEY,
|
||||
};
|
||||
} else if (parameterIsSkyvernCredential(parameter)) {
|
||||
const hasCredentialRotation =
|
||||
(parameter.credentialIds?.length ?? 0) >= 2;
|
||||
if (
|
||||
parameter.dataType === WorkflowParameterValueType.CredentialId &&
|
||||
!hasCredentialRotation
|
||||
) {
|
||||
return {
|
||||
parameter_type: WorkflowParameterTypes.Workflow,
|
||||
workflow_parameter_type:
|
||||
WorkflowParameterValueType.CredentialId,
|
||||
default_value: parameter.credentialId,
|
||||
key: parameter.key,
|
||||
description: parameter.description || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
parameter_type: WorkflowParameterTypes.Workflow,
|
||||
workflow_parameter_type: WorkflowParameterValueType.CredentialId,
|
||||
default_value: parameter.credentialId,
|
||||
parameter_type: WorkflowParameterTypes.Credential,
|
||||
credential_id: parameter.credentialId,
|
||||
credential_ids: parameter.credentialIds ?? null,
|
||||
selection_strategy: parameter.selectionStrategy ?? null,
|
||||
key: parameter.key,
|
||||
description: parameter.description || null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import { useUpdate } from "../../useUpdate";
|
|||
import { AppNode, isWorkflowBlockNode } from "..";
|
||||
import { updateNodeAndDescendantsVisibility } from "../../workflowEditorUtils";
|
||||
import { applyEdgeVisibility } from "./applyEdgeVisibility";
|
||||
import {
|
||||
getConditionLabel,
|
||||
orderBranchesWithDefaultsLast,
|
||||
} from "./branchDisplayUtils";
|
||||
import {
|
||||
ConditionalNodeData,
|
||||
createBranchCondition,
|
||||
|
|
@ -61,11 +65,10 @@ function BranchesEditor({ nodeId, data }: Props) {
|
|||
editable: data.editable,
|
||||
});
|
||||
|
||||
const orderedBranches = useMemo(() => {
|
||||
const defaultBranch = data.branches.find((branch) => branch.is_default);
|
||||
const nonDefault = data.branches.filter((branch) => !branch.is_default);
|
||||
return defaultBranch ? [...nonDefault, defaultBranch] : nonDefault;
|
||||
}, [data.branches]);
|
||||
const orderedBranches = useMemo(
|
||||
() => orderBranchesWithDefaultsLast(data.branches),
|
||||
[data.branches],
|
||||
);
|
||||
|
||||
const activeBranch =
|
||||
orderedBranches.find((branch) => branch.id === data.activeBranchId) ??
|
||||
|
|
@ -204,12 +207,10 @@ function BranchesEditor({ nodeId, data }: Props) {
|
|||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
const defaultBranch = data.branches.find((branch) => branch.is_default);
|
||||
const defaultBranches = data.branches.filter((branch) => branch.is_default);
|
||||
const otherBranches = data.branches.filter((branch) => !branch.is_default);
|
||||
const newBranch = createBranchCondition();
|
||||
const updatedBranches = defaultBranch
|
||||
? [...otherBranches, newBranch, defaultBranch]
|
||||
: [...otherBranches, newBranch];
|
||||
const updatedBranches = [...otherBranches, newBranch, ...defaultBranches];
|
||||
|
||||
// Find the START and NodeAdder nodes inside this conditional
|
||||
const startNode = nodes.find(
|
||||
|
|
@ -335,11 +336,9 @@ function BranchesEditor({ nodeId, data }: Props) {
|
|||
newNonDefaultBranches[currentIndex]!,
|
||||
];
|
||||
|
||||
// Reconstruct the array with default branch at the end
|
||||
const defaultBranch = data.branches.find((b) => b.is_default);
|
||||
const reorderedBranches = defaultBranch
|
||||
? [...newNonDefaultBranches, defaultBranch]
|
||||
: newNonDefaultBranches;
|
||||
// Reconstruct the array with default branch(es) at the end.
|
||||
const defaultBranches = data.branches.filter((b) => b.is_default);
|
||||
const reorderedBranches = [...newNonDefaultBranches, ...defaultBranches];
|
||||
|
||||
update({ branches: reorderedBranches });
|
||||
};
|
||||
|
|
@ -366,11 +365,9 @@ function BranchesEditor({ nodeId, data }: Props) {
|
|||
newNonDefaultBranches[currentIndex]!,
|
||||
];
|
||||
|
||||
// Reconstruct with default branch at the end
|
||||
const defaultBranch = data.branches.find((b) => b.is_default);
|
||||
const reorderedBranches = defaultBranch
|
||||
? [...newNonDefaultBranches, defaultBranch]
|
||||
: newNonDefaultBranches;
|
||||
// Reconstruct with default branch(es) at the end.
|
||||
const defaultBranches = data.branches.filter((b) => b.is_default);
|
||||
const reorderedBranches = [...newNonDefaultBranches, ...defaultBranches];
|
||||
|
||||
update({ branches: reorderedBranches });
|
||||
};
|
||||
|
|
@ -395,34 +392,6 @@ function BranchesEditor({ nodeId, data }: Props) {
|
|||
});
|
||||
};
|
||||
|
||||
// Convert number to Excel-style letter (A, B, C... Z, AA, AB, AC...)
|
||||
const getExcelStyleLetter = (index: number): string => {
|
||||
let result = "";
|
||||
let num = index;
|
||||
|
||||
while (num >= 0) {
|
||||
result = String.fromCharCode(65 + (num % 26)) + result;
|
||||
num = Math.floor(num / 26) - 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Generate condition label: A • If, B • Else, C • Else If, etc.
|
||||
const getConditionLabel = (branch: BranchCondition, index: number) => {
|
||||
const letter = getExcelStyleLetter(index);
|
||||
|
||||
if (branch.is_default) {
|
||||
return `${letter} • Else`;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return `${letter} • If`;
|
||||
}
|
||||
|
||||
return `${letter} • Else If`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import type { BranchCondition } from "../../../types/workflowTypes";
|
||||
|
||||
function getExcelStyleLetter(index: number): string {
|
||||
let result = "";
|
||||
let num = index;
|
||||
|
||||
while (num >= 0) {
|
||||
result = String.fromCharCode(65 + (num % 26)) + result;
|
||||
num = Math.floor(num / 26) - 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getConditionLabel(
|
||||
branch: BranchCondition,
|
||||
index: number,
|
||||
): string {
|
||||
const letter = getExcelStyleLetter(index);
|
||||
if (branch.is_default) return `${letter} • Else`;
|
||||
if (index === 0) return `${letter} • If`;
|
||||
return `${letter} • Else If`;
|
||||
}
|
||||
|
||||
export function orderBranchesWithDefaultsLast(
|
||||
branches: Array<BranchCondition>,
|
||||
): Array<BranchCondition> {
|
||||
const nonDefaultBranches = branches.filter((branch) => !branch.is_default);
|
||||
// Keep every default branch visible so invalid or imported data remains editable.
|
||||
const defaultBranches = branches.filter((branch) => branch.is_default);
|
||||
return [...nonDefaultBranches, ...defaultBranches];
|
||||
}
|
||||
|
|
@ -1,3 +1,17 @@
|
|||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -7,21 +21,34 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
|
||||
import CloudContext from "@/store/CloudContext";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
import { CredentialsModal } from "@/routes/credentials/CredentialsModal";
|
||||
import { ExclamationTriangleIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
Cross2Icon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon,
|
||||
ReloadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { getHostname } from "@/util/getHostname";
|
||||
import {
|
||||
CredentialModalTypes,
|
||||
useCredentialModalState,
|
||||
} from "@/routes/credentials/useCredentialModalState";
|
||||
import { useNodes } from "@xyflow/react";
|
||||
import { useNodes, useReactFlow } from "@xyflow/react";
|
||||
import { AppNode } from "..";
|
||||
import { isLoginNode } from "./types";
|
||||
import {
|
||||
isStartNode,
|
||||
isWorkflowStartNodeData,
|
||||
type WorkflowStartNodeData,
|
||||
} from "../StartNode/types";
|
||||
import {
|
||||
parameterIsSkyvernCredential,
|
||||
parameterIsBitwardenCredential,
|
||||
|
|
@ -30,11 +57,15 @@ import {
|
|||
isAutoGeneratedCredentialKey,
|
||||
computeWrappedCredentialIds,
|
||||
} from "../../types";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
const ROTATION_SELECTION_STRATEGY = "round_robin" as const;
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
editable?: boolean;
|
||||
/** Called when a credential with a tested_url is selected to auto-fill the login block URL */
|
||||
onUrlAutoFill?: (url: string) => void;
|
||||
/** Current URL value of the login block — skip auto-fill if already set */
|
||||
|
|
@ -63,11 +94,14 @@ function LoginBlockCredentialSelector({
|
|||
nodeId,
|
||||
value,
|
||||
onChange,
|
||||
editable = true,
|
||||
onUrlAutoFill,
|
||||
currentUrl,
|
||||
}: Props) {
|
||||
const { setIsOpen, setType } = useCredentialModalState();
|
||||
const nodes = useNodes<AppNode>();
|
||||
const { updateNodeData } = useReactFlow<AppNode>();
|
||||
const [rotationDraft, setRotationDraft] = useState(false);
|
||||
const {
|
||||
parameters: workflowParameters,
|
||||
setParameters: setWorkflowParameters,
|
||||
|
|
@ -92,6 +126,19 @@ function LoginBlockCredentialSelector({
|
|||
[credentials],
|
||||
);
|
||||
|
||||
const selectedSkyvernCredentialParameter = useMemo(() => {
|
||||
if (!value) return undefined;
|
||||
const parameter = credentialParameters.find((p) => p.key === value);
|
||||
if (!parameter || !parameterIsSkyvernCredential(parameter)) {
|
||||
return undefined;
|
||||
}
|
||||
return parameter;
|
||||
}, [value, credentialParameters]);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationDraft(false);
|
||||
}, [value]);
|
||||
|
||||
// Determine which credential is currently selected (by credential_id)
|
||||
// This handles multiple cases:
|
||||
// 1. Skyvern credential parameters (have credentialId)
|
||||
|
|
@ -121,6 +168,33 @@ function LoginBlockCredentialSelector({
|
|||
return undefined;
|
||||
}, [value, credentialParameters, workflowParameters]);
|
||||
|
||||
const rotationCredentialIds = useMemo(() => {
|
||||
const sourceIds = selectedSkyvernCredentialParameter?.credentialIds?.length
|
||||
? selectedSkyvernCredentialParameter.credentialIds
|
||||
: selectedCredentialId
|
||||
? [selectedCredentialId]
|
||||
: [];
|
||||
|
||||
return sourceIds.filter((id, index, ids): id is string => {
|
||||
return typeof id === "string" && ids.indexOf(id) === index;
|
||||
});
|
||||
}, [selectedSkyvernCredentialParameter, selectedCredentialId]);
|
||||
|
||||
const rotationIsSaved = rotationCredentialIds.length > 1;
|
||||
const rotationMode =
|
||||
Boolean(selectedSkyvernCredentialParameter) &&
|
||||
(rotationDraft || rotationIsSaved);
|
||||
|
||||
const workflowStartNode = useMemo<
|
||||
{ id: string; data: WorkflowStartNodeData } | undefined
|
||||
>(() => {
|
||||
const node = nodes.find(isStartNode);
|
||||
if (!node || !isWorkflowStartNodeData(node.data)) {
|
||||
return undefined;
|
||||
}
|
||||
return { id: node.id, data: node.data };
|
||||
}, [nodes]);
|
||||
|
||||
// Check if the selected credential is missing (deleted)
|
||||
const isCredentialMissing = useMemo(() => {
|
||||
if (!selectedCredentialId) return false;
|
||||
|
|
@ -160,15 +234,30 @@ function LoginBlockCredentialSelector({
|
|||
return <Skeleton className="h-8 w-full" />;
|
||||
}
|
||||
|
||||
const credentialOptions = credentials
|
||||
.filter((credential) => !wrappedCredentialIds.has(credential.credential_id))
|
||||
.map((credential) => ({
|
||||
label: credential.name,
|
||||
value: credential.credential_id,
|
||||
type: "credential" as const,
|
||||
hasBrowserProfile: !!credential.browser_profile_id,
|
||||
browserProfileUrl: credential.tested_url ?? null,
|
||||
}));
|
||||
const allCredentialOptions = credentials.map((credential) => ({
|
||||
label: credential.name,
|
||||
value: credential.credential_id,
|
||||
type: "credential" as const,
|
||||
hasBrowserProfile: !!credential.browser_profile_id,
|
||||
browserProfileUrl: credential.tested_url ?? null,
|
||||
}));
|
||||
|
||||
const credentialOptions = allCredentialOptions.filter(
|
||||
(credential) => !wrappedCredentialIds.has(credential.value),
|
||||
);
|
||||
|
||||
const selectedRotationCredentials = rotationCredentialIds.map(
|
||||
(credentialId) => {
|
||||
const option = allCredentialOptions.find(
|
||||
(credential) => credential.value === credentialId,
|
||||
);
|
||||
return {
|
||||
label: option?.label ?? credentialId,
|
||||
value: credentialId,
|
||||
hasBrowserProfile: option?.hasBrowserProfile ?? false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-generated Skyvern wrappers are hidden (they back a direct credential
|
||||
// pick); user-authored Skyvern params and external vault params are listed.
|
||||
|
|
@ -216,12 +305,250 @@ function LoginBlockCredentialSelector({
|
|||
selectValue = selectedCredentialId ?? value ?? "";
|
||||
}
|
||||
|
||||
const writeRotationCredentialIds = (credentialIds: Array<string>) => {
|
||||
if (!selectedSkyvernCredentialParameter || credentialIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedCredentialIds = credentialIds.filter((id, index, ids) => {
|
||||
return ids.indexOf(id) === index;
|
||||
});
|
||||
const firstCredentialId = orderedCredentialIds[0];
|
||||
if (!firstCredentialId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newParameters = workflowParameters.map((parameter) => {
|
||||
if (
|
||||
parameter.key !== selectedSkyvernCredentialParameter.key ||
|
||||
parameter.parameterType !== "credential" ||
|
||||
!parameterIsSkyvernCredential(parameter)
|
||||
) {
|
||||
return parameter;
|
||||
}
|
||||
|
||||
if (orderedCredentialIds.length < 2) {
|
||||
return {
|
||||
...parameter,
|
||||
credentialId: firstCredentialId,
|
||||
credentialIds: null,
|
||||
selectionStrategy: null,
|
||||
dataType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...parameter,
|
||||
credentialId: firstCredentialId,
|
||||
credentialIds: orderedCredentialIds,
|
||||
selectionStrategy: ROTATION_SELECTION_STRATEGY,
|
||||
dataType: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
setWorkflowParameters(newParameters);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const toggleRotationCredential = (credentialId: string) => {
|
||||
if (!editable) return;
|
||||
const nextCredentialIds = rotationCredentialIds.includes(credentialId)
|
||||
? rotationCredentialIds.filter((id) => id !== credentialId)
|
||||
: [...rotationCredentialIds, credentialId];
|
||||
|
||||
if (nextCredentialIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeRotationCredentialIds(nextCredentialIds);
|
||||
if (nextCredentialIds.length < 2) {
|
||||
setRotationDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableRotation = () => {
|
||||
if (!editable) return;
|
||||
if (rotationCredentialIds.length > 0) {
|
||||
writeRotationCredentialIds([rotationCredentialIds[0]!]);
|
||||
}
|
||||
setRotationDraft(false);
|
||||
};
|
||||
|
||||
const setRunSequentially = (checked: boolean) => {
|
||||
if (!workflowStartNode) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(workflowStartNode.id, {
|
||||
runSequentially: checked,
|
||||
sequentialKey: checked ? workflowStartNode.data.sequentialKey : null,
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
if (rotationMode) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-10 w-full justify-between px-3 py-2 text-left text-xs font-normal"
|
||||
disabled={!editable}
|
||||
>
|
||||
<span className="truncate">
|
||||
{rotationCredentialIds.length > 1
|
||||
? `Rotates across ${rotationCredentialIds.length} credentials`
|
||||
: "Add credentials to rotate"}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[28rem] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search credentials..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No credentials found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allCredentialOptions.map((option) => {
|
||||
const isSelected = rotationCredentialIds.includes(
|
||||
option.value,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
className="cursor-pointer"
|
||||
onSelect={() =>
|
||||
toggleRotationCredential(option.value)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex size-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.hasBrowserProfile ? (
|
||||
<span className="rounded bg-green-900/40 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
saved-profile
|
||||
</span>
|
||||
) : null}
|
||||
{option.browserProfileUrl ? (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{getHostname(option.browserProfileUrl)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="space-y-1">
|
||||
{selectedRotationCredentials.map((credential, index) => (
|
||||
<div
|
||||
key={credential.value}
|
||||
className="flex items-center gap-2 rounded-md border border-slate-700/60 bg-slate-900/40 px-2 py-1.5"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-slate-800 text-[10px] text-slate-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-200">
|
||||
{credential.label}
|
||||
</span>
|
||||
{credential.hasBrowserProfile ? (
|
||||
<span
|
||||
className="shrink-0 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400"
|
||||
title="Has a saved browser profile - runs with this account keep its logged-in session"
|
||||
>
|
||||
Profile
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-500 transition-colors hover:text-slate-200 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={!editable || selectedRotationCredentials.length < 2}
|
||||
onClick={() => toggleRotationCredential(credential.value)}
|
||||
aria-label={`Remove ${credential.label}`}
|
||||
>
|
||||
<Cross2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rotationCredentialIds.length > 1 ? (
|
||||
<div className="flex items-start gap-2 text-xs text-slate-400">
|
||||
<div className="space-y-0.5">
|
||||
<span>Disable parallel runs</span>
|
||||
<p className="text-[11px] leading-4 text-slate-500">
|
||||
Queues this workflow's runs one at a time so rotated accounts
|
||||
never log in simultaneously.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
className="ml-auto shrink-0"
|
||||
checked={
|
||||
workflowStartNode
|
||||
? workflowStartNode.data.runSequentially
|
||||
: false
|
||||
}
|
||||
disabled={!editable || !workflowStartNode}
|
||||
onCheckedChange={setRunSequentially}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 transition-colors hover:text-slate-200 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={!editable}
|
||||
onClick={disableRotation}
|
||||
>
|
||||
Use single credential
|
||||
</button>
|
||||
</div>
|
||||
<CredentialsModal
|
||||
onCredentialCreated={(id) => {
|
||||
if (!editable) return;
|
||||
const existingKeys = workflowParameters.map((param) => param.key);
|
||||
const newKey = generateDefaultCredentialParameterKey(existingKeys);
|
||||
|
||||
setWorkflowParameters([
|
||||
...workflowParameters,
|
||||
{
|
||||
parameterType: "credential",
|
||||
credentialId: id,
|
||||
key: newKey,
|
||||
},
|
||||
]);
|
||||
setHasChanges(true);
|
||||
onChange?.(newKey);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
disabled={!editable}
|
||||
key={value ?? "no-credential"}
|
||||
value={selectValue}
|
||||
onValueChange={(newValue) => {
|
||||
if (!editable) return;
|
||||
if (newValue === "new") {
|
||||
setIsOpen(true);
|
||||
setType(CredentialModalTypes.PASSWORD);
|
||||
|
|
@ -270,7 +597,8 @@ function LoginBlockCredentialSelector({
|
|||
return (
|
||||
parameter.parameterType === "credential" &&
|
||||
parameterIsSkyvernCredential(parameter) &&
|
||||
parameter.credentialId === newValue
|
||||
parameter.credentialId === newValue &&
|
||||
(parameter.credentialIds?.length ?? 0) < 2
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -303,6 +631,7 @@ function LoginBlockCredentialSelector({
|
|||
setWorkflowParameters(newParameters);
|
||||
// Zustand mutation is invisible to the node-change listener, so the unsaved-changes blocker would miss this edit.
|
||||
setHasChanges(true);
|
||||
setRotationDraft(false);
|
||||
onChange?.(parameterKeyToUse);
|
||||
|
||||
// Auto-fill the login block URL from the credential's tested_url
|
||||
|
|
@ -369,8 +698,23 @@ function LoginBlockCredentialSelector({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!rotationMode &&
|
||||
selectedSkyvernCredentialParameter &&
|
||||
selectedCredentialId &&
|
||||
!isCredentialMissing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-400 transition-colors hover:text-slate-200 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={!editable}
|
||||
onClick={() => setRotationDraft(true)}
|
||||
>
|
||||
<ReloadIcon className="size-3" />
|
||||
Rotate between multiple credentials
|
||||
</button>
|
||||
) : null}
|
||||
<CredentialsModal
|
||||
onCredentialCreated={(id) => {
|
||||
if (!editable) return;
|
||||
const existingKeys = workflowParameters.map((param) => param.key);
|
||||
const newKey = generateDefaultCredentialParameterKey(existingKeys);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ArrowTopRightIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
ArrowTopRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useEdges, useNodes, useNodesData } from "@xyflow/react";
|
||||
|
||||
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||
|
|
@ -18,12 +22,14 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { RunEngineSelector } from "@/components/EngineSelector";
|
||||
import { ModelSelector } from "@/components/ModelSelector";
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
|
||||
import { ErrorCodeMappingEditor } from "../../ErrorCodeMappingEditor";
|
||||
import { AI_IMPROVE_CONFIGS } from "../../constants";
|
||||
import { helpTooltips, placeholders } from "../../helpContent";
|
||||
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
|
||||
import { type AppNode } from "..";
|
||||
import { parameterIsSkyvernCredential } from "../../types";
|
||||
import { BlockExecutionOptions } from "../components/BlockExecutionOptions";
|
||||
import { DisableCache } from "../DisableCache";
|
||||
import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt";
|
||||
|
|
@ -74,9 +80,28 @@ function LoginEditorBody({
|
|||
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id: blockId });
|
||||
const isInsideForLoop = isNodeInsideForLoop(nodes, blockId);
|
||||
const parentLoopSkipsOnFail = getParentLoopSkipsOnFail(nodes, blockId);
|
||||
const workflowParameters = useWorkflowParametersStore(
|
||||
(state) => state.parameters,
|
||||
);
|
||||
const credentialTotpIdentifier = useSelectedCredentialTotpIdentifier(
|
||||
data.parameterKeys.length > 0 ? data.parameterKeys[0] : undefined,
|
||||
);
|
||||
const credentialParameterKey =
|
||||
data.parameterKeys.length > 0 ? data.parameterKeys[0] : undefined;
|
||||
const credentialIsRotating = useMemo(() => {
|
||||
if (!credentialParameterKey) return false;
|
||||
|
||||
const parameter = workflowParameters.find(
|
||||
(workflowParameter) => workflowParameter.key === credentialParameterKey,
|
||||
);
|
||||
|
||||
if (!parameter || parameter.parameterType !== "credential") return false;
|
||||
|
||||
return (
|
||||
parameterIsSkyvernCredential(parameter) &&
|
||||
(parameter.credentialIds?.length ?? 0) >= 2
|
||||
);
|
||||
}, [credentialParameterKey, workflowParameters]);
|
||||
const hasTotpValues = Boolean(
|
||||
data.totpIdentifier?.trim() || data.totpVerificationUrl?.trim(),
|
||||
);
|
||||
|
|
@ -133,6 +158,7 @@ function LoginEditorBody({
|
|||
</p>
|
||||
<LoginBlockCredentialSelector
|
||||
nodeId={blockId}
|
||||
editable={editable}
|
||||
value={
|
||||
data.parameterKeys.length > 0 ? data.parameterKeys[0] : undefined
|
||||
}
|
||||
|
|
@ -154,6 +180,16 @@ function LoginEditorBody({
|
|||
</div>
|
||||
{showTwoFactorFields ? (
|
||||
<div className="space-y-3">
|
||||
{credentialIsRotating && hasTotpValues ? (
|
||||
<p className="flex items-start gap-1.5 text-xs text-amber-400">
|
||||
<ExclamationTriangleIcon className="mt-0.5 size-3 shrink-0" />
|
||||
<span>
|
||||
Block-level 2FA overrides every rotated account with a single
|
||||
destination. Remove it unless all accounts share one 2FA
|
||||
inbox.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">2FA Identifier</Label>
|
||||
|
|
@ -205,6 +241,11 @@ function LoginEditorBody({
|
|||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : credentialIsRotating ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
2FA is handled per account — each rotated credential uses its own
|
||||
verification settings.
|
||||
</p>
|
||||
) : showCredentialTotpSummary ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ function LoopEditorBody({
|
|||
nodeId={blockId}
|
||||
value={loopVariableReference}
|
||||
onChange={(v) => update({ loopVariableReference: v })}
|
||||
data-testid="loop-variable-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ function LoopEditorBody({
|
|||
inferBranchCriteriaTypeFromExpression(v),
|
||||
})
|
||||
}
|
||||
data-testid="while-condition-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ vi.mock("@xyflow/react", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/WorkflowBlockInputTextarea", () => ({
|
||||
WorkflowBlockInputTextarea: ({ value }: { value: string }) => (
|
||||
<textarea readOnly value={value} />
|
||||
),
|
||||
}));
|
||||
|
||||
// The sidebar header now renders an editable title for block nodes, which
|
||||
// pulls in the label-change hook (needs a ReactFlow store). Stub it so these
|
||||
// commit-orchestration tests stay scoped to the body, not the title. The
|
||||
|
|
@ -113,12 +119,41 @@ describe("BLOCK_FORMS dispatcher", () => {
|
|||
expect(BLOCK_FORM_KEYS).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("conditional routes to the sidebar placeholder (canvas tile owns BranchesEditor)", () => {
|
||||
mockNodeFixtures.set("c1", { id: "c1", type: "conditional" });
|
||||
test("conditional routes to a sidebar form that shows branch prompts", () => {
|
||||
mockNodeFixtures.set("c1", {
|
||||
id: "c1",
|
||||
type: "conditional",
|
||||
data: {
|
||||
editable: true,
|
||||
branches: [
|
||||
{
|
||||
id: "branch_a",
|
||||
criteria: {
|
||||
criteria_type: "jinja2_template",
|
||||
expression: "{{ total > 100 }}",
|
||||
description: null,
|
||||
},
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: "branch_default",
|
||||
criteria: null,
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: true,
|
||||
},
|
||||
],
|
||||
activeBranchId: "branch_a",
|
||||
mergeLabel: null,
|
||||
continueOnFailure: false,
|
||||
nextLoopOnFailure: false,
|
||||
},
|
||||
});
|
||||
render(<BlockConfigForm blockId="c1" />);
|
||||
expect(
|
||||
screen.getByTestId("block-config-form-conditional-placeholder"),
|
||||
).toBeDefined();
|
||||
expect(screen.getByTestId("conditional-block-form")).toBeDefined();
|
||||
expect(screen.getByDisplayValue("{{ total > 100 }}")).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns null when the node lookup misses (block was deleted)", () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { usePendingCommitsStore } from "@/store/PendingCommitsStore";
|
|||
import { AppNode, type WorkflowBlockNode } from "../nodes";
|
||||
import { ActionBlockForm } from "./BlockConfigForm/ActionBlockForm";
|
||||
import { CodeBlockBlockForm } from "./BlockConfigForm/CodeBlockBlockForm";
|
||||
import { ConditionalBlockForm } from "./BlockConfigForm/ConditionalBlockForm";
|
||||
import { DownloadBlockForm } from "./BlockConfigForm/DownloadBlockForm";
|
||||
import { ExtractionBlockForm } from "./BlockConfigForm/ExtractionBlockForm";
|
||||
import { FileDownloadBlockForm } from "./BlockConfigForm/FileDownloadBlockForm";
|
||||
|
|
@ -36,20 +37,6 @@ type WorkflowBlockNodeType = WorkflowBlockNode["type"];
|
|||
|
||||
type BlockFormComponent = ComponentType<{ blockId: string }>;
|
||||
|
||||
// BranchesEditor runs auto-default-branch + auto-activeBranchId repair
|
||||
// effects; mounting it in the sidebar would fire the same repairs from
|
||||
// two concurrent instances. The canvas tile is the authoritative mount.
|
||||
function ConditionalSidebarPlaceholder() {
|
||||
return (
|
||||
<div
|
||||
data-testid="block-config-form-conditional-placeholder"
|
||||
className="px-4 py-4 text-sm text-slate-400"
|
||||
>
|
||||
Edit conditional branches on the canvas tile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BLOCK_FORMS: Record<WorkflowBlockNodeType, BlockFormComponent> = {
|
||||
task: TaskBlockForm,
|
||||
taskv2: Taskv2BlockForm,
|
||||
|
|
@ -59,7 +46,7 @@ const BLOCK_FORMS: Record<WorkflowBlockNodeType, BlockFormComponent> = {
|
|||
login: LoginBlockForm,
|
||||
wait: WaitBlockForm,
|
||||
loop: LoopBlockForm,
|
||||
conditional: ConditionalSidebarPlaceholder,
|
||||
conditional: ConditionalBlockForm,
|
||||
textPrompt: TextPromptBlockForm,
|
||||
sendEmail: SendEmailBlockForm,
|
||||
codeBlock: CodeBlockBlockForm,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ConditionalNode } from "../../nodes/ConditionalNode/types";
|
||||
|
|
@ -10,43 +16,65 @@ const mockNodes = new Map<
|
|||
{ id: string; type: string; data?: Record<string, unknown> } | undefined
|
||||
>();
|
||||
const updateNodeData = vi.fn();
|
||||
const isWorkflowBlockNodeMock = vi.fn<(node: { type: string }) => boolean>(
|
||||
(node) => node.type !== "nodeAdder" && node.type !== "start",
|
||||
);
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
|
||||
return {
|
||||
...actual,
|
||||
useNodesData: (id: string) => {
|
||||
const node = mockNodes.get(id);
|
||||
return node ? { id: node.id, type: node.type, data: node.data } : null;
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
getNode: (id: string) => mockNodes.get(id),
|
||||
updateNodeData,
|
||||
}),
|
||||
useNodes: () => Array.from(mockNodes.values()),
|
||||
useEdges: () => [],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../nodes/ConditionalNode/BranchesEditor", () => ({
|
||||
BranchesEditor: (props: {
|
||||
vi.mock("../../nodes", () => ({
|
||||
isWorkflowBlockNode: (node: { type: string }) =>
|
||||
isWorkflowBlockNodeMock(node),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/WorkflowBlockInputTextarea", () => ({
|
||||
WorkflowBlockInputTextarea: ({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
nodeId: string;
|
||||
data: { branches: Array<unknown>; activeBranchId: string | null };
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="branches-editor"
|
||||
data-node-id={props.nodeId}
|
||||
data-branches-count={String(props.data.branches.length)}
|
||||
data-active-branch={props.data.activeBranchId ?? ""}
|
||||
<textarea
|
||||
aria-label={placeholder}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useSidebarSaveStateStore } from "@/store/SidebarSaveStateStore";
|
||||
import { usePendingCommitsStore } from "@/store/PendingCommitsStore";
|
||||
import { useSidebarSaveStateStore } from "@/store/SidebarSaveStateStore";
|
||||
import { ConditionalBlockForm } from "./ConditionalBlockForm";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockNodes.clear();
|
||||
updateNodeData.mockReset();
|
||||
isWorkflowBlockNodeMock.mockReset();
|
||||
isWorkflowBlockNodeMock.mockImplementation(
|
||||
(node) => node.type !== "nodeAdder" && node.type !== "start",
|
||||
);
|
||||
usePendingCommitsStore.setState({ commits: {} });
|
||||
useSidebarSaveStateStore.getState().reset();
|
||||
});
|
||||
|
|
@ -75,7 +103,7 @@ function setConditionalNode(
|
|||
id: "branch_a",
|
||||
criteria: {
|
||||
criteria_type: "jinja2_template",
|
||||
expression: "",
|
||||
expression: "{{ total > 100 }}",
|
||||
description: null,
|
||||
},
|
||||
next_block_label: null,
|
||||
|
|
@ -97,7 +125,7 @@ function setConditionalNode(
|
|||
});
|
||||
}
|
||||
|
||||
describe("ConditionalBlockForm (SKY-9361)", () => {
|
||||
describe("ConditionalBlockForm", () => {
|
||||
test("returns null for missing node", () => {
|
||||
const { container } = render(<ConditionalBlockForm blockId="missing" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
|
@ -109,14 +137,121 @@ describe("ConditionalBlockForm (SKY-9361)", () => {
|
|||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test("renders BranchesEditor with the blockId and node data", () => {
|
||||
test("returns null for non-workflow conditional nodes", () => {
|
||||
isWorkflowBlockNodeMock.mockReturnValue(false);
|
||||
setConditionalNode("c1");
|
||||
|
||||
const { container } = render(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(isWorkflowBlockNodeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "conditional" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("renders branch prompts and the default branch in the sidebar", () => {
|
||||
setConditionalNode("c1");
|
||||
render(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
const editor = screen.getByTestId("branches-editor");
|
||||
expect(editor.getAttribute("data-node-id")).toBe("c1");
|
||||
expect(editor.getAttribute("data-branches-count")).toBe("2");
|
||||
expect(editor.getAttribute("data-active-branch")).toBe("branch_a");
|
||||
expect(screen.getByText("A • If")).toBeDefined();
|
||||
expect(screen.getByText("B • Else")).toBeDefined();
|
||||
expect(screen.getByText("Active")).toBeDefined();
|
||||
expect(screen.getByDisplayValue("{{ total > 100 }}")).toBeDefined();
|
||||
expect(
|
||||
(
|
||||
screen.getByDisplayValue(
|
||||
"Executed when no other condition matches",
|
||||
) as HTMLTextAreaElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves every default branch when ordering branch prompts", () => {
|
||||
setConditionalNode("c1", {
|
||||
branches: [
|
||||
{
|
||||
id: "branch_a",
|
||||
criteria: {
|
||||
criteria_type: "jinja2_template",
|
||||
expression: "{{ total > 100 }}",
|
||||
description: null,
|
||||
},
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: "branch_default_1",
|
||||
criteria: null,
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
id: "branch_b",
|
||||
criteria: {
|
||||
criteria_type: "jinja2_template",
|
||||
expression: "{{ total > 250 }}",
|
||||
description: null,
|
||||
},
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: "branch_default_2",
|
||||
criteria: null,
|
||||
next_block_label: null,
|
||||
description: null,
|
||||
is_default: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
expect(screen.getByText("A • If")).toBeDefined();
|
||||
expect(screen.getByText("B • Else If")).toBeDefined();
|
||||
expect(screen.getByText("C • Else")).toBeDefined();
|
||||
expect(screen.getByText("D • Else")).toBeDefined();
|
||||
expect(
|
||||
screen.getAllByDisplayValue("Executed when no other condition matches"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("editing a branch prompt propagates via updateNodeData", () => {
|
||||
setConditionalNode("c1");
|
||||
render(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("{{ total > 100 }}"), {
|
||||
target: { value: "{{ total > 250 }}" },
|
||||
});
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("c1", {
|
||||
branches: [
|
||||
expect.objectContaining({
|
||||
id: "branch_a",
|
||||
criteria: expect.objectContaining({
|
||||
expression: "{{ total > 250 }}",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "branch_default",
|
||||
criteria: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("non-editable branch prompts do not propagate edits", () => {
|
||||
setConditionalNode("c1", { editable: false });
|
||||
render(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("{{ total > 100 }}"), {
|
||||
target: { value: "{{ total > 250 }}" },
|
||||
});
|
||||
|
||||
expect(updateNodeData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("registers/unregisters commit on mount/unmount", () => {
|
||||
|
|
@ -137,19 +272,4 @@ describe("ConditionalBlockForm (SKY-9361)", () => {
|
|||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
test("propagates updated activeBranchId to BranchesEditor on rerender", () => {
|
||||
setConditionalNode("c1", { activeBranchId: "branch_a" });
|
||||
const { rerender } = render(<ConditionalBlockForm blockId="c1" />);
|
||||
expect(
|
||||
screen.getByTestId("branches-editor").getAttribute("data-active-branch"),
|
||||
).toBe("branch_a");
|
||||
|
||||
setConditionalNode("c1", { activeBranchId: "branch_default" });
|
||||
rerender(<ConditionalBlockForm blockId="c1" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("branches-editor").getAttribute("data-active-branch"),
|
||||
).toBe("branch_default");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,38 +1,41 @@
|
|||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useNodesData } from "@xyflow/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { usePendingCommitsStore } from "@/store/PendingCommitsStore";
|
||||
|
||||
import { AppNode, isWorkflowBlockNode } from "../../nodes";
|
||||
import { BranchesEditor } from "../../nodes/ConditionalNode/BranchesEditor";
|
||||
import { type AppNode, isWorkflowBlockNode } from "../../nodes";
|
||||
import {
|
||||
getConditionLabel,
|
||||
orderBranchesWithDefaultsLast,
|
||||
} from "../../nodes/ConditionalNode/branchDisplayUtils";
|
||||
import {
|
||||
type ConditionalNode,
|
||||
type ConditionalNodeData,
|
||||
defaultBranchCriteria,
|
||||
} from "../../nodes/ConditionalNode/types";
|
||||
import { useUpdate } from "../../useUpdate";
|
||||
import { useDebouncedSidebarSave } from "../useDebouncedSidebarSave";
|
||||
|
||||
function ConditionalBlockForm({ blockId }: { blockId: string }) {
|
||||
const rf = useReactFlow<AppNode>();
|
||||
const node = rf.getNode(blockId);
|
||||
if (!node || !isWorkflowBlockNode(node) || node.type !== "conditional") {
|
||||
const nodeSlice = useNodesData<AppNode>(blockId);
|
||||
if (
|
||||
!nodeSlice ||
|
||||
!isWorkflowBlockNode(nodeSlice as AppNode) ||
|
||||
nodeSlice.type !== "conditional"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ConditionalBlockFormBody
|
||||
blockId={blockId}
|
||||
node={node as ConditionalNode}
|
||||
/>
|
||||
);
|
||||
return <ConditionalBlockFormBody blockId={blockId} data={nodeSlice.data} />;
|
||||
}
|
||||
|
||||
function ConditionalBlockFormBody({
|
||||
blockId,
|
||||
node,
|
||||
data,
|
||||
}: {
|
||||
blockId: string;
|
||||
node: ConditionalNode;
|
||||
data: ConditionalNodeData;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const {
|
||||
branches,
|
||||
activeBranchId,
|
||||
|
|
@ -40,6 +43,14 @@ function ConditionalBlockFormBody({
|
|||
continueOnFailure,
|
||||
nextLoopOnFailure,
|
||||
} = data;
|
||||
const orderedBranches = useMemo(
|
||||
() => orderBranchesWithDefaultsLast(branches),
|
||||
[branches],
|
||||
);
|
||||
const update = useUpdate<ConditionalNodeData>({
|
||||
id: blockId,
|
||||
editable: data.editable,
|
||||
});
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
|
@ -68,9 +79,66 @@ function ConditionalBlockFormBody({
|
|||
return () => store.unregister(blockId);
|
||||
}, [blockId, commit]);
|
||||
|
||||
const handleExpressionChange = (
|
||||
branchId: string,
|
||||
expression: string,
|
||||
): void => {
|
||||
const targetBranch = branches.find((branch) => branch.id === branchId);
|
||||
if (!targetBranch || targetBranch.is_default) {
|
||||
return;
|
||||
}
|
||||
|
||||
update({
|
||||
branches: branches.map((branch) => {
|
||||
if (branch.id !== branchId) {
|
||||
return branch;
|
||||
}
|
||||
return {
|
||||
...branch,
|
||||
criteria: {
|
||||
...(branch.criteria ?? { ...defaultBranchCriteria }),
|
||||
expression,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="conditional-block-form" className="space-y-4">
|
||||
<BranchesEditor nodeId={blockId} data={data as ConditionalNodeData} />
|
||||
<div data-testid="conditional-block-form" className="space-y-3">
|
||||
{orderedBranches.map((branch, index) => {
|
||||
const isDefaultBranch = branch.is_default;
|
||||
const branchExpression = isDefaultBranch
|
||||
? "Executed when no other condition matches"
|
||||
: (branch.criteria?.expression ?? "");
|
||||
return (
|
||||
<div
|
||||
key={branch.id}
|
||||
className="space-y-2 rounded-md border border-border bg-slate-elevation2 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-slate-300">
|
||||
{getConditionLabel(branch, index)}
|
||||
</Label>
|
||||
{branch.id === activeBranchId && (
|
||||
<span className="rounded bg-slate-elevation5 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-300">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
nodeId={blockId}
|
||||
value={branchExpression}
|
||||
disabled={!data.editable || isDefaultBranch}
|
||||
onChange={(nextValue) =>
|
||||
handleExpressionChange(branch.id, nextValue)
|
||||
}
|
||||
placeholder="Enter condition to evaluate (Jinja, natural language, or both)"
|
||||
className="nopan text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,14 +35,16 @@ vi.mock("@/components/WorkflowBlockInput", () => ({
|
|||
WorkflowBlockInput: ({
|
||||
value,
|
||||
onChange,
|
||||
"data-testid": dataTestId,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
nodeId: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}) => (
|
||||
<input
|
||||
data-testid="loop-variable-input"
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
|
|
@ -114,11 +116,15 @@ afterEach(() => {
|
|||
function setLoopNode(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
loopKind: "for_each" | "while";
|
||||
loopVariableReference: string;
|
||||
dataSchema: string;
|
||||
completeIfEmpty: boolean;
|
||||
continueOnFailure: boolean;
|
||||
nextLoopOnFailure: boolean | undefined;
|
||||
whileConditionExpression: string;
|
||||
whileConditionCriteriaType: "jinja2_template" | "prompt";
|
||||
whileConditionDescription: string | null;
|
||||
editable: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
|
|
@ -140,9 +146,11 @@ function setLoopNode(
|
|||
// SKY-8771 introduced loopKind / whileCondition* fields. Default the
|
||||
// fixture to the for-each branch so the existing test cases continue
|
||||
// to exercise the for-each form fields.
|
||||
loopKind: "for_each",
|
||||
whileConditionExpression: "{{ true }}",
|
||||
whileConditionCriteriaType: "jinja2_template",
|
||||
loopKind: data.loopKind ?? "for_each",
|
||||
whileConditionExpression: data.whileConditionExpression ?? "{{ true }}",
|
||||
whileConditionCriteriaType:
|
||||
data.whileConditionCriteriaType ?? "jinja2_template",
|
||||
whileConditionDescription: data.whileConditionDescription ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -206,6 +214,28 @@ describe("LoopBlockForm (SKY-9361)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("renders while loop condition and edits propagate via updateNodeData", () => {
|
||||
setLoopNode("p1", {
|
||||
loopKind: "while",
|
||||
whileConditionExpression: "{{ count < 3 }}",
|
||||
});
|
||||
render(<LoopBlockForm blockId="p1" />);
|
||||
|
||||
expect(screen.getByText("Loop Condition")).toBeDefined();
|
||||
expect(
|
||||
(screen.getByTestId("while-condition-input") as HTMLInputElement).value,
|
||||
).toBe("{{ count < 3 }}");
|
||||
|
||||
fireEvent.change(screen.getByTestId("while-condition-input"), {
|
||||
target: { value: "{{ count < 5 }}" },
|
||||
});
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("p1", {
|
||||
whileConditionExpression: "{{ count < 5 }}",
|
||||
whileConditionCriteriaType: "jinja2_template",
|
||||
});
|
||||
});
|
||||
|
||||
test("editing dataSchema propagates via updateNodeData", () => {
|
||||
setLoopNode("p1");
|
||||
render(<LoopBlockForm blockId="p1" />);
|
||||
|
|
|
|||
|
|
@ -23,27 +23,39 @@ function LoopBlockFormBody({
|
|||
node: LoopNode;
|
||||
}) {
|
||||
const {
|
||||
loopKind,
|
||||
loopVariableReference,
|
||||
dataSchema,
|
||||
completeIfEmpty,
|
||||
continueOnFailure,
|
||||
nextLoopOnFailure,
|
||||
whileConditionExpression,
|
||||
whileConditionCriteriaType,
|
||||
whileConditionDescription,
|
||||
} = node.data;
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
loopKind,
|
||||
loopVariableReference,
|
||||
dataSchema,
|
||||
completeIfEmpty,
|
||||
continueOnFailure,
|
||||
nextLoopOnFailure,
|
||||
whileConditionExpression,
|
||||
whileConditionCriteriaType,
|
||||
whileConditionDescription,
|
||||
}),
|
||||
[
|
||||
loopKind,
|
||||
loopVariableReference,
|
||||
dataSchema,
|
||||
completeIfEmpty,
|
||||
continueOnFailure,
|
||||
nextLoopOnFailure,
|
||||
whileConditionExpression,
|
||||
whileConditionCriteriaType,
|
||||
whileConditionDescription,
|
||||
],
|
||||
);
|
||||
const { commit } = useDebouncedSidebarSave({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
||||
import {
|
||||
CredentialSelectionStrategy,
|
||||
WorkflowParameterValueType,
|
||||
} from "../types/workflowTypes";
|
||||
|
||||
// Keys the Login block auto-generates when a user picks a credential directly
|
||||
// (`credentials`, `credentials_1`, …). Used as the provenance signal that tells
|
||||
|
|
@ -20,6 +23,8 @@ export type SkyvernCredential = {
|
|||
description?: string | null;
|
||||
parameterType: "credential";
|
||||
credentialId: string;
|
||||
credentialIds?: Array<string> | null;
|
||||
selectionStrategy?: CredentialSelectionStrategy | null;
|
||||
dataType?: typeof WorkflowParameterValueType.CredentialId;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ const getInitialParameters = (workflow: WorkflowApiResponse) => {
|
|||
key: parameter.key,
|
||||
parameterType: WorkflowEditorParameterTypes.Credential,
|
||||
credentialId: parameter.credential_id,
|
||||
credentialIds: parameter.credential_ids ?? null,
|
||||
selectionStrategy: parameter.selection_strategy ?? null,
|
||||
description: parameter.description,
|
||||
};
|
||||
} else if (
|
||||
|
|
|
|||
|
|
@ -4055,6 +4055,8 @@ function convertParametersToParameterYAML(
|
|||
...base,
|
||||
parameter_type: WorkflowParameterTypes.Credential,
|
||||
credential_id: parameter.credential_id,
|
||||
credential_ids: parameter.credential_ids ?? null,
|
||||
selection_strategy: parameter.selection_strategy ?? null,
|
||||
};
|
||||
}
|
||||
case WorkflowParameterTypes.OnePassword: {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ type Props = {
|
|||
search?: string;
|
||||
// ANDed with the internal gating (workflow id + globalWorkflows loaded).
|
||||
enabled?: boolean;
|
||||
createdAtStart?: string;
|
||||
createdAtEnd?: string;
|
||||
} & UseQueryOptions;
|
||||
|
||||
function useWorkflowRunsQuery({
|
||||
|
|
@ -32,6 +34,8 @@ function useWorkflowRunsQuery({
|
|||
pageSize,
|
||||
search,
|
||||
enabled,
|
||||
createdAtStart,
|
||||
createdAtEnd,
|
||||
...queryOptions
|
||||
}: Props) {
|
||||
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
|
||||
|
|
@ -48,6 +52,8 @@ function useWorkflowRunsQuery({
|
|||
page,
|
||||
pageSize,
|
||||
search,
|
||||
createdAtStart,
|
||||
createdAtEnd,
|
||||
],
|
||||
activeOrgQueryKeyScope,
|
||||
),
|
||||
|
|
@ -72,6 +78,12 @@ function useWorkflowRunsQuery({
|
|||
if (search) {
|
||||
params.append("search_key", search);
|
||||
}
|
||||
if (createdAtStart) {
|
||||
params.append("created_at_start", createdAtStart);
|
||||
}
|
||||
if (createdAtEnd) {
|
||||
params.append("created_at_end", createdAtEnd);
|
||||
}
|
||||
|
||||
return client
|
||||
.get(`/workflows/${workflowPermanentId}/runs`, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveRunWindow } from "./resolveRunWindow";
|
||||
|
||||
const NOW = new Date("2026-06-08T12:00:00.000Z");
|
||||
|
||||
describe("resolveRunWindow", () => {
|
||||
it("returns an empty window when no period is set (pure-OSS default)", () => {
|
||||
expect(resolveRunWindow(new URLSearchParams(), NOW)).toEqual({});
|
||||
});
|
||||
|
||||
it("maps a preset to a midnight-snapped start with no upper bound", () => {
|
||||
expect(resolveRunWindow(new URLSearchParams("period=7d"), NOW)).toEqual({
|
||||
createdAtStart: "2026-06-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 30d to 30 calendar days (table approximation of the billing period)", () => {
|
||||
expect(resolveRunWindow(new URLSearchParams("period=30d"), NOW)).toEqual({
|
||||
createdAtStart: "2026-05-09T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a valid custom range to inclusive whole-day bounds", () => {
|
||||
expect(
|
||||
resolveRunWindow(
|
||||
new URLSearchParams("period=custom&from=2026-05-01&to=2026-05-03"),
|
||||
NOW,
|
||||
),
|
||||
).toEqual({
|
||||
createdAtStart: "2026-05-01T00:00:00.000Z",
|
||||
createdAtEnd: "2026-05-04T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty window for an invalid custom range", () => {
|
||||
expect(
|
||||
resolveRunWindow(
|
||||
new URLSearchParams("period=custom&from=2026-05-10&to=2026-05-01"),
|
||||
NOW,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("returns an empty window for an unknown preset", () => {
|
||||
expect(resolveRunWindow(new URLSearchParams("period=14d"), NOW)).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
62
skyvern-frontend/src/routes/workflows/resolveRunWindow.ts
Normal file
62
skyvern-frontend/src/routes/workflows/resolveRunWindow.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export type RunWindow = {
|
||||
createdAtStart?: string;
|
||||
createdAtEnd?: string;
|
||||
};
|
||||
|
||||
const PRESET_DAYS: Record<string, number> = {
|
||||
"7d": 7,
|
||||
"30d": 30,
|
||||
"90d": 90,
|
||||
"365d": 365,
|
||||
};
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function isValidIsoDate(value: string): boolean {
|
||||
if (!ISO_DATE_RE.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return (
|
||||
!Number.isNaN(parsed.getTime()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the runs-table created-at window from the shared ?period= URL contract.
|
||||
*
|
||||
* Returns {} when no period is set so pure-OSS pages list all runs.
|
||||
*/
|
||||
export function resolveRunWindow(
|
||||
searchParams: URLSearchParams,
|
||||
now: Date = new Date(),
|
||||
): RunWindow {
|
||||
const period = searchParams.get("period");
|
||||
if (!period) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (period === "custom") {
|
||||
const from = searchParams.get("from") ?? "";
|
||||
const to = searchParams.get("to") ?? "";
|
||||
if (!isValidIsoDate(from) || !isValidIsoDate(to) || from > to) {
|
||||
return {};
|
||||
}
|
||||
const end = new Date(`${to}T00:00:00Z`);
|
||||
end.setUTCDate(end.getUTCDate() + 1); // inclusive of the whole `to` day
|
||||
return {
|
||||
createdAtStart: new Date(`${from}T00:00:00Z`).toISOString(),
|
||||
createdAtEnd: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const days = PRESET_DAYS[period];
|
||||
if (!days) {
|
||||
return {};
|
||||
}
|
||||
const start = new Date(now);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
start.setUTCDate(start.getUTCDate() - days);
|
||||
return { createdAtStart: start.toISOString() };
|
||||
}
|
||||
|
|
@ -85,11 +85,15 @@ export type AzureVaultCredentialParameter = WorkflowParameterBase & {
|
|||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type CredentialSelectionStrategy = "round_robin" | "random";
|
||||
|
||||
export type CredentialParameter = WorkflowParameterBase & {
|
||||
parameter_type: "credential";
|
||||
workflow_id: string;
|
||||
credential_parameter_id: string;
|
||||
credential_id: string;
|
||||
credential_ids?: Array<string> | null;
|
||||
selection_strategy?: CredentialSelectionStrategy | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
deleted_at: string | null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ProxyLocation, RunEngine } from "@/api/types";
|
||||
import {
|
||||
CodeBlockStep,
|
||||
CredentialSelectionStrategy,
|
||||
WorkflowBlockType,
|
||||
WorkflowModel,
|
||||
} from "./workflowTypes";
|
||||
|
|
@ -129,6 +130,8 @@ export type OutputParameterYAML = ParameterYAMLBase & {
|
|||
export type CredentialParameterYAML = ParameterYAMLBase & {
|
||||
parameter_type: "credential";
|
||||
credential_id: string;
|
||||
credential_ids?: Array<string> | null;
|
||||
selection_strategy?: CredentialSelectionStrategy | null;
|
||||
};
|
||||
|
||||
export type BlockYAML =
|
||||
|
|
|
|||
17
skyvern-frontend/src/store/PageSlots.ts
Normal file
17
skyvern-frontend/src/store/PageSlots.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
export type WorkflowAnalyticsPanelProps = {
|
||||
workflowPermanentId: string;
|
||||
};
|
||||
|
||||
export type PageSlots = {
|
||||
workflowAnalyticsPanel?: React.ComponentType<WorkflowAnalyticsPanelProps>;
|
||||
};
|
||||
|
||||
const PageSlotsContext = createContext<PageSlots>({});
|
||||
|
||||
export const PageSlotsProvider = PageSlotsContext.Provider;
|
||||
|
||||
export function usePageSlots(): PageSlots {
|
||||
return useContext(PageSlotsContext);
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ class CredentialParameter(UniversalBaseModel):
|
|||
credential_parameter_id: str
|
||||
workflow_id: str
|
||||
credential_id: str
|
||||
credential_ids: typing.Optional[typing.List[str]] = None
|
||||
selection_strategy: typing.Optional[str] = None
|
||||
created_at: dt.datetime
|
||||
modified_at: dt.datetime
|
||||
deleted_at: typing.Optional[dt.datetime] = None
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class CredentialParameterYaml(UniversalBaseModel):
|
|||
key: str
|
||||
description: typing.Optional[str] = None
|
||||
credential_id: str
|
||||
credential_ids: typing.Optional[typing.List[str]] = None
|
||||
selection_strategy: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ class WorkflowDefinitionParametersItem_Credential(UniversalBaseModel):
|
|||
credential_parameter_id: str
|
||||
workflow_id: str
|
||||
credential_id: str
|
||||
credential_ids: typing.Optional[typing.List[str]] = None
|
||||
selection_strategy: typing.Optional[str] = None
|
||||
created_at: dt.datetime
|
||||
modified_at: dt.datetime
|
||||
deleted_at: typing.Optional[dt.datetime] = None
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ class WorkflowDefinitionYamlParametersItem_Credential(UniversalBaseModel):
|
|||
key: str
|
||||
description: typing.Optional[str] = None
|
||||
credential_id: str
|
||||
credential_ids: typing.Optional[typing.List[str]] = None
|
||||
selection_strategy: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, cast
|
||||
|
|
@ -22,7 +23,7 @@ from skyvern.forge.sdk.copilot.secret_redaction import (
|
|||
)
|
||||
from skyvern.forge.sdk.copilot.workflow_credential_utils import (
|
||||
block_credential_ids,
|
||||
credential_params,
|
||||
credential_param_ids,
|
||||
parse_workflow_yaml,
|
||||
url_origin,
|
||||
workflow_blocks,
|
||||
|
|
@ -837,7 +838,7 @@ def _workflow_broadens_credential_scope(parsed_workflow: dict[str, Any], request
|
|||
if not isinstance(workflow_definition, dict):
|
||||
return False
|
||||
|
||||
credential_params_by_key = credential_params(workflow_definition.get("parameters"))
|
||||
credential_params_by_key = credential_param_ids(workflow_definition.get("parameters"))
|
||||
if not credential_params_by_key:
|
||||
return False
|
||||
|
||||
|
|
@ -861,7 +862,7 @@ def _approved_origins_by_id(request_policy: RequestPolicy) -> dict[str, set[str]
|
|||
|
||||
def _block_broadens_credential_scope(
|
||||
block: dict[str, Any],
|
||||
credential_params_by_key: dict[str, str],
|
||||
credential_params_by_key: Mapping[str, str | set[str]],
|
||||
approved_origins: dict[str, set[str]],
|
||||
) -> bool:
|
||||
credential_ids = block_credential_ids(block, credential_params_by_key)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ def _extract_credential_ids_from_workflow_parameters(parameters: Any) -> list[st
|
|||
if slot_field is None:
|
||||
continue
|
||||
found.extend(_extract_credential_ids_from_tool_value(parameter.get(slot_field)))
|
||||
if slot_field == "credential_id":
|
||||
found.extend(_extract_credential_ids_from_tool_value(parameter.get("credential_ids")))
|
||||
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
|
@ -133,8 +135,11 @@ def _credential_id_misbinding_findings(workflow_yaml: str | None) -> list[dict[s
|
|||
if not isinstance(parameter, dict):
|
||||
return
|
||||
legal_slot_field = _credential_parameter_slot_field(parameter)
|
||||
legal_slot_fields = {legal_slot_field} if legal_slot_field else set()
|
||||
if legal_slot_field == "credential_id":
|
||||
legal_slot_fields.add("credential_ids")
|
||||
for field_name, field_value in parameter.items():
|
||||
if field_name == legal_slot_field:
|
||||
if field_name in legal_slot_fields:
|
||||
continue
|
||||
_scan_value(field_value, location, str(field_name))
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ from skyvern.forge.sdk.copilot.turn_halt import (
|
|||
blocker_signal_is_genuinely_terminal,
|
||||
stash_turn_halt_from_blocker_signal,
|
||||
)
|
||||
from skyvern.forge.sdk.copilot.workflow_credential_utils import credential_params, parse_workflow_yaml, workflow_blocks
|
||||
from skyvern.forge.sdk.copilot.workflow_credential_utils import (
|
||||
credential_param_ids,
|
||||
parse_workflow_yaml,
|
||||
workflow_blocks,
|
||||
)
|
||||
from skyvern.forge.sdk.routes.workflow_copilot import _process_workflow_yaml
|
||||
from skyvern.forge.sdk.workflow.exceptions import BaseWorkflowHTTPException, InsecureCodeDetected
|
||||
from skyvern.forge.sdk.workflow.models.block import CodeBlock
|
||||
|
|
@ -3564,7 +3568,7 @@ def _reconcile_synthesized_parameters(
|
|||
existing_by_key = {
|
||||
str(param.get("key")): param for param in parameters if isinstance(param, dict) and param.get("key")
|
||||
}
|
||||
existing_credentials = credential_params(parameters)
|
||||
existing_credentials = credential_param_ids(parameters)
|
||||
parameter_keys: list[str] = []
|
||||
violations: list[str] = []
|
||||
aliases: dict[str, str] = {}
|
||||
|
|
@ -3614,7 +3618,7 @@ def _reconcile_synthesized_parameters(
|
|||
typed_length = typed_length or scout_typed_length
|
||||
if credential_id:
|
||||
if existing is not None:
|
||||
if existing_credentials.get(key) != credential_id:
|
||||
if credential_id not in existing_credentials.get(key, set()):
|
||||
violations.append(
|
||||
f"Unable to bind synthesized credential parameter `{key}`: submitted credential binding does not match scout metadata."
|
||||
)
|
||||
|
|
@ -4794,7 +4798,7 @@ def _credentialed_code_block_scout_gate_errors(
|
|||
workflow_definition = parsed.get("workflow_definition")
|
||||
if not isinstance(workflow_definition, dict):
|
||||
return []
|
||||
credential_params_by_key = credential_params(workflow_definition.get("parameters"))
|
||||
credential_params_by_key = credential_param_ids(workflow_definition.get("parameters"))
|
||||
if not credential_params_by_key:
|
||||
return []
|
||||
scout_trajectory = getattr(ctx, "scout_trajectory", None)
|
||||
|
|
@ -4808,25 +4812,28 @@ def _credentialed_code_block_scout_gate_errors(
|
|||
code = str(block.get("code") or "")
|
||||
if not code.strip():
|
||||
continue
|
||||
required_fields_by_credential: dict[str, set[str]] = {}
|
||||
required_fields_by_parameter: dict[str, tuple[set[str], set[str]]] = {}
|
||||
for access in _credential_field_accesses(code):
|
||||
if not access.requires_live_scout:
|
||||
continue
|
||||
credential_id = credential_params_by_key.get(access.parameter_key)
|
||||
if credential_id:
|
||||
required_fields_by_credential.setdefault(credential_id, set()).add(access.field)
|
||||
if not required_fields_by_credential:
|
||||
credential_ids = credential_params_by_key.get(access.parameter_key)
|
||||
if credential_ids:
|
||||
allowed_credential_ids, required_fields = required_fields_by_parameter.setdefault(
|
||||
access.parameter_key, (credential_ids, set())
|
||||
)
|
||||
required_fields.add(access.field)
|
||||
if not required_fields_by_parameter:
|
||||
continue
|
||||
|
||||
matched_fill_indexes: list[int] = []
|
||||
matched_source_urls: set[str] = set()
|
||||
missing_fields: list[str] = []
|
||||
for credential_id, required_fields in required_fields_by_credential.items():
|
||||
for allowed_credential_ids, required_fields in required_fields_by_parameter.values():
|
||||
matched_fields: set[str] = set()
|
||||
for index, interaction in enumerate(scout_trajectory):
|
||||
if str(interaction.get("tool_name") or "").strip() != "fill_credential_field":
|
||||
continue
|
||||
if str(interaction.get("credential_id") or "").strip() != credential_id:
|
||||
if str(interaction.get("credential_id") or "").strip() not in allowed_credential_ids:
|
||||
continue
|
||||
field = str(interaction.get("credential_field") or "").strip()
|
||||
if field not in required_fields:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -58,6 +59,36 @@ def credential_params(parameters: Any) -> dict[str, str]:
|
|||
return out
|
||||
|
||||
|
||||
def credential_param_ids(parameters: Any) -> dict[str, set[str]]:
|
||||
if not isinstance(parameters, list):
|
||||
return {}
|
||||
out: dict[str, set[str]] = {}
|
||||
for parameter in parameters:
|
||||
if not isinstance(parameter, dict):
|
||||
continue
|
||||
key = parameter.get("key")
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
parameter_type = str(parameter.get("parameter_type") or "").lower()
|
||||
workflow_parameter_type = str(parameter.get("workflow_parameter_type") or "").lower()
|
||||
if parameter_type == "credential":
|
||||
ids: set[str] = set()
|
||||
credential_ids = parameter.get("credential_ids")
|
||||
if isinstance(credential_ids, list):
|
||||
ids.update(item for item in credential_ids if isinstance(item, str))
|
||||
if isinstance(parameter.get("credential_id"), str):
|
||||
ids.add(parameter["credential_id"])
|
||||
if ids:
|
||||
out[key] = ids
|
||||
elif (
|
||||
parameter_type == "workflow"
|
||||
and workflow_parameter_type == "credential_id"
|
||||
and isinstance(parameter.get("default_value"), str)
|
||||
):
|
||||
out[key] = {parameter["default_value"]}
|
||||
return out
|
||||
|
||||
|
||||
def workflow_blocks(parsed: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
workflow_definition = parsed.get("workflow_definition")
|
||||
if not isinstance(workflow_definition, dict):
|
||||
|
|
@ -97,13 +128,17 @@ def workflow_blocks(parsed: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
return collected
|
||||
|
||||
|
||||
def block_credential_ids(block: dict[str, Any], credential_params_by_key: dict[str, str]) -> set[str]:
|
||||
def block_credential_ids(block: dict[str, Any], credential_params_by_key: Mapping[str, str | set[str]]) -> set[str]:
|
||||
credential_ids: set[str] = set()
|
||||
parameter_keys = block.get("parameter_keys")
|
||||
if isinstance(parameter_keys, list):
|
||||
for key in parameter_keys:
|
||||
if isinstance(key, str) and key in credential_params_by_key:
|
||||
credential_ids.add(credential_params_by_key[key])
|
||||
ids = credential_params_by_key[key]
|
||||
if isinstance(ids, str):
|
||||
credential_ids.add(ids)
|
||||
else:
|
||||
credential_ids.update(ids)
|
||||
direct_credential_id = block.get("credential_id")
|
||||
if isinstance(direct_credential_id, str):
|
||||
credential_ids.add(direct_credential_id)
|
||||
|
|
@ -124,8 +159,10 @@ def workflow_credential_ids_from_parsed(parsed: dict[str, Any]) -> set[str]:
|
|||
if not isinstance(workflow_definition, dict):
|
||||
return set()
|
||||
|
||||
credential_params_by_key = credential_params(workflow_definition.get("parameters"))
|
||||
credential_ids = set(credential_params_by_key.values())
|
||||
credential_params_by_key = credential_param_ids(workflow_definition.get("parameters"))
|
||||
credential_ids: set[str] = set()
|
||||
for ids in credential_params_by_key.values():
|
||||
credential_ids.update(ids)
|
||||
for block in workflow_blocks(parsed):
|
||||
credential_ids.update(block_credential_ids(block, credential_params_by_key))
|
||||
return credential_ids
|
||||
|
|
@ -145,7 +182,7 @@ def workflow_credential_origins_from_parsed(parsed: dict[str, Any]) -> dict[str,
|
|||
if not isinstance(workflow_definition, dict):
|
||||
return {}
|
||||
|
||||
credential_params_by_key = credential_params(workflow_definition.get("parameters"))
|
||||
credential_params_by_key = credential_param_ids(workflow_definition.get("parameters"))
|
||||
origins_by_id: dict[str, set[str]] = {}
|
||||
for block in workflow_blocks(parsed):
|
||||
credential_ids = block_credential_ids(block, credential_params_by_key)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ from skyvern.forge.sdk.db.repositories.scripts import ScriptsRepository
|
|||
from skyvern.forge.sdk.db.repositories.tags import TagsRepository
|
||||
from skyvern.forge.sdk.db.repositories.tasks import TasksRepository
|
||||
from skyvern.forge.sdk.db.repositories.workflow_parameters import WorkflowParametersRepository
|
||||
from skyvern.forge.sdk.db.repositories.workflow_run_credential_selections import (
|
||||
WorkflowRunCredentialSelectionsRepository,
|
||||
)
|
||||
from skyvern.forge.sdk.db.repositories.workflow_runs import WorkflowRunsRepository
|
||||
from skyvern.forge.sdk.db.repositories.workflows import WorkflowsRepository
|
||||
from skyvern.forge.sdk.db.utils import (
|
||||
|
|
@ -366,6 +369,9 @@ class AgentDB(BaseAlchemyDB):
|
|||
self.tasks = TasksRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
self.workflows = WorkflowsRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
self.workflow_params = WorkflowParametersRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
self.workflow_run_credential_selections = WorkflowRunCredentialSelectionsRepository(
|
||||
self.Session, debug_enabled, self.is_retryable_error
|
||||
)
|
||||
self.credentials = CredentialRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
self.credential_folders = CredentialFoldersRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
self.otp = OTPRepository(self.Session, debug_enabled, self.is_retryable_error)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ ORGANIZATION_BILLING_PREFIX = "ob"
|
|||
WORKFLOW_COPILOT_CHAT_PREFIX = "wcc"
|
||||
WORKFLOW_COPILOT_CHAT_MESSAGE_PREFIX = "wccm"
|
||||
WORKFLOW_COPILOT_COMPLETION_CRITERIA_SET_PREFIX = "wccs"
|
||||
WORKFLOW_RUN_CREDENTIAL_SELECTION_PREFIX = "wrcs"
|
||||
SCRIPT_FALLBACK_EPISODE_PREFIX = "sfe"
|
||||
WORKFLOW_SCHEDULE_PREFIX = "wfs"
|
||||
TAG_EVENT_PREFIX = "tge"
|
||||
|
|
@ -322,6 +323,11 @@ def generate_workflow_copilot_completion_criteria_set_id() -> str:
|
|||
return f"{WORKFLOW_COPILOT_COMPLETION_CRITERIA_SET_PREFIX}_{int_id}"
|
||||
|
||||
|
||||
def generate_workflow_run_credential_selection_id() -> str:
|
||||
int_id = generate_id()
|
||||
return f"{WORKFLOW_RUN_CREDENTIAL_SELECTION_PREFIX}_{int_id}"
|
||||
|
||||
|
||||
def generate_script_fallback_episode_id() -> str:
|
||||
int_id = generate_id()
|
||||
return f"{SCRIPT_FALLBACK_EPISODE_PREFIX}_{int_id}"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ from skyvern.forge.sdk.db.id import (
|
|||
generate_workflow_parameter_id,
|
||||
generate_workflow_permanent_id,
|
||||
generate_workflow_run_block_id,
|
||||
generate_workflow_run_credential_selection_id,
|
||||
generate_workflow_run_id,
|
||||
generate_workflow_schedule_id,
|
||||
generate_workflow_script_id,
|
||||
|
|
@ -823,12 +824,37 @@ class CredentialParameterModel(Base):
|
|||
description = Column(String, nullable=True)
|
||||
|
||||
credential_id = Column(String, nullable=False)
|
||||
credential_ids = Column(JSON, nullable=True)
|
||||
selection_strategy = Column(String, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
||||
modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class WorkflowRunCredentialSelectionModel(Base):
|
||||
__tablename__ = "workflow_run_credential_selections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workflow_run_id", "parameter_key", name="uq_wrcs_workflow_run_parameter_key"),
|
||||
Index(
|
||||
"idx_wrcs_lru_lookup",
|
||||
"organization_id",
|
||||
"workflow_permanent_id",
|
||||
"parameter_key",
|
||||
"credential_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
selection_id = Column(String, primary_key=True, default=generate_workflow_run_credential_selection_id)
|
||||
organization_id = Column(String, nullable=False)
|
||||
workflow_run_id = Column(String, nullable=False)
|
||||
workflow_permanent_id = Column(String, nullable=False)
|
||||
parameter_key = Column(String, nullable=False)
|
||||
credential_id = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class OnePasswordCredentialParameterModel(Base):
|
||||
__tablename__ = "onepassword_credential_parameters"
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@ class WorkflowParametersRepository(BaseRepository):
|
|||
key=parameter.key,
|
||||
description=parameter.description,
|
||||
credential_id=parameter.credential_id,
|
||||
credential_ids=parameter.credential_ids,
|
||||
selection_strategy=parameter.selection_strategy,
|
||||
deleted_at=parameter.deleted_at,
|
||||
)
|
||||
elif isinstance(parameter, OnePasswordCredentialParameter):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from skyvern.forge.sdk.db._error_handling import db_operation
|
||||
from skyvern.forge.sdk.db.base_repository import BaseRepository
|
||||
from skyvern.forge.sdk.db.models import WorkflowRunCredentialSelectionModel
|
||||
|
||||
|
||||
class WorkflowRunCredentialSelectionsRepository(BaseRepository):
|
||||
@db_operation("get_workflow_run_credential_selection")
|
||||
async def get_selection(self, workflow_run_id: str, parameter_key: str) -> str | None:
|
||||
async with self.Session() as session:
|
||||
selection = (
|
||||
await session.scalars(
|
||||
select(WorkflowRunCredentialSelectionModel)
|
||||
.where(WorkflowRunCredentialSelectionModel.workflow_run_id == workflow_run_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.parameter_key == parameter_key)
|
||||
)
|
||||
).first()
|
||||
return selection.credential_id if selection else None
|
||||
|
||||
async def _get_selection(self, session: AsyncSession, workflow_run_id: str, parameter_key: str) -> str | None:
|
||||
selection = (
|
||||
await session.scalars(
|
||||
select(WorkflowRunCredentialSelectionModel)
|
||||
.where(WorkflowRunCredentialSelectionModel.workflow_run_id == workflow_run_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.parameter_key == parameter_key)
|
||||
)
|
||||
).first()
|
||||
return selection.credential_id if selection else None
|
||||
|
||||
@db_operation("get_latest_workflow_run_credential_selections")
|
||||
async def get_latest_selections(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
) -> dict[str, datetime]:
|
||||
if not credential_ids:
|
||||
return {}
|
||||
async with self.Session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
WorkflowRunCredentialSelectionModel.credential_id,
|
||||
func.max(WorkflowRunCredentialSelectionModel.created_at),
|
||||
)
|
||||
.where(WorkflowRunCredentialSelectionModel.organization_id == organization_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.workflow_permanent_id == workflow_permanent_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.parameter_key == parameter_key)
|
||||
.where(WorkflowRunCredentialSelectionModel.credential_id.in_(credential_ids))
|
||||
.group_by(WorkflowRunCredentialSelectionModel.credential_id)
|
||||
)
|
||||
).all()
|
||||
return {credential_id: created_at for credential_id, created_at in rows if created_at is not None}
|
||||
|
||||
async def _get_latest_selections(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
) -> dict[str, datetime]:
|
||||
if not credential_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
WorkflowRunCredentialSelectionModel.credential_id,
|
||||
func.max(WorkflowRunCredentialSelectionModel.created_at),
|
||||
)
|
||||
.where(WorkflowRunCredentialSelectionModel.organization_id == organization_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.workflow_permanent_id == workflow_permanent_id)
|
||||
.where(WorkflowRunCredentialSelectionModel.parameter_key == parameter_key)
|
||||
.where(WorkflowRunCredentialSelectionModel.credential_id.in_(credential_ids))
|
||||
.group_by(WorkflowRunCredentialSelectionModel.credential_id)
|
||||
)
|
||||
).all()
|
||||
return {credential_id: created_at for credential_id, created_at in rows if created_at is not None}
|
||||
|
||||
async def _take_rotation_advisory_lock(self, session: AsyncSession, lock_key: str) -> None:
|
||||
bind = session.get_bind()
|
||||
dialect_name = bind.dialect.name if bind is not None else "postgresql"
|
||||
if dialect_name not in {"postgresql", "postgres"}:
|
||||
return
|
||||
await session.execute(select(func.pg_advisory_xact_lock(func.hashtext(lock_key))))
|
||||
|
||||
@db_operation("create_round_robin_workflow_run_credential_selection", log_errors=False)
|
||||
async def create_round_robin_selection(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_run_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
) -> str:
|
||||
async with self.Session() as session:
|
||||
lock_key = f"wrcs:{organization_id}:{workflow_permanent_id}:{parameter_key}"
|
||||
# The lock, idempotency check, LRU read, and insert must stay in this transaction.
|
||||
await self._take_rotation_advisory_lock(session, lock_key)
|
||||
|
||||
existing = await self._get_selection(
|
||||
session,
|
||||
workflow_run_id=workflow_run_id,
|
||||
parameter_key=parameter_key,
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
latest_selections = await self._get_latest_selections(
|
||||
session,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
unseen = next((candidate for candidate in credential_ids if candidate not in latest_selections), None)
|
||||
credential_id = (
|
||||
unseen
|
||||
if unseen is not None
|
||||
else min(credential_ids, key=lambda candidate: latest_selections[candidate])
|
||||
)
|
||||
|
||||
selection = WorkflowRunCredentialSelectionModel(
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(selection)
|
||||
await session.commit()
|
||||
return credential_id
|
||||
|
||||
@db_operation("create_workflow_run_credential_selection", log_errors=False)
|
||||
async def create_selection(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_run_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_id: str,
|
||||
) -> str:
|
||||
async with self.Session() as session:
|
||||
selection = WorkflowRunCredentialSelectionModel(
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(selection)
|
||||
await session.commit()
|
||||
return credential_id
|
||||
|
|
@ -1155,6 +1155,8 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
search_key: str | None = None,
|
||||
error_code: str | None = None,
|
||||
exclude_child_runs: bool = False,
|
||||
created_at_start: datetime | None = None,
|
||||
created_at_end: datetime | None = None,
|
||||
) -> list[WorkflowRun]:
|
||||
"""
|
||||
Get runs for a workflow, with optional `search_key` on run ID, parameter key/description/value,
|
||||
|
|
@ -1175,6 +1177,10 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
query = self._apply_error_code_filter(query, error_code)
|
||||
if status:
|
||||
query = query.filter(WorkflowRunModel.status.in_(status))
|
||||
if created_at_start is not None:
|
||||
query = query.filter(WorkflowRunModel.created_at >= created_at_start)
|
||||
if created_at_end is not None:
|
||||
query = query.filter(WorkflowRunModel.created_at < created_at_end)
|
||||
query = query.order_by(WorkflowRunModel.created_at.desc()).limit(page_size).offset(db_page * page_size)
|
||||
workflow_runs_and_titles_tuples = (await session.execute(query)).all()
|
||||
workflow_runs = [
|
||||
|
|
|
|||
|
|
@ -3866,6 +3866,8 @@ async def _get_workflow_runs_by_id(
|
|||
search_key: str | None,
|
||||
error_code: str | None,
|
||||
exclude_child_runs: bool,
|
||||
created_at_start: datetime | None = None,
|
||||
created_at_end: datetime | None = None,
|
||||
) -> list[WorkflowRun]:
|
||||
analytics.capture("skyvern-oss-agent-workflow-runs-get")
|
||||
return await app.WORKFLOW_SERVICE.get_workflow_runs_for_workflow_permanent_id(
|
||||
|
|
@ -3877,6 +3879,8 @@ async def _get_workflow_runs_by_id(
|
|||
search_key=search_key,
|
||||
error_code=error_code,
|
||||
exclude_child_runs=exclude_child_runs,
|
||||
created_at_start=created_at_start,
|
||||
created_at_end=created_at_end,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3921,6 +3925,14 @@ async def get_workflow_runs_by_id(
|
|||
),
|
||||
examples=["INVALID_CREDENTIALS", "LOGIN_FAILED", "CAPTCHA_DETECTED"],
|
||||
),
|
||||
created_at_start: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Only include runs created at or after this UTC timestamp (ISO 8601)."),
|
||||
] = None,
|
||||
created_at_end: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Only include runs created strictly before this UTC timestamp (ISO 8601)."),
|
||||
] = None,
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> list[WorkflowRun]:
|
||||
"""
|
||||
|
|
@ -3937,6 +3949,8 @@ async def get_workflow_runs_by_id(
|
|||
status=status,
|
||||
search_key=search_key,
|
||||
error_code=error_code,
|
||||
created_at_start=created_at_start,
|
||||
created_at_end=created_at_end,
|
||||
exclude_child_runs=True,
|
||||
)
|
||||
|
||||
|
|
@ -3982,6 +3996,14 @@ async def get_workflow_runs_by_id_legacy(
|
|||
),
|
||||
examples=["INVALID_CREDENTIALS", "LOGIN_FAILED", "CAPTCHA_DETECTED"],
|
||||
),
|
||||
created_at_start: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Only include runs created at or after this UTC timestamp (ISO 8601)."),
|
||||
] = None,
|
||||
created_at_end: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Only include runs created strictly before this UTC timestamp (ISO 8601)."),
|
||||
] = None,
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> list[WorkflowRun]:
|
||||
"""
|
||||
|
|
@ -3997,6 +4019,8 @@ async def get_workflow_runs_by_id_legacy(
|
|||
status=status,
|
||||
search_key=search_key,
|
||||
error_code=error_code,
|
||||
created_at_start=created_at_start,
|
||||
created_at_end=created_at_end,
|
||||
exclude_child_runs=False,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from skyvern.forge.sdk.schemas.organizations import Organization
|
|||
from skyvern.forge.sdk.schemas.tasks import TaskStatus
|
||||
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants, BitwardenService
|
||||
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, normalize_totp_config
|
||||
from skyvern.forge.sdk.workflow.credential_selection import select_credential_for_run
|
||||
from skyvern.forge.sdk.workflow.exceptions import MissingJinjaVariables, OutputParameterKeyCollisionError
|
||||
from skyvern.forge.sdk.workflow.models.parameter import (
|
||||
PARAMETER_TYPE,
|
||||
|
|
@ -237,6 +238,7 @@ class WorkflowRunContext:
|
|||
self.browser_session_id: str | None = None
|
||||
self.include_secrets_in_templates: bool = False
|
||||
self.credential_totp_identifiers: dict[str, str] = {}
|
||||
self.resolved_credential_parameter_ids: dict[str, str] = {}
|
||||
|
||||
def set_workflow(self, workflow: "Workflow") -> None:
|
||||
"""
|
||||
|
|
@ -708,7 +710,9 @@ class WorkflowRunContext:
|
|||
LOG.info("Fetching credential parameter value", parameter_key=parameter.key)
|
||||
|
||||
credential_id = None
|
||||
if parameter.credential_id:
|
||||
if parameter.credential_ids:
|
||||
credential_id = await self.resolve_credential_parameter_id(parameter, organization.organization_id)
|
||||
elif parameter.credential_id:
|
||||
if self.has_parameter(parameter.credential_id) and self.has_value(parameter.credential_id):
|
||||
credential_id = self.values[parameter.credential_id]
|
||||
else:
|
||||
|
|
@ -720,6 +724,28 @@ class WorkflowRunContext:
|
|||
|
||||
await self._register_credential_parameter_value(credential_id, parameter, organization)
|
||||
|
||||
async def resolve_credential_parameter_id(
|
||||
self,
|
||||
parameter: CredentialParameter,
|
||||
organization_id: str,
|
||||
) -> str:
|
||||
cached = self.resolved_credential_parameter_ids.get(parameter.key)
|
||||
if cached:
|
||||
return cached
|
||||
if not parameter.credential_ids:
|
||||
self.resolved_credential_parameter_ids[parameter.key] = parameter.credential_id
|
||||
return parameter.credential_id
|
||||
credential_id = await select_credential_for_run(
|
||||
workflow_run_id=self.workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=self.workflow_permanent_id,
|
||||
parameter_key=parameter.key,
|
||||
credential_ids=parameter.credential_ids,
|
||||
selection_strategy=parameter.selection_strategy,
|
||||
)
|
||||
self.resolved_credential_parameter_ids[parameter.key] = credential_id
|
||||
return credential_id
|
||||
|
||||
async def register_aws_secret_parameter_value(
|
||||
self,
|
||||
parameter: AWSSecretParameter,
|
||||
|
|
|
|||
70
skyvern/forge/sdk/workflow/credential_selection.py
Normal file
70
skyvern/forge/sdk/workflow/credential_selection.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from skyvern.forge import app
|
||||
|
||||
LOG = structlog.get_logger()
|
||||
|
||||
ROUND_ROBIN = "round_robin"
|
||||
RANDOM = "random"
|
||||
VALID_SELECTION_STRATEGIES = frozenset({ROUND_ROBIN, RANDOM})
|
||||
|
||||
|
||||
def normalize_selection_strategy(selection_strategy: str | None) -> str:
|
||||
return selection_strategy or ROUND_ROBIN
|
||||
|
||||
|
||||
async def select_credential_for_run(
|
||||
workflow_run_id: str,
|
||||
organization_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
selection_strategy: str | None,
|
||||
) -> str:
|
||||
existing = await app.DATABASE.workflow_run_credential_selections.get_selection(
|
||||
workflow_run_id=workflow_run_id,
|
||||
parameter_key=parameter_key,
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
strategy = normalize_selection_strategy(selection_strategy)
|
||||
try:
|
||||
if strategy == RANDOM:
|
||||
selected = await app.DATABASE.workflow_run_credential_selections.create_selection(
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_id=random.choice(credential_ids),
|
||||
)
|
||||
else:
|
||||
selected = await app.DATABASE.workflow_run_credential_selections.create_round_robin_selection(
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
except IntegrityError:
|
||||
existing_selection = await app.DATABASE.workflow_run_credential_selections.get_selection(
|
||||
workflow_run_id=workflow_run_id,
|
||||
parameter_key=parameter_key,
|
||||
)
|
||||
if not existing_selection:
|
||||
raise
|
||||
selected = existing_selection
|
||||
|
||||
LOG.info(
|
||||
"Selected workflow run credential",
|
||||
workflow_run_id=workflow_run_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_id=selected,
|
||||
strategy=strategy,
|
||||
)
|
||||
return selected
|
||||
|
|
@ -125,6 +125,8 @@ class CredentialParameter(Parameter):
|
|||
workflow_id: str
|
||||
|
||||
credential_id: str
|
||||
credential_ids: list[str] | None = None
|
||||
selection_strategy: str | None = None
|
||||
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ from skyvern.forge.sdk.workflow.browser_profile_key import (
|
|||
build_workflow_browser_session_storage_key,
|
||||
render_browser_profile_key,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.credential_selection import (
|
||||
VALID_SELECTION_STRATEGIES,
|
||||
normalize_selection_strategy,
|
||||
select_credential_for_run,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.exceptions import (
|
||||
InvalidWorkflowDefinition,
|
||||
WorkflowVersionConflict,
|
||||
|
|
@ -983,6 +988,38 @@ class WorkflowService:
|
|||
if missing:
|
||||
raise InvalidCredentialId(", ".join(missing))
|
||||
|
||||
async def _validate_and_normalize_credential_rotation_parameters(
|
||||
self,
|
||||
parameters: list[Any],
|
||||
organization: Organization,
|
||||
) -> None:
|
||||
credential_ids_to_validate: list[str] = []
|
||||
for parameter in parameters:
|
||||
credential_ids = getattr(parameter, "credential_ids", None)
|
||||
if credential_ids is None:
|
||||
continue
|
||||
key = getattr(parameter, "key", None) or "<unknown>"
|
||||
if not credential_ids:
|
||||
raise SkyvernHTTPException(
|
||||
message=f"credential_ids for credential parameter {key} must be non-empty.",
|
||||
status_code=400,
|
||||
)
|
||||
credential_ids = list(dict.fromkeys(credential_ids))
|
||||
parameter.credential_ids = credential_ids
|
||||
selection_strategy = getattr(parameter, "selection_strategy", None)
|
||||
if normalize_selection_strategy(selection_strategy) not in VALID_SELECTION_STRATEGIES:
|
||||
raise SkyvernHTTPException(
|
||||
message=(
|
||||
f"selection_strategy for credential parameter {key} must be one of: "
|
||||
f"{', '.join(sorted(VALID_SELECTION_STRATEGIES))}."
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
parameter.credential_id = credential_ids[0]
|
||||
credential_ids_to_validate.extend(credential_ids)
|
||||
|
||||
await self._validate_credential_ids(credential_ids_to_validate, organization)
|
||||
|
||||
async def validate_schedule_parameters(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
|
|
@ -3055,6 +3092,7 @@ class WorkflowService:
|
|||
block=block,
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
||||
)
|
||||
# Save the original navigation goal before any mutation so
|
||||
# retries don't stack the browser-session prefix repeatedly.
|
||||
|
|
@ -3726,6 +3764,7 @@ class WorkflowService:
|
|||
block=block,
|
||||
workflow_run_id=None,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=None,
|
||||
)
|
||||
|
||||
async def _resolve_login_block_browser_profile_id(
|
||||
|
|
@ -3733,6 +3772,7 @@ class WorkflowService:
|
|||
block: Block,
|
||||
workflow_run_id: str | None,
|
||||
organization_id: str | None,
|
||||
workflow_permanent_id: str | None,
|
||||
) -> str | None:
|
||||
"""Inspect the block-level parameters and return the browser_profile_id
|
||||
from the credential parameter bound to this specific block."""
|
||||
|
|
@ -3743,6 +3783,8 @@ class WorkflowService:
|
|||
credential_ids = await self._resolve_login_block_credential_ids(
|
||||
block=block,
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
)
|
||||
|
||||
for credential_id in credential_ids:
|
||||
|
|
@ -3787,6 +3829,8 @@ class WorkflowService:
|
|||
self,
|
||||
block: Block,
|
||||
workflow_run_id: str | None,
|
||||
organization_id: str | None = None,
|
||||
workflow_permanent_id: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return credential ids bound to this block, preserving parameter order."""
|
||||
params = block.parameters
|
||||
|
|
@ -3800,7 +3844,12 @@ class WorkflowService:
|
|||
|
||||
# Style 1: CredentialParameter (has credential_id directly)
|
||||
if isinstance(param, CredentialParameter):
|
||||
credential_id = param.credential_id
|
||||
credential_id = await self._resolve_credential_parameter_id(
|
||||
parameter=param,
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
)
|
||||
|
||||
# Style 2: WorkflowParameter with type CREDENTIAL_ID
|
||||
elif (
|
||||
|
|
@ -3842,6 +3891,30 @@ class WorkflowService:
|
|||
|
||||
return credential_ids
|
||||
|
||||
async def _resolve_credential_parameter_id(
|
||||
self,
|
||||
*,
|
||||
parameter: CredentialParameter,
|
||||
workflow_run_id: str | None,
|
||||
organization_id: str | None,
|
||||
workflow_permanent_id: str | None,
|
||||
) -> str:
|
||||
if not parameter.credential_ids or not workflow_run_id or not organization_id or not workflow_permanent_id:
|
||||
return parameter.credential_id
|
||||
|
||||
workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.workflow_run_contexts.get(workflow_run_id)
|
||||
if workflow_run_context:
|
||||
return await workflow_run_context.resolve_credential_parameter_id(parameter, organization_id)
|
||||
|
||||
return await select_credential_for_run(
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter.key,
|
||||
credential_ids=parameter.credential_ids,
|
||||
selection_strategy=parameter.selection_strategy,
|
||||
)
|
||||
|
||||
async def _apply_login_block_credential_proxy_pin(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -3859,6 +3932,8 @@ class WorkflowService:
|
|||
credential_ids = await self._resolve_login_block_credential_ids(
|
||||
block=block,
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=getattr(workflow_run, "workflow_permanent_id", None),
|
||||
)
|
||||
for credential_id in credential_ids:
|
||||
if not organization_id:
|
||||
|
|
@ -4786,6 +4861,13 @@ class WorkflowService:
|
|||
edited_by: str | None | object = _UNSET,
|
||||
) -> Workflow:
|
||||
if workflow_definition is not None:
|
||||
if organization_id is not None:
|
||||
organization = await app.DATABASE.organizations.get_organization(organization_id=organization_id)
|
||||
if organization is not None:
|
||||
await self._validate_and_normalize_credential_rotation_parameters(
|
||||
workflow_definition.parameters,
|
||||
organization,
|
||||
)
|
||||
updated_workflow = await app.DATABASE.workflows.update_workflow_and_reconcile_definition_params(
|
||||
workflow_id=workflow_id,
|
||||
title=title,
|
||||
|
|
@ -5076,6 +5158,8 @@ class WorkflowService:
|
|||
search_key: str | None = None,
|
||||
error_code: str | None = None,
|
||||
exclude_child_runs: bool = False,
|
||||
created_at_start: datetime | None = None,
|
||||
created_at_end: datetime | None = None,
|
||||
) -> list[WorkflowRun]:
|
||||
return await app.DATABASE.workflow_runs.get_workflow_runs_for_workflow_permanent_id(
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
|
|
@ -5086,6 +5170,8 @@ class WorkflowService:
|
|||
search_key=search_key,
|
||||
error_code=error_code,
|
||||
exclude_child_runs=exclude_child_runs,
|
||||
created_at_start=created_at_start,
|
||||
created_at_end=created_at_end,
|
||||
)
|
||||
|
||||
async def get_workflow_runs_for_browser_session(
|
||||
|
|
@ -6942,6 +7028,10 @@ class WorkflowService:
|
|||
organization_id=organization_id,
|
||||
title=title,
|
||||
)
|
||||
await self._validate_and_normalize_credential_rotation_parameters(
|
||||
request.workflow_definition.parameters,
|
||||
organization,
|
||||
)
|
||||
new_workflow_id: str | None = None
|
||||
refresh_schedule_runtime_limits = False
|
||||
effective_max_elapsed_time_minutes: int | None = None
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ def convert_workflow_definition(
|
|||
key=parameter.key,
|
||||
description=parameter.description,
|
||||
credential_id=parameter.credential_id,
|
||||
credential_ids=parameter.credential_ids,
|
||||
selection_strategy=parameter.selection_strategy,
|
||||
created_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -596,6 +596,8 @@ class BitwardenLoginCredentialParameterYAML(ParameterYAML):
|
|||
class CredentialParameterYAML(ParameterYAML):
|
||||
parameter_type: Literal[ParameterType.CREDENTIAL] = ParameterType.CREDENTIAL # type: ignore
|
||||
credential_id: str
|
||||
credential_ids: list[str] | None = None
|
||||
selection_strategy: str | None = None
|
||||
|
||||
|
||||
class BitwardenSensitiveInformationParameterYAML(ParameterYAML):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from skyvern.forge.sdk.db.repositories.workflow_runs import WorkflowRunsRepository
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
def __init__(self, session: MagicMock) -> None:
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self) -> MagicMock:
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _Result:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
def _make_repo(captured: dict[str, Any]) -> WorkflowRunsRepository:
|
||||
async def _execute(query):
|
||||
captured["query"] = query
|
||||
return _Result()
|
||||
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock(side_effect=_execute)
|
||||
return WorkflowRunsRepository(session_factory=lambda: _SessionContext(session), debug_enabled=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workflow_runs_filters_by_created_at_window() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
repo = _make_repo(captured)
|
||||
|
||||
await repo.get_workflow_runs_for_workflow_permanent_id(
|
||||
workflow_permanent_id="wpid_test",
|
||||
organization_id="o_test",
|
||||
created_at_start=datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||
created_at_end=datetime(2026, 6, 8, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
where = str(captured["query"].whereclause)
|
||||
assert "created_at >=" in where
|
||||
assert "created_at <" in where
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workflow_runs_omits_created_at_filter_when_unset() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
repo = _make_repo(captured)
|
||||
|
||||
await repo.get_workflow_runs_for_workflow_permanent_id(
|
||||
workflow_permanent_id="wpid_test",
|
||||
organization_id="o_test",
|
||||
)
|
||||
|
||||
where = str(captured["query"].whereclause)
|
||||
assert "created_at >=" not in where
|
||||
assert "created_at <" not in where
|
||||
|
|
@ -85,6 +85,61 @@ def test_credential_id_bound_through_block_credential_parameter_is_allowed() ->
|
|||
assert _credential_id_misbinding_findings(yaml) == []
|
||||
|
||||
|
||||
def test_credential_ids_bound_through_credential_parameter_are_allowed() -> None:
|
||||
yaml = _yaml(
|
||||
"""
|
||||
title: Sign in
|
||||
workflow_definition:
|
||||
parameters:
|
||||
- key: login_credentials
|
||||
parameter_type: credential
|
||||
credential_id: cred_527971855302737592
|
||||
credential_ids:
|
||||
- cred_527971855302737592
|
||||
- cred_827971855302737593
|
||||
blocks:
|
||||
- block_type: login
|
||||
label: login_to_portal
|
||||
url: https://authenticationtest.com/loginUserAndPassword/
|
||||
parameter_keys: [login_credentials]
|
||||
"""
|
||||
)
|
||||
|
||||
assert _credential_id_misbinding_findings(yaml) == []
|
||||
|
||||
|
||||
def test_credential_parameter_unrelated_field_with_credential_id_is_still_flagged() -> None:
|
||||
yaml = _yaml(
|
||||
"""
|
||||
title: Sign in
|
||||
workflow_definition:
|
||||
parameters:
|
||||
- key: login_credentials
|
||||
parameter_type: credential
|
||||
credential_id: cred_527971855302737592
|
||||
credential_ids:
|
||||
- cred_527971855302737592
|
||||
- cred_827971855302737593
|
||||
description: Use cred_927971855302737594 for this login.
|
||||
blocks:
|
||||
- block_type: login
|
||||
label: login_to_portal
|
||||
url: https://authenticationtest.com/loginUserAndPassword/
|
||||
parameter_keys: [login_credentials]
|
||||
"""
|
||||
)
|
||||
|
||||
findings = _credential_id_misbinding_findings(yaml)
|
||||
|
||||
assert findings == [
|
||||
{
|
||||
"location": "workflow",
|
||||
"field": "description",
|
||||
"credential_id": "cred_927971855302737594",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_credential_id_in_parameter_keys_list_is_flagged() -> None:
|
||||
yaml = _yaml(
|
||||
"""
|
||||
|
|
|
|||
423
tests/unit/test_credential_rotation.py
Normal file
423
tests/unit/test_credential_rotation.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from skyvern.exceptions import InvalidCredentialId, SkyvernHTTPException
|
||||
from skyvern.forge.sdk.copilot.output_policy import OutputPolicyReason, evaluate_output_policy
|
||||
from skyvern.forge.sdk.copilot.request_policy import RequestPolicy
|
||||
from skyvern.forge.sdk.db.agent_db import AgentDB, _build_engine
|
||||
from skyvern.forge.sdk.db.models import Base, WorkflowRunCredentialSelectionModel
|
||||
from skyvern.forge.sdk.db.repositories.workflow_run_credential_selections import (
|
||||
WorkflowRunCredentialSelectionsRepository,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.credential_selection import select_credential_for_run
|
||||
from skyvern.forge.sdk.workflow.models.parameter import CredentialParameter
|
||||
from skyvern.forge.sdk.workflow.service import WorkflowService
|
||||
from skyvern.forge.sdk.workflow.workflow_definition_converter import convert_workflow_definition
|
||||
from skyvern.schemas.workflows import CredentialParameterYAML, WorkflowDefinitionYAML
|
||||
|
||||
|
||||
def _credential_parameter(
|
||||
*,
|
||||
credential_id: str = "cred_a",
|
||||
credential_ids: list[str] | None = None,
|
||||
selection_strategy: str | None = None,
|
||||
) -> CredentialParameter:
|
||||
now = datetime.now(timezone.utc)
|
||||
return CredentialParameter(
|
||||
key="login_cred",
|
||||
credential_parameter_id="cp_test",
|
||||
workflow_id="wf_test",
|
||||
credential_id=credential_id,
|
||||
credential_ids=credential_ids,
|
||||
selection_strategy=selection_strategy,
|
||||
created_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
|
||||
|
||||
class _SelectionRepo:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
existing: dict[tuple[str, str], str] | None = None,
|
||||
latest: dict[str, datetime] | None = None,
|
||||
raise_on_create: bool = False,
|
||||
) -> None:
|
||||
self.existing = existing or {}
|
||||
self.latest = latest or {}
|
||||
self.raise_on_create = raise_on_create
|
||||
self.created: list[dict[str, str]] = []
|
||||
|
||||
async def get_selection(self, workflow_run_id: str, parameter_key: str) -> str | None:
|
||||
return self.existing.get((workflow_run_id, parameter_key))
|
||||
|
||||
async def get_latest_selections(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
) -> dict[str, datetime]:
|
||||
return {
|
||||
credential_id: self.latest[credential_id]
|
||||
for credential_id in credential_ids
|
||||
if credential_id in self.latest
|
||||
}
|
||||
|
||||
async def create_selection(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_run_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_id: str,
|
||||
) -> str:
|
||||
if self.raise_on_create:
|
||||
self.existing[(workflow_run_id, parameter_key)] = "cred_winner"
|
||||
raise IntegrityError("insert", {}, Exception("duplicate"))
|
||||
self.created.append(
|
||||
{
|
||||
"organization_id": organization_id,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"workflow_permanent_id": workflow_permanent_id,
|
||||
"parameter_key": parameter_key,
|
||||
"credential_id": credential_id,
|
||||
}
|
||||
)
|
||||
self.existing[(workflow_run_id, parameter_key)] = credential_id
|
||||
return credential_id
|
||||
|
||||
async def create_round_robin_selection(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_run_id: str,
|
||||
workflow_permanent_id: str,
|
||||
parameter_key: str,
|
||||
credential_ids: list[str],
|
||||
) -> str:
|
||||
existing = await self.get_selection(workflow_run_id=workflow_run_id, parameter_key=parameter_key)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
latest_selections = await self.get_latest_selections(
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
unseen = next((candidate for candidate in credential_ids if candidate not in latest_selections), None)
|
||||
credential_id = (
|
||||
unseen if unseen is not None else min(credential_ids, key=lambda candidate: latest_selections[candidate])
|
||||
)
|
||||
return await self.create_selection(
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
parameter_key=parameter_key,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sqlite_engine() -> AsyncEngine:
|
||||
engine = _build_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sqlite_db(sqlite_engine: AsyncEngine) -> AgentDB:
|
||||
return AgentDB("sqlite+aiosqlite:///:memory:", db_engine=sqlite_engine)
|
||||
|
||||
|
||||
async def _select(repo: _SelectionRepo, credential_ids: list[str], strategy: str | None = None) -> str:
|
||||
with patch("skyvern.forge.sdk.workflow.credential_selection.app") as mock_app:
|
||||
mock_app.DATABASE.workflow_run_credential_selections = repo
|
||||
return await select_credential_for_run(
|
||||
workflow_run_id="wr_test",
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
parameter_key="login_cred",
|
||||
credential_ids=credential_ids,
|
||||
selection_strategy=strategy,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_robin_picks_unseen_first() -> None:
|
||||
repo = _SelectionRepo(latest={"cred_a": datetime.now(timezone.utc)})
|
||||
|
||||
selected = await _select(repo, ["cred_a", "cred_b", "cred_c"])
|
||||
|
||||
assert selected == "cred_b"
|
||||
assert repo.created[0]["credential_id"] == "cred_b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_robin_picks_oldest_last_used_and_ties_by_list_order() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
repo = _SelectionRepo(latest={"cred_a": now, "cred_b": now - timedelta(minutes=5), "cred_c": now})
|
||||
|
||||
selected = await _select(repo, ["cred_a", "cred_b", "cred_c"])
|
||||
|
||||
assert selected == "cred_b"
|
||||
|
||||
tied_repo = _SelectionRepo(latest={"cred_a": now, "cred_b": now, "cred_c": now})
|
||||
tied_selected = await _select(tied_repo, ["cred_a", "cred_b", "cred_c"])
|
||||
|
||||
assert tied_selected == "cred_a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_is_idempotent_for_run_and_key() -> None:
|
||||
repo = _SelectionRepo(existing={("wr_test", "login_cred"): "cred_a"})
|
||||
|
||||
first = await _select(repo, ["cred_a", "cred_b"])
|
||||
second = await _select(repo, ["cred_a", "cred_b"])
|
||||
|
||||
assert first == "cred_a"
|
||||
assert second == "cred_a"
|
||||
assert repo.created == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_selection_returns_member(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = _SelectionRepo()
|
||||
monkeypatch.setattr("skyvern.forge.sdk.workflow.credential_selection.random.choice", lambda ids: ids[-1])
|
||||
|
||||
selected = await _select(repo, ["cred_a", "cred_b"], "random")
|
||||
|
||||
assert selected == "cred_b"
|
||||
assert selected in {"cred_a", "cred_b"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_insert_race_returns_existing_winner() -> None:
|
||||
repo = _SelectionRepo(raise_on_create=True)
|
||||
|
||||
selected = await _select(repo, ["cred_a", "cred_b"])
|
||||
|
||||
assert selected == "cred_winner"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_robin_repository_serialized_path_picks_distinct_credentials(sqlite_db: AgentDB) -> None:
|
||||
repo = sqlite_db.workflow_run_credential_selections
|
||||
|
||||
first = await repo.create_round_robin_selection(
|
||||
organization_id="org_test",
|
||||
workflow_run_id="wr_one",
|
||||
workflow_permanent_id="wpid_test",
|
||||
parameter_key="login_cred",
|
||||
credential_ids=["cred_a", "cred_b"],
|
||||
)
|
||||
second = await repo.create_round_robin_selection(
|
||||
organization_id="org_test",
|
||||
workflow_run_id="wr_two",
|
||||
workflow_permanent_id="wpid_test",
|
||||
parameter_key="login_cred",
|
||||
credential_ids=["cred_a", "cred_b"],
|
||||
)
|
||||
|
||||
assert first == "cred_a"
|
||||
assert second == "cred_b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_robin_repository_idempotent_recall_returns_existing(sqlite_db: AgentDB) -> None:
|
||||
repo = sqlite_db.workflow_run_credential_selections
|
||||
|
||||
first = await repo.create_round_robin_selection(
|
||||
organization_id="org_test",
|
||||
workflow_run_id="wr_one",
|
||||
workflow_permanent_id="wpid_test",
|
||||
parameter_key="login_cred",
|
||||
credential_ids=["cred_a", "cred_b"],
|
||||
)
|
||||
second = await repo.create_round_robin_selection(
|
||||
organization_id="org_test",
|
||||
workflow_run_id="wr_one",
|
||||
workflow_permanent_id="wpid_test",
|
||||
parameter_key="login_cred",
|
||||
credential_ids=["cred_a", "cred_b"],
|
||||
)
|
||||
|
||||
async with sqlite_db.Session() as session:
|
||||
count = (
|
||||
await session.execute(select(func.count()).select_from(WorkflowRunCredentialSelectionModel))
|
||||
).scalar_one()
|
||||
|
||||
assert first == "cred_a"
|
||||
assert second == "cred_a"
|
||||
assert count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotation_advisory_lock_skips_non_postgres_dialect() -> None:
|
||||
repo = WorkflowRunCredentialSelectionsRepository(MagicMock())
|
||||
session = MagicMock()
|
||||
session.get_bind.return_value = SimpleNamespace(dialect=SimpleNamespace(name="sqlite"))
|
||||
session.execute = AsyncMock()
|
||||
|
||||
await repo._take_rotation_advisory_lock(session, "wrcs:org:wpid:login_cred")
|
||||
|
||||
session.execute.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_save_validation_rejects_empty_credential_ids() -> None:
|
||||
service = WorkflowService()
|
||||
org = SimpleNamespace(organization_id="org_test")
|
||||
parameter = _credential_parameter(credential_ids=[])
|
||||
|
||||
with pytest.raises(SkyvernHTTPException, match="credential_ids"):
|
||||
await service._validate_and_normalize_credential_rotation_parameters([parameter], org)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_save_validation_rejects_unknown_credential_id() -> None:
|
||||
service = WorkflowService()
|
||||
org = SimpleNamespace(organization_id="org_test")
|
||||
parameter = _credential_parameter(credential_ids=["cred_missing"])
|
||||
|
||||
with patch("skyvern.forge.sdk.workflow.service.app") as mock_app:
|
||||
mock_app.DATABASE.credentials.get_credentials_by_ids = AsyncMock(return_value=[])
|
||||
with pytest.raises(InvalidCredentialId):
|
||||
await service._validate_and_normalize_credential_rotation_parameters([parameter], org)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_save_validation_rejects_bad_strategy() -> None:
|
||||
service = WorkflowService()
|
||||
org = SimpleNamespace(organization_id="org_test")
|
||||
parameter = _credential_parameter(credential_ids=["cred_a"], selection_strategy="newest")
|
||||
|
||||
with pytest.raises(SkyvernHTTPException, match="selection_strategy"):
|
||||
await service._validate_and_normalize_credential_rotation_parameters([parameter], org)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_save_validation_normalizes_credential_id_to_first_rotating_id() -> None:
|
||||
service = WorkflowService()
|
||||
org = SimpleNamespace(organization_id="org_test")
|
||||
parameter = _credential_parameter(credential_id="cred_stale", credential_ids=["cred_a", "cred_b"])
|
||||
existing = [SimpleNamespace(credential_id="cred_a"), SimpleNamespace(credential_id="cred_b")]
|
||||
|
||||
with patch("skyvern.forge.sdk.workflow.service.app") as mock_app:
|
||||
mock_app.DATABASE.credentials.get_credentials_by_ids = AsyncMock(return_value=existing)
|
||||
await service._validate_and_normalize_credential_rotation_parameters([parameter], org)
|
||||
|
||||
assert parameter.credential_id == "cred_a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_save_validation_dedupes_credential_ids_preserving_order() -> None:
|
||||
service = WorkflowService()
|
||||
org = SimpleNamespace(organization_id="org_test")
|
||||
parameter = _credential_parameter(
|
||||
credential_id="cred_stale",
|
||||
credential_ids=["cred_a", "cred_b", "cred_a", "cred_c", "cred_b"],
|
||||
)
|
||||
existing = [
|
||||
SimpleNamespace(credential_id="cred_a"),
|
||||
SimpleNamespace(credential_id="cred_b"),
|
||||
SimpleNamespace(credential_id="cred_c"),
|
||||
]
|
||||
|
||||
with patch("skyvern.forge.sdk.workflow.service.app") as mock_app:
|
||||
mock_get_credentials = AsyncMock(return_value=existing)
|
||||
mock_app.DATABASE.credentials.get_credentials_by_ids = mock_get_credentials
|
||||
await service._validate_and_normalize_credential_rotation_parameters([parameter], org)
|
||||
|
||||
assert parameter.credential_ids == ["cred_a", "cred_b", "cred_c"]
|
||||
assert parameter.credential_id == "cred_a"
|
||||
mock_get_credentials.assert_awaited_once_with(["cred_a", "cred_b", "cred_c"], organization_id="org_test")
|
||||
|
||||
|
||||
def test_output_policy_origin_broadening_checks_non_first_rotating_credential() -> None:
|
||||
workflow_yaml = """
|
||||
title: Login
|
||||
workflow_definition:
|
||||
parameters:
|
||||
- parameter_type: credential
|
||||
key: login_cred
|
||||
credential_id: cred_first
|
||||
credential_ids:
|
||||
- cred_first
|
||||
- cred_second
|
||||
blocks:
|
||||
- block_type: login
|
||||
label: Login
|
||||
url: https://portal.example.com/login
|
||||
parameter_keys:
|
||||
- login_cred
|
||||
"""
|
||||
request_policy = RequestPolicy(
|
||||
resolved_credentials=[
|
||||
SimpleNamespace(credential_id="cred_first", tested_url="https://portal.example.com/login"),
|
||||
SimpleNamespace(credential_id="cred_second", tested_url="https://other.example.com/login"),
|
||||
]
|
||||
)
|
||||
|
||||
verdict = evaluate_output_policy(request_policy=request_policy, workflow_yaml=workflow_yaml)
|
||||
|
||||
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
||||
|
||||
|
||||
def test_yaml_to_credential_parameter_round_trip_preserves_rotation_fields() -> None:
|
||||
yaml_definition = WorkflowDefinitionYAML(
|
||||
parameters=[
|
||||
CredentialParameterYAML(
|
||||
key="login_cred",
|
||||
credential_id="cred_a",
|
||||
credential_ids=["cred_a", "cred_b"],
|
||||
selection_strategy="round_robin",
|
||||
)
|
||||
],
|
||||
blocks=[],
|
||||
)
|
||||
|
||||
definition = convert_workflow_definition(yaml_definition, workflow_id="wf_test")
|
||||
parameter = definition.parameters[0]
|
||||
|
||||
assert isinstance(parameter, CredentialParameter)
|
||||
assert parameter.credential_id == "cred_a"
|
||||
assert parameter.credential_ids == ["cred_a", "cred_b"]
|
||||
assert parameter.selection_strategy == "round_robin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_login_block_credential_ids_returns_selected_rotating_id() -> None:
|
||||
service = WorkflowService()
|
||||
parameter = _credential_parameter(credential_ids=["cred_a", "cred_b"])
|
||||
context = MagicMock()
|
||||
context.resolve_credential_parameter_id = AsyncMock(return_value="cred_b")
|
||||
block = SimpleNamespace(parameters=[parameter])
|
||||
|
||||
with patch("skyvern.forge.sdk.workflow.service.app") as mock_app:
|
||||
mock_app.WORKFLOW_CONTEXT_MANAGER.workflow_run_contexts = {"wr_test": context}
|
||||
credential_ids = await service._resolve_login_block_credential_ids(
|
||||
block=block,
|
||||
workflow_run_id="wr_test",
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
)
|
||||
|
||||
assert credential_ids == ["cred_b"]
|
||||
context.resolve_credential_parameter_id.assert_awaited_once_with(parameter, "org_test")
|
||||
311
tests/unit/test_execute_step_exception_handling.py
Normal file
311
tests/unit/test_execute_step_exception_handling.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""Characterization tests for ``ForgeAgent.execute_step`` exception handling (SKY-11786).
|
||||
|
||||
These pin the CURRENT observable behavior of the exception handlers at the tail of
|
||||
``skyvern/forge/agent.py::ForgeAgent.execute_step``. For each exception type they pin:
|
||||
|
||||
* whether ``fail_task`` runs,
|
||||
* whether ``clean_up_task`` runs (and, for the conditional handlers, whether that is
|
||||
gated on ``fail_task`` reporting the task as failed),
|
||||
* the webhook decision (``need_call_webhook``) and the final-screenshot decision
|
||||
(``need_final_screenshot``) handed to ``clean_up_task``.
|
||||
|
||||
They gate the SKY-11743 restructure (SKY-11787), which collapses the nine cleanup
|
||||
handlers behind shared per-exception configuration; the suite must pass against current
|
||||
main with no change to ``execute_step``.
|
||||
|
||||
Each handler is reached by making the first in-``try`` await
|
||||
(``app.AGENT_FUNCTION.validate_step_execution``) raise the target exception, which lands
|
||||
control directly in the matching ``except`` clause. ``fail_task`` and ``clean_up_task``
|
||||
are mocked, so the assertions read the decisions off their call args and never touch a
|
||||
real browser or database. Webhook/screenshot are asserted as *effective* values
|
||||
(kwarg-or-default), so a restructure that makes the current defaults explicit still
|
||||
passes — only a change in behavior fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
from unittest.mock import AsyncMock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from skyvern.exceptions import (
|
||||
FailedToNavigateToUrl,
|
||||
FailedToParseActionInstruction,
|
||||
FailedToSendWebhook,
|
||||
InvalidTaskStatusTransition,
|
||||
MissingBrowserStatePage,
|
||||
ScrapingFailed,
|
||||
StepTerminationError,
|
||||
StepUnableToExecuteError,
|
||||
TaskAlreadyCanceled,
|
||||
TaskAlreadyTimeout,
|
||||
UnsupportedActionType,
|
||||
UnsupportedTaskType,
|
||||
)
|
||||
from skyvern.forge.agent import ForgeAgent
|
||||
from skyvern.forge.sdk.core import skyvern_context
|
||||
from skyvern.forge.sdk.core.skyvern_context import SkyvernContext
|
||||
from skyvern.forge.sdk.models import StepStatus
|
||||
from tests.unit.helpers import make_organization, make_step, make_task
|
||||
|
||||
# ``clean_up_task``'s real signature defaults; handlers that omit these kwargs get them.
|
||||
_CLEANUP_WEBHOOK_DEFAULT = True
|
||||
_CLEANUP_SCREENSHOT_DEFAULT = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecuteStepOutcome:
|
||||
returned: tuple | None
|
||||
raised: BaseException | None
|
||||
fail_task: AsyncMock
|
||||
clean_up_task: AsyncMock
|
||||
|
||||
@property
|
||||
def fail_task_called(self) -> bool:
|
||||
return self.fail_task.await_count > 0
|
||||
|
||||
@property
|
||||
def cleanup_called(self) -> bool:
|
||||
return self.clean_up_task.await_count > 0
|
||||
|
||||
def _cleanup_kwarg(self, name: str, default: Any) -> Any:
|
||||
assert self.cleanup_called, "clean_up_task was not called"
|
||||
return self.clean_up_task.await_args.kwargs.get(name, default)
|
||||
|
||||
@property
|
||||
def effective_webhook(self) -> bool:
|
||||
return bool(self._cleanup_kwarg("need_call_webhook", _CLEANUP_WEBHOOK_DEFAULT))
|
||||
|
||||
@property
|
||||
def effective_final_screenshot(self) -> bool:
|
||||
return bool(self._cleanup_kwarg("need_final_screenshot", _CLEANUP_SCREENSHOT_DEFAULT))
|
||||
|
||||
|
||||
async def _drive_execute_step(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
exc: BaseException,
|
||||
*,
|
||||
fail_task_result: bool = True,
|
||||
) -> ExecuteStepOutcome:
|
||||
"""Run ``execute_step`` so that ``exc`` is raised at the first in-``try`` await."""
|
||||
agent = ForgeAgent()
|
||||
now = datetime.now(UTC)
|
||||
organization = make_organization(now)
|
||||
task = make_task(now, organization)
|
||||
step = make_step(now, task, step_id="step-0", status=StepStatus.running, order=0, output=None)
|
||||
|
||||
fail_task_mock = AsyncMock(return_value=fail_task_result)
|
||||
clean_up_task_mock = AsyncMock(return_value=None)
|
||||
agent.fail_task = fail_task_mock # type: ignore[method-assign]
|
||||
agent.clean_up_task = clean_up_task_mock # type: ignore[method-assign]
|
||||
|
||||
# Pre-``try`` DB reads in execute_step: keep them inert so we reach the try body.
|
||||
monkeypatch.setattr("skyvern.forge.agent.app.DATABASE.tasks.get_task", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr("skyvern.forge.agent.app.DATABASE.tasks.update_task", AsyncMock(return_value=task))
|
||||
# First in-``try`` await; raising here routes straight to the matching except clause.
|
||||
monkeypatch.setattr(
|
||||
"skyvern.forge.agent.app.AGENT_FUNCTION.validate_step_execution",
|
||||
AsyncMock(side_effect=exc),
|
||||
)
|
||||
|
||||
context = SkyvernContext(
|
||||
organization_id=organization.organization_id,
|
||||
task_id=task.task_id,
|
||||
step_id=None,
|
||||
tz_info=ZoneInfo("UTC"),
|
||||
)
|
||||
skyvern_context.set(context)
|
||||
returned: tuple | None = None
|
||||
raised: BaseException | None = None
|
||||
try:
|
||||
returned = await agent.execute_step(
|
||||
organization=organization,
|
||||
task=task,
|
||||
step=step,
|
||||
api_key="api-key",
|
||||
download_baseline_files=[],
|
||||
)
|
||||
except BaseException as caught: # noqa: BLE001 - we characterize which exceptions propagate
|
||||
raised = caught
|
||||
finally:
|
||||
skyvern_context.reset()
|
||||
|
||||
return ExecuteStepOutcome(
|
||||
returned=returned,
|
||||
raised=raised,
|
||||
fail_task=fail_task_mock,
|
||||
clean_up_task=clean_up_task_mock,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CleanupHandlerCase:
|
||||
"""Pinned contract for one cleanup ``except`` clause in ``execute_step``."""
|
||||
|
||||
id: str
|
||||
exc_factory: Callable[[], BaseException]
|
||||
fail_task_called: bool
|
||||
effective_webhook: bool
|
||||
effective_final_screenshot: bool
|
||||
# True => clean_up_task runs only when fail_task reports the task as failed.
|
||||
cleanup_gated_on_fail_task: bool
|
||||
|
||||
|
||||
# The nine cleanup ``except`` clauses (SKY-11743's "nine cleanup handlers"). The
|
||||
# unsupported-* clause catches three exception types and is exercised by all three below.
|
||||
CLEANUP_CASES: list[CleanupHandlerCase] = [
|
||||
CleanupHandlerCase(
|
||||
id="task_already_timeout",
|
||||
exc_factory=lambda: TaskAlreadyTimeout("task-123"),
|
||||
fail_task_called=False,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="step_termination",
|
||||
exc_factory=lambda: StepTerminationError("terminated", step_id="step-0", task_id="task-123"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=True,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="failed_to_navigate",
|
||||
exc_factory=lambda: FailedToNavigateToUrl("https://example.com", "boom"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=False, # the only handler that suppresses the final screenshot
|
||||
cleanup_gated_on_fail_task=True,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="task_already_canceled",
|
||||
exc_factory=lambda: TaskAlreadyCanceled("failed", "task-123"),
|
||||
fail_task_called=False,
|
||||
effective_webhook=False,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="invalid_status_transition",
|
||||
exc_factory=lambda: InvalidTaskStatusTransition("running", "failed", "task-123"),
|
||||
fail_task_called=False,
|
||||
effective_webhook=False,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="unsupported_action_type",
|
||||
exc_factory=lambda: UnsupportedActionType("MYSTERY_ACTION"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=False,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="unsupported_task_type",
|
||||
exc_factory=lambda: UnsupportedTaskType("mystery_task"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=False,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="failed_to_parse_action",
|
||||
exc_factory=lambda: FailedToParseActionInstruction("bad", "ValueError"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=False,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="scraping_failed",
|
||||
exc_factory=lambda: ScrapingFailed(reason="page gone"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="missing_browser_state_page",
|
||||
exc_factory=lambda: MissingBrowserStatePage(task_id="task-123"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=False,
|
||||
),
|
||||
CleanupHandlerCase(
|
||||
id="generic_exception",
|
||||
exc_factory=lambda: RuntimeError("something unexpected"),
|
||||
fail_task_called=True,
|
||||
effective_webhook=True,
|
||||
effective_final_screenshot=True,
|
||||
cleanup_gated_on_fail_task=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("case", CLEANUP_CASES, ids=[c.id for c in CLEANUP_CASES])
|
||||
async def test_cleanup_handler_pins_webhook_screenshot_and_fail_task(
|
||||
monkeypatch: pytest.MonkeyPatch, case: CleanupHandlerCase
|
||||
) -> None:
|
||||
"""Each cleanup handler runs clean_up_task with a pinned webhook/screenshot decision.
|
||||
|
||||
Driven with fail_task reporting the task as failed, so the gated handlers also clean up.
|
||||
"""
|
||||
outcome = await _drive_execute_step(monkeypatch, case.exc_factory(), fail_task_result=True)
|
||||
|
||||
assert outcome.raised is None, f"{case.id} unexpectedly propagated {outcome.raised!r}"
|
||||
assert outcome.fail_task_called is case.fail_task_called
|
||||
assert outcome.cleanup_called is True
|
||||
assert outcome.effective_webhook is case.effective_webhook
|
||||
assert outcome.effective_final_screenshot is case.effective_final_screenshot
|
||||
# Every cleanup handler returns (step, detailed_output, next_step); nothing advanced here.
|
||||
assert outcome.returned is not None
|
||||
assert outcome.returned[0].step_id == "step-0"
|
||||
assert outcome.returned[2] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("case", CLEANUP_CASES, ids=[c.id for c in CLEANUP_CASES])
|
||||
async def test_cleanup_gating_when_fail_task_reports_not_failed(
|
||||
monkeypatch: pytest.MonkeyPatch, case: CleanupHandlerCase
|
||||
) -> None:
|
||||
"""Pin whether clean_up_task is skipped when fail_task reports the task was NOT failed.
|
||||
|
||||
Only three handlers (step-termination, failed-to-navigate, generic Exception) gate
|
||||
cleanup on that result; the rest clean up unconditionally.
|
||||
"""
|
||||
outcome = await _drive_execute_step(monkeypatch, case.exc_factory(), fail_task_result=False)
|
||||
|
||||
assert outcome.raised is None
|
||||
assert outcome.cleanup_called is not case.cleanup_gated_on_fail_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_unable_to_execute_reraises_without_cleanup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""StepUnableToExecuteError propagates out of execute_step; no fail_task, no cleanup."""
|
||||
outcome = await _drive_execute_step(monkeypatch, StepUnableToExecuteError("step-0", "cannot run"))
|
||||
|
||||
assert isinstance(outcome.raised, StepUnableToExecuteError)
|
||||
assert outcome.returned is None
|
||||
assert outcome.fail_task_called is False
|
||||
assert outcome.cleanup_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_to_send_webhook_is_swallowed_without_cleanup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""FailedToSendWebhook is swallowed (step returned); it never fails or cleans up the task."""
|
||||
outcome = await _drive_execute_step(monkeypatch, FailedToSendWebhook(task_id="task-123"))
|
||||
|
||||
assert outcome.raised is None
|
||||
assert outcome.fail_task_called is False
|
||||
assert outcome.cleanup_called is False
|
||||
assert outcome.returned is not None
|
||||
assert outcome.returned[0].step_id == "step-0"
|
||||
assert outcome.returned[2] is None
|
||||
|
|
@ -131,6 +131,8 @@ async def test_get_workflow_runs_by_id_child_filter_depends_on_route(
|
|||
search_key="login",
|
||||
error_code="LOGIN_FAILED",
|
||||
exclude_child_runs=expected_exclude_child_runs,
|
||||
created_at_start=None,
|
||||
created_at_end=None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue