Private/FSvc.Run.ps1

# Scheduled-run helpers: a lock file so overlapping runs cannot double-apply,
# and optional transcript logging.

function Enter-FSvcRunLock {
    param([string]$Path, [int]$StaleMinutes = 240)
    for ($attempt = 0; $attempt -lt 2; $attempt++) {
        try {
            return [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
        } catch {
            try {
                $age = (Get-Date) - (Get-Item -LiteralPath $Path -ErrorAction Stop).LastWriteTime
                if ($age.TotalMinutes -ge $StaleMinutes) {
                    Remove-Item -LiteralPath $Path -Force -ErrorAction Stop -WhatIf:$false
                    continue
                }
            } catch { }
            return $null
        }
    }
    return $null
}

function Exit-FSvcRunLock {
    param([AllowNull()]$Handle, [string]$Path)
    if ($null -ne $Handle) { try { $Handle.Close() } catch { } }
    try { if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force -WhatIf:$false } } catch { }
}

function Start-FSvcLogging {
    param([string]$Path)
    if (-not $Path) { return $false }
    try {
        Start-Transcript -Path $Path -Append | Out-Null
        return $true
    } catch {
        Write-Warning ("Could not start transcript at {0}: {1}" -f $Path, $_.Exception.Message)
        return $false
    }
}

function Stop-FSvcLogging {
    param([bool]$Active)
    if ($Active) { try { Stop-Transcript | Out-Null } catch { } }
}

# Runs a set of ticket changes as one guarded batch: acquires the run lock,
# optionally starts a transcript, asks the injected decision scriptblock (which
# the caller binds to $PSCmdlet.ShouldProcess) per change, applies approved
# changes, tags each with Applied, and always releases lock/logging.
#
# -Change : objects with at least Id, Field, From, To
# -Apply : scriptblock run per approved change, receives the change
# -Should : scriptblock (Target, Action) -> [bool]; defaults to always apply
function Invoke-FSvcChangeSet {
    param(
        [AllowEmptyCollection()][object[]]$Change,
        [Parameter(Mandatory)][scriptblock]$Apply,
        [scriptblock]$Should = { param($Target, $Action) $true },
        [hashtable]$Config,
        [string]$LockName = 'fsvc-change-set',
        [string]$LogPath
    )

    if (-not $Change -or $Change.Count -eq 0) { return }

    $lockPath = Join-Path ([System.IO.Path]::GetTempPath()) ($LockName + '.lock')
    $lock = Enter-FSvcRunLock -Path $lockPath
    if ($null -eq $lock) {
        throw "Another fsvc run is in progress (lock: $lockPath). Delete it if stale."
    }
    $logging = Start-FSvcLogging -Path $LogPath
    try {
        foreach ($c in $Change) {
            $applied = $false
            $target = "ticket {0}" -f $c.Id
            $action = "set {0} to {1}" -f $c.Field, $c.To
            if (& $Should $target $action) {
                $null = & $Apply $c
                $applied = $true
            }
            $c | Add-Member -NotePropertyName Applied -NotePropertyValue $applied -Force
            $c
        }
    } finally {
        Stop-FSvcLogging -Active $logging
        Exit-FSvcRunLock -Handle $lock -Path $lockPath
    }
}