scripts/internal/session-management.ps1
|
<#
.SYNOPSIS Active-session lock management for Specrew multi-session foundation (F-051 Iteration 2a). .DESCRIPTION Maintains the per-session lock file `.specrew/active-sessions.yml` (FR-007) and the collision-detection / stale-clearing logic (FR-008 through FR-011). The lock is LOCAL (gitignored, per-session): it catches same-machine/worktree concurrent starts. Cross- machine coordination is the committed feature-claims file's job (see feature-claims.ps1, drift D-003). The rich machine_fingerprint stays only in this gitignored file (FR-043). All writes route through the shared race-safe atomic primitive; corrupt/missing files degrade to empty (safe-degradation). Dot-source to use. #> Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'atomic-write.ps1') . (Join-Path $PSScriptRoot 'yaml-list.ps1') . (Join-Path $PSScriptRoot 'specrew-time.ps1') $script:SpecrewActiveSessionsTopKey = 'sessions' function Get-ActiveSessionsPath { param([Parameter(Mandatory = $true)][string]$ProjectRoot) return (Join-Path $ProjectRoot '.specrew/active-sessions.yml') } function Get-MachineFingerprint { <# .SYNOPSIS Local-only machine fingerprint (FR-043): hostname + username + a short stable local hash. Computed entirely from local identifiers; makes NO network/telemetry call. #> $machine = [System.Environment]::MachineName $user = [System.Environment]::UserName $seed = '{0}|{1}' -f $machine, $user $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($seed)) } finally { $sha.Dispose() } $short = -join ($bytes[0..3] | ForEach-Object { $_.ToString('x2') }) return ('{0}-{1}-{2}' -f $machine, $user, $short) } function Read-ActiveSessions { param([Parameter(Mandatory = $true)][string]$ProjectRoot) return @(Read-SpecrewYamlList -Path (Get-ActiveSessionsPath -ProjectRoot $ProjectRoot) -TopKey $script:SpecrewActiveSessionsTopKey) } function Write-ActiveSessions { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowEmptyCollection()][AllowNull()][object[]]$Sessions ) $content = ConvertTo-SpecrewYamlList -TopKey $script:SpecrewActiveSessionsTopKey -Entries $Sessions Write-SpecrewFileAtomic -Path (Get-ActiveSessionsPath -ProjectRoot $ProjectRoot) -Content $content } function Register-SessionLock { <# Add or refresh the lock entry for (feature_id + this machine). Idempotent (FR-008). #> param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureId, [string]$User = [System.Environment]::UserName, [string]$Fingerprint = (Get-MachineFingerprint), [string]$NowUtc = (Get-SpecrewUtcNow) ) $sessions = @(Read-ActiveSessions -ProjectRoot $ProjectRoot) $existing = $sessions | Where-Object { $_['feature_id'] -eq $FeatureId -and $_['machine_fingerprint'] -eq $Fingerprint } | Select-Object -First 1 if ($null -ne $existing) { $existing['last_heartbeat_time'] = $NowUtc } else { $sessions = @($sessions) + ,([ordered]@{ feature_id = $FeatureId user = $User machine_fingerprint = $Fingerprint session_start_time = $NowUtc last_heartbeat_time = $NowUtc }) } Write-ActiveSessions -ProjectRoot $ProjectRoot -Sessions $sessions } function Remove-SessionLock { <# Remove the lock entry for (feature_id [+ fingerprint]); no-op if absent (FR-009). #> param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureId, [string]$Fingerprint ) $sessions = @(Read-ActiveSessions -ProjectRoot $ProjectRoot) $kept = @($sessions | Where-Object { -not ($_['feature_id'] -eq $FeatureId -and ([string]::IsNullOrEmpty($Fingerprint) -or $_['machine_fingerprint'] -eq $Fingerprint)) }) if ($kept.Count -ne $sessions.Count) { Write-ActiveSessions -ProjectRoot $ProjectRoot -Sessions $kept } } function Test-SessionCollision { <# Return an active lock for the same feature held by a DIFFERENT machine, else $null (FR-010). #> param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureId, [string]$Fingerprint = (Get-MachineFingerprint) ) $sessions = @(Read-ActiveSessions -ProjectRoot $ProjectRoot) return ($sessions | Where-Object { $_['feature_id'] -eq $FeatureId -and $_['machine_fingerprint'] -ne $Fingerprint } | Select-Object -First 1) } function Clear-StaleSessionLocks { <# Remove locks whose last_heartbeat_time is older than ThresholdHours; return count cleared (FR-011). #> param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [int]$ThresholdHours = 24, [string]$NowUtc = (Get-SpecrewUtcNow) ) $now = ConvertTo-SpecrewUtc -Value $NowUtc if ($null -eq $now) { return 0 } $sessions = @(Read-ActiveSessions -ProjectRoot $ProjectRoot) $kept = @($sessions | Where-Object { $hb = ConvertTo-SpecrewUtc -Value ([string]$_['last_heartbeat_time']) # Keep entries that are not parseable (do not silently destroy) OR within threshold. ($null -eq $hb) -or (($now - $hb).TotalHours -lt $ThresholdHours) }) $cleared = $sessions.Count - $kept.Count if ($cleared -gt 0) { Write-ActiveSessions -ProjectRoot $ProjectRoot -Sessions $kept } return $cleared } |