src/Engine/Tasks.ps1

# Tasks.ps1 — in-session background task registry (ThreadJob-based).
# Powers the UI's unobtrusive progress overlay (#25), the unmanaged-module
# scan (#26), background Update-Help (#35) and any user-initiated long work.
# No rendering here — the UI asks for summaries and draws them itself.

function Start-PSMMTask {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Label,
        [Parameter(Mandatory)][scriptblock]$ScriptBlock,
        [object[]]$ArgumentList,
        [string]$Kind = 'generic',   # lets the UI react to completion (install/updatecheck/scan/...)
        $Data                        # optional payload for the completion handler
    )
    if (-not $script:PSMM_Tasks) { $script:PSMM_Tasks = [System.Collections.Generic.List[object]]::new() }
    $script:PSMM_TaskSeq = [int]$script:PSMM_TaskSeq + 1
    $job = Start-ThreadJob -Name "psmm-task-$($script:PSMM_TaskSeq)" -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
    $task = [pscustomobject]@{
        Id        = $script:PSMM_TaskSeq
        Label     = $Label
        Kind      = $Kind
        Data      = $Data
        Job       = $job
        StartedAt = [datetime]::Now
        Output    = @()      # harvested lines (filled by Update-PSMMTask)
        Done      = $false
        Failed    = $false
        Seen      = $false   # UI sets this after showing the completion notice
    }
    $script:PSMM_Tasks.Add($task)
    $task
}

# Harvest state + new output from every task. Cheap; safe to call per poll.
function Update-PSMMTask {
    [CmdletBinding()] param()
    foreach ($t in (Get-PSMMTask)) {
        if ($t.Done) { continue }
        $state = "$($t.Job.State)"
        $t.Output = @(Receive-Job -Job $t.Job -Keep -ErrorAction SilentlyContinue)
        if ($state -notin 'NotStarted', 'Running') {
            $t.Done = $true
            $t.Failed = ($state -ne 'Completed')
        }
    }
}

function Get-PSMMTask {
    [CmdletBinding()] param()
    if ($script:PSMM_Tasks) { @($script:PSMM_Tasks) } else { @() }
}

# Remove finished tasks from the registry (and their jobs).
function Clear-PSMMTask {
    [CmdletBinding()] param()
    if (-not $script:PSMM_Tasks) { return }
    foreach ($t in @($script:PSMM_Tasks | Where-Object Done)) {
        Remove-Job -Job $t.Job -Force -ErrorAction SilentlyContinue
        $null = $script:PSMM_Tasks.Remove($t)
    }
}

# A cheap change-fingerprint: the UI's key loop polls this and re-renders
# when it changes (task started/finished/produced output).
function Get-PSMMTaskFingerprint {
    [CmdletBinding()] param()
    $parts = foreach ($t in (Get-PSMMTask)) { "$($t.Id):$($t.Job.State):$($t.Output.Count)" }
    $job = Get-PSMMStartupJob
    if ($job) {
        $c = 0; try { $c = @(Receive-Job -Job $job -Keep -ErrorAction SilentlyContinue).Count } catch { }
        $parts = @($parts) + "startup:$($job.State):$c"
    }
    $parts -join '|'
}

# One-line summary for the grid's side overlay; $null when nothing to show.
function Get-PSMMTaskSummary {
    [CmdletBinding()] param()
    Update-PSMMTask
    $running = @(Get-PSMMTask | Where-Object { -not $_.Done })
    $fresh   = @(Get-PSMMTask | Where-Object { $_.Done -and -not $_.Seen })
    if (-not $running.Count -and -not $fresh.Count) { return $null }
    $bits = @()
    foreach ($t in $running) {
        $n = $t.Output.Count
        $bits += if ($n) { "$($t.Label) ($n)" } else { $t.Label }
    }
    foreach ($t in $fresh) {
        $bits += if ($t.Failed) { "$($t.Label) FAILED" } else { "$($t.Label) done" }
    }
    [pscustomobject]@{
        RunningCount = $running.Count
        Text         = ($bits -join ' | ')
    }
}