src/qrun/process-state.ps1
|
#requires -version 7.0 function Get-ProcessReservedKeys { return (Get-MapLanguage build).reservedKeys } function Get-ProcessStepNames { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$ProcessEntry ) $reservedKeys = Get-ProcessReservedKeys if ($ProcessEntry.exec -is [array] -and $ProcessEntry.exec.Count -gt 0) { return @($ProcessEntry.exec) } $names = [System.Collections.Generic.List[string]]::new() foreach ($kvp in $ProcessEntry.GetEnumerator()) { if ($kvp.Key -in $reservedKeys) { continue } if ($kvp.Value -is [scriptblock]) { $names.Add([string]$kvp.Key) continue } if ($kvp.Value -is [System.Collections.IDictionary] -and (Get-EntryHasExec $kvp.Value)) { $names.Add([string]$kvp.Key) } } return @($names) } function Get-ProcessStepEntry { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$ProcessEntry, [Parameter(Mandatory = $true)] [string]$StepName ) if ($ProcessEntry.Keys -notcontains $StepName) { throw "Step '$StepName' not found in process" } return $ProcessEntry[$StepName] } function Resolve-ProcessTarget { <# .SYNOPSIS Resolve a qrun entry path to a process and optional implied step. Supports qbuild-style paths: process, process.step, process.all #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [Parameter(Mandatory = $true)] [string]$Entry, $OperationContext ) $entryKey = $Entry $impliedStep = $null if ($entryKey -match '^(.*)\.all$') { $entryKey = $Matches[1] } $resolved = Get-MapEntry $Map $entryKey -language "build" -OperationContext $OperationContext if ($null -eq $resolved) { throw "Process '$Entry' not found. Run 'qrun list' to see available processes." } $stepNames = @() if ($resolved -is [System.Collections.IDictionary]) { $stepNames = @(Get-ProcessStepNames $resolved) } if ($stepNames.Count -gt 0) { return [pscustomobject]@{ ProcessKey = $entryKey ProcessEntry = $resolved StepNames = $stepNames ImpliedStep = $null } } # Leaf path like process.step — walk up to the parent process if ($entryKey -notmatch '\.') { if ($resolved -is [scriptblock]) { throw "Entry '$Entry' is a single scriptblock. Define an ordered hashtable of steps for qrun." } throw "Process '$Entry' has no steps." } $lastDot = $entryKey.LastIndexOf('.') $processKey = $entryKey.Substring(0, $lastDot) $impliedStep = $entryKey.Substring($lastDot + 1) $processEntry = Get-MapEntry $Map $processKey -language "build" -OperationContext $OperationContext if ($null -eq $processEntry -or $processEntry -isnot [System.Collections.IDictionary]) { throw "Process '$Entry' not found. Run 'qrun list' to see available processes." } $stepNames = @(Get-ProcessStepNames $processEntry) if ($impliedStep -notin $stepNames) { throw "Step '$impliedStep' not found in process '$processKey'. Available steps: $($stepNames -join ', ')" } return [pscustomobject]@{ ProcessKey = $processKey ProcessEntry = $processEntry StepNames = $stepNames ImpliedStep = $impliedStep } } function Get-ProcessStateRoot { param( [string]$BaseDir = (Get-Location).Path, [string]$MapFile ) $stateRoot = Join-Path $BaseDir '.configmap' 'process' if ($MapFile) { $mapLeaf = [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Leaf $MapFile)) $safeMap = ($mapLeaf -replace '[<>:"/\\|?*]', '_').Trim() if ($safeMap) { $stateRoot = Join-Path $stateRoot $safeMap } } return $stateRoot } function Get-ProcessStatePath { param( [Parameter(Mandatory = $true)] [string]$ProcessKey, [string]$BaseDir = (Get-Location).Path, [string]$MapFile ) $safeKey = ($ProcessKey -replace '[<>:"/\\|?*]', '_').Trim() if ([string]::IsNullOrWhiteSpace($safeKey)) { $safeKey = 'process' } $stateRoot = Get-ProcessStateRoot -BaseDir $BaseDir -MapFile $MapFile return Join-Path $stateRoot "$safeKey.state.json" } function Get-ProcessStateFiles { param( [string]$BaseDir = (Get-Location).Path, [string]$MapFile, [string]$ProcessKey ) $stateRoot = Get-ProcessStateRoot -BaseDir $BaseDir -MapFile $MapFile if (!(Test-Path -LiteralPath $stateRoot -PathType Container)) { return @() } if ($ProcessKey) { $path = Get-ProcessStatePath -ProcessKey $ProcessKey -BaseDir $BaseDir -MapFile $MapFile if (Test-Path -LiteralPath $path -PathType Leaf) { return @(Get-Item -LiteralPath $path) } return @() } return @(Get-ChildItem -LiteralPath $stateRoot -Filter '*.state.json' -File -ErrorAction SilentlyContinue | Sort-Object Name) } function Get-ProcessMapCatalog { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, $OperationContext, # When set, only entries with process=<name>. When IncludeImplicit, also # entries with no process key (standalone .process.map.ps1 via qrun). [string]$ProcessName, [switch]$IncludeImplicit ) $reservedKeys = Get-ProcessReservedKeys $catalog = [System.Collections.Generic.List[object]]::new() foreach ($kvp in $Map.GetEnumerator()) { $key = [string]$kvp.Key if ($key -in $reservedKeys) { continue } if ($key.StartsWith('_')) { continue } if ($kvp.Value -isnot [System.Collections.IDictionary]) { continue } if ($ProcessName) { $entryProcess = Get-EntryProcess $kvp.Value -Default '' $matchesExplicit = ($entryProcess -eq $ProcessName) $matchesImplicit = ($IncludeImplicit -and -not $entryProcess) if (-not $matchesExplicit -and -not $matchesImplicit) { continue } } try { $target = Resolve-ProcessTarget -Map $Map -Entry $key -OperationContext $OperationContext } catch { continue } if ($target.ImpliedStep -or $target.StepNames.Count -eq 0) { continue } $description = '' if ($target.ProcessEntry.description) { $description = [string]$target.ProcessEntry.description } $catalog.Add([pscustomobject]@{ ProcessKey = $target.ProcessKey ProcessEntry = $target.ProcessEntry StepNames = @($target.StepNames) Description = $description }) } return @($catalog) } function Invoke-ProcessStateCommand { param( [Parameter(Mandatory = $true)] [ValidateSet('show', 'clean')] [string]$Action, [string]$ProcessKey, [System.Collections.IDictionary]$Map, [string]$BaseDir = (Get-Location).Path, [string]$MapFile, $OperationContext, [string]$Title = 'QRUN', [string]$ProcessName, [switch]$IncludeImplicit ) if ($Action -eq 'clean') { $files = @(Get-ProcessStateFiles -BaseDir $BaseDir -MapFile $MapFile -ProcessKey $ProcessKey) if ($files.Count -eq 0) { $scope = if ($ProcessKey) { "for process '$ProcessKey'" } else { '' } Write-Host "No process state to clean $scope".Trim() -ForegroundColor DarkGray return } foreach ($file in $files) { Remove-Item -LiteralPath $file.FullName -Force Write-Host "Removed $($file.FullName)" -ForegroundColor Yellow } $stateRoot = Get-ProcessStateRoot -BaseDir $BaseDir -MapFile $MapFile if ((Test-Path -LiteralPath $stateRoot -PathType Container) -and -not (Get-ChildItem -LiteralPath $stateRoot -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $stateRoot -Force -ErrorAction SilentlyContinue } return } # show — catalog from the map (like list), markers from persisted state if (!$Map) { throw "No process map file found. Run 'qrun !init' to create one, or provide -map." } $catalogParams = @{ Map = $Map OperationContext = $OperationContext } if ($ProcessName) { $catalogParams.ProcessName = $ProcessName $catalogParams.IncludeImplicit = $IncludeImplicit } $catalog = @(Get-ProcessMapCatalog @catalogParams) if ($ProcessKey) { $catalog = @($catalog | Where-Object { $_.ProcessKey -eq $ProcessKey }) if ($catalog.Count -eq 0) { # allow process.step filter by resolving to parent process $target = Resolve-ProcessTarget -Map $Map -Entry $ProcessKey -OperationContext $OperationContext $catalog = @(Get-ProcessMapCatalog @catalogParams | Where-Object { $_.ProcessKey -eq $target.ProcessKey }) } if ($catalog.Count -eq 0) { throw "Process '$ProcessKey' not found. Run 'qrun list' to see available processes." } } if ($catalog.Count -eq 0) { Write-Host "" Write-Host $Title -ForegroundColor Cyan Write-Host "No processes found in the map." -ForegroundColor DarkGray Write-Host "" return } Write-Host "" Write-Host $Title -ForegroundColor Cyan Write-Host "Process state" -ForegroundColor Gray Write-Host "" Write-Host "PROCESSES:" -ForegroundColor Yellow foreach ($item in $catalog) { $statePath = Get-ProcessStatePath -ProcessKey $item.ProcessKey -BaseDir $BaseDir -MapFile $MapFile $state = Read-ProcessState -StatePath $statePath -ProcessKey $item.ProcessKey -StepNames $item.StepNames -MapFile $MapFile Write-ProcessStatus -ProcessKey $item.ProcessKey -StepNames $item.StepNames -State $state -Description $item.Description } } function New-ProcessState { param( [Parameter(Mandatory = $true)] [string]$ProcessKey, [string[]]$StepNames = @(), [string]$MapFile ) $steps = [ordered]@{} foreach ($name in $StepNames) { $steps[$name] = [ordered]@{ status = 'Pending' startedAt = $null finishedAt = $null error = $null } } return [ordered]@{ version = 1 process = $ProcessKey mapFile = $MapFile updatedAt = (Get-Date).ToString('o') steps = $steps } } function Read-ProcessState { param( [Parameter(Mandatory = $true)] [string]$StatePath, [Parameter(Mandatory = $true)] [string]$ProcessKey, [string[]]$StepNames = @(), [string]$MapFile ) if (!(Test-Path -LiteralPath $StatePath -PathType Leaf)) { return New-ProcessState -ProcessKey $ProcessKey -StepNames $StepNames -MapFile $MapFile } try { $raw = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { Write-Warning "Could not read process state '$StatePath'; starting fresh. $_" return New-ProcessState -ProcessKey $ProcessKey -StepNames $StepNames -MapFile $MapFile } $state = New-ProcessState -ProcessKey $ProcessKey -StepNames $StepNames -MapFile $MapFile if ($raw.steps) { foreach ($name in $StepNames) { $existing = $raw.steps.$name if ($null -eq $existing) { continue } $state.steps[$name] = [ordered]@{ status = if ($existing.status) { [string]$existing.status } else { 'Pending' } startedAt = $existing.startedAt finishedAt = $existing.finishedAt error = $existing.error } } } return $state } function Write-ProcessState { param( [Parameter(Mandatory = $true)] [string]$StatePath, [Parameter(Mandatory = $true)] $State ) $State.updatedAt = (Get-Date).ToString('o') $dir = Split-Path -Parent $StatePath if (!(Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null } $json = $State | ConvertTo-Json -Depth 8 Set-Content -LiteralPath $StatePath -Value $json -Encoding utf8 -ErrorAction Stop } function Reset-ProcessState { param( [Parameter(Mandatory = $true)] [string]$StatePath ) if (Test-Path -LiteralPath $StatePath -PathType Leaf) { Remove-Item -LiteralPath $StatePath -Force } } function Get-ProcessStepsToRun { param( [Parameter(Mandatory = $true)] [string[]]$StepNames, [string]$FromStep, [switch]$Follow ) if ($Follow -and -not $FromStep) { throw "-Follow requires a step path (e.g. qrun bootstrap.restore -Follow)" } if (-not $FromStep) { return @($StepNames) } if ($FromStep -notin $StepNames) { throw "Step '$FromStep' not found. Available steps: $($StepNames -join ', ')" } if ($Follow) { $idx = [array]::IndexOf($StepNames, $FromStep) return @($StepNames[$idx..($StepNames.Count - 1)]) } return @($FromStep) } function Assert-ProcessPriorSteps { param( [Parameter(Mandatory = $true)] [string[]]$StepNames, [Parameter(Mandatory = $true)] [string]$FromStep, [Parameter(Mandatory = $true)] $State, [switch]$Force ) if ($FromStep -notin $StepNames) { throw "Step '$FromStep' not found. Available steps: $($StepNames -join ', ')" } $idx = [array]::IndexOf($StepNames, $FromStep) if ($idx -le 0) { return @() } $prior = @($StepNames[0..($idx - 1)]) $incomplete = [System.Collections.Generic.List[string]]::new() foreach ($name in $prior) { $status = if ($State.steps[$name]) { [string]$State.steps[$name].status } else { 'Pending' } if ($status -ne 'Succeeded') { $incomplete.Add("$name ($status)") } } if ($incomplete.Count -eq 0) { return @() } if (-not $Force) { throw "Cannot start at '$FromStep' — prior steps are incomplete: $($incomplete -join ', '). Re-run from the beginning, or use -Force to skip them." } # Names of prior steps being skipped due to -Force $forcedSkip = [System.Collections.Generic.List[string]]::new() foreach ($name in $prior) { $status = if ($State.steps[$name]) { [string]$State.steps[$name].status } else { 'Pending' } if ($status -ne 'Succeeded') { $forcedSkip.Add($name) } } return @($forcedSkip) } function Get-ProcessDynamicParam { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [Parameter(Mandatory = $true)] [string]$ProcessKey, [switch]$Follow ) try { $target = Resolve-ProcessTarget -Map $Map -Entry $ProcessKey } catch { return New-Object System.Management.Automation.RuntimeDefinedParameterDictionary } $processEntry = $target.ProcessEntry $stepNames = @($target.StepNames) if ($stepNames.Count -eq 0) { return New-Object System.Management.Automation.RuntimeDefinedParameterDictionary } try { $selected = Get-ProcessStepsToRun -StepNames $stepNames -FromStep $target.ImpliedStep -Follow:$Follow } catch { $selected = $stepNames } $merged = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary foreach ($stepName in $selected) { $stepEntry = Get-ProcessStepEntry -ProcessEntry $processEntry -StepName $stepName try { $command = Get-EntryCommand $stepEntry "exec" } catch { continue } if ($command -isnot [scriptblock]) { continue } $entryContext = if ($stepEntry -is [System.Collections.IDictionary]) { $stepEntry } else { $null } $stepParams = Get-ScriptArgs $command -entry $entryContext foreach ($paramName in $stepParams.Keys) { if (-not $merged.ContainsKey($paramName)) { $merged.Add($paramName, $stepParams[$paramName]) } } } return $merged } function Get-ProcessStatusMarker { param([string]$Status) switch ($Status) { 'Succeeded' { return @{ Glyph = [char]0x2713; Color = 'Green' } } # ✓ 'Failed' { return @{ Glyph = [char]0x2717; Color = 'Red' } } # ✗ 'Running' { return @{ Glyph = [char]0x25CF; Color = 'Yellow' } } # ● 'Skipped' { return @{ Glyph = [char]0x2013; Color = 'DarkCyan' } } # – default { return @{ Glyph = [char]0x25CB; Color = 'DarkGray' } } # ○ } } function Write-ProcessStatus { param( [Parameter(Mandatory = $true)] [string]$ProcessKey, [Parameter(Mandatory = $true)] [string[]]$StepNames, [Parameter(Mandatory = $true)] $State, [string]$Description ) Write-Host "" Write-Host " " -NoNewline Write-Host $ProcessKey -NoNewline -ForegroundColor Green if ($Description) { Write-Host " $Description" -ForegroundColor White } else { Write-Host "" } $count = $StepNames.Count for ($i = 0; $i -lt $count; $i++) { $name = $StepNames[$i] $isLast = ($i -eq $count - 1) $branch = if ($isLast) { "$([char]0x2514)$([char]0x2500) " } else { "$([char]0x251C)$([char]0x2500) " } # └── / ├── $pad = if ($isLast) { ' ' } else { "$([char]0x2502) " } # │ $stepState = $State.steps[$name] $status = if ($stepState) { [string]$stepState.status } else { 'Pending' } $marker = Get-ProcessStatusMarker $status Write-Host " " -NoNewline Write-Host $branch -NoNewline -ForegroundColor DarkGray Write-Host "$($marker.Glyph) " -NoNewline -ForegroundColor $marker.Color Write-Host $name -ForegroundColor White if ($status -eq 'Failed' -and $stepState.error) { Write-Host " " -NoNewline Write-Host $pad -NoNewline -ForegroundColor DarkGray Write-Host $stepState.error -ForegroundColor DarkRed } } Write-Host "" } |