Payload/scripts/kaden/harness/lib/HarnessJournal.psm1
|
#Requires -Version 5.1 <# .SYNOPSIS Append-only harness event journal (ledger SoT). .DESCRIPTION Implements harness.journal.event/v1 under docs/harness/features/<slug>/journal/events.jsonl. Exclusive append lock; monotonic seq; ActivityStarted/Stopped/Adjusted helpers. Journal answers what happened; gate completeness is HarnessGateProjection + artifacts. See docs/kaden/harness-journal.md and ADR 0003. #> Set-StrictMode -Version Latest $script:HarnessJournalSchema = 'harness.journal.event/v1' # Stamp helpers live in HarnessContract; require the module so journal append cannot fail-open without provenance. Import-Module (Join-Path $PSScriptRoot 'HarnessContract.psm1') -Force function Get-HarnessJournalDirectory { param([Parameter(Mandatory = $true)][string]$FeatureRoot) return Join-Path $FeatureRoot 'journal' } function Get-HarnessJournalPath { param([Parameter(Mandatory = $true)][string]$FeatureRoot) return Join-Path (Get-HarnessJournalDirectory -FeatureRoot $FeatureRoot) 'events.jsonl' } function Initialize-HarnessJournal { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param([Parameter(Mandatory = $true)][string]$FeatureRoot) $dir = Get-HarnessJournalDirectory -FeatureRoot $FeatureRoot if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $path = Get-HarnessJournalPath -FeatureRoot $FeatureRoot $created = $false if (-not (Test-Path $path)) { [System.IO.File]::WriteAllText($path, '') $created = $true } if ($created) { Write-HarnessGateFileProvenance -FeatureRoot $FeatureRoot -GateFile journal -Source 'Initialize-HarnessJournal' } return $path } function New-HarnessJournalEventId { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure id factory - no persistent state change.')] param() # UUID without dashes is acceptable per journal-entry-structure.md (ULID preferred). return ([guid]::NewGuid().ToString('N')).ToUpperInvariant() } function New-HarnessJournalActivityId { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure id factory - no persistent state change.')] param() return 'act_' + (New-HarnessJournalEventId) } function Get-HarnessJournalEvents { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Returns the append-only event stream - plural noun matches journal vocabulary.')] param([Parameter(Mandatory = $true)][string]$FeatureRoot) $path = Get-HarnessJournalPath -FeatureRoot $FeatureRoot if (-not (Test-Path $path)) { return @() } $lines = Get-Content -Path $path -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $events = [System.Collections.ArrayList]@() foreach ($line in $lines) { [void]$events.Add(($line | ConvertFrom-Json)) } return @($events.ToArray()) } function Get-HarnessLatestJournalEventByName { param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$SliceId, [Parameter(Mandatory = $true)][string]$Name, [string[]]$Kinds = @('SensorRunRecorded', 'GateCheckRun'), [string]$Phase = '' ) $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot) $matched = @($events | Where-Object { $kind = [string]$_.kind if ($Kinds.Count -gt 0 -and ($Kinds -notcontains $kind)) { return $false } if ([string]$_.slice -ne $SliceId) { return $false } $payload = $null if ($_.PSObject.Properties['payload']) { $payload = $_.payload } if ($null -eq $payload -or $null -eq $payload.PSObject.Properties['name'] -or [string]$payload.name -ne $Name) { return $false } if (-not [string]::IsNullOrWhiteSpace($Phase)) { if (-not ($payload.PSObject.Properties['phase']) -or [string]$payload.phase -ne $Phase) { return $false } } return $true } | Sort-Object -Property seq -Descending | Select-Object -First 1) if ($matched.Count -gt 0) { return $matched[0] } return $null } function Get-NextHarnessJournalSeq { param([Parameter(Mandatory = $true)][string]$FeatureRoot) $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot) if ($events.Count -eq 0) { return 1 } $max = ($events | Measure-Object -Property seq -Maximum).Maximum if ($null -eq $max) { return 1 } return [int]$max + 1 } function New-HarnessJournalActor { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory actor value object - no persistent state change.')] param( [string]$Agent = 'harness-script', # Untyped so absent parent stays JSON null (not empty string). $ParentAgent = $null, [string]$Tool = 'harness-script', [string]$Provider = 'other', [string]$Slug = 'unknown', [string]$Role = 'primary', [string]$ModelSource = '' ) $resolvedSlug = if (Get-Command Get-HarnessResolvedModelSlug -ErrorAction SilentlyContinue) { Get-HarnessResolvedModelSlug -ModelSlug $Slug } else { $Slug } $parentVal = $null if ($null -ne $ParentAgent -and -not [string]::IsNullOrWhiteSpace([string]$ParentAgent)) { $parentVal = [string]$ParentAgent } $actor = [pscustomobject]@{ agent = $Agent parent_agent = $parentVal tool = $Tool model = [pscustomobject]@{ provider = $Provider slug = $resolvedSlug role = $Role } } if (-not [string]::IsNullOrWhiteSpace($ModelSource)) { $actor | Add-Member -NotePropertyName 'model_source' -NotePropertyValue $ModelSource } return $actor } function Test-HarnessJournalTurnBoundaryActivity { <# .SYNOPSIS True for ownership-turn start/stop activities (type turn or legacy other). #> param( $Activity = $null, [ValidateSet('start', 'stop', '')][string]$Name = '' ) if (-not $Activity) { return $false } $type = [string]$Activity.type $actName = [string]$Activity.name if ($type -notin @('turn', 'other')) { return $false } if ([string]::IsNullOrWhiteSpace($Name)) { return ($actName -in @('start', 'stop')) } return ($actName -eq $Name) } function New-HarnessJournalActivity { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory activity value object - no persistent state change.')] param( [Parameter(Mandatory = $true)][string]$Id, [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$Type, [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][ValidateSet('start', 'stop', 'instant', 'adjust')][string]$Role ) return [pscustomobject]@{ id = $Id type = $Type name = $Name role = $Role } } function Add-HarnessJournalEvent { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)] [string]$FeatureRoot, [Parameter(Mandatory = $true)] [string]$Feature, # Untyped so omitted/null slice stays JSON null (typed [string]$null becomes ""). $Slice = $null, [Parameter(Mandatory = $true)] [string]$Kind, $Activity = $null, $Actor = $null, $Refs = $null, # Align with journal-entry-structure.md; sensors may use PASS_WITH_FINDINGS. # Validated in-body so $null remains legal (PS ValidateScript rejects null binds). $Outcome = $null, [Parameter(Mandatory = $true)] [string]$Summary, $Payload = $null, [string]$OccurredAt = '', [string]$EventId = '' ) $allowedKinds = @( 'ActivityStarted', 'ActivityStopped', 'ActivityAdjusted', 'PhaseStatusChanged', 'SensorRunRecorded', 'GateCheckRun', 'GateRelevantValidationRun', 'GateValidationPassed', 'ReviewerCycleRecorded', 'HandoffRecorded', 'HumanGateDecided', 'OwnershipChanged', 'BlockerDeclared', 'EmissionGapMarked', 'TaskStarted', 'TaskHeartbeat', 'TaskTimedOut', 'TaskRecovered', 'TaskAbandoned', 'ArtifactRegistered', 'StoryIdsAssigned' ) if ($allowedKinds -notcontains [string]$Kind) { throw "Invalid Kind '$Kind'. Allowed: $($allowedKinds -join ', ')." } if ($null -ne $Outcome) { $allowedOutcomes = @('PASS', 'FAIL', 'BLOCKED', 'SKIPPED', 'PASS_WITH_FINDINGS', 'DENY') if ($allowedOutcomes -notcontains [string]$Outcome) { throw "Invalid Outcome '$Outcome'. Allowed: $($allowedOutcomes -join ', ') or null." } } $path = Initialize-HarnessJournal -FeatureRoot $FeatureRoot $now = if (Get-Command Get-HarnessIso8601UtcNow -ErrorAction SilentlyContinue) { Get-HarnessIso8601UtcNow } else { [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') } if ([string]::IsNullOrWhiteSpace($OccurredAt)) { $OccurredAt = $now } if ([string]::IsNullOrWhiteSpace($EventId)) { $EventId = New-HarnessJournalEventId } if ($null -eq $Actor) { $Actor = New-HarnessJournalActor } if ($null -eq $Refs) { $Refs = [pscustomobject]@{ amends_event_id = $null pair_event_id = $null artifact = $null command = $null } } if ($null -eq $Payload) { $Payload = [pscustomobject]@{} } # This event opens a turn iff it is the ActivityStarted emitted by harness-turn-start.ps1 # (activity type 'turn'/'start'; legacy fixtures may still use type 'other'/'start'). $opensTurn = ($Kind -eq 'ActivityStarted' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $Activity -Name 'start')) if ($opensTurn -and $Slice -and (Get-Command Test-HarnessSliceTurnOpen -ErrorAction SilentlyContinue)) { if (Test-HarnessSliceTurnOpen -FeatureRoot $FeatureRoot -Slice ([string]$Slice)) { throw "ActivityStarted refused: slice $Slice already has an open turn. Do not harness-turn-start again; record FeaturePhase with harness-record-phase.ps1 or use -ExecuteNext." } } # Exclusive lock around max(seq) + append so concurrent writers cannot forge/race seq. $utf8 = New-Object System.Text.UTF8Encoding $false $lockStream = $null $written = $null try { $lockStream = [System.IO.File]::Open( $path, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) $lockStream.Position = 0 $reader = New-Object System.IO.StreamReader($lockStream, $utf8, $true, 4096, $true) $existing = $reader.ReadToEnd() $maxSeq = 0 # Derive this slice's currently-open turn id from journal history so every event recorded # mid-turn (sensor/phase/review/handoff) can be grouped without agents passing a new param. $openTurnActivityId = $null $lastClosedTurnId = $null foreach ($existingLine in ($existing -split '\r?\n')) { if ([string]::IsNullOrWhiteSpace($existingLine)) { continue } try { $parsed = $existingLine | ConvertFrom-Json if ($null -ne $parsed.seq) { $seqVal = [int]$parsed.seq if ($seqVal -gt $maxSeq) { $maxSeq = $seqVal } } if ($Slice -and $parsed.PSObject.Properties['slice'] -and [string]$parsed.slice -eq [string]$Slice) { $pActivity = $parsed.activity $pKind = [string]$parsed.kind if ($pKind -eq 'ActivityStarted' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $pActivity -Name 'start')) { $openTurnActivityId = [string]$pActivity.id } elseif ($pKind -eq 'ActivityStopped' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $pActivity -Name 'stop')) { $lastClosedTurnId = $openTurnActivityId $openTurnActivityId = $null } } } catch { # Malformed JSONL line: skip for seq/turn-id computation; append remains append-only. Write-Verbose "Skipping malformed journal line while computing next seq: $($_.Exception.Message)" } } $nextSeq = $maxSeq + 1 $turnId = if ($opensTurn) { [string]$Activity.id } elseif ($Slice) { if ($openTurnActivityId) { $openTurnActivityId } else { $lastClosedTurnId } } else { $null } if (-not ($Refs.PSObject.Properties.Name -contains 'turn_id')) { $Refs | Add-Member -NotePropertyName 'turn_id' -NotePropertyValue $null -Force } $Refs.turn_id = $turnId $journalEvent = [ordered]@{ schema = $script:HarnessJournalSchema event_id = $EventId seq = $nextSeq recorded_at = $now occurred_at = $OccurredAt feature = $Feature slice = $Slice kind = $Kind activity = $Activity actor = $Actor refs = $Refs outcome = $Outcome summary = $Summary payload = $Payload } $line = ($journalEvent | ConvertTo-Json -Compress -Depth 20) $bytes = $utf8.GetBytes($line + [Environment]::NewLine) $lockStream.Seek(0, [System.IO.SeekOrigin]::End) | Out-Null $lockStream.Write($bytes, 0, $bytes.Length) $lockStream.Flush() $written = [pscustomobject]$journalEvent # Hash via the open exclusive stream so provenance stamps under the lock without Get-FileHash sharing conflict. $lockStream.Position = 0 $sha = [System.Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha.ComputeHash($lockStream) } finally { $sha.Dispose() } $shaHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) Write-HarnessGateFileProvenance -FeatureRoot $FeatureRoot -GateFile journal -Source 'Add-HarnessJournalEvent' -Sha256 $shaHex } finally { if ($null -ne $lockStream) { $lockStream.Dispose() } } return $written } function Start-HarnessJournalActivity { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$Feature, $Slice = $null, [Parameter(Mandatory = $true)][string]$ActivityId, [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$ActivityType, [Parameter(Mandatory = $true)][string]$ActivityName, $Actor = $null, [string]$Summary = '', $Payload = $null, [string]$OccurredAt = '' ) if ([string]::IsNullOrWhiteSpace($Summary)) { $Summary = "$ActivityName started" } $activity = New-HarnessJournalActivity -Id $ActivityId -Type $ActivityType -Name $ActivityName -Role 'start' return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice ` -Kind 'ActivityStarted' -Activity $activity -Actor $Actor -Outcome $null ` -Summary $Summary -Payload $Payload -OccurredAt $OccurredAt } function Stop-HarnessJournalActivity { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$Feature, $Slice = $null, [Parameter(Mandatory = $true)][string]$ActivityId, [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$ActivityType, [Parameter(Mandatory = $true)][string]$ActivityName, [ValidateSet('PASS', 'FAIL', 'BLOCKED', 'SKIPPED', 'PASS_WITH_FINDINGS')][string]$Outcome = 'PASS', $Actor = $null, [string]$Summary = '', $Payload = $null, [string]$PairEventId = '', [string]$OccurredAt = '' ) if ([string]::IsNullOrWhiteSpace($Summary)) { $Summary = "$ActivityName stopped ($Outcome)" } $activity = New-HarnessJournalActivity -Id $ActivityId -Type $ActivityType -Name $ActivityName -Role 'stop' $refs = [pscustomobject]@{ amends_event_id = $null pair_event_id = $(if ($PairEventId) { $PairEventId } else { $null }) artifact = $null command = $null } return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice ` -Kind 'ActivityStopped' -Activity $activity -Actor $Actor -Refs $refs -Outcome $Outcome ` -Summary $Summary -Payload $Payload -OccurredAt $OccurredAt } function Add-HarnessJournalAdjustingEntry { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$Feature, $Slice = $null, [Parameter(Mandatory = $true)][string]$AmendsEventId, [Parameter(Mandatory = $true)][string]$Summary, $Activity = $null, $Actor = $null, $Correction = $null, [string]$OccurredAt = '' ) $refs = [pscustomobject]@{ amends_event_id = $AmendsEventId pair_event_id = $null artifact = $null command = $null } if ($null -eq $Activity) { $Activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'other' -Name 'adjust' -Role 'adjust' } $payload = [pscustomobject]@{ correction = $Correction } return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice ` -Kind 'ActivityAdjusted' -Activity $Activity -Actor $Actor -Refs $refs -Outcome $null ` -Summary $Summary -Payload $payload -OccurredAt $OccurredAt } Export-ModuleMember -Function * |