From 6ca664db5204e9875783de674da940dafae7c239 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:21:53 +0000 Subject: [PATCH 1/9] Initial plan From 702f878c4e798f7f2eef4deecfb1d75ae37de579 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:36:09 +0000 Subject: [PATCH 2/9] Add runtime preconditions (Blocked stop behavior) to IdLE step execution Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/extend/steps.md | 14 + docs/use/preconditions.md | 188 +++++++ .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 79 +++ .../Private/Test-IdleWorkflowSchema.ps1 | 64 ++- .../Public/Invoke-IdlePlanObject.ps1 | 90 ++- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 517 ++++++++++++++++++ 6 files changed, 949 insertions(+), 3 deletions(-) create mode 100644 docs/use/preconditions.md create mode 100644 tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 diff --git a/docs/extend/steps.md b/docs/extend/steps.md index fd63ff72..07c7c7e3 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -231,6 +231,20 @@ $result.OnFailure.Steps # Array of OnFailure step results For details on declaring OnFailureSteps, see [Workflows](../use/workflows.md). +### Blocked outcome (runtime preconditions) + +A step may produce a `Blocked` outcome when a **runtime precondition** fails at execution time. + +`Blocked` is a first-class outcome distinct from `Failed`: + +- `Blocked` represents a **policy/safety gate**, not a technical error. +- Execution stops immediately (subsequent steps are not run). +- `OnFailureSteps` do **not** run for a `Blocked` outcome. +- `result.Status` is `'Blocked'`; `result.OnFailure.Status` is `'NotRun'`. + +For details on configuring runtime preconditions, see +[Runtime Preconditions](../use/preconditions.md). + --- ## Common pitfalls diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md new file mode 100644 index 00000000..7c4313e6 --- /dev/null +++ b/docs/use/preconditions.md @@ -0,0 +1,188 @@ +--- +title: Runtime Preconditions +sidebar_label: Runtime Preconditions +--- + +Runtime Preconditions are **read-only execution guards** evaluated immediately before a step runs. +They protect against stale plans: when time passes between plan creation and execution, external +state may have changed. Preconditions check live (or request-context) data at execution time and +stop the run before an unsafe action is taken. + +:::info Planning-time vs. runtime +`Condition` is evaluated at **planning time** and controls whether a step is included in the plan +(`Status = Planned | NotApplicable`). Preconditions are evaluated at **execution time**, after the +plan is built, immediately before each step runs. This keeps planning deterministic while enabling +safety guards. +::: + +--- + +## When to use preconditions + +Use preconditions when: + +- The validity of a step depends on **current state** that may change after plan creation. +- A policy or compliance rule must be checked **live** before an action is allowed to proceed. +- You want to surface a structured, human-readable message to an operator when a gate fails. + +**Example — BYOD policy:** + +Before disabling an identity, the system should verify that company data has been wiped from any +BYOD (Bring Your Own Device) device. If the wipe confirmation is missing, execution must stop with +a `Blocked` outcome and a message instructing the operator to perform the wipe manually. + +--- + +## Schema + +Add these optional properties to a workflow step definition: + +| Property | Type | Required | Description | +|---|---|---|---| +| `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. | +| `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default) or `Fail`. | +| `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | + +### PreconditionEvent schema + +| Key | Type | Required | Description | +|---|---|---|---| +| `Type` | `String` | **Yes** | Event type string (for example: `ManualActionRequired`). | +| `Message` | `String` | **Yes** | Human-readable description of the required action. | +| `Data` | `Hashtable` | No | Optional key-value payload. Must not contain secrets. | + +--- + +## Example + +```powershell +@{ + Name = 'Leaver' + LifecycleEvent = 'Leaver' + + Steps = @( + @{ + Name = 'DisableIdentity' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' + } + + # Runtime guard: only execute if BYOD wipe is confirmed. + Preconditions = @( + @{ + Equals = @{ + Path = 'Request.Context.Byod.WipeConfirmed' + Value = 'true' + } + } + ) + OnPreconditionFalse = 'Blocked' + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Perform Intune retire / wipe company data for BYOD device before disabling the identity.' + Data = @{ + Reason = 'BYOD wipe not confirmed' + } + } + } + ) +} +``` + +--- + +## Condition DSL + +Each entry in `Preconditions` uses the same **declarative condition DSL** as the `Condition` +property. Supported operators: + +| Operator | Shape | Description | +|---|---|---| +| `Equals` | `@{ Path = '...'; Value = '...' }` | True when the resolved path equals the value (string comparison). | +| `NotEquals` | `@{ Path = '...'; Value = '...' }` | True when the resolved path does not equal the value. | +| `Exists` | `'path'` or `@{ Path = '...' }` | True when the resolved path is non-null. | +| `In` | `@{ Path = '...'; Values = @(...) }` | True when the resolved path value is in the list. | +| `All` | `@{ All = @( ... ) }` | True when all child conditions are true (AND). | +| `Any` | `@{ Any = @( ... ) }` | True when at least one child condition is true (OR). | +| `None` | `@{ None = @( ... ) }` | True when no child conditions are true (NOR). | + +### Path resolution + +Paths are resolved against the **execution-time context**, which includes: + +| Root | Description | +|---|---| +| `Plan.*` | The plan object (e.g. `Plan.LifecycleEvent`). | +| `Request.*` | The lifecycle request, including `Request.Intent.*`, `Request.Context.*`, `Request.IdentityKeys.*`. | + +A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` +resolves identically to `Request.Intent.Department`). + +--- + +## Blocked vs. Failed outcomes + +| Outcome | `OnPreconditionFalse` | Meaning | OnFailureSteps triggered? | +|---|---|---|---| +| `Blocked` | `Blocked` (default) | A policy or precondition gate stopped execution. Not a technical failure. | **No** | +| `Failed` | `Fail` | Treated as a genuine failure (same semantics as a step error). | **Yes** | + +### Execution result + +When a step is `Blocked`: + +- `result.Status` is `'Blocked'`. +- `result.Steps[n].Status` is `'Blocked'` for the blocking step. +- `result.OnFailure.Status` is `'NotRun'` (OnFailureSteps do not execute). +- A `StepPreconditionFailed` engine event is always emitted. +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. + +When `OnPreconditionFalse = 'Fail'`: + +- `result.Status` is `'Failed'`. +- `result.Steps[n].Status` is `'Failed'` with `Error = 'Precondition check failed.'`. +- `OnFailureSteps` run (same behavior as any other step failure). + +--- + +## Events emitted on precondition failure + +The engine always emits a `StepPreconditionFailed` event containing: + +| Field | Value | +|---|---| +| `Type` | `StepPreconditionFailed` | +| `StepName` | The name of the blocked step. | +| `Data.StepType` | The step type identifier. | +| `Data.OnPreconditionFalse` | `Blocked` or `Fail`. | + +If `PreconditionEvent` is configured, an additional event is emitted with: + +| Field | Value | +|---|---| +| `Type` | The configured `PreconditionEvent.Type`. | +| `Message` | The configured `PreconditionEvent.Message`. | +| `StepName` | The name of the blocked step. | +| `Data` | The configured `PreconditionEvent.Data` (if provided). | + +:::warning Log safety +`PreconditionEvent.Data` is surfaced as a structured event and may be forwarded to audit sinks. +Do **not** include secrets, credentials, or personal data in `Data`. +::: + +--- + +## Backward compatibility + +Steps without `Preconditions` behave exactly as before. Adding preconditions to a step does not +affect any other steps. + +--- + +## Reference + +- [Condition DSL reference](../reference/specs/conditions.md) (shared between `Condition` and `Preconditions`) +- [Steps reference](../reference/steps.md) +- [Concepts: Plan → Execute separation](../about/concepts.md) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index a4448264..2128c4d4 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -126,12 +126,91 @@ function ConvertTo-IdleWorkflowSteps { $null } + # Runtime Preconditions: evaluated at execution time (not planning time). + # Each precondition uses the same declarative condition DSL as Condition. + $preconditions = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'Preconditions') { + $rawPreconditions = Get-IdlePropertyValue -Object $s -Name 'Preconditions' + if ($null -ne $rawPreconditions) { + $pcList = @($rawPreconditions) + for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { + $pc = $pcList[$pcIdx] + if ($pc -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $stepName, $pcIdx), + 'Workflow' + ) + } + $pcErrors = Test-IdleConditionSchema -Condition $pc -StepName $stepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $stepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) + } + } + $preconditions = $pcList + } + } + + $onPreconditionFalse = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { + $rawOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse') + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail')) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked' or 'Fail'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), + 'Workflow' + ) + } + $onPreconditionFalse = $rawOnPreconditionFalse + } + + $preconditionEvent = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'PreconditionEvent') { + $rawPreconditionEvent = Get-IdlePropertyValue -Object $s -Name 'PreconditionEvent' + if ($null -ne $rawPreconditionEvent) { + if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $stepName), + 'Workflow' + ) + } + $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtType)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $stepName), + 'Workflow' + ) + } + $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $stepName), + 'Workflow' + ) + } + # PreconditionEvent.Data is optional but must be a hashtable if present. + if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { + if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $stepName), + 'Workflow' + ) + } + } + $preconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent + } + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = $stepName Type = $stepType Description = $description Condition = Copy-IdleDataObject -Value $condition + Preconditions = $preconditions + OnPreconditionFalse = $onPreconditionFalse + PreconditionEvent = $preconditionEvent With = $with RequiresCapabilities = $requiresCaps Status = $status diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 12a3566d..39a70bf1 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', 'RetryProfile') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'OnPreconditionFalse', 'PreconditionEvent') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -57,6 +57,62 @@ function Test-IdleWorkflowSchema { } } + # Helper: Validate Preconditions, OnPreconditionFalse, and PreconditionEvent fields on a step. + function Test-IdleWorkflowStepPreconditions { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { + if (-not ($Step.Preconditions -is [System.Collections.IEnumerable]) -or $Step.Preconditions -is [string]) { + $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables.") + } + else { + $pcIdx = 0 + foreach ($pc in @($Step.Preconditions)) { + if ($pc -isnot [hashtable]) { + $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") + } + $pcIdx++ + } + } + } + + if ($Step.ContainsKey('OnPreconditionFalse') -and $null -ne $Step.OnPreconditionFalse) { + $opf = [string]$Step.OnPreconditionFalse + if ($opf -notin @('Blocked', 'Fail')) { + $ErrorList.Add("'$StepPath.OnPreconditionFalse' must be 'Blocked' or 'Fail'. Got: '$opf'.") + } + } + + if ($Step.ContainsKey('PreconditionEvent') -and $null -ne $Step.PreconditionEvent) { + if ($Step.PreconditionEvent -isnot [hashtable]) { + $ErrorList.Add("'$StepPath.PreconditionEvent' must be a hashtable.") + } + else { + $pcEvt = $Step.PreconditionEvent + if (-not $pcEvt.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Type)) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Type' is required and must be a non-empty string.") + } + if (-not $pcEvt.ContainsKey('Message') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Message)) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Message' is required and must be a non-empty string.") + } + if ($pcEvt.ContainsKey('Data') -and $null -ne $pcEvt.Data -and $pcEvt.Data -isnot [hashtable]) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Data' must be a hashtable when provided.") + } + } + } + } + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { @@ -120,6 +176,9 @@ function Test-IdleWorkflowSchema { # Validate RetryProfile Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + # Validate Preconditions, OnPreconditionFalse, PreconditionEvent + Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + $i++ } } @@ -171,6 +230,9 @@ function Test-IdleWorkflowSchema { # Validate RetryProfile Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + # Validate Preconditions, OnPreconditionFalse, PreconditionEvent + Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + $i++ } } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index bd0b1af2..46becf50 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -269,8 +269,15 @@ function Invoke-IdlePlanObject { ) $failed = $false + $blocked = $false $stepResults = @() + # Precondition evaluation context: includes Plan and Request for condition DSL path resolution. + $preconditionContext = @{ + Plan = $Plan + Request = $request + } + $i = 0 foreach ($step in $Plan.Steps) { @@ -311,6 +318,84 @@ function Invoke-IdlePlanObject { continue } + # Runtime Preconditions: evaluated immediately before step execution (online, not planning-time). + # If any precondition fails, execution stops immediately. + # Blocked = policy/precondition gate (does not trigger OnFailureSteps). + # Fail = treated as a technical failure (triggers OnFailureSteps). + $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' + if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { + $preconditionPassed = $true + foreach ($pc in @($stepPreconditions)) { + if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { + $preconditionPassed = $false + break + } + } + + if (-not $preconditionPassed) { + $onPreconditionFalse = [string](Get-IdlePropertyValue -Object $step -Name 'OnPreconditionFalse') + if ([string]::IsNullOrWhiteSpace($onPreconditionFalse)) { $onPreconditionFalse = 'Blocked' } + + # Always emit StepPreconditionFailed for engine observability. + $context.EventSink.WriteEvent( + 'StepPreconditionFailed', + "Step '$stepName' precondition check failed.", + $stepName, + @{ + StepType = $stepType + Index = $i + OnPreconditionFalse = $onPreconditionFalse + } + ) + + # Emit the caller-configured PreconditionEvent if present. + $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent' + if ($null -ne $pcEvt) { + $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') + $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') + $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' + $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { + [hashtable]$pcEvtData + } elseif ($null -ne $pcEvtData) { + $ht = @{} + foreach ($prop in $pcEvtData.PSObject.Properties) { $ht[$prop.Name] = $prop.Value } + $ht + } else { + $null + } + $context.EventSink.WriteEvent($pcEvtType, $pcEvtMsg, $stepName, $pcEvtDataHt) + } + + if ($onPreconditionFalse -eq 'Fail') { + $failed = $true + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Failed' + Error = 'Precondition check failed.' + Attempts = 0 + } + } + else { + # Default: Blocked. Does not trigger OnFailureSteps. + $blocked = $true + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Blocked' + Attempts = 0 + } + } + + break + } + } + + # Stop processing if a precondition failure was handled above. + if ($failed -or $blocked) { break } + $context.EventSink.WriteEvent( 'StepStarted', "Step '$stepName' started.", @@ -437,7 +522,7 @@ function Invoke-IdlePlanObject { $i++ } - $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + $runStatus = if ($blocked) { 'Blocked' } elseif ($failed) { 'Failed' } else { 'Completed' } # Public result contract: the OnFailure section is always present. $onFailure = [pscustomobject]@{ @@ -452,7 +537,8 @@ function Invoke-IdlePlanObject { $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ } } - if ($failed -and @($planOnFailureSteps).Count -gt 0) { + # OnFailureSteps run only for genuine failures, NOT for Blocked outcomes (policy gates). + if ($failed -and -not $blocked -and @($planOnFailureSteps).Count -gt 0) { $context.EventSink.WriteEvent( 'OnFailureStarted', 'Executing OnFailureSteps (best effort).', diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 new file mode 100644 index 00000000..affc51ae --- /dev/null +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -0,0 +1,517 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + function global:Invoke-IdlePreconditionTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function global:Invoke-IdlePreconditionTestSecondStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('SecondStepRan', 'Second step executed', $Step.Name, @{}) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function global:Invoke-IdlePreconditionTestOnFailureStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('OnFailureRan', 'OnFailure step executed', $Step.Name, @{}) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestNoopStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestSecondStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestOnFailureStep' -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan - Runtime Preconditions' { + + Context 'Step without preconditions' { + It 'behaves exactly as before (no preconditions = no change)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-preconditions.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'No Preconditions' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.NoPrecondition' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NoPrecondition') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.NoPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NoPrecondition') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 0 + } + } + + Context 'Passing preconditions' { + It 'executes the step when all preconditions pass' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'passing-preconditions.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Passing Preconditions' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PassingPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + ) + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PassingPrecondition') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.PassingPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PassingPrecondition') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 0 + } + } + + Context 'Failing precondition - Blocked (default)' { + It 'produces Blocked step result and stops execution when precondition fails' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-precondition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Blocked Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Blocked' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedPrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.BlockedPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedPrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.Steps.Count | Should -Be 1 + $result.Steps[0].Name | Should -Be 'Step1' + $result.Steps[0].Status | Should -Be 'Blocked' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 + } + + It 'uses Blocked as the default when OnPreconditionFalse is omitted' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-default.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Blocked Default' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedDefault' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedDefault') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.BlockedDefault' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedDefault') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.Steps[0].Status | Should -Be 'Blocked' + } + } + + Context 'Failing precondition - Fail' { + It 'produces Failed step result and stops execution when OnPreconditionFalse=Fail' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'fail-precondition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Fail Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.Steps.Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Not -BeNullOrEmpty + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 + } + } + + Context 'Blocked does not trigger OnFailureSteps' { + It 'does not run OnFailureSteps when a step is Blocked' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-no-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Blocked No OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedNoOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedNoOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.BlockedNoOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedNoOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } + + It 'does run OnFailureSteps when OnPreconditionFalse=Fail' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'fail-runs-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Fail Runs OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 1 + } + } + + Context 'PreconditionEvent emission' { + It 'emits configured PreconditionEvent when precondition fails' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'precondition-event.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Precondition Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Perform Intune wipe before proceeding' + Data = @{ + Reason = 'BYOD wipe not confirmed' + } + } + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PreconditionEvent') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.PreconditionEvent' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PreconditionEvent') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + + # StepPreconditionFailed must be emitted + $pcFailedEvent = $result.Events | Where-Object Type -eq 'StepPreconditionFailed' + $pcFailedEvent | Should -Not -BeNullOrEmpty + + # Configured PreconditionEvent should also be emitted with the declared Type/Message + $customEvent = $result.Events | Where-Object Type -eq 'ManualActionRequired' + $customEvent | Should -Not -BeNullOrEmpty + $customEvent.Message | Should -Be 'Perform Intune wipe before proceeding' + $customEvent.Data['Reason'] | Should -Be 'BYOD wipe not confirmed' + } + } + + Context 'Invalid precondition schema at planning time' { + It 'throws when a precondition node has an invalid schema' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-precondition-schema.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Invalid Precondition Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPreconditionSchema' + Preconditions = @( + @{ + UnknownKey = 'bad' + } + ) + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPreconditionSchema') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + + It 'throws when OnPreconditionFalse has an invalid value' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-onpreconditionfalse.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Invalid OnPreconditionFalse' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidOPF' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Skip' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidOPF') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + + It 'throws when PreconditionEvent is missing required Type' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-preconditionevent-type.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Invalid PreconditionEvent Type' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPCEvt' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Message = 'Some message' + } + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCEvt') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + } +} From b7149da2ca21ab3b56f0354ca3223d4c7516e468 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:39:12 +0000 Subject: [PATCH 3/9] Address code review: fix string IEnumerable check order, simplify PSObject conversion, improve docs and tests Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/preconditions.md | 7 +++++++ src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 | 2 +- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 13 ++++--------- tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 | 4 ++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md index 7c4313e6..2969ecdc 100644 --- a/docs/use/preconditions.md +++ b/docs/use/preconditions.md @@ -70,6 +70,8 @@ Add these optional properties to a workflow step definition: } # Runtime guard: only execute if BYOD wipe is confirmed. + # Note: the condition DSL compares values as strings. + # Request.Context.Byod.WipeConfirmed must be the string 'true' (e.g. set by a ContextResolver). Preconditions = @( @{ Equals = @{ @@ -172,6 +174,11 @@ If `PreconditionEvent` is configured, an additional event is emitted with: Do **not** include secrets, credentials, or personal data in `Data`. ::: +:::note String comparison +The condition DSL always compares values as **strings** (for example, boolean `$true` becomes `'True'`). +Ensure context values are stored as strings when using `Equals` or `In` operators. +::: + --- ## Backward compatibility diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 39a70bf1..60f569df 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -73,7 +73,7 @@ function Test-IdleWorkflowSchema { ) if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { - if (-not ($Step.Preconditions -is [System.Collections.IEnumerable]) -or $Step.Preconditions -is [string]) { + if ($Step.Preconditions -is [string] -or -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables.") } else { diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 46becf50..e796e2b3 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -326,6 +326,7 @@ function Invoke-IdlePlanObject { if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { $preconditionPassed = $true foreach ($pc in @($stepPreconditions)) { + if ($pc -isnot [System.Collections.IDictionary]) { continue } if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { $preconditionPassed = $false break @@ -354,15 +355,9 @@ function Invoke-IdlePlanObject { $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' - $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { - [hashtable]$pcEvtData - } elseif ($null -ne $pcEvtData) { - $ht = @{} - foreach ($prop in $pcEvtData.PSObject.Properties) { $ht[$prop.Name] = $prop.Value } - $ht - } else { - $null - } + # PreconditionEvent.Data is validated as a hashtable at planning time and + # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here. + $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { [hashtable]$pcEvtData } else { $null } $context.EventSink.WriteEvent($pcEvtType, $pcEvtMsg, $stepName, $pcEvtDataHt) } diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index affc51ae..c89fc9e3 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -37,7 +37,7 @@ BeforeAll { [object] $Step ) - $Context.EventSink.WriteEvent('SecondStepRan', 'Second step executed', $Step.Name, @{}) + $Context.EventSink.WriteEvent('SecondStepRan', 'Second step executed', $Step.Name, @{ StepType = $Step.Type }) return [pscustomobject]@{ PSTypeName = 'IdLE.StepResult' @@ -60,7 +60,7 @@ BeforeAll { [object] $Step ) - $Context.EventSink.WriteEvent('OnFailureRan', 'OnFailure step executed', $Step.Name, @{}) + $Context.EventSink.WriteEvent('OnFailureRan', 'OnFailure step executed', $Step.Name, @{ StepType = $Step.Type }) return [pscustomobject]@{ PSTypeName = 'IdLE.StepResult' From 308be949ac75272449387885e7d8cfbde99dad26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:00:37 +0000 Subject: [PATCH 4/9] Add Continue mode to OnPreconditionFalse: emit events and skip step, continue execution Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/preconditions.md | 31 ++++-- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 4 +- .../Private/Test-IdleWorkflowSchema.ps1 | 4 +- .../Public/Invoke-IdlePlanObject.ps1 | 18 +++- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 97 +++++++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md index 2969ecdc..b86eb1cf 100644 --- a/docs/use/preconditions.md +++ b/docs/use/preconditions.md @@ -40,7 +40,7 @@ Add these optional properties to a workflow step definition: | Property | Type | Required | Description | |---|---|---|---| | `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. | -| `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default) or `Fail`. | +| `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. | | `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | ### PreconditionEvent schema @@ -124,14 +124,15 @@ resolves identically to `Request.Intent.Department`). --- -## Blocked vs. Failed outcomes +## Blocked vs. Failed vs. Continue outcomes -| Outcome | `OnPreconditionFalse` | Meaning | OnFailureSteps triggered? | -|---|---|---|---| -| `Blocked` | `Blocked` (default) | A policy or precondition gate stopped execution. Not a technical failure. | **No** | -| `Failed` | `Fail` | Treated as a genuine failure (same semantics as a step error). | **Yes** | +| Outcome | `OnPreconditionFalse` | Meaning | Stops execution? | OnFailureSteps triggered? | +|---|---|---|---|---| +| `Blocked` | `Blocked` (default) | A policy or precondition gate stopped execution. Not a technical failure. | **Yes** | **No** | +| `Failed` | `Fail` | Treated as a genuine failure (same semantics as a step error). | **Yes** | **Yes** | +| `PreconditionSkipped` | `Continue` | Emits observability events and skips the step; subsequent steps run normally. | **No** | **No** | -### Execution result +### Execution result — Blocked When a step is `Blocked`: @@ -141,12 +142,28 @@ When a step is `Blocked`: - A `StepPreconditionFailed` engine event is always emitted. - If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. +### Execution result — Fail + When `OnPreconditionFalse = 'Fail'`: - `result.Status` is `'Failed'`. - `result.Steps[n].Status` is `'Failed'` with `Error = 'Precondition check failed.'`. - `OnFailureSteps` run (same behavior as any other step failure). +### Execution result — Continue + +When `OnPreconditionFalse = 'Continue'`: + +- `result.Status` is `'Completed'` (unless a subsequent step fails for another reason). +- `result.Steps[n].Status` is `'PreconditionSkipped'` for the skipped step. +- Subsequent steps execute as normal. +- A `StepPreconditionFailed` engine event is always emitted for observability. +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. + +Use `Continue` when a precondition failure is advisory rather than blocking — for example, to emit +an audit event noting that an optional step was skipped due to a policy condition, while allowing +the rest of the workflow to complete. + --- ## Events emitted on precondition failure diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 2128c4d4..df1e5999 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -156,9 +156,9 @@ function ConvertTo-IdleWorkflowSteps { $onPreconditionFalse = $null if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { $rawOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse') - if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail')) { + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { throw [System.ArgumentException]::new( - ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked' or 'Fail'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), 'Workflow' ) } diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 60f569df..0f3a4928 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -89,8 +89,8 @@ function Test-IdleWorkflowSchema { if ($Step.ContainsKey('OnPreconditionFalse') -and $null -ne $Step.OnPreconditionFalse) { $opf = [string]$Step.OnPreconditionFalse - if ($opf -notin @('Blocked', 'Fail')) { - $ErrorList.Add("'$StepPath.OnPreconditionFalse' must be 'Blocked' or 'Fail'. Got: '$opf'.") + if ($opf -notin @('Blocked', 'Fail', 'Continue')) { + $ErrorList.Add("'$StepPath.OnPreconditionFalse' must be 'Blocked', 'Fail', or 'Continue'. Got: '$opf'.") } } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index e796e2b3..9921441b 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -319,9 +319,9 @@ function Invoke-IdlePlanObject { } # Runtime Preconditions: evaluated immediately before step execution (online, not planning-time). - # If any precondition fails, execution stops immediately. - # Blocked = policy/precondition gate (does not trigger OnFailureSteps). - # Fail = treated as a technical failure (triggers OnFailureSteps). + # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution. + # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution. + # Continue = emits events but skips the step and continues to the next step. $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { $preconditionPassed = $true @@ -372,6 +372,18 @@ function Invoke-IdlePlanObject { Attempts = 0 } } + elseif ($onPreconditionFalse -eq 'Continue') { + # Emit events and skip the step; continue to subsequent steps. + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'PreconditionSkipped' + Attempts = 0 + } + $i++ + continue + } else { # Default: Blocked. Does not trigger OnFailureSteps. $blocked = $true diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index c89fc9e3..77b4aa06 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -281,6 +281,103 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { } } + Context 'Failing precondition - Continue' { + It 'emits events, marks step as PreconditionSkipped, and continues execution of subsequent steps' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'continue-precondition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Continue Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ContinuePrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + # Overall run completes successfully + $result.Status | Should -Be 'Completed' + # Step1 is skipped, Step2 runs + $result.Steps.Count | Should -Be 2 + $result.Steps[0].Name | Should -Be 'Step1' + $result.Steps[0].Status | Should -Be 'PreconditionSkipped' + $result.Steps[1].Name | Should -Be 'Step2' + $result.Steps[1].Status | Should -Be 'Completed' + # Engine event is emitted for observability + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + # Subsequent step ran + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 1 + } + + It 'emits PreconditionEvent when Continue mode is used' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'continue-precondition-event.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Continue With Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + PreconditionEvent = @{ + Type = 'PolicyAdvisory' + Message = 'Step skipped due to policy advisory' + Data = @{ Hint = 'BYOD check not met' } + } + } + ) +} +'@ + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePreconditionEvent') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ContinuePreconditionEvent' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePreconditionEvent') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'PreconditionSkipped' + ($result.Events | Where-Object Type -eq 'PolicyAdvisory').Message | Should -Be 'Step skipped due to policy advisory' + } + } + Context 'Blocked does not trigger OnFailureSteps' { It 'does not run OnFailureSteps when a step is Blocked' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-no-onfailure.psd1' From 05b5708913cc2dccc9960948a4f0b94070f26693 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:06:34 +0000 Subject: [PATCH 5/9] Move precondition test workflows to fixtures; add preconditions to sidebar and workflows.md Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows.md | 1 + .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 328 +----------------- .../preconditions/blocked-default.psd1 | 18 + .../preconditions/blocked-no-onfailure.psd1 | 24 ++ .../workflows/preconditions/blocked.psd1 | 23 ++ .../preconditions/continue-event.psd1 | 24 ++ .../workflows/preconditions/continue.psd1 | 23 ++ .../workflows/preconditions/event.psd1 | 25 ++ .../preconditions/fail-runs-onfailure.psd1 | 25 ++ .../workflows/preconditions/fail.psd1 | 23 ++ .../preconditions/invalid-event-type.psd1 | 21 ++ .../invalid-onpreconditionfalse.psd1 | 19 + .../preconditions/invalid-schema.psd1 | 15 + .../preconditions/no-preconditions.psd1 | 10 + .../workflows/preconditions/passing.psd1 | 18 + website/sidebars.js | 1 + 16 files changed, 288 insertions(+), 310 deletions(-) create mode 100644 tests/fixtures/workflows/preconditions/blocked-default.psd1 create mode 100644 tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 create mode 100644 tests/fixtures/workflows/preconditions/blocked.psd1 create mode 100644 tests/fixtures/workflows/preconditions/continue-event.psd1 create mode 100644 tests/fixtures/workflows/preconditions/continue.psd1 create mode 100644 tests/fixtures/workflows/preconditions/event.psd1 create mode 100644 tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 create mode 100644 tests/fixtures/workflows/preconditions/fail.psd1 create mode 100644 tests/fixtures/workflows/preconditions/invalid-event-type.psd1 create mode 100644 tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 create mode 100644 tests/fixtures/workflows/preconditions/invalid-schema.psd1 create mode 100644 tests/fixtures/workflows/preconditions/no-preconditions.psd1 create mode 100644 tests/fixtures/workflows/preconditions/passing.psd1 diff --git a/docs/use/workflows.md b/docs/use/workflows.md index cd14e79b..fa00fde7 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -165,5 +165,6 @@ For full definitions and reference, see: ## Next steps +- Add runtime safety guards: [Runtime Preconditions](preconditions.md) - Map external systems: [Providers](providers.md) - Review and export plans: [Plan Export](plan-export.md) (e.g. for CI systems) diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 77b4aa06..3baa446d 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows/preconditions' function global:Invoke-IdlePreconditionTestNoopStep { [CmdletBinding()] @@ -82,19 +83,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Step without preconditions' { It 'behaves exactly as before (no preconditions = no change)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-preconditions.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'No Preconditions' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.NoPrecondition' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'no-preconditions.psd1' $req = New-IdleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NoPrecondition') } $providers = @{ @@ -112,27 +101,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Passing preconditions' { It 'executes the step when all preconditions pass' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'passing-preconditions.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Passing Preconditions' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.PassingPrecondition' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Leaver' - } - } - ) - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'passing.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PassingPrecondition') } $providers = @{ @@ -150,32 +119,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Failing precondition - Blocked (default)' { It 'produces Blocked step result and stops execution when precondition fails' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-precondition.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Blocked Precondition' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.BlockedPrecondition' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Blocked' - } - @{ - Name = 'Step2' - Type = 'IdLE.Step.SecondStep' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedPrecondition', 'IdLE.Step.SecondStep') } $providers = @{ @@ -197,27 +141,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { } It 'uses Blocked as the default when OnPreconditionFalse is omitted' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-default.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Blocked Default' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.BlockedDefault' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked-default.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedDefault') } $providers = @{ @@ -234,32 +158,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Failing precondition - Fail' { It 'produces Failed step result and stops execution when OnPreconditionFalse=Fail' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'fail-precondition.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Fail Precondition' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.FailPrecondition' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Fail' - } - @{ - Name = 'Step2' - Type = 'IdLE.Step.SecondStep' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'fail.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrecondition', 'IdLE.Step.SecondStep') } $providers = @{ @@ -283,32 +182,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Failing precondition - Continue' { It 'emits events, marks step as PreconditionSkipped, and continues execution of subsequent steps' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'continue-precondition.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Continue Precondition' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.ContinuePrecondition' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Continue' - } - @{ - Name = 'Step2' - Type = 'IdLE.Step.SecondStep' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'continue.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePrecondition', 'IdLE.Step.SecondStep') } $providers = @{ @@ -336,33 +210,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { } It 'emits PreconditionEvent when Continue mode is used' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'continue-precondition-event.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Continue With Event' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.ContinuePreconditionEvent' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Continue' - PreconditionEvent = @{ - Type = 'PolicyAdvisory' - Message = 'Step skipped due to policy advisory' - Data = @{ Hint = 'BYOD check not met' } - } - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'continue-event.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePreconditionEvent') } $providers = @{ @@ -380,33 +228,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Blocked does not trigger OnFailureSteps' { It 'does not run OnFailureSteps when a step is Blocked' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'blocked-no-onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Blocked No OnFailure' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.BlockedNoOnFailure' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - } - ) - OnFailureSteps = @( - @{ - Name = 'Cleanup' - Type = 'IdLE.Step.OnFailureCleanup' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked-no-onfailure.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedNoOnFailure', 'IdLE.Step.OnFailureCleanup') } $providers = @{ @@ -426,34 +248,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { } It 'does run OnFailureSteps when OnPreconditionFalse=Fail' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'fail-runs-onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Fail Runs OnFailure' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Fail' - } - ) - OnFailureSteps = @( - @{ - Name = 'Cleanup' - Type = 'IdLE.Step.OnFailureCleanup' - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'fail-runs-onfailure.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } $providers = @{ @@ -474,34 +269,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'PreconditionEvent emission' { It 'emits configured PreconditionEvent when precondition fails' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'precondition-event.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Precondition Event' - LifecycleEvent = 'Leaver' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.PreconditionEvent' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - PreconditionEvent = @{ - Type = 'ManualActionRequired' - Message = 'Perform Intune wipe before proceeding' - Data = @{ - Reason = 'BYOD wipe not confirmed' - } - } - } - ) -} -'@ + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'event.psd1' $req = New-IdleRequest -LifecycleEvent 'Leaver' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PreconditionEvent') } $providers = @{ @@ -527,88 +295,28 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { Context 'Invalid precondition schema at planning time' { It 'throws when a precondition node has an invalid schema' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-precondition-schema.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Invalid Precondition Schema' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.InvalidPreconditionSchema' - Preconditions = @( - @{ - UnknownKey = 'bad' - } - ) - } - ) -} -'@ - $req = New-IdleRequest -LifecycleEvent 'Joiner' + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-schema.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPreconditionSchema') } { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } It 'throws when OnPreconditionFalse has an invalid value' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-onpreconditionfalse.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Invalid OnPreconditionFalse' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.InvalidOPF' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - OnPreconditionFalse = 'Skip' - } - ) -} -'@ - $req = New-IdleRequest -LifecycleEvent 'Joiner' + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-onpreconditionfalse.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidOPF') } { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } It 'throws when PreconditionEvent is missing required Type' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'invalid-preconditionevent-type.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Invalid PreconditionEvent Type' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Step1' - Type = 'IdLE.Step.InvalidPCEvt' - Preconditions = @( - @{ - Equals = @{ - Path = 'Plan.LifecycleEvent' - Value = 'Joiner' - } - } - ) - PreconditionEvent = @{ - Message = 'Some message' - } - } - ) -} -'@ - $req = New-IdleRequest -LifecycleEvent 'Joiner' + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-event-type.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCEvt') } { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } } } + diff --git a/tests/fixtures/workflows/preconditions/blocked-default.psd1 b/tests/fixtures/workflows/preconditions/blocked-default.psd1 new file mode 100644 index 00000000..a216facd --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked-default.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Blocked Default' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedDefault' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 new file mode 100644 index 00000000..ebfccaf4 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 @@ -0,0 +1,24 @@ +@{ + Name = 'Blocked No OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedNoOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/blocked.psd1 b/tests/fixtures/workflows/preconditions/blocked.psd1 new file mode 100644 index 00000000..8d51859a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Blocked Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Blocked' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/continue-event.psd1 b/tests/fixtures/workflows/preconditions/continue-event.psd1 new file mode 100644 index 00000000..a3f5ea0d --- /dev/null +++ b/tests/fixtures/workflows/preconditions/continue-event.psd1 @@ -0,0 +1,24 @@ +@{ + Name = 'Continue With Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + PreconditionEvent = @{ + Type = 'PolicyAdvisory' + Message = 'Step skipped due to policy advisory' + Data = @{ Hint = 'BYOD check not met' } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/continue.psd1 b/tests/fixtures/workflows/preconditions/continue.psd1 new file mode 100644 index 00000000..0d56086c --- /dev/null +++ b/tests/fixtures/workflows/preconditions/continue.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Continue Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/event.psd1 b/tests/fixtures/workflows/preconditions/event.psd1 new file mode 100644 index 00000000..3e6cacf1 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/event.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Precondition Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Perform Intune wipe before proceeding' + Data = @{ + Reason = 'BYOD wipe not confirmed' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 new file mode 100644 index 00000000..ada7f0be --- /dev/null +++ b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Fail Runs OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/fail.psd1 b/tests/fixtures/workflows/preconditions/fail.psd1 new file mode 100644 index 00000000..1a572cf7 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/fail.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Fail Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 new file mode 100644 index 00000000..8de5d90a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 @@ -0,0 +1,21 @@ +@{ + Name = 'Invalid PreconditionEvent Type' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPCEvt' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Message = 'Some message' + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 new file mode 100644 index 00000000..166161e8 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 @@ -0,0 +1,19 @@ +@{ + Name = 'Invalid OnPreconditionFalse' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidOPF' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Skip' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 new file mode 100644 index 00000000..3caa0e71 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 @@ -0,0 +1,15 @@ +@{ + Name = 'Invalid Precondition Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPreconditionSchema' + Preconditions = @( + @{ + UnknownKey = 'bad' + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/no-preconditions.psd1 b/tests/fixtures/workflows/preconditions/no-preconditions.psd1 new file mode 100644 index 00000000..a39e9d2a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/no-preconditions.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'No Preconditions' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.NoPrecondition' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/passing.psd1 b/tests/fixtures/workflows/preconditions/passing.psd1 new file mode 100644 index 00000000..4374340b --- /dev/null +++ b/tests/fixtures/workflows/preconditions/passing.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Passing Preconditions' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PassingPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + ) + } + ) +} diff --git a/website/sidebars.js b/website/sidebars.js index 2ac97e77..f861a6fa 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -47,6 +47,7 @@ const sidebars = { ], }, 'use/workflows', + 'use/preconditions', 'use/providers', 'use/plan-export', ], From 2f68fd22928abe01d90a27518c83f30d0be292de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:26:03 +0000 Subject: [PATCH 6/9] Emit StepBlocked/StepFailed events on precondition outcomes; add step execution controls table to workflows.md Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/preconditions.md | 22 +++++++++++++++---- docs/use/workflows.md | 10 +++++++++ .../Public/Invoke-IdlePlanObject.ps1 | 19 ++++++++++++++++ .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 2 ++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md index b86eb1cf..403f6d45 100644 --- a/docs/use/preconditions.md +++ b/docs/use/preconditions.md @@ -140,6 +140,7 @@ When a step is `Blocked`: - `result.Steps[n].Status` is `'Blocked'` for the blocking step. - `result.OnFailure.Status` is `'NotRun'` (OnFailureSteps do not execute). - A `StepPreconditionFailed` engine event is always emitted. +- A `StepBlocked` engine event is emitted for the blocked step. - If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. ### Execution result — Fail @@ -149,6 +150,9 @@ When `OnPreconditionFalse = 'Fail'`: - `result.Status` is `'Failed'`. - `result.Steps[n].Status` is `'Failed'` with `Error = 'Precondition check failed.'`. - `OnFailureSteps` run (same behavior as any other step failure). +- A `StepPreconditionFailed` engine event is always emitted. +- A `StepFailed` engine event is emitted (matching the format of regular step failure events). +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. ### Execution result — Continue @@ -168,14 +172,24 @@ the rest of the workflow to complete. ## Events emitted on precondition failure -The engine always emits a `StepPreconditionFailed` event containing: +| Event type | `OnPreconditionFalse` modes | Description | +|---|---|---| +| `StepPreconditionFailed` | All (`Blocked`, `Fail`, `Continue`) | Always emitted. Contains `StepType`, `Index`, `OnPreconditionFalse`. | +| `StepBlocked` | `Blocked` | Emitted when the step outcome is `Blocked`. Contains `StepType`, `Index`. | +| `StepFailed` | `Fail` | Emitted when the step outcome is `Failed`. Contains `StepType`, `Index`, `Error`. | +| Configured `PreconditionEvent.Type` | All (if `PreconditionEvent` configured) | Caller-defined event. | + +### StepPreconditionFailed event | Field | Value | |---|---| | `Type` | `StepPreconditionFailed` | -| `StepName` | The name of the blocked step. | +| `StepName` | The name of the affected step. | | `Data.StepType` | The step type identifier. | -| `Data.OnPreconditionFalse` | `Blocked` or `Fail`. | +| `Data.Index` | The step index in the plan. | +| `Data.OnPreconditionFalse` | `Blocked`, `Fail`, or `Continue`. | + +### PreconditionEvent (caller-configured) If `PreconditionEvent` is configured, an additional event is emitted with: @@ -183,7 +197,7 @@ If `PreconditionEvent` is configured, an additional event is emitted with: |---|---| | `Type` | The configured `PreconditionEvent.Type`. | | `Message` | The configured `PreconditionEvent.Message`. | -| `StepName` | The name of the blocked step. | +| `StepName` | The name of the affected step. | | `Data` | The configured `PreconditionEvent.Data` (if provided). | :::warning Log safety diff --git a/docs/use/workflows.md b/docs/use/workflows.md index fa00fde7..1570141e 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -24,6 +24,16 @@ At a high level, a workflow contains: The Big Picture is described in [Concepts](../about/concepts.md). +### Step execution controls + +Each step supports several optional execution control properties: + +| Property | Evaluated at | Purpose | +|---|---|---| +| `Condition` | Plan time | Include or skip the step based on request/intent data. | +| `Preconditions` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](preconditions.md). | +| `OnFailureSteps` | After failure (workflow-level) | Cleanup/rollback steps run after a primary step fails. | + --- ## Minimal workflow example diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 9921441b..defd3cc9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -371,6 +371,16 @@ function Invoke-IdlePlanObject { Error = 'Precondition check failed.' Attempts = 0 } + $context.EventSink.WriteEvent( + 'StepFailed', + "Step '$stepName' failed (precondition check failed).", + $stepName, + @{ + StepType = $stepType + Index = $i + Error = 'Precondition check failed.' + } + ) } elseif ($onPreconditionFalse -eq 'Continue') { # Emit events and skip the step; continue to subsequent steps. @@ -394,6 +404,15 @@ function Invoke-IdlePlanObject { Status = 'Blocked' Attempts = 0 } + $context.EventSink.WriteEvent( + 'StepBlocked', + "Step '$stepName' blocked (precondition check failed).", + $stepName, + @{ + StepType = $stepType + Index = $i + } + ) } break diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 3baa446d..03ce53a7 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -137,6 +137,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { $result.Steps[0].Name | Should -Be 'Step1' $result.Steps[0].Status | Should -Be 'Blocked' @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'StepBlocked').Count | Should -Be 1 @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 } @@ -176,6 +177,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { $result.Steps[0].Status | Should -Be 'Failed' $result.Steps[0].Error | Should -Not -BeNullOrEmpty @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'StepFailed').Count | Should -Be 1 @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 } } From 5c5d74d6c204cb3ddfb137d7d9445ac757b55ed3 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:19:57 +0100 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index df1e5999..22da8a13 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -149,20 +149,28 @@ function ConvertTo-IdleWorkflowSteps { ) } } - $preconditions = $pcList + $preconditions = @() + foreach ($pc in $pcList) { + $preconditions += Copy-IdleDataObject -Value $pc + } } } $onPreconditionFalse = $null if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { - $rawOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse') - if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), - 'Workflow' - ) + $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse' + if ($null -ne $rawOnPreconditionFalseValue) { + $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue + if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), + 'Workflow' + ) + } + $onPreconditionFalse = $rawOnPreconditionFalse + } } - $onPreconditionFalse = $rawOnPreconditionFalse } $preconditionEvent = $null From 1c8e9f944fb3ed80da05eda8e053351c95ce169f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:24:14 +0000 Subject: [PATCH 8/9] Fix precondition type safety: reject single hashtable, fail closed on bad nodes, evaluate preconditions on OnFailureSteps Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Test-IdleWorkflowSchema.ps1 | 8 +- .../Public/Invoke-IdlePlanObject.ps1 | 99 ++++++++++++++++++- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 54 ++++++++++ .../invalid-single-hashtable.psd1 | 16 +++ .../onfailure-blocked-precondition.psd1 | 33 +++++++ .../onfailure-continue-precondition.psd1 | 34 +++++++ 6 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 create mode 100644 tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 create mode 100644 tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 0f3a4928..767fb7e3 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -73,13 +73,15 @@ function Test-IdleWorkflowSchema { ) if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { - if ($Step.Preconditions -is [string] -or -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { - $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables.") + if ($Step.Preconditions -is [string] -or + $Step.Preconditions -is [System.Collections.IDictionary] -or + -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { + $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables (not a single hashtable).") } else { $pcIdx = 0 foreach ($pc in @($Step.Preconditions)) { - if ($pc -isnot [hashtable]) { + if ($pc -isnot [System.Collections.IDictionary]) { $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") } $pcIdx++ diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index defd3cc9..f060f765 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -322,11 +322,16 @@ function Invoke-IdlePlanObject { # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution. # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution. # Continue = emits events but skips the step and continues to the next step. + # Non-IDictionary precondition nodes are treated as precondition failures (fail closed). $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { $preconditionPassed = $true foreach ($pc in @($stepPreconditions)) { - if ($pc -isnot [System.Collections.IDictionary]) { continue } + if ($pc -isnot [System.Collections.IDictionary]) { + # Fail closed: a malformed or unexpected node type is treated as a failed precondition. + $preconditionPassed = $false + break + } if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { $preconditionPassed = $false break @@ -616,6 +621,98 @@ function Invoke-IdlePlanObject { continue } + # Runtime Preconditions for OnFailure steps: evaluated immediately before execution. + # OnFailure runs best-effort, so precondition failures skip the step but do not halt + # remaining OnFailure steps. Non-IDictionary nodes are treated as failures (fail closed). + $ofPreconditions = Get-IdlePropertyValue -Object $ofStep -Name 'Preconditions' + if ($null -ne $ofPreconditions -and @($ofPreconditions).Count -gt 0) { + $ofPreconditionPassed = $true + foreach ($opc in @($ofPreconditions)) { + if ($opc -isnot [System.Collections.IDictionary]) { + $ofPreconditionPassed = $false + break + } + if (-not (Test-IdleCondition -Condition ([hashtable]$opc) -Context $preconditionContext)) { + $ofPreconditionPassed = $false + break + } + } + + if (-not $ofPreconditionPassed) { + $ofOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $ofStep -Name 'OnPreconditionFalse') + if ([string]::IsNullOrWhiteSpace($ofOnPreconditionFalse)) { $ofOnPreconditionFalse = 'Blocked' } + + # Always emit StepPreconditionFailed for engine observability. + $context.EventSink.WriteEvent( + 'StepPreconditionFailed', + "OnFailure step '$ofName' precondition check failed.", + $ofName, + @{ + StepType = $ofType + Index = $j + OnPreconditionFalse = $ofOnPreconditionFalse + } + ) + + # Emit the caller-configured PreconditionEvent if present. + $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent' + if ($null -ne $ofPcEvt) { + $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type') + $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') + $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data' + $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null } + $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt) + } + + if ($ofOnPreconditionFalse -eq 'Fail') { + $onFailureHadFailures = $true + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = 'Failed' + Error = 'Precondition check failed.' + Attempts = 0 + } + $context.EventSink.WriteEvent( + 'StepFailed', + "OnFailure step '$ofName' failed (precondition check failed).", + $ofName, + @{ + StepType = $ofType + Index = $j + Error = 'Precondition check failed.' + } + ) + } + else { + # Blocked or Continue: skip this OnFailure step and proceed to the next. + $ofStatus = if ($ofOnPreconditionFalse -eq 'Continue') { 'PreconditionSkipped' } else { 'Blocked' } + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = $ofStatus + Attempts = 0 + } + if ($ofOnPreconditionFalse -ne 'Continue') { + $context.EventSink.WriteEvent( + 'StepBlocked', + "OnFailure step '$ofName' blocked (precondition check failed).", + $ofName, + @{ + StepType = $ofType + Index = $j + } + ) + } + } + + $j++ + continue + } + } + $context.EventSink.WriteEvent( 'OnFailureStepStarted', "OnFailure step '$ofName' started.", diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 03ce53a7..31b6e19b 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -319,6 +319,60 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } + + It 'throws when Preconditions is a single hashtable instead of an array' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-single-hashtable.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCSingleHt') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + } + + Context 'OnFailureSteps with preconditions' { + It 'evaluates preconditions on OnFailureSteps: Blocked skips the step but continues remaining OnFailure steps' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'onfailure-blocked-precondition.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + # OnFailure cleanup step was blocked by its precondition (Leaver != Joiner) + $result.OnFailure.Steps.Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'Blocked' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 2 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } + + It 'evaluates preconditions on OnFailureSteps: Continue skips the step but continues remaining OnFailure steps' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'onfailure-continue-precondition.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + # OnFailure cleanup step was skipped via Continue + $result.OnFailure.Steps.Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'PreconditionSkipped' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 2 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } } } diff --git a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 new file mode 100644 index 00000000..316f51b7 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Invalid Preconditions Single Hashtable' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPCSingleHt' + Preconditions = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 new file mode 100644 index 00000000..5fcc341c --- /dev/null +++ b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 @@ -0,0 +1,33 @@ +@{ + Name = 'Blocked OnFailure Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 new file mode 100644 index 00000000..9ac0b888 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 @@ -0,0 +1,34 @@ +@{ + Name = 'Continue OnFailure Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + } + ) +} From f2cfc5d5463714bafdd4ed44b085e6f2a2814b74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:46:39 +0000 Subject: [PATCH 9/9] Remove broken condition DSL reference link from preconditions.md Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/preconditions.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md index 403f6d45..a862c5f9 100644 --- a/docs/use/preconditions.md +++ b/docs/use/preconditions.md @@ -221,6 +221,5 @@ affect any other steps. ## Reference -- [Condition DSL reference](../reference/specs/conditions.md) (shared between `Condition` and `Preconditions`) - [Steps reference](../reference/steps.md) - [Concepts: Plan → Execute separation](../about/concepts.md)