From f489d42697cae6409f5f17b2ee78486671d44834 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:33:12 +0000 Subject: [PATCH 1/8] Initial plan From 4e458181726c6e0731e5b3b55e70f1348d07f59f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:43:23 +0000 Subject: [PATCH 2/8] Add retry profile configuration support - Add ExecutionOptions parameter to Invoke-IdlePlan and Invoke-IdlePlanObject - Create Assert-IdleExecutionOptions to validate ExecutionOptions - Create Resolve-IdleStepRetryParameters to resolve retry parameters per step - Add RetryProfile property to step schema - Update Test-IdleWorkflowSchema to allow RetryProfile in steps - Update New-IdlePlanObject to preserve RetryProfile in plan steps - Modify Invoke-IdlePlanObject to apply resolved retry parameters - Add comprehensive integration tests for ExecutionOptions Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Assert-IdleExecutionOptions.ps1 | 173 ++++++++ .../Resolve-IdleStepRetryParameters.ps1 | 141 ++++++ .../Private/Test-IdleWorkflowSchema.ps1 | 24 +- .../Public/Invoke-IdlePlanObject.ps1 | 41 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 8 + src/IdLE/Public/Invoke-IdlePlan.ps1 | 22 +- tests/ExecutionOptions.Tests.ps1 | 231 ++++++++++ ...Invoke-IdlePlan.ExecutionOptions.Tests.ps1 | 417 ++++++++++++++++++ 8 files changed, 1051 insertions(+), 6 deletions(-) create mode 100644 src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 create mode 100644 src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 create mode 100644 tests/ExecutionOptions.Tests.ps1 create mode 100644 tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 diff --git a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 new file mode 100644 index 00000000..a38dc308 --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 @@ -0,0 +1,173 @@ +# Asserts that ExecutionOptions is valid and rejects ScriptBlocks. +# Validates the structure and constraints for retry profiles. + +function Assert-IdleExecutionOptions { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $ExecutionOptions + ) + + if ($null -eq $ExecutionOptions) { + return + } + + # ExecutionOptions must be a hashtable or IDictionary + if ($ExecutionOptions -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + 'ExecutionOptions must be a hashtable or IDictionary.', + 'ExecutionOptions' + ) + } + + # Reject ScriptBlocks anywhere in ExecutionOptions + Assert-IdleNoScriptBlock -InputObject $ExecutionOptions -Path 'ExecutionOptions' + + # Validate RetryProfiles if present + if ($ExecutionOptions.Contains('RetryProfiles')) { + $retryProfiles = $ExecutionOptions['RetryProfiles'] + + if ($null -ne $retryProfiles -and $retryProfiles -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + 'ExecutionOptions.RetryProfiles must be a hashtable or IDictionary.', + 'ExecutionOptions' + ) + } + + if ($null -ne $retryProfiles) { + foreach ($profileKey in $retryProfiles.Keys) { + # Profile key must match pattern: ^[A-Za-z0-9_.-]{1,64}$ + if ([string]$profileKey -notmatch '^[A-Za-z0-9_.-]{1,64}$') { + throw [System.ArgumentException]::new( + "RetryProfile key '$profileKey' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$", + 'ExecutionOptions' + ) + } + + $profile = $retryProfiles[$profileKey] + + if ($null -eq $profile) { + throw [System.ArgumentException]::new( + "RetryProfile '$profileKey' is null. Each profile must be a hashtable with retry parameters.", + 'ExecutionOptions' + ) + } + + if ($profile -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + "RetryProfile '$profileKey' must be a hashtable or IDictionary.", + 'ExecutionOptions' + ) + } + + # Validate individual retry parameters + Assert-IdleRetryProfile -Profile $profile -ProfileKey $profileKey + } + } + } + + # Validate DefaultRetryProfile if present + if ($ExecutionOptions.Contains('DefaultRetryProfile')) { + $defaultProfile = $ExecutionOptions['DefaultRetryProfile'] + + if ($null -ne $defaultProfile -and [string]::IsNullOrWhiteSpace([string]$defaultProfile)) { + throw [System.ArgumentException]::new( + 'ExecutionOptions.DefaultRetryProfile must not be an empty string.', + 'ExecutionOptions' + ) + } + + # DefaultRetryProfile must reference a valid profile key + if ($null -ne $defaultProfile -and $ExecutionOptions.Contains('RetryProfiles')) { + $retryProfiles = $ExecutionOptions['RetryProfiles'] + if ($null -ne $retryProfiles -and -not $retryProfiles.Contains([string]$defaultProfile)) { + throw [System.ArgumentException]::new( + "DefaultRetryProfile '$defaultProfile' references a profile that does not exist in RetryProfiles.", + 'ExecutionOptions' + ) + } + } + } +} + +function Assert-IdleRetryProfile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Profile, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ProfileKey + ) + + # Validate MaxAttempts (0..10) + if ($Profile.Contains('MaxAttempts')) { + $maxAttempts = $Profile['MaxAttempts'] + if ($maxAttempts -isnot [int] -or $maxAttempts -lt 0 -or $maxAttempts -gt 10) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': MaxAttempts must be an integer between 0 and 10 (inclusive).", + 'ExecutionOptions' + ) + } + } + + # Validate InitialDelayMilliseconds (0..60000) + if ($Profile.Contains('InitialDelayMilliseconds')) { + $initialDelay = $Profile['InitialDelayMilliseconds'] + if ($initialDelay -isnot [int] -or $initialDelay -lt 0 -or $initialDelay -gt 60000) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': InitialDelayMilliseconds must be an integer between 0 and 60000 (inclusive).", + 'ExecutionOptions' + ) + } + } + + # Validate BackoffFactor (>= 1.0) + if ($Profile.Contains('BackoffFactor')) { + $backoffFactor = $Profile['BackoffFactor'] + # Accept both int and double + if (($backoffFactor -isnot [double] -and $backoffFactor -isnot [int]) -or ([double]$backoffFactor -lt 1.0)) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': BackoffFactor must be a number >= 1.0.", + 'ExecutionOptions' + ) + } + } + + # Validate MaxDelayMilliseconds (0..300000 and >= InitialDelayMilliseconds) + if ($Profile.Contains('MaxDelayMilliseconds')) { + $maxDelay = $Profile['MaxDelayMilliseconds'] + if ($maxDelay -isnot [int] -or $maxDelay -lt 0 -or $maxDelay -gt 300000) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': MaxDelayMilliseconds must be an integer between 0 and 300000 (inclusive).", + 'ExecutionOptions' + ) + } + + # Check that MaxDelayMilliseconds >= InitialDelayMilliseconds + if ($Profile.Contains('InitialDelayMilliseconds')) { + $initialDelay = $Profile['InitialDelayMilliseconds'] + if ($maxDelay -lt $initialDelay) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': MaxDelayMilliseconds ($maxDelay) must be >= InitialDelayMilliseconds ($initialDelay).", + 'ExecutionOptions' + ) + } + } + } + + # Validate JitterRatio (0.0..1.0) + if ($Profile.Contains('JitterRatio')) { + $jitterRatio = $Profile['JitterRatio'] + # Accept both int and double + if (($jitterRatio -isnot [double] -and $jitterRatio -isnot [int]) -or ([double]$jitterRatio -lt 0.0) -or ([double]$jitterRatio -gt 1.0)) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': JitterRatio must be a number between 0.0 and 1.0 (inclusive).", + 'ExecutionOptions' + ) + } + } +} diff --git a/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 new file mode 100644 index 00000000..1a12929a --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 @@ -0,0 +1,141 @@ +# Resolves effective retry parameters for a step based on ExecutionOptions and step's RetryProfile. + +function Resolve-IdleStepRetryParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter()] + [AllowNull()] + [object] $ExecutionOptions + ) + + # Default retry parameters (engine defaults) + $effectiveParams = @{ + MaxAttempts = 3 + InitialDelayMilliseconds = 250 + BackoffFactor = 2.0 + MaxDelayMilliseconds = 5000 + JitterRatio = 0.2 + } + + # If no ExecutionOptions provided, return defaults + if ($null -eq $ExecutionOptions) { + return $effectiveParams + } + + if ($ExecutionOptions -isnot [System.Collections.IDictionary]) { + return $effectiveParams + } + + # Check if ExecutionOptions has RetryProfiles + $retryProfiles = $null + if ($ExecutionOptions.Contains('RetryProfiles')) { + $retryProfiles = $ExecutionOptions['RetryProfiles'] + } + + # Determine which profile to use + $profileKey = $null + + # Check if step has a RetryProfile property + if ($Step -is [System.Collections.IDictionary]) { + if ($Step.Contains('RetryProfile')) { + $profileKey = [string]$Step['RetryProfile'] + } + } + else { + $stepPropNames = @($Step.PSObject.Properties.Name) + if ($stepPropNames -contains 'RetryProfile') { + $profileKey = [string]$Step.RetryProfile + } + } + + # If step specifies a RetryProfile but no profiles are configured, fail + if (-not [string]::IsNullOrWhiteSpace($profileKey) -and ($null -eq $retryProfiles -or $retryProfiles -isnot [System.Collections.IDictionary])) { + $stepName = '' + if ($Step -is [System.Collections.IDictionary]) { + if ($Step.Contains('Name')) { + $stepName = [string]$Step['Name'] + } + } + else { + if ($null -eq $stepPropNames) { + $stepPropNames = @($Step.PSObject.Properties.Name) + } + if ($stepPropNames -contains 'Name') { + $stepName = [string]$Step.Name + } + } + + throw [System.ArgumentException]::new( + "Step '$stepName' references RetryProfile '$profileKey' but ExecutionOptions.RetryProfiles is not configured.", + 'ExecutionOptions' + ) + } + + # If no RetryProfiles configured and step doesn't specify one, return defaults + if ($null -eq $retryProfiles -or $retryProfiles -isnot [System.Collections.IDictionary]) { + return $effectiveParams + } + + # If step doesn't specify a RetryProfile, use DefaultRetryProfile + if ([string]::IsNullOrWhiteSpace($profileKey)) { + if ($ExecutionOptions.Contains('DefaultRetryProfile')) { + $profileKey = [string]$ExecutionOptions['DefaultRetryProfile'] + } + } + + # If still no profile key, return defaults + if ([string]::IsNullOrWhiteSpace($profileKey)) { + return $effectiveParams + } + + # Look up the profile + if (-not $retryProfiles.Contains($profileKey)) { + # Fail-fast: Unknown RetryProfile key + $stepName = '' + if ($Step -is [System.Collections.IDictionary]) { + if ($Step.Contains('Name')) { + $stepName = [string]$Step['Name'] + } + } + else { + $stepPropNames = @($Step.PSObject.Properties.Name) + if ($stepPropNames -contains 'Name') { + $stepName = [string]$Step.Name + } + } + + throw [System.ArgumentException]::new( + "Step '$stepName' references unknown RetryProfile '$profileKey'. Available profiles: $([string]::Join(', ', $retryProfiles.Keys))", + 'ExecutionOptions' + ) + } + + $profile = $retryProfiles[$profileKey] + + # Apply profile parameters, preserving defaults for missing values + if ($profile.Contains('MaxAttempts')) { + $effectiveParams['MaxAttempts'] = [int]$profile['MaxAttempts'] + } + + if ($profile.Contains('InitialDelayMilliseconds')) { + $effectiveParams['InitialDelayMilliseconds'] = [int]$profile['InitialDelayMilliseconds'] + } + + if ($profile.Contains('BackoffFactor')) { + $effectiveParams['BackoffFactor'] = [double]$profile['BackoffFactor'] + } + + if ($profile.Contains('MaxDelayMilliseconds')) { + $effectiveParams['MaxDelayMilliseconds'] = [int]$profile['MaxDelayMilliseconds'] + } + + if ($profile.Contains('JitterRatio')) { + $effectiveParams['JitterRatio'] = [double]$profile['JitterRatio'] + } + + return $effectiveParams +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 2365cde5..6eefde61 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -23,7 +23,7 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -91,6 +91,17 @@ function Test-IdleWorkflowSchema { $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") } + # 'RetryProfile' must be a string matching the pattern ^[A-Za-z0-9_.-]{1,64}$ + if ($step.ContainsKey('RetryProfile') -and $null -ne $step.RetryProfile) { + $retryProfile = [string]$step.RetryProfile + if ([string]::IsNullOrWhiteSpace($retryProfile)) { + $errors.Add("'$stepPath.RetryProfile' must not be an empty string.") + } + elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') { + $errors.Add("'$stepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$") + } + } + $i++ } } @@ -139,6 +150,17 @@ function Test-IdleWorkflowSchema { $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") } + # 'RetryProfile' must be a string matching the pattern ^[A-Za-z0-9_.-]{1,64}$ + if ($step.ContainsKey('RetryProfile') -and $null -ne $step.RetryProfile) { + $retryProfile = [string]$step.RetryProfile + if ([string]::IsNullOrWhiteSpace($retryProfile)) { + $errors.Add("'$stepPath.RetryProfile' must not be an empty string.") + } + elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') { + $errors.Add("'$stepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$") + } + } + $i++ } } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 596b35f4..cca51fec 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -19,6 +19,10 @@ function Invoke-IdlePlanObject { .PARAMETER EventSink Optional external event sink object. Must provide a WriteEvent(event) method. + .PARAMETER ExecutionOptions + Optional host-owned execution options. Supports retry profile configuration. + Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile. + .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> @@ -34,7 +38,11 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [object] $EventSink + [object] $EventSink, + + [Parameter()] + [AllowNull()] + [hashtable] $ExecutionOptions ) function Get-IdleCommandParameterNames { @@ -232,6 +240,9 @@ function Invoke-IdlePlanObject { Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + # Validate ExecutionOptions + Assert-IdleExecutionOptions -ExecutionOptions $ExecutionOptions + # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. $stepRegistry = Get-IdleStepRegistry -Providers $Providers @@ -438,8 +449,20 @@ function Invoke-IdlePlanObject { # Safe-by-default transient retries: # - Only retries if the thrown exception is explicitly marked transient. # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. + # - Retry parameters resolved from ExecutionOptions if provided. $retrySeed = "$corr|$stepType|$stepName|$i" - $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $stepName -OperationName 'StepExecution' -DeterministicSeed $retrySeed + $retryParams = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $ExecutionOptions + $retry = Invoke-IdleWithRetry ` + -Operation { & $impl @invokeParams } ` + -MaxAttempts $retryParams.MaxAttempts ` + -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds ` + -BackoffFactor $retryParams.BackoffFactor ` + -MaxDelayMilliseconds $retryParams.MaxDelayMilliseconds ` + -JitterRatio $retryParams.JitterRatio ` + -EventSink $context.EventSink ` + -StepName $stepName ` + -OperationName 'StepExecution' ` + -DeterministicSeed $retrySeed $result = $retry.Value $attempts = [int]$retry.Attempts @@ -617,8 +640,20 @@ function Invoke-IdlePlanObject { } # Reuse safe-by-default transient retries for OnFailure steps. + # - Retry parameters resolved from ExecutionOptions if provided. $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j" - $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $ofName -OperationName 'OnFailureStepExecution' -DeterministicSeed $retrySeed + $retryParams = Resolve-IdleStepRetryParameters -Step $ofStep -ExecutionOptions $ExecutionOptions + $retry = Invoke-IdleWithRetry ` + -Operation { & $impl @invokeParams } ` + -MaxAttempts $retryParams.MaxAttempts ` + -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds ` + -BackoffFactor $retryParams.BackoffFactor ` + -MaxDelayMilliseconds $retryParams.MaxDelayMilliseconds ` + -JitterRatio $retryParams.JitterRatio ` + -EventSink $context.EventSink ` + -StepName $ofName ` + -OperationName 'OnFailureStepExecution' ` + -DeterministicSeed $retrySeed $result = $retry.Value $attempts = [int]$retry.Attempts diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 318faa58..bd2ae187 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -575,6 +575,13 @@ function New-IdlePlanObject { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'RetryProfile') + } + else { + $null + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = $stepName @@ -584,6 +591,7 @@ function New-IdlePlanObject { With = $with RequiresCapabilities = $requiresCaps Status = $status + RetryProfile = $retryProfile } } diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 26570ea1..4fde87f9 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -16,9 +16,23 @@ function Invoke-IdlePlan { .PARAMETER EventSink Optional external event sink for streaming. Must be an object with a WriteEvent(event) method. + .PARAMETER ExecutionOptions + Optional host-owned execution options. Supports retry profile configuration. + Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile. + .EXAMPLE Invoke-IdlePlan -Plan $plan -Providers $providers + .EXAMPLE + $execOptions = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200 } + ExchangeOnline = @{ MaxAttempts = 6; InitialDelayMilliseconds = 500 } + } + DefaultRetryProfile = 'Default' + } + Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $execOptions + .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> @@ -34,7 +48,11 @@ function Invoke-IdlePlan { [Parameter()] [AllowNull()] - [object] $EventSink + [object] $EventSink, + + [Parameter()] + [AllowNull()] + [hashtable] $ExecutionOptions ) process { @@ -71,6 +89,6 @@ function Invoke-IdlePlan { } } - return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink -ExecutionOptions $ExecutionOptions } } diff --git a/tests/ExecutionOptions.Tests.ps1 b/tests/ExecutionOptions.Tests.ps1 new file mode 100644 index 00000000..6c050fd8 --- /dev/null +++ b/tests/ExecutionOptions.Tests.ps1 @@ -0,0 +1,231 @@ +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Assert-IdleExecutionOptions' { + + It 'accepts null ExecutionOptions' { + { Assert-IdleExecutionOptions -ExecutionOptions $null } | Should -Not -Throw + } + + It 'accepts an empty hashtable' { + { Assert-IdleExecutionOptions -ExecutionOptions @{} } | Should -Not -Throw + } + + It 'rejects ExecutionOptions that is not a hashtable' { + { Assert-IdleExecutionOptions -ExecutionOptions 'invalid' } | Should -Throw -ExpectedMessage '*must be a hashtable or IDictionary*' + } + + It 'rejects ScriptBlocks in ExecutionOptions' { + $opts = @{ + SomeKey = { Write-Host 'test' } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } + + It 'accepts valid RetryProfiles' { + $opts = @{ + RetryProfiles = @{ + Default = @{ + MaxAttempts = 3 + InitialDelayMilliseconds = 200 + BackoffFactor = 2.0 + MaxDelayMilliseconds = 5000 + JitterRatio = 0.2 + } + } + DefaultRetryProfile = 'Default' + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Not -Throw + } + + It 'rejects invalid profile key pattern' { + $opts = @{ + RetryProfiles = @{ + 'Invalid Key!' = @{ MaxAttempts = 3 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*Invalid Key!*is invalid*' + } + + It 'rejects MaxAttempts outside valid range' { + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 11 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxAttempts must be an integer between 0 and 10*' + } + + It 'rejects InitialDelayMilliseconds outside valid range' { + $opts = @{ + RetryProfiles = @{ + Default = @{ InitialDelayMilliseconds = 70000 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*InitialDelayMilliseconds must be an integer between 0 and 60000*' + } + + It 'rejects BackoffFactor less than 1.0' { + $opts = @{ + RetryProfiles = @{ + Default = @{ BackoffFactor = 0.5 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*BackoffFactor must be a number >= 1.0*' + } + + It 'rejects MaxDelayMilliseconds outside valid range' { + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxDelayMilliseconds = 400000 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds must be an integer between 0 and 300000*' + } + + It 'rejects MaxDelayMilliseconds less than InitialDelayMilliseconds' { + $opts = @{ + RetryProfiles = @{ + Default = @{ + InitialDelayMilliseconds = 5000 + MaxDelayMilliseconds = 1000 + } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds*must be >= InitialDelayMilliseconds*' + } + + It 'rejects JitterRatio outside valid range' { + $opts = @{ + RetryProfiles = @{ + Default = @{ JitterRatio = 1.5 } + } + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*JitterRatio must be a number between 0.0 and 1.0*' + } + + It 'rejects DefaultRetryProfile that does not exist in RetryProfiles' { + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3 } + } + DefaultRetryProfile = 'Unknown' + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*DefaultRetryProfile*Unknown*does not exist*' + } + + It 'accepts DefaultRetryProfile that exists in RetryProfiles' { + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3 } + Custom = @{ MaxAttempts = 5 } + } + DefaultRetryProfile = 'Custom' + } + { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Not -Throw + } +} + +Describe 'Resolve-IdleStepRetryParameters' { + + It 'returns engine defaults when ExecutionOptions is null' { + $step = @{ Name = 'TestStep'; Type = 'Test' } + $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $null + + $result.MaxAttempts | Should -Be 3 + $result.InitialDelayMilliseconds | Should -Be 250 + $result.BackoffFactor | Should -Be 2.0 + $result.MaxDelayMilliseconds | Should -Be 5000 + $result.JitterRatio | Should -Be 0.2 + } + + It 'returns engine defaults when ExecutionOptions has no RetryProfiles' { + $step = @{ Name = 'TestStep'; Type = 'Test' } + $opts = @{} + $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts + + $result.MaxAttempts | Should -Be 3 + $result.InitialDelayMilliseconds | Should -Be 250 + } + + It 'returns profile when step specifies RetryProfile' { + $step = @{ + Name = 'TestStep' + Type = 'Test' + RetryProfile = 'Custom' + } + $opts = @{ + RetryProfiles = @{ + Custom = @{ + MaxAttempts = 6 + InitialDelayMilliseconds = 500 + } + } + } + $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts + + $result.MaxAttempts | Should -Be 6 + $result.InitialDelayMilliseconds | Should -Be 500 + $result.BackoffFactor | Should -Be 2.0 # Default + } + + It 'returns default profile when step does not specify RetryProfile' { + $step = @{ + Name = 'TestStep' + Type = 'Test' + } + $opts = @{ + RetryProfiles = @{ + Default = @{ + MaxAttempts = 5 + } + Custom = @{ + MaxAttempts = 10 + } + } + DefaultRetryProfile = 'Default' + } + $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts + + $result.MaxAttempts | Should -Be 5 + } + + It 'throws when step references unknown RetryProfile' { + $step = @{ + Name = 'TestStep' + Type = 'Test' + RetryProfile = 'Unknown' + } + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3 } + } + } + { Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*references unknown RetryProfile*Unknown*' + } + + It 'works with PSCustomObject steps' { + $step = [pscustomobject]@{ + Name = 'TestStep' + Type = 'Test' + RetryProfile = 'Custom' + } + $opts = @{ + RetryProfiles = @{ + Custom = @{ + MaxAttempts = 7 + } + } + } + $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts + + $result.MaxAttempts | Should -Be 7 + } +} diff --git a/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 b/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 new file mode 100644 index 00000000..afc7b18c --- /dev/null +++ b/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 @@ -0,0 +1,417 @@ +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Create a dedicated test module for retry profile testing + $script:RetryProfileTestModuleName = 'IdLE.RetryProfileTest' + $script:RetryProfileTestModule = New-Module -Name $script:RetryProfileTestModuleName -ScriptBlock { + Set-StrictMode -Version Latest + + $script:CallLog = [System.Collections.ArrayList]::new() + + function Invoke-IdleRetryProfileTestStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $null = $script:CallLog.Add(@{ + StepName = [string]$Step.Name + Timestamp = [DateTimeOffset]::UtcNow + }) + + # Succeed on first attempt + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function Invoke-IdleRetryProfileTransientStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $stepName = [string]$Step.Name + $attempts = @($script:CallLog | Where-Object { $_.StepName -eq $stepName }).Count + 1 + + $null = $script:CallLog.Add(@{ + StepName = $stepName + Attempt = $attempts + Timestamp = [DateTimeOffset]::UtcNow + }) + + # Fail on first attempt, succeed on second + if ($attempts -eq 1) { + $ex = [System.Exception]::new("Transient failure for $stepName") + $ex.Data['Idle.IsTransient'] = $true + throw $ex + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function Reset-IdleRetryProfileTestState { + [CmdletBinding()] + param() + + $script:CallLog.Clear() + } + + function Get-IdleRetryProfileTestCallLog { + [CmdletBinding()] + param() + + return [array]$script:CallLog + } + + Export-ModuleMember -Function @( + 'Invoke-IdleRetryProfileTestStep', + 'Invoke-IdleRetryProfileTransientStep', + 'Reset-IdleRetryProfileTestState', + 'Get-IdleRetryProfileTestCallLog' + ) + } + + Import-Module -ModuleInfo $script:RetryProfileTestModule -Force -ErrorAction Stop +} + +AfterAll { + Remove-Module -Name $script:RetryProfileTestModuleName -Force -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan - ExecutionOptions validation' { + + It 'rejects ExecutionOptions with invalid type' -Skip { + # Note: PowerShell parameter type validation catches this before our validation, + # so this test is skipped. The validation still exists and would catch it if + # the parameter type was changed to [object]. + $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + { Invoke-IdlePlan -Plan $plan -ExecutionOptions 'invalid' } | Should -Throw -ExpectedMessage '*must be a hashtable or IDictionary*' + } + + It 'rejects ExecutionOptions with ScriptBlocks' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $opts = @{ + SomeKey = { Write-Host 'test' } + } + + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } + + It 'rejects RetryProfile with invalid MaxAttempts' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $opts = @{ + RetryProfiles = @{ + Invalid = @{ MaxAttempts = 50 } + } + } + + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxAttempts must be an integer between 0 and 10*' + } + + It 'rejects DefaultRetryProfile that does not exist' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3 } + } + DefaultRetryProfile = 'Unknown' + } + + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*does not exist*' + } +} + +Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { + + BeforeEach { + & "$script:RetryProfileTestModuleName\Reset-IdleRetryProfileTestState" + } + + It 'executes successfully without ExecutionOptions (backward compatibility)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-opts.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'Test.Step' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + } + + It 'executes with custom RetryProfile on step' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'custom-profile.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'Test.TransientStep'; RetryProfile = 'Custom' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') + } + + $opts = @{ + RetryProfiles = @{ + Custom = @{ + MaxAttempts = 5 + InitialDelayMilliseconds = 100 + } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[0].Attempts | Should -Be 2 + + # Verify retry event was emitted + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 1 + } + + It 'uses DefaultRetryProfile when step does not specify RetryProfile' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'default-profile.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'Test.TransientStep' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') + } + + $opts = @{ + RetryProfiles = @{ + Default = @{ + MaxAttempts = 10 + InitialDelayMilliseconds = 50 + } + } + DefaultRetryProfile = 'Default' + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Attempts | Should -Be 2 + } + + It 'fails fast when step references unknown RetryProfile' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'unknown-profile.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'Test.Step'; RetryProfile = 'Unknown' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') + } + + $opts = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3 } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + # The error should be caught and reported in the result + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'unknown RetryProfile.*Unknown' + } + + It 'applies RetryProfile to OnFailureSteps' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + + # Create a module with a failing step + $failingModuleName = 'IdLE.FailingTest' + $failingModule = New-Module -Name $failingModuleName -ScriptBlock { + function Invoke-IdleFailingStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + throw [System.Exception]::new('Intentional failure for test') + } + + Export-ModuleMember -Function 'Invoke-IdleFailingStep' + } + Import-Module -ModuleInfo $failingModule -Force -ErrorAction Stop + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-profile.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'FailingStep'; Type = 'Test.Failing' } + ) + OnFailureSteps = @( + @{ Name = 'CleanupStep'; Type = 'Test.TransientStep'; RetryProfile = 'Cleanup' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.Failing' = "$failingModuleName\Invoke-IdleFailingStep" + 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Failing', 'Test.TransientStep') + } + + $opts = @{ + RetryProfiles = @{ + Cleanup = @{ + MaxAttempts = 3 + InitialDelayMilliseconds = 100 + } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'Completed' + $result.OnFailure.Steps[0].Attempts | Should -Be 2 + + Remove-Module -Name $failingModuleName -Force -ErrorAction SilentlyContinue + } +} From 917ff006ad2330348d2de6c170459487a5e9fd9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:45:24 +0000 Subject: [PATCH 3/8] Remove direct unit tests for private functions The private functions are already tested through integration tests in Invoke-IdlePlan.ExecutionOptions.Tests.ps1 Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/ExecutionOptions.Tests.ps1 | 231 ------------------------------- 1 file changed, 231 deletions(-) delete mode 100644 tests/ExecutionOptions.Tests.ps1 diff --git a/tests/ExecutionOptions.Tests.ps1 b/tests/ExecutionOptions.Tests.ps1 deleted file mode 100644 index 6c050fd8..00000000 --- a/tests/ExecutionOptions.Tests.ps1 +++ /dev/null @@ -1,231 +0,0 @@ -BeforeDiscovery { - . (Join-Path $PSScriptRoot '_testHelpers.ps1') - Import-IdleTestModule -} - -BeforeAll { - . (Join-Path $PSScriptRoot '_testHelpers.ps1') - Import-IdleTestModule -} - -Describe 'Assert-IdleExecutionOptions' { - - It 'accepts null ExecutionOptions' { - { Assert-IdleExecutionOptions -ExecutionOptions $null } | Should -Not -Throw - } - - It 'accepts an empty hashtable' { - { Assert-IdleExecutionOptions -ExecutionOptions @{} } | Should -Not -Throw - } - - It 'rejects ExecutionOptions that is not a hashtable' { - { Assert-IdleExecutionOptions -ExecutionOptions 'invalid' } | Should -Throw -ExpectedMessage '*must be a hashtable or IDictionary*' - } - - It 'rejects ScriptBlocks in ExecutionOptions' { - $opts = @{ - SomeKey = { Write-Host 'test' } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' - } - - It 'accepts valid RetryProfiles' { - $opts = @{ - RetryProfiles = @{ - Default = @{ - MaxAttempts = 3 - InitialDelayMilliseconds = 200 - BackoffFactor = 2.0 - MaxDelayMilliseconds = 5000 - JitterRatio = 0.2 - } - } - DefaultRetryProfile = 'Default' - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Not -Throw - } - - It 'rejects invalid profile key pattern' { - $opts = @{ - RetryProfiles = @{ - 'Invalid Key!' = @{ MaxAttempts = 3 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*Invalid Key!*is invalid*' - } - - It 'rejects MaxAttempts outside valid range' { - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 11 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxAttempts must be an integer between 0 and 10*' - } - - It 'rejects InitialDelayMilliseconds outside valid range' { - $opts = @{ - RetryProfiles = @{ - Default = @{ InitialDelayMilliseconds = 70000 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*InitialDelayMilliseconds must be an integer between 0 and 60000*' - } - - It 'rejects BackoffFactor less than 1.0' { - $opts = @{ - RetryProfiles = @{ - Default = @{ BackoffFactor = 0.5 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*BackoffFactor must be a number >= 1.0*' - } - - It 'rejects MaxDelayMilliseconds outside valid range' { - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxDelayMilliseconds = 400000 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds must be an integer between 0 and 300000*' - } - - It 'rejects MaxDelayMilliseconds less than InitialDelayMilliseconds' { - $opts = @{ - RetryProfiles = @{ - Default = @{ - InitialDelayMilliseconds = 5000 - MaxDelayMilliseconds = 1000 - } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds*must be >= InitialDelayMilliseconds*' - } - - It 'rejects JitterRatio outside valid range' { - $opts = @{ - RetryProfiles = @{ - Default = @{ JitterRatio = 1.5 } - } - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*JitterRatio must be a number between 0.0 and 1.0*' - } - - It 'rejects DefaultRetryProfile that does not exist in RetryProfiles' { - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 3 } - } - DefaultRetryProfile = 'Unknown' - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*DefaultRetryProfile*Unknown*does not exist*' - } - - It 'accepts DefaultRetryProfile that exists in RetryProfiles' { - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 3 } - Custom = @{ MaxAttempts = 5 } - } - DefaultRetryProfile = 'Custom' - } - { Assert-IdleExecutionOptions -ExecutionOptions $opts } | Should -Not -Throw - } -} - -Describe 'Resolve-IdleStepRetryParameters' { - - It 'returns engine defaults when ExecutionOptions is null' { - $step = @{ Name = 'TestStep'; Type = 'Test' } - $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $null - - $result.MaxAttempts | Should -Be 3 - $result.InitialDelayMilliseconds | Should -Be 250 - $result.BackoffFactor | Should -Be 2.0 - $result.MaxDelayMilliseconds | Should -Be 5000 - $result.JitterRatio | Should -Be 0.2 - } - - It 'returns engine defaults when ExecutionOptions has no RetryProfiles' { - $step = @{ Name = 'TestStep'; Type = 'Test' } - $opts = @{} - $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts - - $result.MaxAttempts | Should -Be 3 - $result.InitialDelayMilliseconds | Should -Be 250 - } - - It 'returns profile when step specifies RetryProfile' { - $step = @{ - Name = 'TestStep' - Type = 'Test' - RetryProfile = 'Custom' - } - $opts = @{ - RetryProfiles = @{ - Custom = @{ - MaxAttempts = 6 - InitialDelayMilliseconds = 500 - } - } - } - $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts - - $result.MaxAttempts | Should -Be 6 - $result.InitialDelayMilliseconds | Should -Be 500 - $result.BackoffFactor | Should -Be 2.0 # Default - } - - It 'returns default profile when step does not specify RetryProfile' { - $step = @{ - Name = 'TestStep' - Type = 'Test' - } - $opts = @{ - RetryProfiles = @{ - Default = @{ - MaxAttempts = 5 - } - Custom = @{ - MaxAttempts = 10 - } - } - DefaultRetryProfile = 'Default' - } - $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts - - $result.MaxAttempts | Should -Be 5 - } - - It 'throws when step references unknown RetryProfile' { - $step = @{ - Name = 'TestStep' - Type = 'Test' - RetryProfile = 'Unknown' - } - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 3 } - } - } - { Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*references unknown RetryProfile*Unknown*' - } - - It 'works with PSCustomObject steps' { - $step = [pscustomobject]@{ - Name = 'TestStep' - Type = 'Test' - RetryProfile = 'Custom' - } - $opts = @{ - RetryProfiles = @{ - Custom = @{ - MaxAttempts = 7 - } - } - } - $result = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $opts - - $result.MaxAttempts | Should -Be 7 - } -} From bf549dfbcbb1f121ee7711d4cd932c5fc82813f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:47:50 +0000 Subject: [PATCH 4/8] Address code review feedback: refactor and use constants - Extract helper function Get-StepPropertyValue to reduce code duplication - Define retry parameter limits as named constants - Use constants in validation logic Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Assert-IdleExecutionOptions.ps1 | 17 +++-- .../Resolve-IdleStepRetryParameters.ps1 | 75 +++++++++---------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 index a38dc308..0f9843dd 100644 --- a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 @@ -1,6 +1,11 @@ # Asserts that ExecutionOptions is valid and rejects ScriptBlocks. # Validates the structure and constraints for retry profiles. +# Retry parameter limits (hard constraints to prevent misconfiguration) +$script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10 +$script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000 +$script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000 + function Assert-IdleExecutionOptions { [CmdletBinding()] param( @@ -106,9 +111,9 @@ function Assert-IdleRetryProfile { # Validate MaxAttempts (0..10) if ($Profile.Contains('MaxAttempts')) { $maxAttempts = $Profile['MaxAttempts'] - if ($maxAttempts -isnot [int] -or $maxAttempts -lt 0 -or $maxAttempts -gt 10) { + if ($maxAttempts -isnot [int] -or $maxAttempts -lt 0 -or $maxAttempts -gt $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT) { throw [System.ArgumentException]::new( - "RetryProfile '$ProfileKey': MaxAttempts must be an integer between 0 and 10 (inclusive).", + "RetryProfile '$ProfileKey': MaxAttempts must be an integer between 0 and $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT (inclusive).", 'ExecutionOptions' ) } @@ -117,9 +122,9 @@ function Assert-IdleRetryProfile { # Validate InitialDelayMilliseconds (0..60000) if ($Profile.Contains('InitialDelayMilliseconds')) { $initialDelay = $Profile['InitialDelayMilliseconds'] - if ($initialDelay -isnot [int] -or $initialDelay -lt 0 -or $initialDelay -gt 60000) { + if ($initialDelay -isnot [int] -or $initialDelay -lt 0 -or $initialDelay -gt $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT) { throw [System.ArgumentException]::new( - "RetryProfile '$ProfileKey': InitialDelayMilliseconds must be an integer between 0 and 60000 (inclusive).", + "RetryProfile '$ProfileKey': InitialDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT (inclusive).", 'ExecutionOptions' ) } @@ -140,9 +145,9 @@ function Assert-IdleRetryProfile { # Validate MaxDelayMilliseconds (0..300000 and >= InitialDelayMilliseconds) if ($Profile.Contains('MaxDelayMilliseconds')) { $maxDelay = $Profile['MaxDelayMilliseconds'] - if ($maxDelay -isnot [int] -or $maxDelay -lt 0 -or $maxDelay -gt 300000) { + if ($maxDelay -isnot [int] -or $maxDelay -lt 0 -or $maxDelay -gt $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT) { throw [System.ArgumentException]::new( - "RetryProfile '$ProfileKey': MaxDelayMilliseconds must be an integer between 0 and 300000 (inclusive).", + "RetryProfile '$ProfileKey': MaxDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT (inclusive).", 'ExecutionOptions' ) } diff --git a/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 index 1a12929a..db87cff4 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 @@ -1,5 +1,10 @@ # Resolves effective retry parameters for a step based on ExecutionOptions and step's RetryProfile. +# Retry parameter limits (hard constraints to prevent misconfiguration) +$script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10 +$script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000 +$script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000 + function Resolve-IdleStepRetryParameters { [CmdletBinding()] param( @@ -12,6 +17,33 @@ function Resolve-IdleStepRetryParameters { [object] $ExecutionOptions ) + function Get-StepPropertyValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $PropertyName + ) + + if ($Step -is [System.Collections.IDictionary]) { + if ($Step.Contains($PropertyName)) { + return $Step[$PropertyName] + } + return $null + } + + $propNames = @($Step.PSObject.Properties.Name) + if ($propNames -contains $PropertyName) { + return $Step.$PropertyName + } + + return $null + } + # Default retry parameters (engine defaults) $effectiveParams = @{ MaxAttempts = 3 @@ -37,37 +69,11 @@ function Resolve-IdleStepRetryParameters { } # Determine which profile to use - $profileKey = $null - - # Check if step has a RetryProfile property - if ($Step -is [System.Collections.IDictionary]) { - if ($Step.Contains('RetryProfile')) { - $profileKey = [string]$Step['RetryProfile'] - } - } - else { - $stepPropNames = @($Step.PSObject.Properties.Name) - if ($stepPropNames -contains 'RetryProfile') { - $profileKey = [string]$Step.RetryProfile - } - } + $profileKey = [string](Get-StepPropertyValue -Step $Step -PropertyName 'RetryProfile') # If step specifies a RetryProfile but no profiles are configured, fail if (-not [string]::IsNullOrWhiteSpace($profileKey) -and ($null -eq $retryProfiles -or $retryProfiles -isnot [System.Collections.IDictionary])) { - $stepName = '' - if ($Step -is [System.Collections.IDictionary]) { - if ($Step.Contains('Name')) { - $stepName = [string]$Step['Name'] - } - } - else { - if ($null -eq $stepPropNames) { - $stepPropNames = @($Step.PSObject.Properties.Name) - } - if ($stepPropNames -contains 'Name') { - $stepName = [string]$Step.Name - } - } + $stepName = [string](Get-StepPropertyValue -Step $Step -PropertyName 'Name') throw [System.ArgumentException]::new( "Step '$stepName' references RetryProfile '$profileKey' but ExecutionOptions.RetryProfiles is not configured.", @@ -95,18 +101,7 @@ function Resolve-IdleStepRetryParameters { # Look up the profile if (-not $retryProfiles.Contains($profileKey)) { # Fail-fast: Unknown RetryProfile key - $stepName = '' - if ($Step -is [System.Collections.IDictionary]) { - if ($Step.Contains('Name')) { - $stepName = [string]$Step['Name'] - } - } - else { - $stepPropNames = @($Step.PSObject.Properties.Name) - if ($stepPropNames -contains 'Name') { - $stepName = [string]$Step.Name - } - } + $stepName = [string](Get-StepPropertyValue -Step $Step -PropertyName 'Name') throw [System.ArgumentException]::new( "Step '$stepName' references unknown RetryProfile '$profileKey'. Available profiles: $([string]::Join(', ', $retryProfiles.Keys))", From 6ddd9f4e5e0303610269479acac69f9943d55f0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:48:43 +0000 Subject: [PATCH 5/8] Add example workflow demonstrating retry profiles Create comprehensive example showing: - Different retry profiles for different target systems (Graph API, Exchange Online) - RetryProfile property usage on steps - ExecutionOptions configuration with multiple profiles - DefaultRetryProfile fallback behavior Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../workflows/joiner-with-retry-profiles.psd1 | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 examples/workflows/joiner-with-retry-profiles.psd1 diff --git a/examples/workflows/joiner-with-retry-profiles.psd1 b/examples/workflows/joiner-with-retry-profiles.psd1 new file mode 100644 index 00000000..e7559142 --- /dev/null +++ b/examples/workflows/joiner-with-retry-profiles.psd1 @@ -0,0 +1,127 @@ +@{ + # Example workflow demonstrating configurable retry behavior + # + # This workflow shows how to use RetryProfile to configure + # different retry behavior for steps targeting different systems. + + Name = 'Joiner - With Retry Profiles' + LifecycleEvent = 'Joiner' + Description = 'Example workflow with custom retry profiles for different target systems' + + Steps = @( + @{ + Name = 'Resolve identity from HR system' + Type = 'IdLE.Step.ResolveIdentity' + Description = 'Lookup user in HR database' + # Uses default retry profile (or no retry if not configured) + } + + @{ + Name = 'Create Entra ID account' + Type = 'IdLE.Step.CreateIdentity' + Description = 'Create user in Entra ID (Microsoft Graph API)' + RetryProfile = 'GraphAPI' + # Microsoft Graph has specific throttling limits - use a profile + # optimized for Graph API retry behavior + } + + @{ + Name = 'Create mailbox' + Type = 'IdLE.Step.EnsureEntitlement' + Description = 'Provision Exchange Online mailbox' + RetryProfile = 'ExchangeOnline' + With = @{ + Kind = 'Mailbox' + MailboxType = 'UserMailbox' + } + # Exchange Online has different throttling characteristics + # than Graph - use a dedicated profile + } + + @{ + Name = 'Add to security group' + Type = 'IdLE.Step.EnsureEntitlement' + Description = 'Add user to Entra ID security group' + RetryProfile = 'GraphAPI' + With = @{ + Kind = 'Group' + Value = 'All_Users' + } + } + + @{ + Name = 'Set manager attribute' + Type = 'IdLE.Step.EnsureAttribute' + Description = 'Set manager reference in Entra ID' + RetryProfile = 'GraphAPI' + With = @{ + AttributeName = 'manager' + Value = '{{Request.Data.ManagerId}}' + } + } + ) + + OnFailureSteps = @( + @{ + Name = 'Emit failure notification' + Type = 'IdLE.Step.EmitEvent' + Description = 'Notify on workflow failure' + RetryProfile = 'Notifications' + # Notification systems may have their own rate limits + With = @{ + Message = 'Joiner workflow failed for user {{Request.Data.UserPrincipalName}}' + } + } + ) +} + +<# +Example ExecutionOptions configuration: + +$executionOptions = @{ + RetryProfiles = @{ + Default = @{ + MaxAttempts = 3 + InitialDelayMilliseconds = 200 + BackoffFactor = 2.0 + MaxDelayMilliseconds = 5000 + JitterRatio = 0.2 + } + GraphAPI = @{ + # Microsoft Graph throttling can be aggressive + # Use more retries with longer delays + MaxAttempts = 5 + InitialDelayMilliseconds = 1000 + BackoffFactor = 2.0 + MaxDelayMilliseconds = 16000 + JitterRatio = 0.3 + } + ExchangeOnline = @{ + # Exchange Online often requires patience + MaxAttempts = 6 + InitialDelayMilliseconds = 500 + BackoffFactor = 2.5 + MaxDelayMilliseconds = 30000 + JitterRatio = 0.25 + } + Notifications = @{ + # Notifications should retry but not delay the workflow too much + MaxAttempts = 3 + InitialDelayMilliseconds = 100 + BackoffFactor = 1.5 + MaxDelayMilliseconds = 1000 + JitterRatio = 0.1 + } + } + DefaultRetryProfile = 'Default' +} + +# Invoke the plan with retry configuration +$result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $executionOptions + +# Each step will use its configured retry profile: +# - Steps without RetryProfile use 'Default' (from DefaultRetryProfile) +# - Steps with RetryProfile='GraphAPI' use the GraphAPI profile +# - Steps with RetryProfile='ExchangeOnline' use the ExchangeOnline profile +# - Steps with RetryProfile='Notifications' use the Notifications profile +#> From 94e7651293e13094a0eba1738f7429652da0db0d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:06:03 +0100 Subject: [PATCH 6/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/workflows/joiner-with-retry-profiles.psd1 | 8 ++++++-- src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 | 5 +---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/workflows/joiner-with-retry-profiles.psd1 b/examples/workflows/joiner-with-retry-profiles.psd1 index e7559142..1e7d80a5 100644 --- a/examples/workflows/joiner-with-retry-profiles.psd1 +++ b/examples/workflows/joiner-with-retry-profiles.psd1 @@ -11,9 +11,13 @@ Steps = @( @{ Name = 'Resolve identity from HR system' - Type = 'IdLE.Step.ResolveIdentity' + Type = 'IdLE.Step.EmitEvent' Description = 'Lookup user in HR database' - # Uses default retry profile (or no retry if not configured) + # In a real deployment, this would be a system-specific "resolve from HR" step. + # Here we emit an event as a simple example, using the default retry profile. + With = @{ + Message = 'Resolve identity for HR record {{Request.Data.HrEmployeeId}}' + } } @{ diff --git a/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 index db87cff4..6b68ab28 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepRetryParameters.ps1 @@ -1,9 +1,6 @@ # Resolves effective retry parameters for a step based on ExecutionOptions and step's RetryProfile. -# Retry parameter limits (hard constraints to prevent misconfiguration) -$script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10 -$script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000 -$script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000 +# Retry parameter limits are defined centrally (e.g., in Assert-IdleExecutionOptions.ps1) function Resolve-IdleStepRetryParameters { [CmdletBinding()] From 419f375bc9f675a1f8293a34010feb5b4051874e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:10:59 +0000 Subject: [PATCH 7/8] Address code review feedback 1. Fix MaxAttempts=0 support: Allow 0 in Invoke-IdleWithRetry (no retry, run once) 2. Fix MaxDelayMilliseconds validation: Check against engine default InitialDelayMilliseconds (250ms) 3. Extract RetryProfile validation to helper function in Test-IdleWorkflowSchema 4. Add test for MaxAttempts=0 behavior 5. Add test for MaxDelayMilliseconds validation with engine defaults 6. Update cmdlet reference documentation for Invoke-IdlePlan with ExecutionOptions parameter and examples Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/cmdlets/Invoke-IdlePlan.md | 33 ++++++++- .../Private/Assert-IdleExecutionOptions.ps1 | 20 +++--- .../Private/Invoke-IdleWithRetry.ps1 | 12 +++- .../Private/Test-IdleWorkflowSchema.ps1 | 50 ++++++++------ ...Invoke-IdlePlan.ExecutionOptions.Tests.ps1 | 68 +++++++++++++++++++ 5 files changed, 153 insertions(+), 30 deletions(-) diff --git a/docs/reference/cmdlets/Invoke-IdlePlan.md b/docs/reference/cmdlets/Invoke-IdlePlan.md index 2f002d73..f987761d 100644 --- a/docs/reference/cmdlets/Invoke-IdlePlan.md +++ b/docs/reference/cmdlets/Invoke-IdlePlan.md @@ -14,7 +14,7 @@ Executes an IdLE plan. ``` Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] - [-ProgressAction ] [-WhatIf] [-Confirm] [] + [[-ExecutionOptions] ] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -28,6 +28,20 @@ Delegates execution to IdLE.Core. Invoke-IdlePlan -Plan $plan -Providers $providers ``` +### EXAMPLE 2 +``` +$executionOptions = @{ + RetryProfiles = @{ + Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200; BackoffFactor = 2.0; MaxDelayMilliseconds = 5000; JitterRatio = 0.2 } + ExchangeOnline = @{ MaxAttempts = 6; InitialDelayMilliseconds = 500; BackoffFactor = 2.0; MaxDelayMilliseconds = 30000; JitterRatio = 0.3 } + } + DefaultRetryProfile = 'Default' +} +Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $executionOptions +``` + +Executes the plan with custom retry profiles for different target systems. + ## PARAMETERS ### -Plan @@ -76,6 +90,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ExecutionOptions +Optional host-owned execution options. +Supports retry profile configuration. +Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Aliases: + +Required: False +Position: 4 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -WhatIf Shows what would happen if the cmdlet runs. The cmdlet is not run. diff --git a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 index 0f9843dd..f9a01a5f 100644 --- a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 @@ -153,14 +153,18 @@ function Assert-IdleRetryProfile { } # Check that MaxDelayMilliseconds >= InitialDelayMilliseconds - if ($Profile.Contains('InitialDelayMilliseconds')) { - $initialDelay = $Profile['InitialDelayMilliseconds'] - if ($maxDelay -lt $initialDelay) { - throw [System.ArgumentException]::new( - "RetryProfile '$ProfileKey': MaxDelayMilliseconds ($maxDelay) must be >= InitialDelayMilliseconds ($initialDelay).", - 'ExecutionOptions' - ) - } + # Use the profile's InitialDelayMilliseconds if present, otherwise use engine default (250ms) + $initialDelay = if ($Profile.Contains('InitialDelayMilliseconds')) { + $Profile['InitialDelayMilliseconds'] + } else { + 250 # Engine default + } + + if ($maxDelay -lt $initialDelay) { + throw [System.ArgumentException]::new( + "RetryProfile '$ProfileKey': MaxDelayMilliseconds ($maxDelay) must be >= InitialDelayMilliseconds ($initialDelay).", + 'ExecutionOptions' + ) } } diff --git a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 index 7a8c44da..d97e8896 100644 --- a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 @@ -82,7 +82,7 @@ function Invoke-IdleWithRetry { [scriptblock] $Operation, [Parameter()] - [ValidateRange(1, 50)] + [ValidateRange(0, 50)] [int] $MaxAttempts = 3, [Parameter()] @@ -118,6 +118,16 @@ function Invoke-IdleWithRetry { [string] $DeterministicSeed = '' ) + # Handle MaxAttempts = 0 (no retry): run once and propagate any error + if ($MaxAttempts -eq 0) { + $value = & $Operation + return [pscustomobject]@{ + PSTypeName = 'IdLE.RetryResult' + Value = $value + Attempts = 1 + } + } + $attempt = 0 while ($attempt -lt $MaxAttempts) { diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 6eefde61..6e37a514 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -31,6 +31,32 @@ function Test-IdleWorkflowSchema { } } + # Helper: Validate RetryProfile property + function Test-IdleWorkflowStepRetryProfile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + if ($Step.ContainsKey('RetryProfile') -and $null -ne $Step.RetryProfile) { + $retryProfile = [string]$Step.RetryProfile + if ([string]::IsNullOrWhiteSpace($retryProfile)) { + $ErrorList.Add("'$StepPath.RetryProfile' must not be an empty string.") + } + elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') { + $ErrorList.Add("'$StepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$") + } + } + } + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { @@ -91,16 +117,8 @@ function Test-IdleWorkflowSchema { $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") } - # 'RetryProfile' must be a string matching the pattern ^[A-Za-z0-9_.-]{1,64}$ - if ($step.ContainsKey('RetryProfile') -and $null -ne $step.RetryProfile) { - $retryProfile = [string]$step.RetryProfile - if ([string]::IsNullOrWhiteSpace($retryProfile)) { - $errors.Add("'$stepPath.RetryProfile' must not be an empty string.") - } - elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') { - $errors.Add("'$stepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$") - } - } + # Validate RetryProfile + Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors $i++ } @@ -150,16 +168,8 @@ function Test-IdleWorkflowSchema { $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") } - # 'RetryProfile' must be a string matching the pattern ^[A-Za-z0-9_.-]{1,64}$ - if ($step.ContainsKey('RetryProfile') -and $null -ne $step.RetryProfile) { - $retryProfile = [string]$step.RetryProfile - if ([string]::IsNullOrWhiteSpace($retryProfile)) { - $errors.Add("'$stepPath.RetryProfile' must not be an empty string.") - } - elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') { - $errors.Add("'$stepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$") - } - } + # Validate RetryProfile + Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors $i++ } diff --git a/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 b/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 index afc7b18c..fef30cfd 100644 --- a/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 +++ b/tests/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 @@ -192,6 +192,30 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*does not exist*' } + + It 'rejects MaxDelayMilliseconds less than engine default InitialDelayMilliseconds' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $opts = @{ + RetryProfiles = @{ + Invalid = @{ + MaxDelayMilliseconds = 100 # Less than engine default InitialDelayMilliseconds (250) + } + } + } + + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds*must be >= InitialDelayMilliseconds*' + } } Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { @@ -347,6 +371,50 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { $result.Steps[0].Error | Should -Match 'unknown RetryProfile.*Unknown' } + It 'supports MaxAttempts = 0 (no retry)' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-retry.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Test Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'Test.TransientStep'; RetryProfile = 'NoRetry' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') + } + + $opts = @{ + RetryProfiles = @{ + NoRetry = @{ + MaxAttempts = 0 + } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + # With MaxAttempts = 0, the step runs once and fails without retry + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Attempts | Should -Be 1 + + # No retry event should be emitted + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 0 + Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 0 + } + It 'applies RetryProfile to OnFailureSteps' { Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } From 0a6c2218aa97fb3a97e80f31de212774b0140f2b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:23:34 +0100 Subject: [PATCH 8/8] docs: updated cmdlet reference --- docs/reference/cmdlets/Invoke-IdlePlan.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/reference/cmdlets/Invoke-IdlePlan.md b/docs/reference/cmdlets/Invoke-IdlePlan.md index f987761d..74fdc782 100644 --- a/docs/reference/cmdlets/Invoke-IdlePlan.md +++ b/docs/reference/cmdlets/Invoke-IdlePlan.md @@ -14,7 +14,8 @@ Executes an IdLE plan. ``` Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] - [[-ExecutionOptions] ] [-ProgressAction ] [-WhatIf] [-Confirm] [] + [[-ExecutionOptions] ] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -30,18 +31,16 @@ Invoke-IdlePlan -Plan $plan -Providers $providers ### EXAMPLE 2 ``` -$executionOptions = @{ +$execOptions = @{ RetryProfiles = @{ - Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200; BackoffFactor = 2.0; MaxDelayMilliseconds = 5000; JitterRatio = 0.2 } - ExchangeOnline = @{ MaxAttempts = 6; InitialDelayMilliseconds = 500; BackoffFactor = 2.0; MaxDelayMilliseconds = 30000; JitterRatio = 0.3 } + Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200 } + ExchangeOnline = @{ MaxAttempts = 6; InitialDelayMilliseconds = 500 } } DefaultRetryProfile = 'Default' } -Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $executionOptions +Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $execOptions ``` -Executes the plan with custom retry profiles for different target systems. - ## PARAMETERS ### -Plan