private/StateSnapshot.ps1

# FR-006: export prior state before mutating; research.md -> reg.exe export/import for
# registry (atomic, multi-value-safe), plain JSON-friendly objects for services/tasks.

function ConvertTo-OctaRegExePath {
    param([Parameter(Mandatory)][string]$PsPath)
    # HKLM:\SOFTWARE\Foo -> HKLM\SOFTWARE\Foo (reg.exe does not use the PSDrive colon form)
    return ($PsPath -replace '^([A-Za-z]+):\\', '$1\')
}

function Save-OctaRegistrySnapshot {
    param(
        [Parameter(Mandatory)][string]$RegistryPath,
        [Parameter(Mandatory)][string]$DestinationFile
    )
    if (-not (Test-Path $RegistryPath)) {
        return $false
    }
    $regExePath = ConvertTo-OctaRegExePath -PsPath $RegistryPath
    & reg.exe export $regExePath $DestinationFile /y | Out-Null
    return $true
}

function Restore-OctaRegistrySnapshot {
    <#
        reg.exe writes a benign "some keys were open" warning to stderr - and returns a
        non-zero exit code - even when the value was, in fact, written correctly (observed:
        HKCU keys the shell/Explorer also has open). Neither reg.exe's stderr text nor its
        exit code reliably distinguishes that from a genuine failure here, so this function
        redirects stderr (so the benign warning doesn't print as an alarming "ERROR" line) but
        does NOT treat a non-zero exit code as failure on its own. If reg.exe genuinely cannot
        run (missing file, bad path), that surfaces as a real PowerShell exception instead.
    #>

    param([Parameter(Mandatory)][string]$SnapshotFile)
    if (Test-Path $SnapshotFile) {
        # Stream redirection alone (2>$null, *>$null) does NOT stop $ErrorActionPreference =
        # 'Stop' (e.g. GitHub Actions' default for `shell: powershell` steps) from turning
        # reg.exe's benign stderr warning into a terminating NativeCommandError - redirection
        # only decides where content goes *if* execution isn't stopped first. try/catch is the
        # only reliable way to swallow it, verified real (before/after registry state) to be a
        # false failure signal, not an actual one.
        try {
            & reg.exe import $SnapshotFile 2>$null 1>$null
        }
        catch {
            # Intentionally ignored - see above.
        }
    }
}

function Save-OctaServiceState {
    param([Parameter(Mandatory)][string]$ServiceName)
    $wmiSvc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$ServiceName'" -ErrorAction SilentlyContinue
    if (-not $wmiSvc) { return $null }
    return [pscustomobject]@{ Type = 'Service'; Name = $ServiceName; PriorStartMode = $wmiSvc.StartMode }
}

function Restore-OctaServiceState {
    param([Parameter(Mandatory)][object]$State)
    $startupTypeMap = @{ 'Auto' = 'Automatic'; 'Manual' = 'Manual'; 'Disabled' = 'Disabled' }
    $mapped = $startupTypeMap[$State.PriorStartMode]
    if ($mapped) {
        Set-Service -Name $State.Name -StartupType $mapped -ErrorAction SilentlyContinue
    }
}

function Save-OctaScheduledTaskState {
    param(
        [Parameter(Mandatory)][string]$TaskPath,
        [Parameter(Mandatory)][string]$TaskName
    )
    $task = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue
    if (-not $task) { return $null }
    return [pscustomobject]@{ Type = 'ScheduledTask'; TaskPath = $TaskPath; TaskName = $TaskName; PriorState = $task.State.ToString() }
}

function Restore-OctaScheduledTaskState {
    param([Parameter(Mandatory)][object]$State)
    if ($State.PriorState -eq 'Disabled') {
        Disable-ScheduledTask -TaskPath $State.TaskPath -TaskName $State.TaskName -ErrorAction SilentlyContinue | Out-Null
    }
    else {
        Enable-ScheduledTask -TaskPath $State.TaskPath -TaskName $State.TaskName -ErrorAction SilentlyContinue | Out-Null
    }
}

function Save-OctaActionSnapshot {
    <#
        Dispatches to the right snapshot function for an Action (data-model.md), writing any
        file-based snapshot (registry .reg export) into $RunFolder. Returns a snapshot record
        suitable for Run Record's ActionSnapshots list, or $null for irreversible actions.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Action,
        [Parameter(Mandatory)][string]$RunFolder
    )

    if (-not $Action.Reversible) {
        return $null
    }

    switch ($Action.TargetType) {
        'Registry' {
            # TargetIdentifier is "RegistryKeyPath|ValueName" - only the key path is exportable.
            $keyPath = ($Action.TargetIdentifier -split '\|', 2)[0]
            $fileName = "reg-{0:N}.reg" -f [guid]::NewGuid()
            $destination = Join-Path $RunFolder $fileName
            if (Save-OctaRegistrySnapshot -RegistryPath $keyPath -DestinationFile $destination) {
                return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'Registry'; SnapshotFile = $fileName }
            }
            return $null
        }
        'Service' {
            $state = Save-OctaServiceState -ServiceName $Action.TargetIdentifier
            return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'Service'; State = $state }
        }
        'ScheduledTask' {
            $parts = $Action.TargetIdentifier -split '\|', 2
            $state = Save-OctaScheduledTaskState -TaskPath $parts[0] -TaskName $parts[1]
            return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'ScheduledTask'; State = $state }
        }
        default {
            return $null
        }
    }
}

function Restore-OctaActionSnapshot {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Snapshot,
        [Parameter(Mandatory)][string]$RunFolder
    )

    switch ($Snapshot.Type) {
        'Registry' {
            Restore-OctaRegistrySnapshot -SnapshotFile (Join-Path $RunFolder $Snapshot.SnapshotFile)
        }
        'Service' {
            if ($Snapshot.State) { Restore-OctaServiceState -State $Snapshot.State }
        }
        'ScheduledTask' {
            if ($Snapshot.State) { Restore-OctaScheduledTaskState -State $Snapshot.State }
        }
    }
}