private/progress/Update-ZtProgressState.ps1

function Update-ZtProgressState {
    <#
    .SYNOPSIS
        Updates the shared progress state used by the web-based progress dashboard.
 
    .DESCRIPTION
        Writes structured progress data to the process-wide PSFDynamicContentObject dictionary.
        This function is safe to call from any thread (main or worker runspaces) because the
        underlying ConcurrentDictionary is thread-safe.
 
        The progress state is read by the HTTP server (Start-ZtProgressServer) and served
        as JSON to the browser-based dashboard.
 
    .PARAMETER Stage
        The current top-level stage identifier (e.g. 'export', 'database', 'tests', 'tenantinfo', 'results', 'html', 'done').
 
    .PARAMETER StageNumber
        The numeric stage number (1-6) for display.
 
    .PARAMETER StageName
        Human-readable stage name (e.g. 'Exporting Tenant Data').
 
    .PARAMETER TotalStages
        Total number of stages. Defaults to 6.
 
    .PARAMETER TotalItems
        Total number of work items in the current stage.
 
    .PARAMETER CompletedItems
        Number of completed work items in the current stage.
 
    .PARAMETER FailedItems
        Number of failed work items in the current stage.
 
    .PARAMETER InProgressItems
        Number of work items currently in progress.
 
    .PARAMETER WorkerId
        Unique identifier for a worker (test ID or export name).
 
    .PARAMETER WorkerName
        Display name for the worker.
 
    .PARAMETER WorkerStatus
        Status of the worker: Pending, Waiting, Running, Done, Failed, TimedOut.
 
    .PARAMETER WorkerDetail
        The current detail/step the worker is executing. This is shown as the 'flashing' detail line.
 
    .PARAMETER ClearWorkers
        If specified, removes all worker entries from the progress state. Used when transitioning between stages.
 
    .EXAMPLE
        PS C:\> Update-ZtProgressState -Stage 'export' -StageNumber 1 -StageName 'Exporting Tenant Data' -TotalItems 30
 
        Updates the top-level stage to 'export' with 30 total items.
 
    .EXAMPLE
        PS C:\> Update-ZtProgressState -WorkerId '21770' -WorkerName 'MFA Registration Policy' -WorkerStatus 'Running' -WorkerDetail 'Querying policies...'
 
        Updates the progress for a specific worker.
    #>

    [CmdletBinding()]
    param (
        [string]$Stage,
        [int]$StageNumber,
        [string]$StageName,
        [int]$TotalStages = 6,
        [int]$TotalItems,
        [int]$CompletedItems,
        [int]$FailedItems,
        [int]$InProgressItems,

        [string]$WorkerId,
        [string]$WorkerName,
        [string]$WorkerStatus,
        [string]$WorkerDetail,

        [switch]$ClearWorkers
    )
    process {
        $state = $script:__ZtSession.ProgressState

        if ($Stage) {
            $state.Value['_stage'] = $Stage
            $state.Value['_stageNumber'] = $StageNumber
            $state.Value['_stageName'] = $StageName
            $state.Value['_totalStages'] = $TotalStages

            # Only set _startedAt on the first stage transition so the dashboard
            # elapsed timer reflects total assessment time, not per-stage time.
            if (-not $state.Value.ContainsKey('_startedAt')) {
                $state.Value['_startedAt'] = (Get-Date).ToString('o')
            }

            # Write the full stage definitions so the dashboard can render all stages.
            # Stored as a JSON string (not hashtable array) for reliable cross-runspace access.
            # The server runspace parses this with ConvertFrom-Json to get PSCustomObjects with
            # reliable property access, avoiding the issue where hashtable .property returns $null
            # in isolated runspaces.
            $state.Value['_stageDefinitions'] = '[{"number":1,"name":"Exporting Tenant Data"},{"number":2,"name":"Running Tests"},{"number":3,"name":"Adding Tenant Information"},{"number":4,"name":"Generating Test Results"},{"number":5,"name":"Writing Report Data"},{"number":6,"name":"Generating HTML Report"}]'
        }

        if ($PSBoundParameters.ContainsKey('TotalItems')) {
            $state.Value['_totalItems'] = $TotalItems
        }
        if ($PSBoundParameters.ContainsKey('CompletedItems')) {
            $state.Value['_completedItems'] = $CompletedItems
        }
        if ($PSBoundParameters.ContainsKey('FailedItems')) {
            $state.Value['_failedItems'] = $FailedItems
        }
        if ($PSBoundParameters.ContainsKey('InProgressItems')) {
            $state.Value['_inProgressItems'] = $InProgressItems
        }

        if ($ClearWorkers) {
            $workerKeys = @($state.Value.Keys | Where-Object { $_ -like 'worker_*' -or $_ -like 'rs_*' })
            foreach ($key in $workerKeys) {
                $null = $state.Value.TryRemove($key, [ref]$null)
            }
        }

        if ($WorkerId) {
            $workerKey = "worker_$WorkerId"
            $now = (Get-Date).ToString('o')

            # Preserve StartedAt from the existing worker entry if it exists
            $startedAt = $now
            $existingWorker = $null
            if ($state.Value.TryGetValue($workerKey, [ref]$existingWorker) -and $existingWorker.StartedAt) {
                $startedAt = $existingWorker.StartedAt
            }

            $state.Value[$workerKey] = [PSCustomObject]@{
                Id        = $WorkerId
                Name      = $WorkerName
                Status    = $WorkerStatus
                Detail    = $WorkerDetail
                StartedAt = $startedAt
                UpdatedAt = $now
            }
        }
    }
}