Payload/scripts/kaden/harness/harness-timeline.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Generate a human-readable journal timeline grouped by turn_id.

.DESCRIPTION
    Reads journal/events.jsonl and writes journal/timeline.md with turns as sections
    and sensor / phase lines nested under the owning turn_id.

.EXAMPLE
    pwsh -File scripts/kaden/harness-invoke.ps1 harness-timeline.ps1 -Feature saved-search-queries -Slice mo-save-list
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$Feature,

    [string]$Slice = '',

    [string]$FeatureRoot = '',

    [string]$RepoRoot = '',

    [int]$GapMinutes = 8,

    [switch]$AllowArbitraryFeatureRoot
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module (Join-Path $PSScriptRoot 'lib\HarnessContract.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'lib\HarnessJournal.psm1') -Force
Test-HarnessContractModuleReady

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $RepoRoot = Get-HarnessRepoRoot -StartPath $PSScriptRoot
}

$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature `
    -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot

$events = @(Get-HarnessJournalEvents -FeatureRoot $featureRootPath)
if ($Slice) {
    $events = @($events | Where-Object { [string]$_.slice -eq $Slice })
}

$turnGroups = [System.Collections.ArrayList]@()
$turnIndex = @{}
$openTurn = $null

foreach ($journalEvent in ($events | Sort-Object { [int](Get-HarnessProperty -Object $_ -Name 'seq' -Default 0) })) {
    $kind = [string](Get-HarnessProperty -Object $journalEvent -Name 'kind')
    $refs = Get-HarnessProperty -Object $journalEvent -Name 'refs'
    $turnId = [string](Get-HarnessProperty -Object $refs -Name 'turn_id')
    $activity = Get-HarnessProperty -Object $journalEvent -Name 'activity'
    $activityName = [string](Get-HarnessProperty -Object $activity -Name 'name')

    if ($kind -eq 'ActivityStarted' -and $activityName -eq 'start') {
        $turnId = [string](Get-HarnessProperty -Object $activity -Name 'id')
        $openTurn = [pscustomobject]@{
            turn_id    = $turnId
            slice      = [string]$journalEvent.slice
            started_at = [string]$journalEvent.occurred_at
            stopped_at = ''
            summary    = [string](Get-HarnessProperty -Object $journalEvent -Name 'summary')
            events     = [System.Collections.ArrayList]@()
        }
        [void]$turnGroups.Add($openTurn)
        $turnIndex[$turnId] = $openTurn
        continue
    }

    if ($kind -eq 'ActivityStopped' -and $activityName -eq 'stop' -and $openTurn) {
        $openTurn.stopped_at = [string]$journalEvent.occurred_at
        [void]$openTurn.events.Add($journalEvent)
        $openTurn = $null
        continue
    }

    $target = $null
    if (-not [string]::IsNullOrWhiteSpace($turnId) -and $turnIndex.ContainsKey($turnId)) {
        $target = $turnIndex[$turnId]
    }
    elseif ($openTurn) {
        $target = $openTurn
    }

    if ($target) {
        [void]$target.events.Add($journalEvent)
    }
    else {
        $orphanId = "orphan-$([string]$journalEvent.seq)"
        $orphan = [pscustomobject]@{
            turn_id    = $orphanId
            slice      = [string]$journalEvent.slice
            started_at = [string]$journalEvent.occurred_at
            stopped_at = ''
            summary    = 'events outside a turn window'
            events     = [System.Collections.ArrayList]@(@($journalEvent))
        }
        [void]$turnGroups.Add($orphan)
        $turnIndex[$orphanId] = $orphan
    }
}

function Format-HarnessTimelineEventLine {
    param([Parameter(Mandatory = $true)]$JournalEvent)

    $kind = [string](Get-HarnessProperty -Object $JournalEvent -Name 'kind')
    $outcome = [string](Get-HarnessProperty -Object $JournalEvent -Name 'outcome')
    $payload = Get-HarnessProperty -Object $JournalEvent -Name 'payload'
    $actor = Get-HarnessProperty -Object $JournalEvent -Name 'actor'
    $agent = [string](Get-HarnessProperty -Object $actor -Name 'agent')
    $occurredRaw = Get-HarnessProperty -Object $JournalEvent -Name 'occurred_at'
    $occurred = if ($null -ne $occurredRaw) {
        Convert-HarnessTimestampToUtcZString -Value $occurredRaw
    }
    else {
        ''
    }
    $payloadName = [string](Get-HarnessProperty -Object $payload -Name 'name')
    $line = "- $occurred $kind"

    if ($kind -eq 'SensorRunRecorded' -and $payloadName) {
        $line += " sensor=$payloadName"
        if ($outcome) { $line += " outcome=$outcome" }
    }
    elseif ($kind -eq 'PhaseStatusChanged') {
        $phase = [string](Get-HarnessProperty -Object $payload -Name 'phase')
        $status = [string](Get-HarnessProperty -Object $payload -Name 'status')
        if ($phase) { $line += " phase=$phase status=$status" }
    }
    elseif ($kind -in @('ActivityStarted', 'ActivityStopped')) {
        if ($agent) { $line += " agent=$agent" }
    }

    $summary = [string](Get-HarnessProperty -Object $JournalEvent -Name 'summary')
    if ($summary) { $line += " -- $summary" }
    return $line
}

$lines = [System.Collections.ArrayList]@()
$titleSlice = if ($Slice) { " / $Slice" } else { '' }
[void]$lines.Add("# Journal timeline -- $Feature$titleSlice")
[void]$lines.Add('')
[void]$lines.Add("Generated: $(Get-HarnessIso8601UtcNow)")
[void]$lines.Add('')

$gapThreshold = if ($GapMinutes -gt 0) { $GapMinutes } else { 8 }

foreach ($turn in $turnGroups) {
    $window = $turn.started_at
    if ($turn.stopped_at) { $window += " -> $($turn.stopped_at)" }
    [void]$lines.Add("## Turn $($turn.turn_id) (slice $($turn.slice))")
    [void]$lines.Add($window)
    if ($turn.summary) {
        [void]$lines.Add($turn.summary)
    }
    [void]$lines.Add('')
    $openPhases = @{}
    $prevInstant = $null
    $startRaw = [string]$turn.started_at
    if (-not [string]::IsNullOrWhiteSpace($startRaw)) {
        try {
            $prevInstant = [datetime]::Parse($startRaw, $null, [System.Globalization.DateTimeStyles]::AdjustToUniversal)
        }
        catch {
            $prevInstant = $null
        }
    }
    foreach ($ev in $turn.events) {
        $evInstant = Convert-HarnessJournalEventInstant -Event $ev -PreferOccurredAt
        if ($null -ne $prevInstant -and $null -ne $evInstant) {
            $gapMin = [Math]::Floor(($evInstant - $prevInstant).TotalMinutes)
            if ($gapMin -gt $gapThreshold) {
                [void]$lines.Add("- gap $gapMin min")
            }
        }
        [void]$lines.Add((Format-HarnessTimelineEventLine -JournalEvent $ev))
        $kind = [string](Get-HarnessProperty -Object $ev -Name 'kind')
        if ($kind -eq 'PhaseStatusChanged') {
            $payload = Get-HarnessProperty -Object $ev -Name 'payload'
            $phase = [string](Get-HarnessProperty -Object $payload -Name 'phase')
            $status = [string](Get-HarnessProperty -Object $payload -Name 'status')
            $occurredRaw = Get-HarnessProperty -Object $ev -Name 'occurred_at'
            if ($null -eq $occurredRaw -or [string]::IsNullOrWhiteSpace([string]$occurredRaw)) {
                $occurredRaw = Get-HarnessProperty -Object $ev -Name 'recorded_at'
            }
            $occurred = if ($null -ne $occurredRaw) {
                Convert-HarnessTimestampToUtcZString -Value $occurredRaw
            }
            else {
                ''
            }
            if (-not [string]::IsNullOrWhiteSpace($phase)) {
                if ($status -eq 'in_progress') {
                    $openPhases[$phase] = $occurred
                }
                elseif ($status -eq 'complete' -or $status -eq 'skipped') {
                    $openPhases.Remove($phase)
                }
            }
        }
        if ($null -ne $evInstant) {
            $prevInstant = $evInstant
        }
    }
    foreach ($phaseName in @($openPhases.Keys | Sort-Object)) {
        [void]$lines.Add("- open phase=$phaseName since $($openPhases[$phaseName])")
    }
    [void]$lines.Add('')
}

$journalDir = Get-HarnessJournalDirectory -FeatureRoot $featureRootPath
if (-not (Test-Path -LiteralPath $journalDir)) {
    New-Item -ItemType Directory -Path $journalDir -Force | Out-Null
}
$outPath = Join-Path $journalDir 'timeline.md'
Set-Content -LiteralPath $outPath -Value ($lines -join [Environment]::NewLine) -Encoding UTF8
Write-Output "harness-timeline: wrote $outPath ($($turnGroups.Count) turn section(s), $($events.Count) event(s))"