Assets/Cleanup-OpsLogs.ps1

<#
.SYNOPSIS
    Cleans up old Ops logs.
.DESCRIPTION
    Deletes transcript logs older than the retention window and trims the
    per-job NDJSON history files down to the same window. The history files are
    appended to on every run and are never rotated otherwise, so they grow
    without bound.
.PARAMETER DaysToKeep
    Number of days to keep logs. Default 30.
#>

param (
    [ValidateRange(0, 3650)]
    [int]$DaysToKeep = 30
)

$ErrorActionPreference = "Stop"

# The Ops root defaults to C:\Ops. OPS_ROOT overrides it for development and
# testing, matching the dashboard server's existing convention.
$OpsRoot = if ([string]::IsNullOrWhiteSpace($env:OPS_ROOT)) { "C:\Ops" } else { $env:OPS_ROOT }
$LogDir = Join-Path $OpsRoot "Logs"
if (-not (Test-Path -Path $LogDir)) {
    Write-Output "Log directory '$LogDir' does not exist. Nothing to do."
    return
}

$CutoffDate = (Get-Date).AddDays(-$DaysToKeep)

Write-Output "Cleaning up logs older than $CutoffDate in $LogDir"

# Only transcripts are deleted outright. The previous version deleted every file
# in the directory, which would have taken the job history with it.
Get-ChildItem -Path $LogDir -Filter "*.log" -File | Where-Object { $_.LastWriteTime -lt $CutoffDate } | ForEach-Object {
    try {
        Remove-Item -Path $_.FullName -Force -ErrorAction Stop
        Write-Output "Deleted: $($_.Name)"
    } catch {
        Write-Error "Failed to delete $($_.Name): $($_.Exception.Message)"
    }
}

# Trim history files in place, keeping entries inside the retention window.
Get-ChildItem -Path $LogDir -Filter "*.history.json" -File | ForEach-Object {
    $HistoryFile = $_
    try {
        $Kept = foreach ($Line in (Get-Content -Path $HistoryFile.FullName -ErrorAction Stop)) {
            if ([string]::IsNullOrWhiteSpace($Line)) { continue }
            $Entry = $null
            try { $Entry = $Line | ConvertFrom-Json } catch { }

            $EntryDate = [datetime]::MinValue
            if ($Entry -and $Entry.Timestamp) {
                # Written by Write-OpsLog / ops-utils.js in this exact shape.
                [void][datetime]::TryParseExact(
                    [string]$Entry.Timestamp,
                    "yyyy-MM-dd HH:mm:ss",
                    [cultureinfo]::InvariantCulture,
                    [System.Globalization.DateTimeStyles]::None,
                    [ref]$EntryDate)
            }

            # Unparseable lines are dropped along with expired ones rather than
            # accumulating forever.
            if ($EntryDate -ge $CutoffDate) { $Line }
        }

        $Kept = @($Kept)
        if ($Kept.Count -eq 0) {
            Remove-Item -Path $HistoryFile.FullName -Force -ErrorAction Stop
            Write-Output "Deleted empty history: $($HistoryFile.Name)"
        }
        else {
            Set-Content -Path $HistoryFile.FullName -Value $Kept -Encoding UTF8 -ErrorAction Stop
            Write-Output "Trimmed history: $($HistoryFile.Name) ($($Kept.Count) entries kept)"
        }
    }
    catch {
        Write-Error "Failed to trim $($HistoryFile.Name): $($_.Exception.Message)"
    }
}