From cdacadb95d439a4549ad0532be3c274972b62e3a Mon Sep 17 00:00:00 2001 From: Suchintan Date: Sun, 14 Jun 2026 23:03:56 -0400 Subject: [PATCH] Allow uncapped workflow runtime configuration (#SKY-10265) (#6567) Co-authored-by: Suchintan Singh --- docs/api-reference/openapi.json | 14 +-- fern/openapi/skyvern_openapi.json | 68 +++++------ .../StartNode/WorkflowSettingsEditor.tsx | 14 ++- skyvern/client/client.py | 4 +- skyvern/client/raw_client.py | 4 +- .../types/workflow_run_request_output.py | 2 +- skyvern/forge/agent_functions.py | 1 + .../forge/sdk/routes/workflow_schedules.py | 2 + .../forge/sdk/workflow/models/run_limits.py | 12 +- skyvern/forge/sdk/workflow/models/workflow.py | 4 +- skyvern/forge/sdk/workflow/service.py | 107 ++++++++++++++---- skyvern/schemas/runs.py | 11 +- skyvern/schemas/workflows.py | 4 +- .../unit/test_workflow_browser_profile_id.py | 62 +++++++++- tests/unit/test_workflow_elapsed_timeout.py | 87 +++++++++++--- 15 files changed, 290 insertions(+), 106 deletions(-) diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index c9259d8ca..d841bd60a 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -19621,7 +19621,7 @@ "max_iterations": { "type": "integer", "title": "Max Iterations", - "default": 10 + "default": 50 }, "max_steps": { "type": "integer", @@ -19729,7 +19729,7 @@ "max_iterations": { "type": "integer", "title": "Max Iterations", - "default": 10 + "default": 50 }, "max_steps": { "type": "integer", @@ -22466,7 +22466,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -24245,7 +24245,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -24253,7 +24253,7 @@ } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ @@ -24490,7 +24490,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -24498,7 +24498,7 @@ } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ diff --git a/fern/openapi/skyvern_openapi.json b/fern/openapi/skyvern_openapi.json index 9c0d91c66..d841bd60a 100644 --- a/fern/openapi/skyvern_openapi.json +++ b/fern/openapi/skyvern_openapi.json @@ -1989,12 +1989,12 @@ } } }, - "400": { - "description": "Timeline not available for this run type" - }, "404": { "description": "Run not found" }, + "400": { + "description": "Timeline not available for this run type" + }, "422": { "description": "Validation Error", "content": { @@ -3000,12 +3000,12 @@ } } }, - "403": { - "description": "Unauthorized - Invalid or missing authentication" - }, "404": { "description": "Browser session not found" }, + "403": { + "description": "Unauthorized - Invalid or missing authentication" + }, "409": { "description": "Conflict - browser session has already ended and can no longer be updated" }, @@ -3074,12 +3074,12 @@ } } }, - "403": { - "description": "Unauthorized - Invalid or missing authentication" - }, "404": { "description": "Browser session not found" }, + "403": { + "description": "Unauthorized - Invalid or missing authentication" + }, "422": { "description": "Validation Error", "content": { @@ -4521,12 +4521,12 @@ } } }, - "400": { - "description": "Folder not found" - }, "404": { "description": "Workflow not found" }, + "400": { + "description": "Folder not found" + }, "422": { "description": "Validation Error", "content": { @@ -5481,6 +5481,9 @@ "404": { "description": "Workflow not found" }, + "500": { + "description": "Storage deletion failed; retry" + }, "422": { "description": "Validation Error", "content": { @@ -5490,9 +5493,6 @@ } } } - }, - "500": { - "description": "Storage deletion failed; retry" } }, "x-hidden": true @@ -8692,7 +8692,7 @@ "type": "number", "title": "Timeout", "description": "Timeout in milliseconds", - "default": 10000 + "default": 5000 } }, "type": "object", @@ -9267,8 +9267,8 @@ "anyOf": [ { "type": "integer", - "maximum": 1440, - "minimum": 5 + "maximum": 1440.0, + "minimum": 5.0 }, { "type": "null" @@ -14134,7 +14134,7 @@ "type": "number", "title": "Timeout", "description": "Timeout in milliseconds", - "default": 10000 + "default": 5000 } }, "type": "object", @@ -17132,7 +17132,7 @@ "type": "number", "title": "Timeout", "description": "Timeout in milliseconds", - "default": 10000 + "default": 5000 } }, "type": "object", @@ -20390,7 +20390,7 @@ "type": "number", "title": "Timeout", "description": "Timeout in milliseconds", - "default": 10000 + "default": 5000 } }, "type": "object", @@ -20944,8 +20944,8 @@ }, "confidence_float": { "type": "number", - "maximum": 1, - "minimum": 0, + "maximum": 1.0, + "minimum": 0.0, "title": "Confidence Float" }, "error_type": { @@ -22466,8 +22466,8 @@ "anyOf": [ { "type": "integer", - "maximum": 240, - "minimum": 1 + "maximum": 480.0, + "minimum": 1.0 }, { "type": "null" @@ -22538,8 +22538,8 @@ "anyOf": [ { "type": "integer", - "maximum": 2, - "minimum": 1 + "maximum": 2.0, + "minimum": 1.0 }, { "type": "null" @@ -24245,15 +24245,15 @@ "anyOf": [ { "type": "integer", - "maximum": 240, - "minimum": 1 + "maximum": 480.0, + "minimum": 1.0 }, { "type": "null" } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ @@ -24490,15 +24490,15 @@ "anyOf": [ { "type": "integer", - "maximum": 240, - "minimum": 1 + "maximum": 480.0, + "minimum": 1.0 }, { "type": "null" } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ @@ -25561,4 +25561,4 @@ "x-fern-server-name": "Local" } ] -} \ No newline at end of file +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/WorkflowSettingsEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/WorkflowSettingsEditor.tsx index 610e31a29..d9afd366c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/WorkflowSettingsEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/WorkflowSettingsEditor.tsx @@ -51,6 +51,9 @@ const PREVENT_OVERLAPPING_RUNS_TOOLTIP = const SEQUENTIAL_KEY_TOOLTIP = "Scope the run queue. Runs with the same key are queued together; runs with different keys can still execute in parallel. Templated against agent inputs, for example {{ account_id }} to serialize per account."; +const WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES = 4 * 60; +const WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 8 * 60; + function WorkflowSettingsEditor({ blockId }: { blockId: string }) { // Subscribe to the start node's data slice. The sidebar mount lives // outside the per-node renderer, and the body also subscribes to @@ -396,14 +399,14 @@ function WorkflowSettingsEditorBody({
- +
{ const rawValue = event.target.value; @@ -411,7 +414,10 @@ function WorkflowSettingsEditorBody({ const value = rawValue === "" || !Number.isFinite(parsedValue) ? null - : Math.min(240, Math.max(1, Math.trunc(parsedValue))); + : Math.min( + WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + Math.max(1, Math.trunc(parsedValue)), + ); update({ maxElapsedTimeMinutes: value }); }} /> diff --git a/skyvern/client/client.py b/skyvern/client/client.py index 74cadd89a..5d4a5c698 100644 --- a/skyvern/client/client.py +++ b/skyvern/client/client.py @@ -431,7 +431,7 @@ class Skyvern: The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot. max_elapsed_time_minutes : typing.Optional[int] - Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours. + Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes. extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]] The extra HTTP headers for the requests in browser. @@ -3177,7 +3177,7 @@ class AsyncSkyvern: The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot. max_elapsed_time_minutes : typing.Optional[int] - Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours. + Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes. extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]] The extra HTTP headers for the requests in browser. diff --git a/skyvern/client/raw_client.py b/skyvern/client/raw_client.py index 0757d005b..8fdfd6508 100644 --- a/skyvern/client/raw_client.py +++ b/skyvern/client/raw_client.py @@ -389,7 +389,7 @@ class RawSkyvern: The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot. max_elapsed_time_minutes : typing.Optional[int] - Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours. + Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes. extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]] The extra HTTP headers for the requests in browser. @@ -4181,7 +4181,7 @@ class AsyncRawSkyvern: The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot. max_elapsed_time_minutes : typing.Optional[int] - Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours. + Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes. extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]] The extra HTTP headers for the requests in browser. diff --git a/skyvern/client/types/workflow_run_request_output.py b/skyvern/client/types/workflow_run_request_output.py index 5c6a2ba49..cf48778f8 100644 --- a/skyvern/client/types/workflow_run_request_output.py +++ b/skyvern/client/types/workflow_run_request_output.py @@ -96,7 +96,7 @@ class WorkflowRunRequestOutput(UniversalBaseModel): max_elapsed_time_minutes: typing.Optional[int] = pydantic.Field(default=None) """ - Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours. + Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes. """ extra_http_headers: typing.Optional[typing.Dict[str, typing.Optional[str]]] = pydantic.Field(default=None) diff --git a/skyvern/forge/agent_functions.py b/skyvern/forge/agent_functions.py index e4451fec0..bcc356853 100644 --- a/skyvern/forge/agent_functions.py +++ b/skyvern/forge/agent_functions.py @@ -802,6 +802,7 @@ class AgentFunction: timezone: str, enabled: bool, parameters: dict[str, Any] | None = None, + max_elapsed_time_minutes: int | None = None, ) -> None: """Upsert a recurring schedule with the execution backend (e.g. Temporal). diff --git a/skyvern/forge/sdk/routes/workflow_schedules.py b/skyvern/forge/sdk/routes/workflow_schedules.py index b35d258ea..9a2a412e8 100644 --- a/skyvern/forge/sdk/routes/workflow_schedules.py +++ b/skyvern/forge/sdk/routes/workflow_schedules.py @@ -326,6 +326,7 @@ async def create_workflow_schedule( timezone=body.timezone, enabled=body.enabled, parameters=stored_parameters, + max_elapsed_time_minutes=workflow.max_elapsed_time_minutes, ) except Exception as e: LOG.exception( @@ -492,6 +493,7 @@ async def update_workflow_schedule( timezone=body.timezone, enabled=body.enabled, parameters=stored_parameters, + max_elapsed_time_minutes=workflow.max_elapsed_time_minutes, ) except Exception as e: LOG.exception( diff --git a/skyvern/forge/sdk/workflow/models/run_limits.py b/skyvern/forge/sdk/workflow/models/run_limits.py index bf1a7e091..e0b359937 100644 --- a/skyvern/forge/sdk/workflow/models/run_limits.py +++ b/skyvern/forge/sdk/workflow/models/run_limits.py @@ -1,4 +1,5 @@ -DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 4 * 60 +WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES = 4 * 60 +WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 8 * 60 def reject_bool_max_elapsed_time_minutes(max_elapsed_time_minutes: object) -> object: @@ -8,9 +9,10 @@ def reject_bool_max_elapsed_time_minutes(max_elapsed_time_minutes: object) -> ob def get_effective_workflow_run_max_elapsed_time_minutes(max_elapsed_time_minutes: int | None) -> int: + if max_elapsed_time_minutes is None: + return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES if not isinstance(max_elapsed_time_minutes, int) or isinstance(max_elapsed_time_minutes, bool): - return DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES if max_elapsed_time_minutes <= 0: - return DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES - # Product hard ceiling: explicit workflow/run settings cannot extend past the default 4 hour cap. - return min(max_elapsed_time_minutes, DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) + return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES + return min(max_elapsed_time_minutes, WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) diff --git a/skyvern/forge/sdk/workflow/models/workflow.py b/skyvern/forge/sdk/workflow/models/workflow.py index 9be4f69ae..439b5f8ad 100644 --- a/skyvern/forge/sdk/workflow/models/workflow.py +++ b/skyvern/forge/sdk/workflow/models/workflow.py @@ -16,7 +16,7 @@ from skyvern.forge.sdk.workflow.exceptions import ( from skyvern.forge.sdk.workflow.models.block import BlockTypeVar, ForLoopBlock, WhileLoopBlock, get_all_blocks from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter from skyvern.forge.sdk.workflow.models.run_limits import ( - DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, reject_bool_max_elapsed_time_minutes, ) from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with @@ -36,7 +36,7 @@ class WorkflowRequestBody(BaseModel): browser_session_id: str | None = None browser_profile_id: str | None = None max_screenshot_scrolls: int | None = None - max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) + max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) extra_http_headers: dict[str, str] | None = None cdp_connect_headers: dict[str, str] | None = None browser_address: str | None = None diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index 4cab85787..a1dd724d4 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -198,6 +198,13 @@ def _format_workflow_run_elapsed_timeout_failure_reason(effective_minutes: int) return f"Workflow run exceeded max elapsed runtime limit of {effective_minutes} {minute_label}." +def _require_elapsed_timeout_failure_reason(timeout_failure_reason: str | None) -> str: + if timeout_failure_reason is None: + LOG.error("timeout_failure_reason missing when workflow elapsed timeout is set") + return "Workflow run exceeded max elapsed runtime limit." + return timeout_failure_reason + + def _get_workflow_webhook_delivery_status( *, webhook_callback_url: str | None, @@ -1782,43 +1789,48 @@ class WorkflowService: started_at=workflow_run.started_at, ) blocks_to_update: set[str] = set() + + async def execute_workflow_blocks() -> None: + nonlocal workflow_run, blocks_to_update + # Check if there's a related workflow script that should be used instead. + workflow_script, _, script_is_pinned = await workflow_script_service.get_workflow_script( + workflow, workflow_run, block_labels + ) + current_context = skyvern_context.current() + if current_context: + if workflow_script: + current_context.generate_script = False + if workflow_run.code_gen: + current_context.generate_script = True + workflow_run, blocks_to_update = await self._execute_workflow_blocks( + workflow=workflow, + workflow_run=workflow_run, + organization=organization, + browser_session_id=browser_session_id, + browser_profile_id=browser_profile_id, + block_labels=block_labels, + block_outputs=block_outputs, + script=workflow_script, + script_is_pinned=script_is_pinned, + ) + if max_elapsed_timeout_seconds <= 0: workflow_run = await self.mark_workflow_run_as_timed_out( workflow_run_id=workflow_run_id, - failure_reason=timeout_failure_reason, + failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason), ) return workflow_run else: timeout_context = asyncio.timeout(max_elapsed_timeout_seconds) try: async with timeout_context: - # Check if there's a related workflow script that should be used instead. - workflow_script, _, script_is_pinned = await workflow_script_service.get_workflow_script( - workflow, workflow_run, block_labels - ) - current_context = skyvern_context.current() - if current_context: - if workflow_script: - current_context.generate_script = False - if workflow_run.code_gen: - current_context.generate_script = True - workflow_run, blocks_to_update = await self._execute_workflow_blocks( - workflow=workflow, - workflow_run=workflow_run, - organization=organization, - browser_session_id=browser_session_id, - browser_profile_id=browser_profile_id, - block_labels=block_labels, - block_outputs=block_outputs, - script=workflow_script, - script_is_pinned=script_is_pinned, - ) + await execute_workflow_blocks() except TimeoutError: if not timeout_context.expired(): raise workflow_run = await self.mark_workflow_run_as_timed_out( workflow_run_id=workflow_run_id, - failure_reason=timeout_failure_reason, + failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason), ) return workflow_run @@ -1834,7 +1846,7 @@ class WorkflowService: workflow_run=workflow_run, pre_finally_status=pre_finally_status, pre_finally_failure_reason=pre_finally_failure_reason, - timeout_failure_reason=timeout_failure_reason, + timeout_failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason), ) return workflow_run @@ -1962,7 +1974,7 @@ class WorkflowService: workflow_run=workflow_run, pre_finally_status=pre_finally_status, pre_finally_failure_reason=pre_finally_failure_reason, - timeout_failure_reason=timeout_failure_reason, + timeout_failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason), ) finally: # Shielded finalize runs even when the try body was cancelled @@ -6276,6 +6288,8 @@ class WorkflowService: title=title, ) new_workflow_id: str | None = None + refresh_schedule_runtime_limits = False + effective_max_elapsed_time_minutes: int | None = None if workflow_permanent_id: # Would return 404: WorkflowNotFound to the client if wpid does not match the organization @@ -6306,6 +6320,9 @@ class WorkflowService: if "max_elapsed_time_minutes" in request.model_fields_set else existing_latest_workflow.max_elapsed_time_minutes ) + refresh_schedule_runtime_limits = ( + effective_max_elapsed_time_minutes != existing_latest_workflow.max_elapsed_time_minutes + ) # NOTE: it's only potential, as it may be immediately deleted! potential_workflow = await self.create_workflow( @@ -6408,6 +6425,13 @@ class WorkflowService: delete_script=delete_script, ) + if refresh_schedule_runtime_limits: + await self._refresh_workflow_schedule_runtime_limits( + workflow_permanent_id=updated_workflow.workflow_permanent_id, + organization_id=organization_id, + max_elapsed_time_minutes=effective_max_elapsed_time_minutes, + ) + return updated_workflow except SkyvernHTTPException: # Bubble up well-formed client errors (e.g. WorkflowNotFound 404) @@ -6426,6 +6450,39 @@ class WorkflowService: LOG.exception(f"Failed to create workflow from request, title: {title}") raise e + async def _refresh_workflow_schedule_runtime_limits( + self, + *, + workflow_permanent_id: str, + organization_id: str, + max_elapsed_time_minutes: int | None, + ) -> None: + schedules = await app.DATABASE.schedules.get_workflow_schedules( + workflow_permanent_id=workflow_permanent_id, + organization_id=organization_id, + ) + for schedule in schedules: + if not schedule.backend_schedule_id: + continue + try: + await app.AGENT_FUNCTION.upsert_workflow_schedule( + backend_schedule_id=schedule.backend_schedule_id, + organization_id=organization_id, + workflow_permanent_id=workflow_permanent_id, + workflow_schedule_id=schedule.workflow_schedule_id, + cron_expression=schedule.cron_expression, + timezone=schedule.timezone, + enabled=schedule.enabled, + parameters=schedule.parameters, + max_elapsed_time_minutes=max_elapsed_time_minutes, + ) + except Exception: + LOG.exception( + "Failed to refresh workflow schedule runtime limit", + workflow_permanent_id=workflow_permanent_id, + workflow_schedule_id=schedule.workflow_schedule_id, + ) + @staticmethod async def create_output_parameter_for_block(workflow_id: str, block_yaml: BLOCK_YAML_TYPES) -> OutputParameter: output_parameter_key = f"{block_yaml.label}_output" diff --git a/skyvern/schemas/runs.py b/skyvern/schemas/runs.py index 04d480538..e7af263e6 100644 --- a/skyvern/schemas/runs.py +++ b/skyvern/schemas/runs.py @@ -15,7 +15,8 @@ from pydantic import ( from skyvern.forge.sdk.schemas.files import FileInfo from skyvern.forge.sdk.workflow.models.run_limits import ( - DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, reject_bool_max_elapsed_time_minutes, ) from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with @@ -263,8 +264,12 @@ class WorkflowRunRequest(BaseModel): max_elapsed_time_minutes: int | None = Field( default=None, ge=1, - le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, - description="Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.", + le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + description=( + "Timeout this workflow run after the configured elapsed runtime in minutes. " + f"When omitted, the platform default is {WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES} minutes. " + f"The maximum configurable value is {WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES} minutes." + ), ) extra_http_headers: dict[str, str] | None = Field( default=None, diff --git a/skyvern/schemas/workflows.py b/skyvern/schemas/workflows.py index 2993be28e..e4e24b70e 100644 --- a/skyvern/schemas/workflows.py +++ b/skyvern/schemas/workflows.py @@ -12,7 +12,7 @@ from skyvern.forge.sdk.api.llm.config_registry import LLMConfigRegistry from skyvern.forge.sdk.settings_manager import SettingsManager from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType, WorkflowParameterType from skyvern.forge.sdk.workflow.models.run_limits import ( - DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, reject_bool_max_elapsed_time_minutes, ) from skyvern.forge.sdk.workflow.models.validators import normalize_run_with @@ -1219,7 +1219,7 @@ class WorkflowCreateYAMLRequest(BaseModel): workflow_definition: WorkflowDefinitionYAML is_saved_task: bool = False max_screenshot_scrolls: int | None = None - max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) + max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES) extra_http_headers: dict[str, str] | None = None cdp_connect_headers: dict[str, str] | None = None status: WorkflowStatus = WorkflowStatus.published diff --git a/tests/unit/test_workflow_browser_profile_id.py b/tests/unit/test_workflow_browser_profile_id.py index 40893bd23..1cbff4935 100644 --- a/tests/unit/test_workflow_browser_profile_id.py +++ b/tests/unit/test_workflow_browser_profile_id.py @@ -92,6 +92,9 @@ async def test_create_workflow_from_request_preserves_existing_max_elapsed_time_ create_workflow_mock.assert_awaited_once() assert create_workflow_mock.await_args is not None assert create_workflow_mock.await_args.kwargs["max_elapsed_time_minutes"] == 90 + refresh_schedules_mock = service._refresh_workflow_schedule_runtime_limits + assert isinstance(refresh_schedules_mock, AsyncMock) + refresh_schedules_mock.assert_not_awaited() @pytest.mark.asyncio @@ -116,6 +119,62 @@ async def test_create_workflow_from_request_allows_explicit_null_to_clear_existi create_workflow_mock.assert_awaited_once() assert create_workflow_mock.await_args is not None assert create_workflow_mock.await_args.kwargs["max_elapsed_time_minutes"] is None + refresh_schedules_mock = service._refresh_workflow_schedule_runtime_limits + assert isinstance(refresh_schedules_mock, AsyncMock) + refresh_schedules_mock.assert_awaited_once_with( + workflow_permanent_id="wpid_test", + organization_id="org_1", + max_elapsed_time_minutes=None, + ) + + +@pytest.mark.asyncio +async def test_refresh_workflow_schedule_runtime_limits_reupserts_backend_schedules() -> None: + service = WorkflowService() + schedule_with_backend = SimpleNamespace( + backend_schedule_id="temporal_1", + workflow_schedule_id="wfs_1", + cron_expression="0 */6 * * *", + timezone="UTC", + enabled=True, + parameters={"url": "https://example.com"}, + ) + schedule_without_backend = SimpleNamespace( + backend_schedule_id=None, + workflow_schedule_id="wfs_local", + cron_expression="0 */12 * * *", + timezone="UTC", + enabled=False, + parameters=None, + ) + + with patch("skyvern.forge.sdk.workflow.service.app") as mock_app: + mock_app.DATABASE.schedules.get_workflow_schedules = AsyncMock( + return_value=[schedule_with_backend, schedule_without_backend] + ) + mock_app.AGENT_FUNCTION.upsert_workflow_schedule = AsyncMock() + + await service._refresh_workflow_schedule_runtime_limits( + workflow_permanent_id="wpid_test", + organization_id="org_1", + max_elapsed_time_minutes=360, + ) + + mock_app.DATABASE.schedules.get_workflow_schedules.assert_awaited_once_with( + workflow_permanent_id="wpid_test", + organization_id="org_1", + ) + mock_app.AGENT_FUNCTION.upsert_workflow_schedule.assert_awaited_once_with( + backend_schedule_id="temporal_1", + organization_id="org_1", + workflow_permanent_id="wpid_test", + workflow_schedule_id="wfs_1", + cron_expression="0 */6 * * *", + timezone="UTC", + enabled=True, + parameters={"url": "https://example.com"}, + max_elapsed_time_minutes=360, + ) def _make_workflow_update_service( @@ -131,7 +190,7 @@ def _make_workflow_update_service( max_elapsed_time_minutes=existing_max_elapsed_time_minutes, ) potential_workflow = SimpleNamespace(workflow_id="wf_new") - updated_workflow = SimpleNamespace(workflow_id="wf_new") + updated_workflow = SimpleNamespace(workflow_id="wf_new", workflow_permanent_id="wpid_test") service.get_workflow_by_permanent_id = AsyncMock(return_value=existing_workflow) # type: ignore[method-assign] service.create_workflow = AsyncMock(return_value=potential_workflow) # type: ignore[method-assign] @@ -142,6 +201,7 @@ def _make_workflow_update_service( service._validate_payload_templates = Mock() # type: ignore[method-assign] service.update_workflow_definition = AsyncMock(return_value=updated_workflow) # type: ignore[method-assign] service.maybe_delete_cached_code = AsyncMock() # type: ignore[method-assign] + service._refresh_workflow_schedule_runtime_limits = AsyncMock() # type: ignore[method-assign] return service, updated_workflow diff --git a/tests/unit/test_workflow_elapsed_timeout.py b/tests/unit/test_workflow_elapsed_timeout.py index 283b9abe1..d4c75a20c 100644 --- a/tests/unit/test_workflow_elapsed_timeout.py +++ b/tests/unit/test_workflow_elapsed_timeout.py @@ -10,7 +10,8 @@ from pydantic import ValidationError from skyvern.forge.sdk.routes.agent_protocol import _workflow_run_request_to_legacy_request from skyvern.forge.sdk.workflow import service as service_module from skyvern.forge.sdk.workflow.models.run_limits import ( - DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES, + WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES, get_effective_workflow_run_max_elapsed_time_minutes, ) from skyvern.forge.sdk.workflow.models.workflow import ( @@ -25,7 +26,12 @@ from skyvern.schemas.runs import WorkflowRunRequest from skyvern.schemas.workflows import WorkflowCreateYAMLRequest, WorkflowDefinitionYAML -def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleNamespace: +def _workflow_run( + status: WorkflowRunStatus, + *, + started_at: datetime, + max_elapsed_time_minutes: int | None = 1, +) -> SimpleNamespace: return SimpleNamespace( workflow_run_id="wr_1", workflow_id="wf_1", @@ -38,7 +44,7 @@ def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleN ignore_inherited_workflow_system_prompt=False, parent_workflow_run_id=None, proxy_location=None, - max_elapsed_time_minutes=1, + max_elapsed_time_minutes=max_elapsed_time_minutes, started_at=started_at, created_at=started_at, code_gen=False, @@ -46,14 +52,42 @@ def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleN ) -def test_workflow_request_rejects_max_elapsed_time_above_cap() -> None: - with pytest.warns(DeprecationWarning), pytest.raises(ValidationError): - WorkflowRequestBody(max_elapsed_time_minutes=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1) +def test_workflow_request_accepts_max_elapsed_time_above_legacy_four_hour_runtime() -> None: + with pytest.warns(DeprecationWarning): + request = WorkflowRequestBody(max_elapsed_time_minutes=300) - assert ( - get_effective_workflow_run_max_elapsed_time_minutes(DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1) - == DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + assert request.max_elapsed_time_minutes == 300 + assert get_effective_workflow_run_max_elapsed_time_minutes(None) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES + assert get_effective_workflow_run_max_elapsed_time_minutes(0) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES + assert get_effective_workflow_run_max_elapsed_time_minutes(-1) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES + assert get_effective_workflow_run_max_elapsed_time_minutes(cast(int, "bad")) == ( + WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES ) + assert get_effective_workflow_run_max_elapsed_time_minutes(300) == 300 + assert get_effective_workflow_run_max_elapsed_time_minutes(600) == WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + + +def test_elapsed_timeout_failure_reason_falls_back_when_invariant_is_missing() -> None: + assert ( + service_module._require_elapsed_timeout_failure_reason(None) + == "Workflow run exceeded max elapsed runtime limit." + ) + + +def test_workflow_run_elapsed_timeout_uses_platform_default_when_max_elapsed_time_is_none() -> None: + workflow_run = cast( + WorkflowRun, + _workflow_run( + WorkflowRunStatus.running, + started_at=datetime.now(timezone.utc), + max_elapsed_time_minutes=None, + ), + ) + + timeout_seconds = service_module._get_workflow_run_max_elapsed_timeout_seconds(workflow_run) + assert timeout_seconds is not None + assert (WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES * 60) - 1 <= timeout_seconds + assert timeout_seconds <= WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES * 60 def test_workflow_request_rejects_bool_max_elapsed_time() -> None: @@ -61,22 +95,30 @@ def test_workflow_request_rejects_bool_max_elapsed_time() -> None: WorkflowRequestBody(max_elapsed_time_minutes=True) -def test_workflow_run_request_accepts_max_elapsed_time() -> None: - request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=10) +def test_workflow_request_rejects_max_elapsed_time_above_platform_cap() -> None: + with pytest.warns(DeprecationWarning), pytest.raises(ValidationError): + WorkflowRequestBody(max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1) - assert request.max_elapsed_time_minutes == 10 + +def test_workflow_run_request_accepts_max_elapsed_time() -> None: + request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=300) + + assert request.max_elapsed_time_minutes == 300 def test_workflow_run_request_rejects_invalid_max_elapsed_time() -> None: with pytest.raises(ValidationError): - WorkflowRunRequest( - workflow_id="wpid_test", - max_elapsed_time_minutes=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1, - ) + WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=0) with pytest.raises(ValidationError): WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=True) + with pytest.raises(ValidationError): + WorkflowRunRequest( + workflow_id="wpid_test", + max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1, + ) + def test_public_workflow_run_request_preserves_max_elapsed_time_for_legacy_runner() -> None: request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=10) @@ -96,9 +138,18 @@ def test_workflow_create_yaml_request_rejects_bool_max_elapsed_time() -> None: ) -def test_workflow_domain_models_accept_db_values_above_current_elapsed_cap() -> None: +def test_workflow_create_yaml_request_rejects_max_elapsed_time_above_platform_cap() -> None: + with pytest.raises(ValidationError): + WorkflowCreateYAMLRequest( + title="test", + workflow_definition=WorkflowDefinitionYAML(parameters=[], blocks=[]), + max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1, + ) + + +def test_workflow_domain_models_accept_long_elapsed_timeout_values() -> None: now = datetime.now(timezone.utc) - max_elapsed_time_minutes = DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1 + max_elapsed_time_minutes = 300 workflow = Workflow( workflow_id="wf_1",