SessionIdentity.ps1
|
# Discovery-only recovery. These helpers never persist an inferred identity. function Test-CopilotRepositoryCompatibility { param([string]$Recorded, [string]$Canonical) if (-not $Recorded -or $Recorded -ieq $Canonical) { return $true } $parts = $Canonical.Split('/') if ($parts.Count -ne 3) { return $false } return ($Recorded -ieq "$($parts[1])/$($parts[2])" -or $Recorded -ieq "DefaultCollection/$($parts[1])/$($parts[2])") } function Get-CopilotLifecycleContext { [CmdletBinding()] param([Parameter(Mandatory)][string]$SessionPath) $journal = Join-Path $SessionPath 'events.jsonl' if (-not (Test-Path -LiteralPath $journal -PathType Leaf -ErrorAction Stop)) { return } if (-not ('Shmuelie.Copilot.SessionIdentityJournal' -as [type])) { Add-Type -Path (Join-Path $PSScriptRoot 'Classes' 'SessionIdentityJournal.cs') -ErrorAction Stop } $snapshot = Get-Item -LiteralPath $journal -ErrorAction Stop $length = $snapshot.Length $modified = $snapshot.LastWriteTimeUtc $latest = $null $records = [Shmuelie.Copilot.SessionIdentityJournal]::Read($journal) try { while ($records.MoveNext()) { $record = $records.Current if ($record.Incomplete) { throw [IO.EndOfStreamException]::new('Identity recovery journal has an incomplete trailing record.') } $event = ConvertFrom-Json -InputObject $record.Json -AsHashtable -ErrorAction Stop if ($event.data -isnot [System.Collections.IDictionary]) { throw [FormatException]::new('Lifecycle data must be an object.') } if ($event.data.sessionId -and $event.data.sessionId -cne (Split-Path $SessionPath -Leaf)) { throw [FormatException]::new('Lifecycle sessionId does not match the session directory.') } # A later lifecycle boundary without context invalidates old evidence. $latest = $event.data.context if ($null -ne $latest -and $latest -isnot [System.Collections.IDictionary]) { throw [FormatException]::new('Lifecycle context must be an object.') } } } finally { $records.Dispose() } $snapshot.Refresh() if (-not $snapshot.Exists -or $snapshot.Length -ne $length -or $snapshot.LastWriteTimeUtc -ne $modified) { throw [IO.EndOfStreamException]::new('Identity recovery journal changed while being read.') } if ($latest) { foreach ($field in 'repository', 'branch', 'cwd', 'gitRoot', 'hostType', 'repositoryHost') { if ($null -ne $latest[$field] -and $latest[$field] -isnot [string]) { throw [FormatException]::new("Lifecycle context $field must be a string.") } } return $latest } } function Get-CopilotRecordedOriginIdentity { param([Parameter(Mandatory)][string]$Path) if (-not [IO.Path]::IsPathFullyQualified($Path) -or -not (Test-Path -LiteralPath $Path -PathType Container -ErrorAction Stop)) { return } $previousExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction Ignore $previousExitCodeValue = if ($previousExitCode) { $previousExitCode.Value } $PSNativeCommandUseErrorActionPreference = $false try { $global:LASTEXITCODE = $null $inside = @(git -C $Path rev-parse --is-inside-work-tree 2>$null) if ($LASTEXITCODE -ne 0 -or $inside.Count -ne 1 -or $inside[0] -cne 'true') { return } $global:LASTEXITCODE = $null $urls = @(git -C $Path config --get-all remote.origin.url 2>$null) if ($LASTEXITCODE -ne 0 -or $urls.Count -ne 1) { throw [FormatException]::new('Recorded Git path does not have one origin.') } $identity = ConvertTo-CopilotAdoRepositoryIdentity -Url $urls[0] if (-not $identity) { $identity = ConvertTo-CopilotGitHubRepositoryIdentity -Url $urls[0] } if (-not $identity) { throw [FormatException]::new('Recorded Git origin is not recognized.') } return $identity } finally { if ($previousExitCode) { $global:LASTEXITCODE = $previousExitCodeValue } else { Remove-Variable -Name LASTEXITCODE -Scope Global -WhatIf:$false -Confirm:$false } } } function Resolve-CopilotSessionIdentity { [CmdletBinding()] param( [Parameter(Mandatory)][string]$SessionPath, [string]$Cwd, [string]$Repository, [string]$Branch, $Scope ) $recorded = [pscustomobject]@{ Cwd = $Cwd; Repository = $Repository; Branch = $Branch } if (-not $Scope.Repository -or -not $Scope.Branch) { return $recorded } $adoScope = $Scope.Repository -like '*/*/*' # Current scope may rule evidence out, but never supplies missing identity. if (($Branch -and $Branch -cne $Scope.Branch) -or ($Repository -and -not (Test-CopilotRepositoryCompatibility -Recorded $Repository -Canonical $Scope.Repository))) { return $recorded } # Complete ordinary identities need no journal or historical worktree reads. $legacy = $adoScope -and ($Repository.Split('/').Count -eq 2 -or $Repository -like 'DefaultCollection/*') if ($Repository -and $Branch -and -not $legacy) { return $recorded } try { $context = Get-CopilotLifecycleContext -SessionPath $SessionPath $effectiveRepository = $Repository $effectiveBranch = $Branch $effectiveCwd = $Cwd $paths = [System.Collections.Generic.List[string]]::new() if ($Cwd) { $paths.Add($Cwd) } if ($context) { # Never borrow context from a different known directory or branch. $contextPaths = @($context.cwd, $context.gitRoot | Where-Object { $_ }) if (($Cwd -and $contextPaths.Count -gt 0 -and $Cwd -notin $contextPaths) -or ($Branch -and $context.branch -and $context.branch -cne $Branch)) { return $recorded } $contextRepository = $context.repository $organization = $null if ($context.repositoryHost) { if ($context.repositoryHost -match '\A([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.visualstudio\.com\z') { $organization = $Matches[1].ToLowerInvariant() } elseif ($context.repositoryHost -notin @('dev.azure.com', 'ssh.dev.azure.com', 'github.com')) { throw [FormatException]::new('Lifecycle repositoryHost is not a supported ADO hostname.') } if (($context.repositoryHost -ieq 'github.com' -and $adoScope) -or ($context.repositoryHost -ine 'github.com' -and $context.hostType -and $context.hostType -notin @('ado', 'azure-devops', 'azureDevOps'))) { return $recorded } } if ($context.hostType -and $context.hostType -notin @('ado', 'azure-devops', 'azureDevOps', 'github')) { return $recorded } if ($context.hostType -ieq 'github' -and $adoScope) { return $recorded } if ($organization) { if ($contextRepository) { $parts = $contextRepository.Split('/') if ($parts.Count -eq 2) { $contextRepository = "$organization/$contextRepository" } elseif ($parts.Count -eq 3 -and $parts[0] -ieq 'DefaultCollection') { $contextRepository = "$organization/$($parts[1])/$($parts[2])" } elseif ($parts.Count -ne 3 -or $parts[0] -ine $organization) { return $recorded } } elseif ($legacy) { $parts = $Repository.Split('/') $contextRepository = "$organization/$($parts[-2])/$($parts[-1])" } } if ($contextRepository) { # Validate decoded canonical ADO identities with the URL parser. $parts = $contextRepository.Split('/') $canonical = if ($parts.Count -eq 3 -and $parts[0] -ine 'DefaultCollection') { $urlParts = @($parts | ForEach-Object { [uri]::EscapeDataString($_) }) ConvertTo-CopilotAdoRepositoryIdentity -Url "https://dev.azure.com/$($urlParts[0])/$($urlParts[1])/_git/$($urlParts[2])" } elseif (-not $adoScope -and $parts.Count -eq 2) { ConvertTo-CopilotGitHubRepositoryIdentity -Url "https://github.com/$contextRepository" } if ($canonical) { if (-not (Test-CopilotRepositoryCompatibility -Recorded $Repository -Canonical $canonical)) { return $recorded } $effectiveRepository = $canonical } elseif ($adoScope -and ($parts.Count -eq 2 -or ($parts.Count -eq 3 -and $parts[0] -ieq 'DefaultCollection'))) { # Keep the unresolved shape pending independent Git evidence. if ($Repository -and $Repository -ine $contextRepository -and -not (Test-CopilotRepositoryCompatibility -Recorded $contextRepository -Canonical $Repository)) { return $recorded } if (-not $Repository) { $effectiveRepository = $contextRepository } } else { return $recorded } } if (-not $Branch) { $effectiveBranch = $context.branch } if (-not $Cwd) { $effectiveCwd = $context.cwd } foreach ($path in $context.cwd, $context.gitRoot) { if ($path -and $path -notin $paths) { $paths.Add($path) } } } # A complete lifecycle recovery needs no Git probe. Otherwise only # recorded paths can establish origin; never read today's branch. $stillLegacy = $adoScope -and ($effectiveRepository.Split('/').Count -eq 2 -or $effectiveRepository -like 'DefaultCollection/*') if (-not $effectiveRepository -or $stillLegacy) { $origins = @($paths | ForEach-Object { Get-CopilotRecordedOriginIdentity -Path $_ } | Sort-Object -Unique) if ($origins.Count -gt 1) { return $recorded } if ($origins.Count -eq 1) { if (-not (Test-CopilotRepositoryCompatibility -Recorded $effectiveRepository -Canonical $origins[0])) { return $recorded } $effectiveRepository = $origins[0] } } [pscustomobject]@{ Cwd = $effectiveCwd; Repository = $effectiveRepository; Branch = $effectiveBranch } } catch { if ($_.Exception.GetBaseException() -is [IO.EndOfStreamException]) { Write-Verbose "Identity recovery unavailable for Copilot session '$(Split-Path $SessionPath -Leaf)': $($_.Exception.GetBaseException().Message)" return $recorded } $message = "Invalid identity recovery evidence for Copilot session '$(Split-Path $SessionPath -Leaf)' in '$SessionPath': $($_.Exception.Message)" $recorded | Add-Member -NotePropertyName RecoveryError -NotePropertyValue ([System.Management.Automation.ErrorRecord]::new( [FormatException]::new($message), 'InvalidCopilotSessionIdentity', [System.Management.Automation.ErrorCategory]::InvalidData, $SessionPath)) return $recorded } } |