Payload/scripts/kaden/utils/tail-journal.ps1
|
<# .SYNOPSIS Tail the journal/events.jsonl for a harness feature. .DESCRIPTION Reads the most recent events from docs/harness/features/<Feature>/journal/events.jsonl and prints them with aligned columns and CliKit coloring. Gaps between events in the same slice that exceed a threshold are highlighted in red so long-running work is easy to spot. Reviewer subagents, orchestrator, and human gates are exempt from the gap highlight because those rows represent waiting time, not processing time. With -Wait, the screen stays on the last TailSize events and redraws when the journal changes. A clock under the rows shows how long ago the latest event occurred, and checks for new events once a second. .PARAMETER Feature Feature slug. The directory docs/harness/features/<Feature> must exist. .PARAMETER TailSize Number of recent events to keep on screen. Default: 50. .PARAMETER LogLevel compact or detail. Default: compact. .PARAMETER Wait Keep redrawing the last TailSize events as the journal changes, and show how long ago the latest event occurred. .PARAMETER ThresholdMinutes Gap threshold for red time highlighting. Default: 5. .PARAMETER FeatureRoot Optional override for the directory that contains feature folders. Defaults to docs/harness/features under the repository root. .PARAMETER NoColor Print plain text without CliKit color tokens. .EXAMPLE pwsh -NoProfile -File .\scripts\kaden\utils\tail-journal.ps1 -Feature host-surface-consistency .EXAMPLE pwsh -NoProfile -File .\scripts\kaden\utils\tail-journal.ps1 -Feature host-surface-consistency -Wait -LogLevel detail #> #Requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [string]$Feature, [ValidateRange(1, 10000)] [int]$TailSize = 50, [ValidateSet("compact", "detail")] [string]$LogLevel = "compact", [switch]$Wait, [ValidateRange(0, 1440)] [int]$ThresholdMinutes = 5, [string]$FeatureRoot = "", [switch]$NoColor ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit/PsCliUiKit.psd1" if (-not (Test-Path -LiteralPath $cliKitManifest)) { throw "PsCliUiKit manifest not found: $cliKitManifest" } Import-Module -Name $cliKitManifest -Force $script:Colorize = -not $NoColor.IsPresent $script:TailJournalIdleWidth = 0 $script:PausedAgents = @( "slice-reviewer" "spec-writer-reviewer" "test-design-reviewer" "atdd-developer-reviewer" "code-smell-detector" "security-assessor" "consistency-checker" "documentation-reviewer" "feature-orchestrator" ) $script:PausedKinds = @( "HumanGateDecided" ) function Resolve-TailJournalRepoRoot { $current = $PSScriptRoot while ($true) { $marker = Join-Path $current "scripts/kaden/harness-invoke.ps1" if (Test-Path -LiteralPath $marker) { return $current } $parent = Split-Path -Path $current -Parent if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) { return $null } $current = $parent } } function Get-TailJournalConsoleWidth { try { $width = $Host.UI.RawUI.WindowSize.Width if ($width -gt 0) { return [int]$width } } catch { [void]$_ } try { $width = $Host.UI.RawUI.BufferSize.Width if ($width -gt 0) { return [int]$width } } catch { [void]$_ } return 120 } function ConvertTo-TailJournalLocalTime { param( [Parameter(Mandatory = $true)] [string]$OccurredAt ) $utc = [System.DateTime]::Parse($OccurredAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) $local = [System.TimeZoneInfo]::ConvertTimeFromUtc($utc, [System.TimeZoneInfo]::Local) return $local } function Format-TailJournalTime { param( [Parameter(Mandatory = $true)] [System.DateTime]$LocalTime ) return $LocalTime.ToString("yyyy-MM-dd HH:mm:ss") } function Format-TailJournalKindLabel { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Kind ) if ([string]::IsNullOrEmpty($Kind)) { return "" } return [regex]::Replace($Kind, '(?<=[a-z])(?=[A-Z])', ' ') } function Format-TailJournalSinceLastEvent { param( [Parameter(Mandatory = $true)] [System.TimeSpan]$Elapsed ) if ($Elapsed.Ticks -lt 0 -or $Elapsed.TotalSeconds -lt 1) { return "0s since last event" } $totalSeconds = [int][Math]::Floor($Elapsed.TotalSeconds) $days = [int][Math]::Floor($totalSeconds / 86400) $hours = [int][Math]::Floor(($totalSeconds % 86400) / 3600) $minutes = [int][Math]::Floor(($totalSeconds % 3600) / 60) $seconds = $totalSeconds % 60 $parts = New-Object System.Collections.Generic.List[string] if ($days -gt 0) { [void]$parts.Add("${days}d") } if ($hours -gt 0) { [void]$parts.Add("${hours}h") } if ($minutes -gt 0) { [void]$parts.Add("${minutes}m") } if ($seconds -gt 0 -or $parts.Count -eq 0) { [void]$parts.Add("${seconds}s") } return (($parts -join " ") + " since last event") } function Get-TailJournalLastEventTime { param( [Parameter(Mandatory = $true)] $Entries ) $list = @($Entries) if ($list.Count -eq 0 -or $null -eq $list[-1]) { return $null } $occurred = Get-TailJournalEntryField -Entry $list[-1] -Path @("occurred_at") if ([string]::IsNullOrWhiteSpace($occurred)) { return $null } return ConvertTo-TailJournalLocalTime -OccurredAt $occurred } function Get-TailJournalOutcomeStyle { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Outcome ) switch ($Outcome) { "PASS" { return "success" } "FAIL" { return "error" } "BLOCKED" { return "error" } "SKIPPED" { return "warning" } default { return $null } } } function Get-TailJournalEntryField { # Journal events are schema-v1 but older/foreign writers omit optional blocks # (actor.model, refs, activity). StrictMode makes any missing hop throw, so # every optional read goes through this accessor and degrades to "". param( [Parameter(Mandatory = $true)] [object]$Entry, [Parameter(Mandatory = $true)] [string[]]$Path ) $current = $Entry foreach ($name in $Path) { if ($null -eq $current) { return "" } $property = $current.PSObject.Properties[$name] if ($null -eq $property) { return "" } $current = $property.Value } if ($null -eq $current) { return "" } return [string]$current } function Test-TailJournalPausedEvent { param( [Parameter(Mandatory = $true)] [object]$Entry ) if ($script:PausedKinds -contains (Get-TailJournalEntryField -Entry $Entry -Path @("kind"))) { return $true } $agent = Get-TailJournalEntryField -Entry $Entry -Path @("actor", "agent") if ($script:PausedAgents -contains $agent) { return $true } return $false } function Get-TailJournalCommandLeaf { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Command ) if ([string]::IsNullOrWhiteSpace($Command)) { return "" } $first = ($Command -split '\s+')[0] # Path.GetFileName keeps '\' on Linux. Journal commands are stored with either separator. $normalized = $first.Replace('\', '/') $slash = $normalized.LastIndexOf('/') if ($slash -ge 0 -and $slash -lt ($normalized.Length - 1)) { return $normalized.Substring($slash + 1) } return $first } function Get-TailJournalProviderSlug { param( [Parameter(Mandatory = $true)] [object]$Entry ) $provider = Get-TailJournalEntryField -Entry $Entry -Path @("actor", "model", "provider") $slug = Get-TailJournalEntryField -Entry $Entry -Path @("actor", "model", "slug") if (-not [string]::IsNullOrWhiteSpace($provider) -and -not [string]::IsNullOrWhiteSpace($slug)) { return "$provider`:$slug" } if (-not [string]::IsNullOrWhiteSpace($provider)) { return $provider } if (-not [string]::IsNullOrWhiteSpace($slug)) { return $slug } return "" } function Format-TailJournalSummary { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Summary, [Parameter(Mandatory = $true)] [int]$MaxLength ) if ($MaxLength -lt 3) { return "" } if ($Summary.Length -le $MaxLength) { return $Summary } return $Summary.Substring(0, $MaxLength - 3) + "..." } function Format-TailJournalCell { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Value, [Parameter(Mandatory = $true)] [int]$Width, [string]$Style = $null, [ValidateSet("left", "right")] [string]$Align = "left" ) $displayValue = if ($Value.Length -gt $Width) { $Value.Substring(0, $Width) } else { $Value } if ($Align -eq "left") { $padded = $displayValue.PadRight($Width) } else { $padded = $displayValue.PadLeft($Width) } if (-not $script:Colorize -or [string]::IsNullOrWhiteSpace($Style)) { return $padded } return "[$Style]$padded[-]" } function Format-TailJournalCompactRow { param( [Parameter(Mandatory = $true)] [object]$Entry, [Parameter(Mandatory = $true)] [hashtable]$Widths, [Parameter(Mandatory = $true)] [bool]$LongGap, [Parameter(Mandatory = $true)] [int]$ConsoleWidth ) $time = Format-TailJournalTime -LocalTime $Entry._localTime $slice = Get-TailJournalEntryField -Entry $Entry -Path @("slice") $kind = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $Entry -Path @("kind")) $outcome = Get-TailJournalEntryField -Entry $Entry -Path @("outcome") $command = Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $Entry -Path @("refs", "command")) $summary = Get-TailJournalEntryField -Entry $Entry -Path @("summary") $timeStyle = if ($LongGap) { "error" } else { $null } $outcomeStyle = Get-TailJournalOutcomeStyle -Outcome $outcome $used = $Widths.Time + $Widths.Slice + $Widths.Kind + $Widths.Outcome + $Widths.Command + 10 $summaryMax = [Math]::Max(20, $ConsoleWidth - $used) $summary = Format-TailJournalSummary -Summary $summary -MaxLength $summaryMax $parts = @( Format-TailJournalCell -Value $time -Width $Widths.Time -Style $timeStyle -Align "left" Format-TailJournalCell -Value $slice -Width $Widths.Slice -Style "muted" -Align "left" Format-TailJournalCell -Value $kind -Width $Widths.Kind -Style "info" -Align "left" Format-TailJournalCell -Value $outcome -Width $Widths.Outcome -Style $outcomeStyle -Align "left" Format-TailJournalCell -Value $command -Width $Widths.Command -Style $null -Align "left" Format-TailJournalCell -Value $summary -Width $summaryMax -Style "muted" -Align "left" ) return ($parts -join " ").TrimEnd() } function Format-TailJournalDetailRow { param( [Parameter(Mandatory = $true)] [object]$Entry, [Parameter(Mandatory = $true)] [hashtable]$Widths, [Parameter(Mandatory = $true)] [bool]$LongGap, [Parameter(Mandatory = $true)] [int]$ConsoleWidth ) $time = Format-TailJournalTime -LocalTime $Entry._localTime $slice = Get-TailJournalEntryField -Entry $Entry -Path @("slice") $kind = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $Entry -Path @("kind")) $activity = Get-TailJournalEntryField -Entry $Entry -Path @("activity", "name") $command = Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $Entry -Path @("refs", "command")) $outcome = Get-TailJournalEntryField -Entry $Entry -Path @("outcome") $model = Get-TailJournalProviderSlug -Entry $Entry $summary = Get-TailJournalEntryField -Entry $Entry -Path @("summary") $timeStyle = if ($LongGap) { "error" } else { $null } $outcomeStyle = Get-TailJournalOutcomeStyle -Outcome $outcome $used = $Widths.Time + $Widths.Slice + $Widths.Kind + $Widths.Activity + $Widths.Command + $Widths.Outcome + $Widths.Model + 14 $summaryMax = [Math]::Max(20, $ConsoleWidth - $used) $summary = Format-TailJournalSummary -Summary $summary -MaxLength $summaryMax $parts = @( Format-TailJournalCell -Value $time -Width $Widths.Time -Style $timeStyle -Align "left" Format-TailJournalCell -Value $slice -Width $Widths.Slice -Style "muted" -Align "left" Format-TailJournalCell -Value $kind -Width $Widths.Kind -Style "info" -Align "left" Format-TailJournalCell -Value $activity -Width $Widths.Activity -Style "muted" -Align "left" Format-TailJournalCell -Value $command -Width $Widths.Command -Style $null -Align "left" Format-TailJournalCell -Value $outcome -Width $Widths.Outcome -Style $outcomeStyle -Align "left" Format-TailJournalCell -Value $model -Width $Widths.Model -Style "muted" -Align "left" Format-TailJournalCell -Value $summary -Width $summaryMax -Style "muted" -Align "left" ) return ($parts -join " ").TrimEnd() } function Get-TailJournalFileSummary { # Single full-file pass so the header reports journal-wide numbers, not just the # slice of the journal the tail happened to load (matters for -Wait and small # -TailSize, where entries are a partial snapshot). param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { return [pscustomobject]@{ Events = 0; Slices = 0 } } $count = 0 $slices = [System.Collections.Generic.HashSet[string]]::new() foreach ($line in [System.IO.File]::ReadLines($Path)) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $count++ try { $parsed = $line | ConvertFrom-Json } catch { continue } $slice = Get-TailJournalEntryField -Entry $parsed -Path @("slice") if (-not [string]::IsNullOrWhiteSpace($slice)) { [void]$slices.Add($slice) } } return [pscustomobject]@{ Events = $count; Slices = $slices.Count } } function Format-TailJournalHeader { param( [Parameter(Mandatory = $true)] [string]$Feature, [Parameter(Mandatory = $true)] [object[]]$Entries, [Parameter(Mandatory = $true)] [int]$TotalEvents, [Parameter(Mandatory = $true)] [int]$TotalSlices ) $firstLocal = ConvertTo-TailJournalLocalTime -OccurredAt $Entries[0].occurred_at $lastLocal = ConvertTo-TailJournalLocalTime -OccurredAt $Entries[-1].occurred_at $invariant = [System.Globalization.CultureInfo]::InvariantCulture $first = $firstLocal.ToString("HH:mm", $invariant) $last = $lastLocal.ToString("HH:mm", $invariant) $arrow = Get-CliGlyph -Name Arrow $shown = $Entries.Count $range = "$first $arrow $last" if (-not $script:Colorize) { return "Feature: $Feature | Slices: $TotalSlices | Events: $shown of $TotalEvents | $range" } return "[muted]Feature:[-] [header]$Feature[-] [muted]| Slices:[-] [header]$TotalSlices[-] [muted]| Events:[-] [header]$shown[-] [muted]of[-] [header]$TotalEvents[-] [muted]| $range[-]" } function Format-TailJournalColumnHeader { param( [Parameter(Mandatory = $true)] [string]$Mode, [Parameter(Mandatory = $true)] [hashtable]$Widths ) $columns = Get-TailJournalColumnOrder -Mode $Mode $parts = @() foreach ($column in $columns) { $parts += Format-TailJournalCell -Value $column -Width $Widths[$column] -Style $null } return ($parts -join " ").TrimEnd() } function Format-TailJournalDivider { param( [Parameter(Mandatory = $true)] [string]$Mode, [Parameter(Mandatory = $true)] [hashtable]$Widths ) $columns = Get-TailJournalColumnOrder -Mode $Mode $char = if ($script:Colorize) { Get-CliGlyph -Name BoxHorizontal } else { "-" } $parts = @() foreach ($column in $columns) { $parts += $char * $Widths[$column] } return ($parts -join " ").TrimEnd() } function Get-TailJournalColumnOrder { param( [Parameter(Mandatory = $true)] [string]$Mode ) if ($Mode -eq "detail") { return @("Time", "Slice", "Kind", "Activity", "Command", "Outcome", "Model", "Summary") } return @("Time", "Slice", "Kind", "Outcome", "Command", "Summary") } function Get-TailJournalColumnWidth { param( [Parameter(Mandatory = $true)] [string]$Mode, [Parameter(Mandatory = $true)] [object[]]$Entries ) $columns = Get-TailJournalColumnOrder -Mode $Mode $widths = @{} foreach ($column in $columns) { $widths[$column] = $column.Length } $widths["Time"] = 19 $caps = if ($Mode -eq "detail") { @{ Slice = 18 Kind = 20 Activity = 16 Command = 20 Outcome = 8 Model = 18 } } else { @{ Slice = 24 Kind = 26 Outcome = 8 Command = 22 } } foreach ($entry in $Entries) { $widths["Slice"] = [Math]::Max($widths["Slice"], (Get-TailJournalEntryField -Entry $entry -Path @("slice")).Length) $kindLabel = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $entry -Path @("kind")) $widths["Kind"] = [Math]::Max($widths["Kind"], $kindLabel.Length) $widths["Outcome"] = [Math]::Max($widths["Outcome"], (Get-TailJournalEntryField -Entry $entry -Path @("outcome")).Length) $widths["Command"] = [Math]::Max($widths["Command"], (Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $entry -Path @("refs", "command"))).Length) if ($Mode -eq "detail") { $widths["Activity"] = [Math]::Max($widths["Activity"], (Get-TailJournalEntryField -Entry $entry -Path @("activity", "name")).Length) $widths["Model"] = [Math]::Max($widths["Model"], (Get-TailJournalProviderSlug -Entry $entry).Length) } } foreach ($column in $caps.Keys) { $widths[$column] = [Math]::Min($widths[$column], $caps[$column]) if ($widths[$column] -lt $column.Length) { $widths[$column] = $column.Length } } return $widths } function Write-TailJournalEvent { param( [Parameter(Mandatory = $true)] [object]$Entry, [Parameter(Mandatory = $true)] [hashtable]$Widths, [Parameter(Mandatory = $true)] [hashtable]$LastTimeBySlice, [Parameter(Mandatory = $true)] [hashtable]$SeenSlice, [Parameter(Mandatory = $true)] [System.TimeSpan]$Threshold, [Parameter(Mandatory = $true)] [int]$ConsoleWidth ) $slice = Get-TailJournalEntryField -Entry $Entry -Path @("slice") $localTime = ConvertTo-TailJournalLocalTime -OccurredAt $Entry.occurred_at $Entry | Add-Member -NotePropertyName "_localTime" -NotePropertyValue $localTime -Force $longGap = $false if (-not $SeenSlice.ContainsKey($slice)) { $SeenSlice[$slice] = $true } else { $previous = $LastTimeBySlice[$slice] if ($null -ne $previous) { $gap = $localTime - $previous if ($gap -gt $Threshold -and -not (Test-TailJournalPausedEvent -Entry $Entry)) { $longGap = $true } } } $LastTimeBySlice[$slice] = $localTime if ($LogLevel -eq "detail") { $line = Format-TailJournalDetailRow -Entry $Entry -Widths $Widths -LongGap $longGap -ConsoleWidth $ConsoleWidth } else { $line = Format-TailJournalCompactRow -Entry $Entry -Widths $Widths -LongGap $longGap -ConsoleWidth $ConsoleWidth } if (-not $script:Colorize) { Write-CliColor -Message $line -Literal } else { Write-CliColor -Message $line } } function Read-TailJournalEvent { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [int]$TailSize ) if (-not (Test-Path -LiteralPath $Path)) { return @() } $lines = Get-TailJournalFileTail -Path $Path -TailSize $TailSize $events = [System.Collections.Generic.List[object]]::new() foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } try { $obj = $line | ConvertFrom-Json $events.Add($obj) } catch { Write-Warning "Skipped malformed JSONL line: $_" } } return $events } function Get-TailJournalFileTail { # The journal writer (HarnessJournal.psm1) takes a brief exclusive lock # (FileShare.None) around every append, and editors/AV can hold one too. # A read that collides throws IOException, so retry with backoff before giving up. param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [int]$TailSize, [int]$MaxAttempts = 5 ) $delayMs = 100 for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { return @(Get-Content -LiteralPath $Path -Tail $TailSize) } catch [System.IO.IOException] { if ($attempt -eq $MaxAttempts) { throw } Start-Sleep -Milliseconds $delayMs $delayMs *= 2 } } } function Test-TailJournalOutputRedirected { try { return [Console]::IsOutputRedirected } catch { return $true } } function Test-TailJournalVirtualTerminal { # PsCliUiKit keeps Get-CliAnsiSupportsVirtualTerminal private. The host flag is the same check. $ui = $Host.UI if ($null -eq $ui) { return $false } $vtProp = $ui.PSObject.Properties["SupportsVirtualTerminal"] if ($null -eq $vtProp) { return $false } return [bool]$vtProp.Value } function Test-TailJournalIoException { param( [Parameter(Mandatory = $true)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $current = $ErrorRecord.Exception while ($null -ne $current) { if ($current -is [System.IO.IOException]) { return $true } $current = $current.InnerException } return $false } function Get-TailJournalSharedLength { # Same lock story as Get-TailJournalFileTail: the writer holds FileShare.None # for the append, so a colliding length read retries instead of aborting the view. param( [Parameter(Mandatory = $true)] [string]$Path, [int]$MaxAttempts = 5 ) $delayMs = 100 for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { $stream = [System.IO.File]::Open( $Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite ) try { return [long]$stream.Length } finally { $stream.Dispose() } } catch [System.IO.IOException] { if ($attempt -eq $MaxAttempts) { throw } Start-Sleep -Milliseconds $delayMs $delayMs *= 2 } } } function Write-TailJournalRepaint { # A terminal redraws in place. A redirected run (tests, pipes) cannot move the # cursor, so each later frame is separated by a form feed and the last frame # is the current window. param( [Parameter(Mandatory = $true)] [int]$PreviousLineCount ) if ($PreviousLineCount -le 0) { return } if (Test-TailJournalOutputRedirected) { Write-CliColor -Message ([string][char]12) -Literal return } if (Test-TailJournalVirtualTerminal) { $esc = [char]27 $sequence = "{0}[{1}A{0}[J" -f $esc, $PreviousLineCount Write-CliColor -Message $sequence -NoNewline -Literal return } Clear-Host } function Write-TailJournalSummaryLine { # Blank lines above and below the feature summary keep it separate from the prompt and the columns. param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Line ) Write-CliColor -Message "" if ($script:Colorize) { Write-CliColor -Message $Line } else { Write-CliColor -Message $Line -Literal } Write-CliColor -Message "" } function Write-TailJournalIdleClock { # A blank line separates the clock from the events. Ticks overwrite that one row. # Erasing it first blanks the whole line for a paint, which reads as a flash. param( [Parameter(Mandatory = $true)] [System.DateTime]$LastEventTime, [switch]$Replace ) $label = Format-TailJournalSinceLastEvent -Elapsed ([System.DateTime]::Now - $LastEventTime) $consoleWidth = Get-TailJournalConsoleWidth $width = [Math]::Min([Math]::Max($label.Length, $script:TailJournalIdleWidth), [Math]::Max(1, $consoleWidth - 1)) $script:TailJournalIdleWidth = $width $padded = $label.PadRight($width) if ($Replace -and -not (Test-TailJournalOutputRedirected) -and (Test-TailJournalVirtualTerminal)) { $esc = [char]27 Write-CliColor -Message ("{0}[1A{0}[1G" -f $esc) -NoNewline -Literal } elseif (-not $Replace) { Write-CliColor -Message "" } if ($script:Colorize) { Write-CliColor -Message "[muted]$padded[-]" } else { Write-CliColor -Message $padded -Literal } } function Write-TailJournalSnapshot { param( [Parameter(Mandatory = $true)] [string]$Feature, [Parameter(Mandatory = $true)] $Entries, [Parameter(Mandatory = $true)] [int]$TotalEvents, [Parameter(Mandatory = $true)] [int]$TotalSlices, [Parameter(Mandatory = $true)] [string]$LogLevel, [Parameter(Mandatory = $true)] [System.TimeSpan]$Threshold, [Parameter(Mandatory = $true)] [int]$PreviousLineCount ) Write-TailJournalRepaint -PreviousLineCount $PreviousLineCount $entryList = @($Entries) if ($entryList.Count -eq 1 -and $null -eq $entryList[0]) { $entryList = @() } if ($entryList.Count -eq 0) { $emptyLine = if ($script:Colorize) { "[muted]Feature:[-] [header]$Feature[-] [muted]| Journal is empty[-]" } else { "Feature: $Feature | Journal is empty" } Write-TailJournalSummaryLine -Line $emptyLine return 3 } $consoleWidth = Get-TailJournalConsoleWidth $widths = Get-TailJournalColumnWidth -Mode $LogLevel -Entries $entryList $header = Format-TailJournalHeader -Feature $Feature -Entries $entryList -TotalEvents $TotalEvents -TotalSlices $TotalSlices Write-TailJournalSummaryLine -Line $header Write-CliColor -Message (Format-TailJournalColumnHeader -Mode $LogLevel -Widths $widths) -Style Muted Write-CliColor -Message (Format-TailJournalDivider -Mode $LogLevel -Widths $widths) -Style Muted $lastTimeBySlice = @{} $seenSlice = @{} foreach ($entry in $entryList) { Write-TailJournalEvent -Entry $entry -Widths $widths -LastTimeBySlice $lastTimeBySlice -SeenSlice $seenSlice -Threshold $Threshold -ConsoleWidth $consoleWidth } return (5 + $entryList.Count) } function Show-TailJournalView { # Re-read the last TailSize lines from disk. If another append lands during the # read, paint once more so the offset cannot skip a line that never reached the screen. param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$FeatureName, [Parameter(Mandatory = $true)] [string]$LogLevel, [Parameter(Mandatory = $true)] [int]$TailSize, [Parameter(Mandatory = $true)] [System.TimeSpan]$Threshold, [Parameter(Mandatory = $true)] [int]$PreviousLineCount ) $lineCount = $PreviousLineCount $length = [long]0 $events = @() for ($attempt = 0; $attempt -lt 3; $attempt++) { $before = Get-TailJournalSharedLength -Path $Path $events = @(Read-TailJournalEvent -Path $Path -TailSize $TailSize) if ($events.Count -eq 0 -and $attempt -eq 0 -and $PreviousLineCount -eq 0) { return [pscustomobject]@{ LineCount = 0 Length = $before Empty = $true LastEventTime = $null } } $stats = Get-TailJournalFileSummary -Path $Path $paintFrom = if ($attempt -eq 0) { $PreviousLineCount } else { $lineCount } $lineCount = Write-TailJournalSnapshot -Feature $FeatureName -Entries $events -TotalEvents $stats.Events -TotalSlices $stats.Slices -LogLevel $LogLevel -Threshold $Threshold -PreviousLineCount $paintFrom $length = Get-TailJournalSharedLength -Path $Path if ($length -eq $before) { break } } return [pscustomobject]@{ LineCount = $lineCount Length = $length Empty = ($events.Count -eq 0) LastEventTime = (Get-TailJournalLastEventTime -Entries $events) } } function Watch-TailJournalFile { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$FeatureName, [Parameter(Mandatory = $true)] [string]$LogLevel, [Parameter(Mandatory = $true)] [int]$TailSize, [Parameter(Mandatory = $true)] [System.TimeSpan]$Threshold, [Parameter(Mandatory = $true)] [int]$PreviousLineCount, [Parameter(Mandatory = $true)] [long]$Offset, $LastEventTime = $null ) # Get-Content -Wait cannot be used here: it reopens the file on every poll, and a # poll that collides with the writer's exclusive append lock (HarnessJournal.psm1 # holds FileShare.None through its full-file seq read) aborts the wait stream even # under ErrorActionPreference 'Continue'. This poll opens briefly with a permissive # share once a second. A length change re-reads the last TailSize lines and redraws # that window. Otherwise only the idle clock under the rows is rewritten. $lineCount = $PreviousLineCount $offset = $Offset $lastEventTime = $LastEventTime while ($true) { Start-Sleep -Milliseconds 1000 try { $length = Get-TailJournalSharedLength -Path $Path -MaxAttempts 1 } catch { if (-not (Test-TailJournalIoException -ErrorRecord $_)) { throw } continue } if ($length -ne $offset) { try { $view = Show-TailJournalView -Path $Path -FeatureName $FeatureName -LogLevel $LogLevel -TailSize $TailSize -Threshold $Threshold -PreviousLineCount $lineCount $lineCount = $view.LineCount $offset = $view.Length $lastEventTime = $view.LastEventTime if ($null -ne $lastEventTime) { Write-TailJournalIdleClock -LastEventTime $lastEventTime $lineCount += 2 } } catch { if (-not (Test-TailJournalIoException -ErrorRecord $_)) { throw } } } elseif ($null -ne $lastEventTime) { Write-TailJournalIdleClock -LastEventTime $lastEventTime -Replace } } } # --- Main --- $repoRoot = Resolve-TailJournalRepoRoot if (-not $repoRoot) { throw "Could not locate repository root from $PSScriptRoot" } $featuresRoot = if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) { $FeatureRoot } else { Join-Path $repoRoot "docs/harness/features" } $featurePath = Join-Path $featuresRoot $Feature if (-not (Test-Path -LiteralPath $featurePath)) { Write-CliStatus -State Error -Message "Feature directory not found: $featurePath" exit 2 } $journalPath = Join-Path $featurePath "journal/events.jsonl" if (-not (Test-Path -LiteralPath $journalPath)) { Write-CliStatus -State Warning -Message "No journal found for $Feature" exit 0 } $threshold = [System.TimeSpan]::FromMinutes($ThresholdMinutes) $view = Show-TailJournalView -Path $journalPath -FeatureName $Feature -LogLevel $LogLevel -TailSize $TailSize -Threshold $threshold -PreviousLineCount 0 if ($view.Empty) { Write-CliStatus -State Info -Message "Journal is empty for $Feature" exit 0 } if ($Wait) { $lineCount = $view.LineCount if ($null -ne $view.LastEventTime) { Write-TailJournalIdleClock -LastEventTime $view.LastEventTime $lineCount += 2 } Watch-TailJournalFile -Path $journalPath -FeatureName $Feature -LogLevel $LogLevel -TailSize $TailSize -Threshold $threshold -PreviousLineCount $lineCount -Offset $view.Length -LastEventTime $view.LastEventTime } |