Add elapsed run time, per-account results CSV, and a default config path

Summaries now show elapsed wall-clock time since the run started, and every
summary (interim and final) overwrites a results.csv (Object ID, UPN,
persona/status) next to the audit log, so an operator has a plain export
without parsing NDJSON. ConfigPath also now defaults to
./config/persona-engine.json instead of requiring -ConfigPath every run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 22:10:24 -04:00
parent 8c8fd47f74
commit 5f125c34f2
14 changed files with 154 additions and 13 deletions
@@ -0,0 +1,49 @@
function Export-PersonaResultsCsv {
<#
.SYNOPSIS
Writes the per-account results CSV (object ID, UPN, persona/status).
.DESCRIPTION
Called after every summary, interim and final, and overwrites the file each
time. Counters.Results accumulates for the whole run, so the file on disk
always lists every account processed so far, not just those since the last
summary.
Export failure never ends the run, matching Write-PersonaAuditRecord's
failure handling for the same reason: a locked file or a full disk is an
operational problem with the export, not a reason to abandon a
classification run mid-population.
.PARAMETER Counters
The run counter set.
.PARAMETER Path
Destination CSV file. The parent directory is created if it does not exist.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Counters,
[Parameter(Mandatory)]
[string] $Path
)
if ($Counters.Results.Count -eq 0) { return }
try {
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
}
# -WhatIf:$false / -Confirm:$false pin this write regardless of any ambient
# $WhatIfPreference in the caller's session, the same reason
# Write-PersonaAuditRecord pins its own sink writes: this is a report, not a
# directory mutation, and must not silently no-op under -WhatIf.
$Counters.Results | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8NoBOM -Force -WhatIf:$false -Confirm:$false
}
catch {
Write-Warning "Results CSV export failed; the run continues without it: $($_.Exception.Message)"
}
}
@@ -24,6 +24,11 @@ function New-PersonaRunCounter {
makes a zero-match rule distinguishable from an absent one - an operator
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
Results accumulates one row per processed account (AccountObjectId,
UserPrincipalName, persona/status) for Export-PersonaResultsCsv. It grows
for the life of the run, not just since the last summary, so the CSV a
summary writes always reflects every account processed so far.
.PARAMETER Rules
The business rule collection, used to seed RuleCounts.
@@ -67,6 +72,7 @@ function New-PersonaRunCounter {
Skipped = 0
RuleCounts = $ruleCounts
Results = [System.Collections.Generic.List[object]]::new()
}
}
@@ -117,4 +123,12 @@ function Add-PersonaRunResult {
'UpdateFailed' { $Counters.UpdateFailed++ }
'Skipped' { $Counters.Skipped++ }
}
# Matched carries the assigned persona; Unclassified/EvaluationError carry
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
$Counters.Results.Add([pscustomobject]@{
AccountObjectId = [string]$Result.AccountObjectId
UserPrincipalName = [string]$Result.UserPrincipalName
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
})
}
+18 -2
View File
@@ -27,6 +27,11 @@ function Write-PersonaSummary {
.PARAMETER Mode
Preview or Enforce, shown in the header so a screenshot of a summary is
self-describing.
.PARAMETER StartedUtc
Run start timestamp. When supplied, the header shows elapsed wall-clock
time since the run began. Optional so callers that only care about counts
are not forced to thread a clock through.
#>
[CmdletBinding()]
param(
@@ -37,14 +42,25 @@ function Write-PersonaSummary {
[string] $SummaryType = 'Interim',
[ValidateSet('Preview', 'Enforce')]
[string] $Mode = 'Preview'
[string] $Mode = 'Preview',
[Nullable[datetime]] $StartedUtc
)
$reconciled = Test-PersonaReconciliation -Counters $Counters
# $StartedUtc arrives here already unwrapped to a plain [datetime] - PowerShell
# collapses [Nullable[datetime]] to DateTime (or $null) at the call boundary, so
# a null check is used rather than .Value / .HasValue.
$elapsed = ($null -ne $StartedUtc) ? ('{0:hh\:mm\:ss}' -f ([DateTime]::UtcNow - $StartedUtc)) : $null
Write-Host ''
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed) -ForegroundColor Cyan
Write-Host (
$elapsed `
? ("{0} summary - mode: {1} - elapsed: {2} - processed: {3}" -f $SummaryType, $Mode, $elapsed, $Counters.Processed) `
: ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed)
) -ForegroundColor Cyan
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray