Allow uncapped workflow runtime configuration (#SKY-10265) (#6567)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-06-14 23:03:56 -04:00 committed by GitHub
parent d0e0982dcd
commit cdacadb95d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 290 additions and 106 deletions

View file

@ -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": [

View file

@ -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"
}
]
}
}

View file

@ -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({
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Max Run Time (minutes)</Label>
<HelpTooltip content="Times out this workflow after the configured elapsed runtime. Maximum runtime is 4 hours (240 minutes)." />
<HelpTooltip content="Times out this workflow after the configured elapsed runtime. Leave blank to use the platform default of 4 hours. Maximum is 8 hours." />
</div>
<Input
value={data.maxElapsedTimeMinutes ?? ""}
placeholder="Default: 4 hours"
placeholder={`Default: ${WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES} minutes`}
type="number"
min={1}
max={240}
max={WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES}
step={1}
onChange={(event) => {
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 });
}}
/>

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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).

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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",