src/private/Write-AitherExecutionRecord.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Persist a playbook execution record, including per-step results.
 
.DESCRIPTION
    THE single writer for the execution-history store. Before this existed, three
    consumers read that store and NOTHING wrote it:
 
      Get-AitherOrchestrationStatus -> library/execution-history
      Stop-AitherOrchestration -> library/execution-history
      Get-AitherExecutionHistory -> library/reports/execution-history
 
    Neither directory existed. Every consumer fails SOFT to an empty result, which
    reads as "no executions have run" rather than "the store was never written" --
    so Resume-AitherOrchestration threw "No orchestration found" on every call and
    the real cause was invisible. Per-step Results were built in memory by
    Invoke-AitherPlaybook and discarded on return.
 
    Resume cannot work without this: knowing WHICH steps completed is the whole
    input to "don't re-run them".
 
    Records are written atomically (temp + move) because a resume reading a
    half-written record would skip steps that never ran -- a worse failure than
    not resuming at all.
 
.PARAMETER ExecutionId
    Unique id for this execution. Becomes the file name.
 
.PARAMETER PlaybookName
    Playbook this execution belongs to.
 
.PARAMETER Status
    Running | Completed | Failed | Stopped.
 
.PARAMETER Results
    Per-step result objects. Each should carry Index, Script, Success, Error.
 
.PARAMETER StartTime
    When the execution began.
 
.PARAMETER Variables
    Variables the playbook was invoked with. Values matching a credential-shaped
    name are replaced with '<withheld>' -- an execution record is a file on disk
    and must never become a secret sink.
 
.OUTPUTS
    System.String. Full path of the record written.
 
.NOTES
    Category: Orchestration
    Platform: Windows, Linux, macOS
#>

function Write-AitherExecutionRecord {
    [OutputType([string])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$ExecutionId,

        [Parameter(Mandatory)]
        [string]$PlaybookName,

        [ValidateSet('Running', 'Completed', 'Failed', 'Stopped')]
        [string]$Status = 'Running',

        [array]$Results = @(),

        [datetime]$StartTime = (Get-Date),

        [hashtable]$Variables = @{}
    )

    # Same withholding rule the quadlet generator uses: match on the NAME, never
    # attempt to detect a secret by the shape of its value.
    $secretRe = '(?i)(secret|password|passwd|token|api[-_]?key|bearer|credential|private[-_]?key)'
    $notSecretRe = '(?i)(_path$|_ttl$|_file$|_url$|_enabled$|_timeout$)'

    $safeVars = @{}
    foreach ($k in $Variables.Keys) {
        if ($k -match $secretRe -and $k -notmatch $notSecretRe) {
            $safeVars[$k] = '<withheld>'
        }
        else {
            $safeVars[$k] = $Variables[$k]
        }
    }

    $moduleRoot = Get-AitherModuleRoot
    $historyPath = Join-Path $moduleRoot 'library' 'reports' 'execution-history'

    if (-not (Test-Path $historyPath)) {
        New-Item -Path $historyPath -ItemType Directory -Force | Out-Null
    }

    $record = [ordered]@{
        ExecutionId  = $ExecutionId
        PlaybookName = $PlaybookName
        Status       = $Status
        StartTime    = $StartTime.ToString('o')
        EndTime      = (Get-Date).ToString('o')
        Variables    = $safeVars
        Total        = @($Results).Count
        Completed    = @($Results | Where-Object { $_.Success }).Count
        Failed       = @($Results | Where-Object { -not $_.Success }).Count
        Results      = @($Results | ForEach-Object {
                [ordered]@{
                    Index   = $_.Index
                    Script  = $_.Script
                    Success = [bool]$_.Success
                    Error   = if ($_.Error) { "$($_.Error)" } else { $null }
                }
            })
    }

    $target = Join-Path $historyPath "$ExecutionId.json"

    if (-not $PSCmdlet.ShouldProcess($target, 'Write execution record')) {
        return $target
    }

    # Atomic: a resume that reads a torn record would skip steps that never ran.
    $tmp = "$target.tmp"
    $record | ConvertTo-Json -Depth 10 | Set-Content -Path $tmp -Encoding utf8 -Force
    Move-Item -Path $tmp -Destination $target -Force

    return $target
}