diff --git a/publish-local.ps1 b/publish-local.ps1 index 63094f7..25bfea4 100644 --- a/publish-local.ps1 +++ b/publish-local.ps1 @@ -66,6 +66,7 @@ $builtMtime = (Get-Item $primaryExe).LastWriteTimeUtc $launcherScripts = @( 'Invoke-PolyphonySdlc.ps1', 'Resolve-GhIdentity.ps1', + 'Resolve-PolyphonyRunId.ps1', 'Twig-Hydration.ps1', 'Migrate-ToBareRepo.ps1', 'bootstrap-conductor.ps1' diff --git a/scripts/Invoke-PolyphonySdlc.Tests.ps1 b/scripts/Invoke-PolyphonySdlc.Tests.ps1 index 243c4b6..55bb27e 100644 --- a/scripts/Invoke-PolyphonySdlc.Tests.ps1 +++ b/scripts/Invoke-PolyphonySdlc.Tests.ps1 @@ -417,7 +417,7 @@ Describe 'Invoke-PolyphonySdlc — command construction' { } finally { Pop-Location } } - It 'Includes all six -m metadata flags' { + It 'Includes all seven -m metadata flags' { Push-Location $script:fx.Main try { $r = & $script:ScriptPath -RootId 9999 -DryRun | ConvertFrom-Json @@ -425,13 +425,14 @@ Describe 'Invoke-PolyphonySdlc — command construction' { for ($i = 0; $i -lt $r.args.Count; $i++) { if ($r.args[$i] -eq '-m') { $mFlags += $r.args[$i + 1] } } - $mFlags.Count | Should -Be 6 + $mFlags.Count | Should -Be 7 ($mFlags | Where-Object { $_ -eq 'tracker=ado' }).Count | Should -Be 1 ($mFlags | Where-Object { $_ -like 'project_url=https://dev.azure.com/test-org/TestProj' }).Count | Should -Be 1 ($mFlags | Where-Object { $_ -like 'git_repo=*' }).Count | Should -Be 1 ($mFlags | Where-Object { $_ -eq 'workitem_id=9999' }).Count | Should -Be 1 ($mFlags | Where-Object { $_ -like 'worktree_name=feature-9999*' }).Count | Should -Be 1 ($mFlags | Where-Object { $_ -like 'cwd=*feature-9999*' }).Count | Should -Be 1 + ($mFlags | Where-Object { $_ -like 'run_id=*' }).Count | Should -Be 1 } finally { Pop-Location } } @@ -805,3 +806,95 @@ Describe 'Invoke-PolyphonySdlc — terminal-state pre-flight (AB#3165)' { } finally { Pop-Location } } } + + +Describe 'Invoke-PolyphonySdlc — POLYPHONY_RUN_ID (W1, AB#3275)' { + + BeforeEach { + $script:fx = New-BareRepoFixture + # Make sure no outer wrapper has pre-set the env var; each test + # owns its own lineage state. + Remove-Item Env:POLYPHONY_RUN_ID -ErrorAction SilentlyContinue + } + AfterEach { + Remove-BareRepoFixture $script:fx + Remove-Item Env:POLYPHONY_RUN_ID -ErrorAction SilentlyContinue + } + + It 'Mints a ULID-shaped run_id on a fresh root and emits -m run_id=' { + Push-Location $script:fx.Main + try { + $r = & $script:ScriptPath -RootId 4321 -DryRun | ConvertFrom-Json + $r.run_id | Should -Match '^[0-9A-HJKMNP-TV-Z]{26}$' + $r.run_id_source | Should -Be 'minted' + $r.args | Should -Contain "run_id=$($r.run_id)" + } finally { Pop-Location } + } + + It 'Reuses an existing manifest run_id on -Intent resume' { + Push-Location $script:fx.Main + try { + # Seed: register the worktree first via init-root (so subsequent + # `-Intent resume` sees a legitimate worktree), then stamp a + # known run_id into the manifest. The dry-run path skips the + # actual checkout, so we add the worktree by hand. + $rootDir = Join-Path $script:fx.Runs 'root-8765' + $wtDir = Join-Path $rootDir 'feature-8765' + New-Item -ItemType Directory -Path $rootDir -Force | Out-Null + & git --git-dir $script:fx.Bare worktree add -b feature/8765 $wtDir main --quiet 2>&1 | Out-Null + $manifestDir = Join-Path $wtDir '.polyphony' + New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null + $known = '01JZ7P0X9KZBKQR4N3FVMT8YWA' + "schema: 2`nrun_id: $known`nroot_id: 8765`n" | + Set-Content -Path (Join-Path $manifestDir 'run.yaml') + + $r = & $script:ScriptPath -RootId 8765 -Intent resume -DryRun | ConvertFrom-Json + $r.run_id | Should -Be $known + $r.run_id_source | Should -Be 'manifest' + $r.args | Should -Contain "run_id=$known" + } finally { Pop-Location } + } + + It 'Honors an externally-exported POLYPHONY_RUN_ID (nested invocation)' { + $external = '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $env:POLYPHONY_RUN_ID = $external + try { + Push-Location $script:fx.Main + try { + $r = & $script:ScriptPath -RootId 2468 -DryRun | ConvertFrom-Json + $r.run_id | Should -Be $external + $r.run_id_source | Should -Be 'external' + $r.args | Should -Contain "run_id=$external" + } finally { Pop-Location } + } finally { + Remove-Item Env:POLYPHONY_RUN_ID -ErrorAction SilentlyContinue + } + } + + It 'Reset path also emits -m run_id and honors prior-manifest lineage' { + Push-Location $script:fx.Main + try { + # Seed a prior-run manifest at the reset path. + $manifestDir = Join-Path $script:fx.Runs 'root-1357/feature-1357/.polyphony' + New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null + $prior = '01JZ7P0X9KZBKQR4N3FVMT8RST' + "schema: 2`nrun_id: $prior`nroot_id: 1357`n" | + Set-Content -Path (Join-Path $manifestDir 'run.yaml') + + $r = & $script:ScriptPath -RootId 1357 -Intent reset -DryRun | ConvertFrom-Json + $r.run_id | Should -Be $prior + $r.run_id_source | Should -Be 'manifest' + $r.args | Should -Contain "run_id=$prior" + } finally { Pop-Location } + } + + It 'Reset path mints a fresh run_id when no prior manifest exists' { + Push-Location $script:fx.Main + try { + $r = & $script:ScriptPath -RootId 9876 -Intent reset -DryRun | ConvertFrom-Json + $r.run_id | Should -Match '^[0-9A-HJKMNP-TV-Z]{26}$' + $r.run_id_source | Should -Be 'minted' + $r.args | Should -Contain "run_id=$($r.run_id)" + } finally { Pop-Location } + } +} diff --git a/scripts/Invoke-PolyphonySdlc.ps1 b/scripts/Invoke-PolyphonySdlc.ps1 index 1b82eac..9f0f019 100644 --- a/scripts/Invoke-PolyphonySdlc.ps1 +++ b/scripts/Invoke-PolyphonySdlc.ps1 @@ -264,6 +264,12 @@ function Invoke-PolyphonyJson { . (Join-Path $PSScriptRoot 'Twig-Hydration.ps1') +# ─── Helper: resolve / mint POLYPHONY_RUN_ID (W1, AB#3275) ─────────────────── +# Implementation lives in Resolve-PolyphonyRunId.ps1 (sibling file) so Pester +# can unit-test the run-id mint / discovery without dot-sourcing the launcher. + +. (Join-Path $PSScriptRoot 'Resolve-PolyphonyRunId.ps1') + # ─── Phase 1: cwd is a worktree of a git repo ──────────────────────────────── if (-not (Get-Command git -ErrorAction SilentlyContinue)) { @@ -347,6 +353,21 @@ if ($Intent -eq 'reset') { $autoConfirmBool = if ($AutoConfirm) { 'true' } else { 'false' } $skipStateBool = if ($SkipState) { 'true' } else { 'false' } + # ─── Resolve POLYPHONY_RUN_ID for lineage continuity across reset ──── + # + # Reset operates on a (possibly already-broken) prior run. We want + # journal rows the reset workflow itself writes to carry a stable run + # id — ideally the same one the prior run used, so post-reset + # forensics can correlate. Fall back to mint when the prior manifest + # is gone or unreadable. + $resetRunsRoot = Join-Path (Split-Path -Parent $commonDir) 'polyphony-runs' + $resetManifest = Join-Path $resetRunsRoot ("root-$RootId\feature-$RootId\.polyphony\run.yaml") + $resetRunIdResolution = Resolve-PolyphonyRunId -ManifestPath $resetManifest + $resetRunId = $resetRunIdResolution.RunId + $resetRunIdSource = $resetRunIdResolution.Source + $env:POLYPHONY_RUN_ID = $resetRunId + Write-Host "[polyphony-sdlc] POLYPHONY_RUN_ID=$resetRunId (source=$resetRunIdSource)" -ForegroundColor Cyan + $resetArgs = @( 'run', 'reset-root@polyphony' '--web' @@ -358,6 +379,7 @@ if ($Intent -eq 'reset') { '--input', "comment=$Comment" '-m', "workitem_id=$RootId" '-m', "cwd=$cwd" + '-m', "run_id=$resetRunId" ) $resetResolved = [pscustomobject]@{ @@ -372,6 +394,8 @@ if ($Intent -eq 'reset') { platform = $resetPlatform cwd = $cwd web_port = $resetWebPort + run_id = $resetRunId + run_id_source = $resetRunIdSource command = "conductor $($resetArgs -join ' ')" args = $resetArgs detached = -not $NoDetach @@ -533,6 +557,21 @@ To bypass this gate (NOT recommended): # ─── Phase 3: invoke init-root (--dry-run if -DryRun) ──────────────────────── +# Resolve POLYPHONY_RUN_ID before any nested polyphony call (W1, AB#3275). +# Order matters: init-root may write `.polyphony/run.yaml` via the shared +# manifest path; that path consults POLYPHONY_RUN_ID through RunContext. +# When a manifest from a prior run already exists at the canonical path, +# we honor its run_id for lineage continuity (resume / replan); otherwise +# we mint a fresh ULID. Either way we export so every nested polyphony +# subprocess and the conductor itself agree on the lineage. +$prelaunchRunsRoot = Join-Path (Split-Path -Parent $commonDir) 'polyphony-runs' +$prelaunchManifest = Join-Path $prelaunchRunsRoot ("root-$RootId\feature-$RootId\.polyphony\run.yaml") +$runIdResolution = Resolve-PolyphonyRunId -ManifestPath $prelaunchManifest +$runId = $runIdResolution.RunId +$runIdSource = $runIdResolution.Source +$env:POLYPHONY_RUN_ID = $runId +Write-Host "[polyphony-sdlc] POLYPHONY_RUN_ID=$runId (source=$runIdSource)" -ForegroundColor Cyan + # init-root self-derives the root worktree path from the common-dir. It MUST # run from a worktree of the bare repo (the operator's cwd is fine; init-root # does not refuse cwd-inside-main because the launcher legitimately calls it @@ -923,6 +962,7 @@ $conductorArgs = @( '-m', "workitem_id=$RootId" '-m', "worktree_name=$worktreeName" '-m', "cwd=$WorktreeRoot" + '-m', "run_id=$runId" ) $resolved = [pscustomobject]@{ @@ -945,6 +985,8 @@ $resolved = [pscustomobject]@{ init_root_outcome = $initOutcome git_repo = $GitRepo project_url = $projectUrl + run_id = $runId + run_id_source = $runIdSource command = "conductor $($conductorArgs -join ' ')" args = $conductorArgs web_port = $webPort diff --git a/scripts/Resolve-PolyphonyRunId.Tests.ps1 b/scripts/Resolve-PolyphonyRunId.Tests.ps1 new file mode 100644 index 0000000..371b5d1 --- /dev/null +++ b/scripts/Resolve-PolyphonyRunId.Tests.ps1 @@ -0,0 +1,167 @@ +<# +Tests for scripts/Resolve-PolyphonyRunId.ps1 — the W1 launcher helpers +(AB#3275). Verifies pure-function behavior of: + + - Read-PolyphonyManifestRunId (parses raw YAML) + - Test-PolyphonyRunIdWellFormed (mirrors RunIdMint.IsWellFormed) + - New-PolyphonyRunId (mints ULIDs matching the C# shape) + - Resolve-PolyphonyRunId (manifest > env > mint precedence) +#> + +BeforeAll { + . (Join-Path $PSScriptRoot 'Resolve-PolyphonyRunId.ps1') + + function New-TmpDir { + $d = Join-Path ([System.IO.Path]::GetTempPath()) "runid-test-$([System.Guid]::NewGuid().ToString('N').Substring(0, 12))" + New-Item -ItemType Directory -Path $d -Force | Out-Null + return $d + } +} + +Describe 'New-PolyphonyRunId' { + + It 'Emits a 26-character string' { + (New-PolyphonyRunId).Length | Should -Be 26 + } + + It 'Emits only Crockford-base32 characters (no I/L/O/U)' { + $id = New-PolyphonyRunId + $id | Should -Match '^[0-9A-HJKMNP-TV-Z]{26}$' + } + + It 'Produces distinct ids across many calls' { + $ids = 1..50 | ForEach-Object { New-PolyphonyRunId } + ($ids | Sort-Object -Unique).Count | Should -Be 50 + } + + It 'Encodes the timestamp prefix lexicographically (older < newer)' { + $older = New-PolyphonyRunId -TimestampMs 1000 + $newer = New-PolyphonyRunId -TimestampMs 2000000 + ($older.Substring(0, 10)) | Should -BeLessThan ($newer.Substring(0, 10)) + } + + It 'Rejects out-of-range timestamps' { + { New-PolyphonyRunId -TimestampMs ([long]0xFFFFFFFFFFFFF) } | Should -Throw + } +} + +Describe 'Test-PolyphonyRunIdWellFormed' { + + It 'Accepts a freshly minted ULID' { + Test-PolyphonyRunIdWellFormed (New-PolyphonyRunId) | Should -BeTrue + } + + It 'Accepts a known-good Crockford ULID' { + Test-PolyphonyRunIdWellFormed '01JZ7P0X9KZBKQR4N3FVMT8YWA' | Should -BeTrue + } + + It 'Rejects strings of the wrong length' { + Test-PolyphonyRunIdWellFormed 'TOO-SHORT' | Should -BeFalse + Test-PolyphonyRunIdWellFormed ('A' * 27) | Should -BeFalse + } + + It 'Rejects strings containing disallowed letters (I / L / O / U)' { + Test-PolyphonyRunIdWellFormed '01JZ7P0X9KZBKQR4N3FVMT8YWI' | Should -BeFalse # ends in I + Test-PolyphonyRunIdWellFormed '01JZ7P0X9KZBKQR4N3FVMT8YWO' | Should -BeFalse # ends in O + } + + It 'Rejects $null and empty string' { + Test-PolyphonyRunIdWellFormed $null | Should -BeFalse + Test-PolyphonyRunIdWellFormed '' | Should -BeFalse + } +} + +Describe 'Read-PolyphonyManifestRunId' { + + BeforeEach { $script:tmp = New-TmpDir } + AfterEach { Remove-Item -Recurse -Force $script:tmp -ErrorAction SilentlyContinue } + + It 'Returns the run_id when the key is present' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nroot_id: 1234`nrun_id: 01JZ7P0X9KZBKQR4N3FVMT8YWA`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YWA' + } + + It 'Returns $null when the file does not exist' { + Read-PolyphonyManifestRunId -ManifestPath (Join-Path $script:tmp 'nope.yaml') | Should -BeNullOrEmpty + } + + It 'Returns $null when the key is missing' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 1`nroot_id: 1234`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -BeNullOrEmpty + } + + It 'Returns $null when the value is literal "null" or "~"' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nrun_id: null`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -BeNullOrEmpty + + "schema: 2`nrun_id: ~`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -BeNullOrEmpty + } + + It 'Strips matched quotes around the value' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nrun_id: `"01JZ7P0X9KZBKQR4N3FVMT8YWA`"`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YWA' + } + + It 'Ignores nested run_id: inside multi-line collections' { + $f = Join-Path $script:tmp 'run.yaml' + # Top-level key absent; an indented `run_id:` inside `rebases:` must not match. + "schema: 2`nrebases:`n - branch: mg/1-data`n run_id: SHOULD-NOT-MATCH`n" | Set-Content -Path $f + Read-PolyphonyManifestRunId -ManifestPath $f | Should -BeNullOrEmpty + } +} + +Describe 'Resolve-PolyphonyRunId' { + + BeforeEach { + $script:tmp = New-TmpDir + Remove-Item Env:POLYPHONY_RUN_ID -ErrorAction SilentlyContinue + } + AfterEach { + Remove-Item -Recurse -Force $script:tmp -ErrorAction SilentlyContinue + Remove-Item Env:POLYPHONY_RUN_ID -ErrorAction SilentlyContinue + } + + It 'Source=manifest when a manifest run_id exists' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nrun_id: 01JZ7P0X9KZBKQR4N3FVMT8YWA`n" | Set-Content -Path $f + $r = Resolve-PolyphonyRunId -ManifestPath $f + $r.RunId | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YWA' + $r.Source | Should -Be 'manifest' + } + + It 'Source=minted when no manifest exists' { + $r = Resolve-PolyphonyRunId -ManifestPath (Join-Path $script:tmp 'absent.yaml') + $r.RunId | Should -Match '^[0-9A-HJKMNP-TV-Z]{26}$' + $r.Source | Should -Be 'minted' + } + + It 'Source=external when POLYPHONY_RUN_ID is exported' { + $env:POLYPHONY_RUN_ID = '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $r = Resolve-PolyphonyRunId -ManifestPath $null + $r.RunId | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $r.Source | Should -Be 'external' + } + + It 'External env beats manifest when -RespectExisting is the default' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nrun_id: 01JZ7P0X9KZBKQR4N3FVMT8YWA`n" | Set-Content -Path $f + $env:POLYPHONY_RUN_ID = '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $r = Resolve-PolyphonyRunId -ManifestPath $f + $r.RunId | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $r.Source | Should -Be 'external' + } + + It '-RespectExisting:$false bypasses the env and reads the manifest' { + $f = Join-Path $script:tmp 'run.yaml' + "schema: 2`nrun_id: 01JZ7P0X9KZBKQR4N3FVMT8YWA`n" | Set-Content -Path $f + $env:POLYPHONY_RUN_ID = '01JZ7P0X9KZBKQR4N3FVMT8YEX' + $r = Resolve-PolyphonyRunId -ManifestPath $f -RespectExisting:$false + $r.RunId | Should -Be '01JZ7P0X9KZBKQR4N3FVMT8YWA' + $r.Source | Should -Be 'manifest' + } +} diff --git a/scripts/Resolve-PolyphonyRunId.ps1 b/scripts/Resolve-PolyphonyRunId.ps1 new file mode 100644 index 0000000..685688d --- /dev/null +++ b/scripts/Resolve-PolyphonyRunId.ps1 @@ -0,0 +1,197 @@ +<# +.SYNOPSIS + Run-id discovery + minting for Invoke-PolyphonySdlc.ps1 (W1, AB#3275). + +.DESCRIPTION + The launcher must export a stable POLYPHONY_RUN_ID for every + `conductor run` and every nested `polyphony` invocation. Lineage rule: + + 1. If `.polyphony/run.yaml` exists and carries a `run_id:` line, use + it (resume / replan / reset continuity). + 2. Otherwise mint a fresh ULID (26-char Crockford base32, 48-bit ms + timestamp + 80 bits random) and persist it through the conductor + input metadata. The C# manifest-init path (W2) will persist that + minted id into `.polyphony/run.yaml` schema-v2 on the first save. + + Implementation lives in a sibling file so Pester can unit-test the + pure helpers without dot-sourcing the entire launcher. + + The minted shape matches `Polyphony.Journal.RunIdMint` so a launcher- + minted id round-trips through `polyphony manifest show / init` without + re-shape on the C# side. +#> + +# Crockford base32 alphabet — same one Polyphony.Journal.RunIdMint uses. +# No I / L / O / U. +$script:PolyphonyCrockfordAlphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'.ToCharArray() +$script:PolyphonyUlidLength = 26 + +function Read-PolyphonyManifestRunId { + <# + .SYNOPSIS + Returns the run_id stamped in a `.polyphony/run.yaml`, or $null. + + .DESCRIPTION + Hand-parses raw YAML (no PowerShell-Yaml dep — keeps the launcher + portable). Looks for a top-level `run_id:` key. Trims surrounding + quotes and whitespace; returns $null if the file is absent, the + key is missing, or the value is empty / `null`. + #> + param([Parameter(Mandatory)][string]$ManifestPath) + + if ([string]::IsNullOrWhiteSpace($ManifestPath)) { return $null } + if (-not (Test-Path -LiteralPath $ManifestPath)) { return $null } + + foreach ($line in Get-Content -LiteralPath $ManifestPath -ErrorAction SilentlyContinue) { + # Only top-level keys (no leading whitespace) count — nested + # `run_id:` under `rebases:` or similar must not match. + if ($line -match '^run_id:\s*(.+)$') { + $value = $matches[1].Trim() + # Strip an inline `# …` YAML comment if present. + if ($value -match '^(.*?)\s+#') { $value = $matches[1].Trim() } + # Strip matched quotes. + if ($value -match '^"(.*)"$' -or $value -match "^'(.*)'$") { + $value = $matches[1] + } + if ([string]::IsNullOrWhiteSpace($value) -or $value -ieq 'null' -or $value -ieq '~') { + return $null + } + return $value + } + } + return $null +} + +function Test-PolyphonyRunIdWellFormed { + <# + .SYNOPSIS + True when the id matches the 26-char Crockford-base32 ULID shape. + Mirrors `RunIdMint.IsWellFormed` on the C# side. + #> + param([string]$RunId) + if ([string]::IsNullOrEmpty($RunId)) { return $false } + if ($RunId.Length -ne $script:PolyphonyUlidLength) { return $false } + $alphabet = [string]::new($script:PolyphonyCrockfordAlphabet) + foreach ($ch in $RunId.ToCharArray()) { + if ($alphabet.IndexOf([char]([string]$ch).ToUpperInvariant()) -lt 0) { + return $false + } + } + return $true +} + +function New-PolyphonyRunId { + <# + .SYNOPSIS + Mints a fresh ULID matching `Polyphony.Journal.RunIdMint`. + + .DESCRIPTION + 48-bit unix-ms timestamp followed by 80 bits of cryptographic + randomness, base32-encoded into 26 characters using the Crockford + alphabet (no I / L / O / U). Lexicographically sortable by mint + time — useful for "newest run first" listings. + + .PARAMETER TimestampMs + Override the timestamp half (deterministic tests). Defaults to + `DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()`. Pass a non- + negative value <= 2^48 - 1. + #> + [CmdletBinding()] + param( + [Parameter()] + [long]$TimestampMs = -1 + ) + + if ($TimestampMs -lt 0) { + $ts = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + } else { + $ts = $TimestampMs + } + if ($ts -gt 0xFFFFFFFFFFFF) { + throw "ULID timestamp must fit in 48 bits (0..2^48 - 1); got $ts." + } + + $randomness = [byte[]]::new(10) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($randomness) + + # Pack into 128 bits = 16 bytes: first 6 bytes timestamp (big-endian), + # then 10 bytes randomness. Then base32-encode 130 bits worth — the + # canonical ULID encoding pads to 26 base32 chars (5 bits each = 130 + # bits, top 2 bits are zero by construction since 48 + 80 = 128). + $bytes = [byte[]]::new(16) + $bytes[0] = [byte](($ts -shr 40) -band 0xFF) + $bytes[1] = [byte](($ts -shr 32) -band 0xFF) + $bytes[2] = [byte](($ts -shr 24) -band 0xFF) + $bytes[3] = [byte](($ts -shr 16) -band 0xFF) + $bytes[4] = [byte](($ts -shr 8) -band 0xFF) + $bytes[5] = [byte]( $ts -band 0xFF) + [System.Array]::Copy($randomness, 0, $bytes, 6, 10) + + # Treat the 16 bytes as a 128-bit big-endian integer and emit 26 + # base32 digits, most-significant first. We process the value in two + # 64-bit halves to avoid BigInteger overhead. + $high = ([uint64]$bytes[0] -shl 56) -bor + ([uint64]$bytes[1] -shl 48) -bor + ([uint64]$bytes[2] -shl 40) -bor + ([uint64]$bytes[3] -shl 32) -bor + ([uint64]$bytes[4] -shl 24) -bor + ([uint64]$bytes[5] -shl 16) -bor + ([uint64]$bytes[6] -shl 8) -bor + ([uint64]$bytes[7]) + $low = ([uint64]$bytes[8] -shl 56) -bor + ([uint64]$bytes[9] -shl 48) -bor + ([uint64]$bytes[10] -shl 40) -bor + ([uint64]$bytes[11] -shl 32) -bor + ([uint64]$bytes[12] -shl 24) -bor + ([uint64]$bytes[13] -shl 16) -bor + ([uint64]$bytes[14] -shl 8) -bor + ([uint64]$bytes[15]) + + $chars = [char[]]::new($script:PolyphonyUlidLength) + for ($i = $script:PolyphonyUlidLength - 1; $i -ge 0; $i--) { + $idx = [int]($low -band 0x1F) + $chars[$i] = $script:PolyphonyCrockfordAlphabet[$idx] + # Shift the 128-bit value right by 5, carrying from high → low. + $carry = ($high -band 0x1F) -shl 59 + $low = (($low -shr 5) -bor $carry) + $high = ($high -shr 5) + } + return [string]::new($chars) +} + +function Resolve-PolyphonyRunId { + <# + .SYNOPSIS + Returns @{ RunId; Source } where Source is one of `manifest`, + `minted`, or `external` (POLYPHONY_RUN_ID already exported by an + outer wrapper — preserved for nested invocations). + + .PARAMETER ManifestPath + Candidate path to `.polyphony/run.yaml`. May be $null when the + worktree does not yet exist (fresh `new` run). + + .PARAMETER RespectExisting + When $true (default), an already-set $env:POLYPHONY_RUN_ID is + honored verbatim (returns Source=external). When $false, the + helper always re-resolves from the manifest / mint path — useful + for tests. + #> + [CmdletBinding()] + param( + [string]$ManifestPath, + [bool]$RespectExisting = $true + ) + + if ($RespectExisting -and -not [string]::IsNullOrWhiteSpace($env:POLYPHONY_RUN_ID)) { + return @{ RunId = $env:POLYPHONY_RUN_ID; Source = 'external' } + } + + if (-not [string]::IsNullOrWhiteSpace($ManifestPath)) { + $existing = Read-PolyphonyManifestRunId -ManifestPath $ManifestPath + if (-not [string]::IsNullOrWhiteSpace($existing)) { + return @{ RunId = $existing; Source = 'manifest' } + } + } + + return @{ RunId = (New-PolyphonyRunId); Source = 'minted' } +} diff --git a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs index 6cc7dfb..48839d1 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs @@ -94,6 +94,33 @@ public async Task EnsureEvidenceBranch( ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) : null; + if (localExisted || remoteExisted) + { + var lineageReason = await CheckBranchAdoptionLineageAsync( + "branch_ensure_evidence_branch", branch, innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new BranchEnsureEvidenceBranchPayload + { + RootId = resolvedRootId, + WorkItemId = workItemId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Orphan = orphan, + FromRef = fromRef, + Error = "foreign_lineage: " + lineageReason, + }; + EmitEvidenceError(workItemId, rootId, fromRef, payload.Error, branch: branch, baseBranch: baseBranch, orphan: orphan); + return ExitCodes.RoutingFailure; + } + } + string action; bool pushed = false; string? createdFrom = null; diff --git a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs index 86765e6..e7b52ed 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs @@ -65,6 +65,38 @@ public async Task EnsureFeature( ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) : null; + if (localExisted || remoteExisted) + { + var lineageReason = await CheckBranchAdoptionLineageAsync( + "branch_ensure_feature", branch, innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new BranchEnsureFeaturePayload + { + RootId = parsedRootId, + WorkItemId = parsedRootId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + Error = "foreign_lineage: " + lineageReason, + }; + var foreignResult = new BranchEnsureFeatureResult + { + Branch = branch, + Action = "error", + RemoteExisted = remoteExisted, + Pushed = false, + Error = payload.Error, + }; + Console.WriteLine(JsonSerializer.Serialize(foreignResult, PolyphonyJsonContext.Default.BranchEnsureFeatureResult)); + return ExitCodes.RoutingFailure; + } + } + string action; bool pushed = false; string? createdFrom = null; diff --git a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs index 1711f13..b7f799e 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs @@ -91,6 +91,32 @@ public async Task EnsureImpl( ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) : null; + if (localExisted || remoteExisted) + { + var lineageReason = await CheckBranchAdoptionLineageAsync( + "branch_ensure_impl", branch, innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new BranchEnsureImplPayload + { + RootId = rootId, + WorkItemId = itemId, + MergeGroupPath = path.Canonical, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitImplError(rootId, itemId, mgPath, payload.Error, branch: branch, baseBranch: baseBranch); + return ExitCodes.RoutingFailure; + } + } + string action; bool pushed = false; string? createdFrom = null; diff --git a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs index 2d74fcd..5efad83 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs @@ -87,6 +87,32 @@ public async Task EnsureMergeGroup( ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) : null; + if (localExisted || remoteExisted) + { + var lineageReason = await CheckBranchAdoptionLineageAsync( + "branch_ensure_merge_group", branch, innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new BranchEnsureMergeGroupPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + Depth = path.Depth, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitMgError(rootId, mgPath, payload.Error, branch: branch, baseBranch: baseBranch, depth: path.Depth); + return ExitCodes.RoutingFailure; + } + } + string action; bool pushed = false; string? createdFrom = null; diff --git a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs index a2ed4b6..f970d82 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs @@ -132,6 +132,34 @@ public async Task EnsurePlan( ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) : null; + if (localExisted || remoteExisted) + { + var lineageReason = await CheckBranchAdoptionLineageAsync( + "branch_ensure_plan", branch, innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new BranchEnsurePlanPayload + { + RootId = rootId, + WorkItemId = itemId, + ParentItemId = parent, + IsRootPlan = isRootPlan, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitPlanError(rootId, itemId, parentItemId, payload.Error, + branch: branch, baseBranch: baseBranch, isRootPlan: isRootPlan); + return ExitCodes.RoutingFailure; + } + } + string action; bool pushed = false; string? createdFrom = null; diff --git a/src/Polyphony/Commands/BranchCommands.LineageCheck.cs b/src/Polyphony/Commands/BranchCommands.LineageCheck.cs new file mode 100644 index 0000000..74a4130 --- /dev/null +++ b/src/Polyphony/Commands/BranchCommands.LineageCheck.cs @@ -0,0 +1,34 @@ +namespace Polyphony.Commands; + +public sealed partial class BranchCommands +{ + /// + /// W10 (AB#3291) wrapper around + /// for the five ensure-* verbs. Centralises the lineage + /// check at the moment a verb would adopt an existing remote or + /// local branch. Returns null on allow, a structured + /// refusal reason on refuse. + /// + /// Only fires when the verb actually observes an existing + /// branch — first-time creation in a fresh checkout never trips + /// the guard. The grounding posture is symmetric with + /// : refuse on + /// positive foreign evidence; allow on silence; fail open on + /// transient journal errors. + /// + internal async Task CheckBranchAdoptionLineageAsync( + string journalAction, + string branchName, + CancellationToken ct) + { + var decision = await BranchLineageGuard.CheckAsync( + journal: _journalStore, + currentRunId: _runContext.RunId, + isManualLineage: _runContext.HasManualLineage, + journalAction: journalAction, + journalTarget: branchName, + ct).ConfigureAwait(false); + + return decision.Allowed ? null : decision.Reason; + } +} diff --git a/src/Polyphony/Commands/BranchCommands.NextImpl.cs b/src/Polyphony/Commands/BranchCommands.NextImpl.cs index ac88b55..ed3100a 100644 --- a/src/Polyphony/Commands/BranchCommands.NextImpl.cs +++ b/src/Polyphony/Commands/BranchCommands.NextImpl.cs @@ -142,11 +142,33 @@ public async Task NextImpl( candidates = implementable; } - var nonTerminal = candidates - .Where(n => !IsTerminalCategory(n.Node.State)) - .Where(n => !PolyphonyTags.HasImplMergedInMg( - TagSet.Parse(n.Node.Tags), implMergedKey)) - .ToList(); + // W8: trust polyphony:impl-merged-in-mg tag only when + // current lineage produced it. When the journal shows + // this lineage never recorded a branch_mark_impl_merged + // for the item but a prior lineage did, treat the tag + // as foreign residue (don't exclude the item from + // candidates). Concurrent async predicate evaluation + // is fine here — JournalLineageGrounding is read-only. + var nonTerminal = new List(); + foreach (var candidate in candidates) + { + if (IsTerminalCategory(candidate.Node.State)) + continue; + if (!PolyphonyTags.HasImplMergedInMg(TagSet.Parse(candidate.Node.Tags), implMergedKey)) + { + nonTerminal.Add(candidate); + continue; + } + var tagIsForeign = await JournalLineageGrounding.IsTagForeignAsync( + _journalStore, _runContext, candidate.Node.WorkItemId, + "branch_mark_impl_merged", innerCt).ConfigureAwait(false); + if (tagIsForeign) + { + await Console.Error.WriteLineAsync( + $"warning: polyphony:impl-merged-in-mg tag on item {candidate.Node.WorkItemId} appears foreign to current lineage '{_runContext.RunId}' — ignoring for routing.").ConfigureAwait(false); + nonTerminal.Add(candidate); + } + } workspace = await ResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); if (nonTerminal.Count == 0) diff --git a/src/Polyphony/Commands/BranchCommands.cs b/src/Polyphony/Commands/BranchCommands.cs index beaeae4..2925a42 100644 --- a/src/Polyphony/Commands/BranchCommands.cs +++ b/src/Polyphony/Commands/BranchCommands.cs @@ -34,10 +34,12 @@ public sealed partial class BranchCommands( Sdlc.Observers.RepoIdentityResolver repoIdentityResolver, Sdlc.Observers.PullRequestReader pullRequestReader, RunContext runContext, - JournaledActionDecorator decorator) + JournaledActionDecorator decorator, + Polyphony.Journal.IJournalStore? journalStore = null) { private readonly RunContext _runContext = runContext; private readonly JournaledActionDecorator _journalDecorator = decorator; + private readonly Polyphony.Journal.IJournalStore? _journalStore = journalStore; /// /// Check ADO predecessor links for blocking dependencies on a work item. diff --git a/src/Polyphony/Commands/BranchLineageGuard.cs b/src/Polyphony/Commands/BranchLineageGuard.cs new file mode 100644 index 0000000..fd7d3f7 --- /dev/null +++ b/src/Polyphony/Commands/BranchLineageGuard.cs @@ -0,0 +1,118 @@ +using Polyphony.Journal; + +namespace Polyphony.Commands; + +/// +/// W10 (AB#3291) branch-side analog of . +/// Git branches carry no body or stamping, so the only grounding +/// signal is the journal: did a successful branch_ensure_* +/// row from a foreign lineage already claim this branch name? +/// +/// The bug this closes: polyphony branch ensure-* verbs +/// observe an existing remote/local branch and adopt it. After a +/// reset wipes the local journal but leaves the remote branch +/// (network drop, partial reset, cross-machine checkout), the new +/// lineage silently adopts the foreign branch, hides the divergent +/// SHA from the user, and corrupts the per-item review attribution +/// downstream. +/// +/// Decision matrix (mirrors +/// posture): +/// +/// Current run id missing OR manual lineage → allow (no +/// grounding signal; refusing would brick local-dev verb usage). +/// Null/Null-store journal → allow (no grounding signal). +/// Query throws → allow (fail open; routing must not brick +/// on transient SQLite errors). +/// Journal has a successful branch_ensure_* row for +/// this target under the current run id → allow (we already own +/// this branch). +/// Journal has rows for this target only under other run +/// ids → refuse (foreign-owned). +/// Journal silent for this target → allow (legacy/pre-W11 +/// branch, or first creation in this lineage). +/// +/// +internal static class BranchLineageGuard +{ + /// + /// Outcome of the lineage check. is populated + /// on both allow and refuse so callers can log the corroborating + /// signal regardless of direction. + /// + public sealed record Decision(bool Allowed, string Reason); + + /// + /// Inspect the journal for prior + /// rows on and decide whether + /// the current lineage may adopt the branch. Fails open on every + /// "unknown" signal; only refuses when there is positive evidence + /// of foreign ownership. + /// + internal static async Task CheckAsync( + IJournalStore? journal, + string? currentRunId, + bool isManualLineage, + string journalAction, + string journalTarget, + CancellationToken ct) + { + if (string.IsNullOrEmpty(currentRunId) || isManualLineage) + { + return new Decision(true, "Lineage check skipped: no run id or manual lineage."); + } + + if (journal is null or NullJournalStore) + { + return new Decision(true, "Lineage check skipped: no journal store available."); + } + + IReadOnlyList rows; + try + { + rows = await journal.QueryAsync( + new JournalQuery { Action = journalAction }, + ct).ConfigureAwait(false); + } + catch + { + // Fail open — transient journal errors must not block the + // hot routing path. PrLineageGuard's symmetry: the journal + // is a corroborating source, not a hard fence. + return new Decision(true, "Lineage check skipped: journal query failed."); + } + + var hasCurrent = false; + var hasOther = false; + string? firstForeignRunId = null; + foreach (var row in rows) + { + if (!string.Equals(row.Target, journalTarget, StringComparison.Ordinal)) continue; + if (row.Outcome != JournalOutcome.Success) continue; + + if (string.Equals(row.RunId, currentRunId, StringComparison.Ordinal)) + { + hasCurrent = true; + } + else + { + hasOther = true; + firstForeignRunId ??= row.RunId; + } + } + + if (hasCurrent) + { + return new Decision(true, "Journal has a prior ensure-row for this branch under the current run id."); + } + + if (hasOther) + { + return new Decision( + false, + $"Branch '{journalTarget}' was previously {journalAction} by run_id='{firstForeignRunId}' which differs from current run_id='{currentRunId}'. Refusing to adopt a foreign-lineage branch."); + } + + return new Decision(true, "Journal silent for this branch; allowing (legacy or first-time create)."); + } +} diff --git a/src/Polyphony/Commands/JournalCommandSupport.cs b/src/Polyphony/Commands/JournalCommandSupport.cs index d70a4ae..e97688d 100644 --- a/src/Polyphony/Commands/JournalCommandSupport.cs +++ b/src/Polyphony/Commands/JournalCommandSupport.cs @@ -13,6 +13,8 @@ internal static RunContext ResolveRunContext(RunContext? runContext) internal static JournaledActionDecorator ResolveDecorator(JournaledActionDecorator? decorator) => decorator ?? new JournaledActionDecorator(new NullJournalStore()); + internal static readonly IJournalStore NullStore = new NullJournalStore(); + internal static JournaledActionInvocation CreateInvocation( RunContext runContext, string action, diff --git a/src/Polyphony/Commands/JournalLineageGrounding.cs b/src/Polyphony/Commands/JournalLineageGrounding.cs new file mode 100644 index 0000000..250ace0 --- /dev/null +++ b/src/Polyphony/Commands/JournalLineageGrounding.cs @@ -0,0 +1,103 @@ +using Polyphony.Journal; + +namespace Polyphony.Commands; + +/// +/// W8 (AB#????): shared helper for "is this twig/git tag foreign to +/// the current run lineage?" decisions made by routing-style verbs +/// (polyphony state next-ready, polyphony branch next-impl). +/// +/// +/// Tags like polyphony:facets=..., polyphony:planned, +/// and polyphony:impl-merged-in-mg=... are mutating +/// observations that earlier runs stamped on ADO work items / git +/// branches. They're NOT scoped to a run id, so a fresh polyphony +/// run sees them and (pre-W8) treats them as authoritative routing +/// input — even though the lineage that produced them has been +/// reset, abandoned, or never belonged to this checkout in the +/// first place. +/// +/// +/// +/// The grounding rule (per the run-id-lineage epic, C2 spec §W8): +/// +/// +/// If the current run carries a manual_* lineage, the +/// launcher never stamped POLYPHONY_RUN_ID; we have no way +/// to ground anything, so trust the tag (status quo). +/// If a real journal is wired AND the current lineage has +/// recorded the action for this work item, trust the tag (we +/// produced it). +/// If a real journal is wired AND the current lineage has +/// NOT recorded the action for this work item but OTHER lineages +/// have rows for this work item, the tag is foreign residue — +/// refuse to trust it for routing purposes. +/// If a real journal is wired and the journal is empty for +/// this work item entirely, trust the tag (legacy/pre-journal +/// row, no grounding signal either way). +/// +/// +/// +/// The check is intentionally async and tolerant of transient +/// journal errors (fail-open: a stuck-run triage tool must not +/// itself be a footgun on the hottest routing paths). +/// +/// +internal static class JournalLineageGrounding +{ + /// + /// True when the journal evidence indicates the tag was stamped + /// by a foreign lineage and should not be trusted as routing + /// input. See class summary for the full decision matrix. + /// + /// Live journal store. null or + /// → returns false (no grounding + /// signal available, fall back to trusting the tag). + /// Current run context. When + /// is true, returns + /// false (no real lineage to ground against). + /// The work item the tag is stamped on. + /// The journal action that, if recorded + /// under the current lineage, demonstrates we produced this tag + /// (e.g. plan_seed_children for the facets tag, + /// branch_mark_impl_merged for the impl-merged-in-mg tag). + /// Cancellation token. + internal static async Task IsTagForeignAsync( + IJournalStore? journalStore, + RunContext runContext, + int workItemId, + string actionName, + CancellationToken ct) + { + if (journalStore is null or NullJournalStore) return false; + if (runContext.HasManualLineage) return false; + + IReadOnlyList rows; + try + { + rows = await journalStore.QueryAsync( + new JournalQuery { WorkItemId = workItemId, Action = actionName }, + ct).ConfigureAwait(false); + } + catch + { + // Fail open. The journal is a hint, not a hard fence — + // a transient SQLite error must not brick routing. + return false; + } + + if (rows.Count == 0) return false; + + var hasCurrent = false; + var hasOther = false; + foreach (var row in rows) + { + if (string.Equals(row.RunId, runContext.RunId, StringComparison.Ordinal)) + hasCurrent = true; + else + hasOther = true; + } + + return !hasCurrent && hasOther; + } +} diff --git a/src/Polyphony/Commands/LineageCommands.Attach.cs b/src/Polyphony/Commands/LineageCommands.Attach.cs new file mode 100644 index 0000000..ddb2794 --- /dev/null +++ b/src/Polyphony/Commands/LineageCommands.Attach.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Models; + +namespace Polyphony.Commands; + +public sealed partial class LineageCommands +{ + /// + /// W14 (AB#3295): record a local journal_lineages stub for a + /// pre-existing run id so a fresh checkout can attach to an + /// in-flight run instead of minting a parallel lineage. + /// + /// The verb is the cross-machine on-ramp called out in B5 §2 + /// of the lineage design doc: on a second checkout (or wiped + /// tmpdir), the journal is empty and would otherwise let a routine + /// auto-fallback start a brand-new manual_* lineage. With + /// attach, the operator pins a real ULID up front, so + /// downstream verbs that consult (W11) + /// see the lineage as already-current. + /// + /// This thin v1 only records the stub locally. A future + /// enhancement will validate remote stamps before recording to + /// catch typos. For now, callers are expected to supply a run id + /// they've verified by other means (e.g. reading + /// .polyphony/run.yaml from the feature branch). + /// + /// Root work-item ID to scope to. + /// ULID-shaped run id to attach. + /// Free-form rationale (recorded as host comment). + /// + /// When false (default), report what would be attached without + /// mutating the journal. + /// + /// Cancellation token. + [Command("attach")] + [VerbResult(typeof(LineageAttachResult))] + public async Task Attach( + int root = RequiredInput.MissingInt, + string runId = "", + string reason = "", + bool execute = false, + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("lineage attach", + ("--root", root == RequiredInput.MissingInt), + ("--run-id", string.IsNullOrWhiteSpace(runId))) is { } halt) + return halt; + + var normalisedReason = string.IsNullOrWhiteSpace(reason) ? null : reason; + + if (root <= 0) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = false, Reason = normalisedReason, + AlreadyExisted = false, Success = false, + Error = "--root must be positive.", + }); + return ExitCodes.ConfigError; + } + + if (journalStore is NullJournalStore) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = execute, Reason = normalisedReason, + AlreadyExisted = false, Success = true, + }); + return ExitCodes.Success; + } + + IReadOnlyList existing; + try + { + existing = await journalStore.GetLineagesAsync(root, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = false, Reason = normalisedReason, + AlreadyExisted = false, Success = false, + Error = $"Failed to enumerate lineages: {ex.Message}", + }); + return ExitCodes.RoutingFailure; + } + + var match = existing.FirstOrDefault(l => string.Equals(l.RunId, runId, StringComparison.Ordinal)); + var alreadyExisted = match is not null; + + // If a non-retired row is already present, attach is a no-op + // success. Retired rows are also treated as already-existing; + // attach refuses rather than silently un-retiring (operators + // should use a fresh run id rather than resurrect a tombstone). + if (alreadyExisted && match!.RetiredAt is not null) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = false, Reason = normalisedReason, + AlreadyExisted = true, Success = false, + Error = $"Lineage '{runId}' for root {root} is retired; mint a new run id instead.", + }); + return ExitCodes.RoutingFailure; + } + + if (alreadyExisted) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = execute, Reason = normalisedReason, + AlreadyExisted = true, Success = true, + }); + return ExitCodes.Success; + } + + if (execute) + { + try + { + await journalStore.RecordLineageAsync( + runId, root, Environment.MachineName, Environment.UserName, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = true, Reason = normalisedReason, + AlreadyExisted = false, Success = false, + Error = $"Failed to record lineage: {ex.Message}", + }); + return ExitCodes.RoutingFailure; + } + } + + EmitAttach(new LineageAttachResult + { + Root = root, RunId = runId, Executed = execute, Reason = normalisedReason, + AlreadyExisted = false, Success = true, + }); + return ExitCodes.Success; + } + + private static void EmitAttach(LineageAttachResult result) + => Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.LineageAttachResult)); +} diff --git a/src/Polyphony/Commands/LineageCommands.Retire.cs b/src/Polyphony/Commands/LineageCommands.Retire.cs new file mode 100644 index 0000000..cdb2080 --- /dev/null +++ b/src/Polyphony/Commands/LineageCommands.Retire.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Models; + +namespace Polyphony.Commands; + +public sealed partial class LineageCommands +{ + /// + /// W12 (AB#3293): tombstone a (run_id, root_id) lineage so future + /// runs treat it as foreign. The reset pipeline calls this with + /// --all-active after cleanup completes; operators can also + /// invoke it directly with --run-id to retire a single + /// surviving lineage from a botched run. + /// + /// Retirement is a journal-bookkeeping concern, not a + /// platform mutation: this verb writes only to the local + /// journal_lineages table. Already-retired lineages are + /// skipped silently (the verb is idempotent). + /// + /// Root work-item ID to scope to. + /// + /// Specific run id to retire. Mutually exclusive with + /// . + /// + /// + /// When true, retire every active (non-tombstoned) lineage for + /// the root. Mutually exclusive with . + /// + /// Free-form retirement reason (recorded). + /// + /// When false (default), report what would be retired without + /// mutating the journal. + /// + /// Cancellation token. + [Command("retire")] + [VerbResult(typeof(LineageRetireResult))] + public async Task Retire( + int root = RequiredInput.MissingInt, + string runId = "", + bool allActive = false, + string reason = "", + bool execute = false, + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("lineage retire", + ("--root", root == RequiredInput.MissingInt)) is { } halt) + return halt; + + var hasRunId = !string.IsNullOrWhiteSpace(runId); + if (hasRunId && allActive) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = runId, + AllActive = true, + Executed = false, + Reason = string.IsNullOrWhiteSpace(reason) ? null : reason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = false, + Error = "--run-id and --all-active are mutually exclusive.", + }); + return ExitCodes.ConfigError; + } + if (!hasRunId && !allActive) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = null, + AllActive = false, + Executed = false, + Reason = string.IsNullOrWhiteSpace(reason) ? null : reason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = false, + Error = "Must specify exactly one of --run-id or --all-active.", + }); + return ExitCodes.ConfigError; + } + + var normalisedReason = string.IsNullOrWhiteSpace(reason) ? null : reason; + + // NullJournalStore short-circuit: nothing to retire, but + // succeed so the reset pipeline can tolerate a journal-less + // env (e.g. unit tests that mock the journal layer). + if (journalStore is NullJournalStore) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = hasRunId ? runId : null, + AllActive = allActive, + Executed = execute, + Reason = normalisedReason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = true, + }); + return ExitCodes.Success; + } + + IReadOnlyList lineages; + try + { + lineages = await journalStore.GetLineagesAsync(root, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = hasRunId ? runId : null, + AllActive = allActive, + Executed = execute, + Reason = normalisedReason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = false, + Error = $"Failed to enumerate lineages: {ex.Message}", + }); + return ExitCodes.RoutingFailure; + } + + var targets = hasRunId + ? lineages.Where(l => string.Equals(l.RunId, runId, StringComparison.Ordinal)).ToList() + : lineages.Where(l => l.RetiredAt is null).ToList(); + var skipped = lineages + .Where(l => !targets.Contains(l) || l.RetiredAt is not null) + .Select(l => l.RunId) + .Distinct(StringComparer.Ordinal) + .ToList(); + // When run-id was specified but no matching row exists, + // surface that as a skip + Success=true (idempotent: maybe + // the lineage was already retired in a prior invocation). + if (hasRunId && targets.Count == 0) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = runId, + AllActive = false, + Executed = execute, + Reason = normalisedReason, + RetiredRunIds = [], + SkippedRunIds = lineages.Select(l => l.RunId).Distinct(StringComparer.Ordinal).ToList(), + Success = true, + }); + return ExitCodes.Success; + } + + var retired = new List(); + var skippedRetiredIds = new List(); + if (execute) + { + foreach (var lineage in targets) + { + if (lineage.RetiredAt is not null) + { + skippedRetiredIds.Add(lineage.RunId); + continue; + } + try + { + var didRetire = await journalStore.RetireLineageAsync( + lineage.RunId, root, normalisedReason, ct).ConfigureAwait(false); + if (didRetire) + retired.Add(lineage.RunId); + else + skippedRetiredIds.Add(lineage.RunId); + } + catch (Exception ex) + { + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = hasRunId ? runId : null, + AllActive = allActive, + Executed = true, + Reason = normalisedReason, + RetiredRunIds = retired, + SkippedRunIds = skippedRetiredIds, + Success = false, + Error = $"Failed to retire run id '{lineage.RunId}': {ex.Message}", + }); + return ExitCodes.RoutingFailure; + } + } + } + else + { + // Dry-run: report what we WOULD retire. + foreach (var lineage in targets) + { + if (lineage.RetiredAt is null) + retired.Add(lineage.RunId); + else + skippedRetiredIds.Add(lineage.RunId); + } + } + + var combinedSkipped = skipped + .Concat(skippedRetiredIds) + .Distinct(StringComparer.Ordinal) + .Where(id => !retired.Contains(id, StringComparer.Ordinal)) + .ToList(); + + EmitRetire(new LineageRetireResult + { + Root = root, + RunId = hasRunId ? runId : null, + AllActive = allActive, + Executed = execute, + Reason = normalisedReason, + RetiredRunIds = retired, + SkippedRunIds = combinedSkipped, + Success = true, + }); + return ExitCodes.Success; + } + + private static void EmitRetire(LineageRetireResult result) + => Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.LineageRetireResult)); +} diff --git a/src/Polyphony/Commands/LineageCommands.cs b/src/Polyphony/Commands/LineageCommands.cs new file mode 100644 index 0000000..80af4df --- /dev/null +++ b/src/Polyphony/Commands/LineageCommands.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Manifest; +using Polyphony.Models; + +namespace Polyphony.Commands; + +/// +/// W15 (AB#3285): polyphony lineage status --root <id> +/// — pure read-only diagnostic that triangulates current lineage, +/// manifest lineage, and journal-observed lineages so an operator +/// (or the future polyphony reconcile verb) can quickly tell: +/// +/// am I on the lineage the manifest expects? +/// does the journal know about THIS lineage at all? +/// is the journal partitioned (other lineages present but +/// not mine — fresh checkout, wiped tmpdir, cross-machine)? +/// +/// +/// Carries no side-effects — no writes to the journal, no +/// branch/PR fetches. Designed to be safe to invoke against any +/// in-flight run from any machine. +/// +/// The forthcoming W11 schema additions +/// (journal_lineages table) will let this verb report retired +/// lineages too; until then "lineages" means "distinct run ids +/// observed in the existing actions table". +/// +[VerbGroup("lineage")] +public sealed partial class LineageCommands( + RunContext runContext, + IJournalStore journalStore) +{ + /// + /// Emit a envelope for + /// . + /// + /// Root work-item ID to scope the report to. + /// + /// Override of the manifest path. Defaults to + /// .polyphony/run.yaml resolved under + /// . + /// + /// Cancellation token. + [Command("status")] + [VerbResult(typeof(LineageStatusResult))] + public async Task Status( + int root = RequiredInput.MissingInt, + string manifestPath = "", + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("lineage status", + ("--root", root == RequiredInput.MissingInt)) is { } halt) + return halt; + + var resolvedManifestPath = string.IsNullOrEmpty(manifestPath) + ? RunManifestStore.DefaultRelativePath + : manifestPath; + + string? manifestRunId = null; + string? manifestError = null; + if (!string.IsNullOrEmpty(resolvedManifestPath) && File.Exists(resolvedManifestPath)) + { + try + { + var yaml = File.ReadAllText(resolvedManifestPath); + var manifest = RunManifestStore.Parse(yaml, resolvedManifestPath); + manifestRunId = string.IsNullOrEmpty(manifest.RunId) ? null : manifest.RunId; + } + catch (Exception ex) + { + manifestError = ex.Message; + } + } + else + { + // Distinguish "no manifest at all" from "manifest exists + // but unreadable" via ManifestError staying null here. + resolvedManifestPath = null; + } + + var lineages = await ReadLineagesAsync(root, ct).ConfigureAwait(false); + var currentRunId = runContext.RunId; + var currentHasRows = lineages.Any(l => string.Equals(l.RunId, currentRunId, StringComparison.Ordinal)); + var otherHasRows = lineages.Any(l => !string.Equals(l.RunId, currentRunId, StringComparison.Ordinal)); + var partitioned = !currentHasRows && otherHasRows; + + var result = new LineageStatusResult + { + Root = root, + CurrentRunId = currentRunId, + CurrentLineageIsManual = runContext.HasManualLineage, + ManifestPath = resolvedManifestPath, + ManifestRunId = manifestRunId, + ManifestError = manifestError, + JournalPath = journalStore is NullJournalStore ? null : journalStore.DatabasePath, + Lineages = lineages, + CurrentLineageHasRows = currentHasRows, + LooksPartitioned = partitioned, + Verdict = ComposeVerdict( + currentRunId, runContext.HasManualLineage, manifestRunId, + currentHasRows, partitioned, lineages.Count), + }; + + Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.LineageStatusResult)); + return ExitCodes.Success; + } + + /// + /// Walk the journal for every distinct run id under , + /// counting rows and bounding first/last activity timestamps so a + /// triage operator can spot "yesterday's stuck run" without + /// running raw SQL. Also merges in W11's journal_lineages + /// rows so retired lineages and lineages-with-no-actions surface + /// alongside lineages-with-actions. + /// + private async Task> ReadLineagesAsync(int rootId, CancellationToken ct) + { + if (journalStore is NullJournalStore) return Array.Empty(); + + IReadOnlyList rows; + try + { + rows = await journalStore.QueryAsync(new JournalQuery { RootId = rootId }, ct).ConfigureAwait(false); + } + catch + { + // Fail open: diagnostic must not throw on a transient + // journal error. Empty list means "we couldn't ask" — the + // verdict logic still produces a useful result from the + // remaining inputs. + return Array.Empty(); + } + + IReadOnlyList tombstones; + try + { + tombstones = await journalStore.GetLineagesAsync(rootId, ct).ConfigureAwait(false); + } + catch + { + tombstones = Array.Empty(); + } + + var byRunId = new Dictionary(StringComparer.Ordinal); + foreach (var group in rows.GroupBy(r => r.RunId, StringComparer.Ordinal)) + { + byRunId[group.Key] = new LineageObservation + { + RunId = group.Key, + RowCount = group.Count(), + FirstSeenAt = group.Min(r => r.StartedAt), + LastSeenAt = group.Max(r => r.FinishedAt ?? r.StartedAt), + IsCurrent = string.Equals(group.Key, runContext.RunId, StringComparison.Ordinal), + }; + } + // Merge in W11 lineage rows. A lineage may exist in + // journal_lineages without any actions (rare — e.g. attach + // recorded a stub, or the lineage was retired before any + // mutating action). Such lineages must still appear in the + // status report so operators can see them. + foreach (var tombstone in tombstones) + { + if (!byRunId.TryGetValue(tombstone.RunId, out var existing)) + { + existing = new LineageObservation + { + RunId = tombstone.RunId, + RowCount = 0, + FirstSeenAt = tombstone.CreatedAt, + LastSeenAt = tombstone.RetiredAt ?? tombstone.CreatedAt, + IsCurrent = string.Equals(tombstone.RunId, runContext.RunId, StringComparison.Ordinal), + }; + } + byRunId[tombstone.RunId] = existing with + { + RetiredAt = tombstone.RetiredAt, + RetiredReason = tombstone.RetiredReason, + }; + } + + return byRunId.Values + .OrderBy(o => o.RunId, StringComparer.Ordinal) + .ToList(); + } + + private string? TryResolveManifestPath(int rootId) + { + try + { + return RunManifestStore.DefaultRelativePath; + } + catch + { + return null; + } + } + + private static string ComposeVerdict( + string currentRunId, + bool isManual, + string? manifestRunId, + bool currentHasRows, + bool partitioned, + int distinctLineageCount) + { + if (isManual) + { + return "current_lineage_manual: no POLYPHONY_RUN_ID exported; verb running outside a launcher invocation."; + } + if (partitioned) + { + return $"journal_partitioned: {distinctLineageCount} other lineage(s) recorded for this root, but no rows for current run id '{currentRunId}'."; + } + if (manifestRunId is not null + && !string.Equals(manifestRunId, currentRunId, StringComparison.Ordinal)) + { + return $"manifest_lineage_mismatch: manifest carries run_id='{manifestRunId}' but current run id is '{currentRunId}'."; + } + if (currentHasRows) + { + return $"current_lineage_active: journal recognises run id '{currentRunId}' for this root."; + } + if (distinctLineageCount == 0) + { + return "no_journal_history: no journal rows for this root under any lineage."; + } + return "current_lineage_silent: journal has no rows for current lineage and the manifest agrees."; + } +} diff --git a/src/Polyphony/Commands/ManifestCommands.cs b/src/Polyphony/Commands/ManifestCommands.cs index 23609a5..bc3c510 100644 --- a/src/Polyphony/Commands/ManifestCommands.cs +++ b/src/Polyphony/Commands/ManifestCommands.cs @@ -252,10 +252,27 @@ private async Task InitCoreAsync( return ExitCodes.ConfigError; } + // Resolve the lineage stamp. Honour the launcher's POLYPHONY_RUN_ID + // when set; mint a fresh ULID rather than persist the ephemeral + // manual_* fallback into a durable manifest. + string resolvedRunId; + string resolvedRunIdSource; + if (_runContext.HasManualLineage) + { + resolvedRunId = RunIdMint.NewRunId(); + resolvedRunIdSource = "minted"; + } + else + { + resolvedRunId = _runContext.RunId; + resolvedRunIdSource = _runContext.RunIdSource; + } + var manifest = new RunManifest { - Schema = RunManifestValidator.SupportedSchema, + Schema = RunManifestValidator.CurrentSchema, RootId = rootId, + RunId = resolvedRunId, PlatformProject = platformProject, CreatedAt = DateTime.UtcNow, CreatedBy = resolvedCreatedBy, @@ -272,6 +289,8 @@ private async Task InitCoreAsync( PlatformProject = platformProject, Created = !existed, CreatedBy = resolvedCreatedBy, + RunId = resolvedRunId, + RunIdSource = resolvedRunIdSource, TopologyHash = manifest.TopologyHash, Message = existed ? $"overwrote existing manifest (--force)" : null, PathSource = resolution.Source, diff --git a/src/Polyphony/Commands/PlanCommands.DetectState.cs b/src/Polyphony/Commands/PlanCommands.DetectState.cs index 9da86d4..9bf94b8 100644 --- a/src/Polyphony/Commands/PlanCommands.DetectState.cs +++ b/src/Polyphony/Commands/PlanCommands.DetectState.cs @@ -5,6 +5,8 @@ using Polyphony.Annotations; using Polyphony.Branching; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; using Polyphony.Manifest; using Polyphony.Sdlc.Observers; @@ -103,6 +105,259 @@ public async Task DetectState( } var localManifestPath = resolvedPath.Path; + // ── 1b. Journal-grounding (W3 + W4, AB#3277/AB#3278). ───────────── + // When the launcher (W1) has stamped a real POLYPHONY_RUN_ID and a + // real journal is wired, the journal — NOT PR archaeology — is the + // anchor for which artifacts belong to this run. The journal + // answers "did THIS lineage open / merge / finish planning for + // this item?"; git/ADO answer "what is the live status of those + // journal-grounded objects?". See B1 walk-through (team-design). + // + // Skipped silently when the journal is a NullJournalStore or the + // RunContext fell back to a `manual_*` lineage — neither case can + // distinguish "we never wrote any" from "no rows". + if (!_runContext.HasManualLineage && _journalStore is not NullJournalStore) + { + JournalGrounding grounding; + try + { + grounding = await GroundFromJournalAsync(rootId, itemId, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // Fail open: a journal read error must not block the + // legacy PR-archaeology path. Note for observability. + Console.Error.WriteLine($"[plan detect-state] journal grounding skipped: {ex.Message}"); + goto SkipJournalShortCircuit; + } + + // Case W3 (B1 cases 1, 7): no journal rows for current + // lineage → ignore any PR/branch/tag residue, emit not_started. + if (grounding.NoRows) + { + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "not_started", + BranchExistsOnOrigin = false, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + // Case W4 (B1 case 5): journal says planning completed → + // candidate `complete`. Run the existing child-overlay so an + // approved child PR with `requests_parent_change: true` flips + // us to `parent_change_pending`. We still need an identity + // for the child PR enumeration. + if (grounding.PlanningCompleted) + { + var identityForChildren = await observer + .TryResolveRepoIdentityAsync(platform, organization, project, repositoryOverride, ct) + .ConfigureAwait(false); + if (identityForChildren is null) + { + EmitDetectStateError(rootId, itemId, planBranch, + "Could not resolve repo identity from origin remote (or supplied overrides)"); + return ExitCodes.Success; + } + + var journalPendingChildren = await CheckChildrenForParentChangeRequestsAsync( + rootId, itemId, identityForChildren, localManifestPath, ct).ConfigureAwait(false); + + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = journalPendingChildren.Count > 0 ? "parent_change_pending" : "complete", + BranchExistsOnOrigin = true, + PrNumber = grounding.PrNumber, + PrUrl = grounding.PrUrl, + PrState = "MERGED", + ParentChangePendingChildren = journalPendingChildren, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + // Case W4 (B1 cases 3, 4): journal says merged but planning + // NOT completed → merged_unseeded. ADO/git corroborate, they + // don't create this state from nothing. + if (grounding.PrMerged) + { + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "merged_unseeded", + BranchExistsOnOrigin = true, + PrNumber = grounding.PrNumber, + PrUrl = grounding.PrUrl, + PrState = "MERGED", + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + // Case W4 (B1 cases 2, 6, 8): journal says PR opened, no + // merge fact yet → poll THAT PR (not "latest PR for branch") + // and route on its live state. + if (grounding.PrNumber is { } journalPrNumber) + { + var identityForPoll = await observer + .TryResolveRepoIdentityAsync(platform, organization, project, repositoryOverride, ct) + .ConfigureAwait(false); + if (identityForPoll is null) + { + EmitDetectStateError(rootId, itemId, planBranch, + "Could not resolve repo identity from origin remote (or supplied overrides)"); + return ExitCodes.Success; + } + + bool journalBranchExists; + try + { + journalBranchExists = await observer + .CheckPlanBranchExistsOrThrowAsync(planBranch, ct).ConfigureAwait(false); + } + catch (ExternalToolException ex) + { + EmitDetectStateError(rootId, itemId, planBranch, $"ls-remote failed: {ex.Message}"); + return ExitCodes.Success; + } + + GhPullRequestPollData? journalPoll; + try + { + journalPoll = await observer.GetPlanPrPollAsync(identityForPoll, journalPrNumber, ct) + .ConfigureAwait(false); + } + catch (ExternalToolTimeoutException ex) + { + EmitDetectStateError(rootId, itemId, planBranch, ex.FormatErrorMessage("pr view")); + return ExitCodes.Success; + } + catch (ExternalToolException ex) + { + EmitDetectStateError(rootId, itemId, planBranch, $"pr view failed: {ex.Message}"); + return ExitCodes.Success; + } + + // PR disappeared from platform — journal still wins for + // identity; surface as closed_unmerged so the operator + // sees the divergence rather than silently rolling back. + if (journalPoll is null) + { + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = journalBranchExists ? "closed_unmerged" : "not_started", + BranchExistsOnOrigin = journalBranchExists, + PrNumber = journalPrNumber, + PrUrl = grounding.PrUrl, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + var journalPrState = journalPoll.State.ToUpperInvariant(); + + if (journalPrState == "OPEN") + { + // Stale-generation check: compare PR's snapshot + // against the current manifest. + var stale = await ComputeStaleAncestorsAsync( + localManifestPath, journalPoll.Body, ct).ConfigureAwait(false); + if (stale.Count > 0) + { + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "stale_generation", + BranchExistsOnOrigin = journalBranchExists, + PrNumber = journalPrNumber, + PrUrl = grounding.PrUrl, + PrState = journalPrState, + StaleAncestors = stale, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "awaiting_review", + BranchExistsOnOrigin = journalBranchExists, + PrNumber = journalPrNumber, + PrUrl = grounding.PrUrl, + PrState = journalPrState, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + if (journalPrState == "MERGED") + { + // Crash-recovery / manual-merge: journal recorded the + // open but no merge row was ever written. + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "merged_unseeded", + BranchExistsOnOrigin = journalBranchExists, + PrNumber = journalPrNumber, + PrUrl = grounding.PrUrl, + PrState = journalPrState, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + // CLOSED / ABANDONED. + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = journalBranchExists ? "closed_unmerged" : "not_started", + BranchExistsOnOrigin = journalBranchExists, + PrNumber = journalPrNumber, + PrUrl = grounding.PrUrl, + PrState = journalPrState, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + + // Journal rows exist but none recorded a PR open yet (only + // plan_write_plan / plan_commit_and_push). Workflow will + // re-enter the architect; idempotent. + EmitDetectState(new PlanDetectStateResult + { + RootId = rootId, + ItemId = itemId, + PlanBranch = planBranch, + State = "not_started", + BranchExistsOnOrigin = false, + LineageAnchor = "journal", + }); + return ExitCodes.Success; + } + SkipJournalShortCircuit: + // ── 2. Repo identity. ───────────────────────────────────────────── var identity = await observer .TryResolveRepoIdentityAsync(platform, organization, project, repositoryOverride, ct) @@ -553,8 +808,17 @@ private async Task> ComputeStaleAncestorsAsync( } private static void EmitDetectState(PlanDetectStateResult result) - => Console.WriteLine(JsonSerializer.Serialize( - result, PolyphonyJsonContext.Default.PlanDetectStateResult)); + { + // Default the W3 lineage_anchor: callers that omit it (every + // PR/branch/tag-observation path) get `archaeology`; the + // journal-grounding short-circuit sets `journal` explicitly; the + // error helper below sets `none`. + var annotated = result.LineageAnchor is null + ? result with { LineageAnchor = "archaeology" } + : result; + Console.WriteLine(JsonSerializer.Serialize( + annotated, PolyphonyJsonContext.Default.PlanDetectStateResult)); + } private static void EmitDetectStateError(int rootId, int itemId, string planBranch, string error) => EmitDetectState(new PlanDetectStateResult @@ -565,8 +829,140 @@ private static void EmitDetectStateError(int rootId, int itemId, string planBran State = "error", BranchExistsOnOrigin = false, Error = error, + LineageAnchor = "none", }); + /// + /// W4 (AB#3278): collapse the journal rows for the current + /// (run-lineage, root, item) into the four facts DetectState + /// needs: NoRows, PrNumber/PrUrl (from pr_open_plan_*), + /// PrMerged (from pr_merge_plan_*), and PlanningCompleted + /// (from plan_seed_children's new PlanningCompleted + /// flag). Only successful entries contribute. The most recent + /// successful PR-open row wins for identity (in case of re-opens). + /// + private async Task GroundFromJournalAsync( + int rootId, int itemId, CancellationToken ct) + { + var rows = await _journalStore.QueryAsync( + new JournalQuery + { + RunId = _runContext.RunId, + RootId = rootId, + WorkItemId = itemId, + }, ct).ConfigureAwait(false); + + if (rows.Count == 0) + { + return new JournalGrounding { NoRows = true }; + } + + int? prNumber = null; + string? prUrl = null; + long? prOpenStartedAt = null; + bool prMerged = false; + bool planningCompleted = false; + + foreach (var row in rows) + { + if (row.Outcome != JournalOutcome.Success) continue; + + switch (row.Action) + { + case "pr_open_plan_pr": + { + if (row.PayloadJson is null) break; + PrOpenPlanPrPayload? p; + try { p = JsonSerializer.Deserialize(row.PayloadJson, + PolyphonyJsonContext.Default.PrOpenPlanPrPayload); } + catch { p = null; } + if (p is null || !p.Succeeded || p.PrNumber <= 0) break; + if (prOpenStartedAt is null || row.StartedAt >= prOpenStartedAt) + { + prNumber = p.PrNumber; + prUrl = p.PrUrl; + prOpenStartedAt = row.StartedAt; + } + break; + } + case "pr_open_plan_ado": + { + if (row.PayloadJson is null) break; + PrOpenPlanAdoPayload? p; + try { p = JsonSerializer.Deserialize(row.PayloadJson, + PolyphonyJsonContext.Default.PrOpenPlanAdoPayload); } + catch { p = null; } + if (p is null || !p.Succeeded || p.PrNumber <= 0) break; + if (prOpenStartedAt is null || row.StartedAt >= prOpenStartedAt) + { + prNumber = p.PrNumber; + prUrl = p.PrUrl; + prOpenStartedAt = row.StartedAt; + } + break; + } + case "pr_merge_plan_pr": + { + if (row.PayloadJson is null) { prMerged = true; break; } + PrMergePlanPrPayload? p; + try { p = JsonSerializer.Deserialize(row.PayloadJson, + PolyphonyJsonContext.Default.PrMergePlanPrPayload); } + catch { p = null; } + if (p is null || p.Succeeded) prMerged = true; + if (p is not null && p.PrNumber > 0 && prNumber is null) + { + prNumber = p.PrNumber; + prUrl = p.PrUrl; + } + break; + } + case "pr_merge_plan_ado": + { + if (row.PayloadJson is null) { prMerged = true; break; } + PrMergePlanAdoPayload? p; + try { p = JsonSerializer.Deserialize(row.PayloadJson, + PolyphonyJsonContext.Default.PrMergePlanAdoPayload); } + catch { p = null; } + if (p is null || p.Succeeded) prMerged = true; + if (p is not null && p.PrNumber > 0 && prNumber is null) + { + prNumber = p.PrNumber; + prUrl = p.PrUrl; + } + break; + } + case "plan_seed_children": + { + if (row.PayloadJson is null) break; + PlanSeedChildrenPayload? p; + try { p = JsonSerializer.Deserialize(row.PayloadJson, + PolyphonyJsonContext.Default.PlanSeedChildrenPayload); } + catch { p = null; } + if (p is not null && p.PlanningCompleted) planningCompleted = true; + break; + } + } + } + + return new JournalGrounding + { + NoRows = false, + PrNumber = prNumber, + PrUrl = prUrl, + PrMerged = prMerged, + PlanningCompleted = planningCompleted, + }; + } + + private sealed record JournalGrounding + { + public bool NoRows { get; init; } + public int? PrNumber { get; init; } + public string? PrUrl { get; init; } + public bool PrMerged { get; init; } + public bool PlanningCompleted { get; init; } + } + /// /// Resolve the GitHub owner/repo slug from /// git remote get-url origin. Used by other diff --git a/src/Polyphony/Commands/PlanCommands.SeedChildren.cs b/src/Polyphony/Commands/PlanCommands.SeedChildren.cs index 5c597ed..22d9fdb 100644 --- a/src/Polyphony/Commands/PlanCommands.SeedChildren.cs +++ b/src/Polyphony/Commands/PlanCommands.SeedChildren.cs @@ -208,6 +208,15 @@ public Task SeedChildren( FacetsTagMutated = result?.FacetsTagSet ?? false, Succeeded = exitCode == ExitCodes.Success && result is not null && result.ErrorCount == 0, WasMutated = result is not null && (result.SeededCount > 0 || result.PlannedTagSet || result.FacetsTagSet), + // W4 (AB#3278): the explicit "planning lifecycle finished" + // fact detect-state's journal-grounded path consumes to + // discriminate `complete` from `merged_unseeded`. True iff + // the seeder succeeded AND the planned tag is now present + // on the parent (newly mutated this run OR already there). + PlanningCompleted = exitCode == ExitCodes.Success + && result is not null + && result.ErrorCount == 0 + && (result.PlannedTagSet || result.PlannedTagAlready), }, PolyphonyJsonContext.Default.PlanSeedChildrenPayload, payload => payload.Succeeded, diff --git a/src/Polyphony/Commands/PlanCommands.cs b/src/Polyphony/Commands/PlanCommands.cs index a1a770d..bca8c1e 100644 --- a/src/Polyphony/Commands/PlanCommands.cs +++ b/src/Polyphony/Commands/PlanCommands.cs @@ -47,10 +47,12 @@ public sealed partial class PlanCommands( RepoIdentityResolver repoIdentityResolver, PullRequestReader pullRequestReader, RunContext? runContext = null, - JournaledActionDecorator? journalDecorator = null) + JournaledActionDecorator? journalDecorator = null, + IJournalStore? journalStore = null) { private readonly RunContext _runContext = JournalCommandSupport.ResolveRunContext(runContext); private readonly JournaledActionDecorator _journalDecorator = JournalCommandSupport.ResolveDecorator(journalDecorator); + private readonly IJournalStore _journalStore = journalStore ?? new NullJournalStore(); /// /// Validates current recursion depth against a configured maximum. Always exits 0. /// diff --git a/src/Polyphony/Commands/PlanPrFrontMatter.cs b/src/Polyphony/Commands/PlanPrFrontMatter.cs index 21e3890..0aa2da9 100644 --- a/src/Polyphony/Commands/PlanPrFrontMatter.cs +++ b/src/Polyphony/Commands/PlanPrFrontMatter.cs @@ -21,11 +21,13 @@ namespace Polyphony.Commands; /// Value of the requests_parent_change flag (false when not Present). /// Snapshot map (empty when not Present). /// Reason for malformed status; null otherwise. +/// W7 (AB#3281): value of the run_id key when Present and well-formed; null otherwise. public sealed record PlanPrFrontMatterStrictResult( FrontMatterStatus Status, bool RequestsParentChange, IReadOnlyDictionary AncestorPlanGenerations, - string? ErrorDetail); + string? ErrorDetail, + string? RunId = null); /// /// Minimal front-matter parser for plan-PR bodies. Plan PRs (opened by @@ -96,11 +98,13 @@ public static PrPollMetadata Parse(string body) var requestsParent = ReadBool(root, "requests_parent_change"); var generations = ReadIntMap(root, "ancestor_plan_generations"); + var runId = ReadString(root, "run_id"); return new PrPollMetadata { RequestsParentChange = requestsParent, AncestorPlanGenerations = generations, + RunId = runId, }; } catch @@ -272,7 +276,24 @@ public static PlanPrFrontMatterStrictResult ParseStrict(string body) generations = dict; } - return new PlanPrFrontMatterStrictResult(FrontMatterStatus.Present, requestsParent, generations, null); + // W7 (AB#3281): optional run_id. Strict parse — when present + // it must be a non-empty plain scalar; anything else is Malformed + // so a bad lineage stamp blocks rather than silently degrades. + string? runId = null; + if (root.Children.TryGetValue(new YamlScalarNode("run_id"), out var ridNode)) + { + if (ridNode is not YamlScalarNode ridScalar || string.IsNullOrEmpty(ridScalar.Value)) + { + return new PlanPrFrontMatterStrictResult( + FrontMatterStatus.Malformed, + false, + emptyDict, + "'run_id' must be a non-empty YAML scalar."); + } + runId = ridScalar.Value; + } + + return new PlanPrFrontMatterStrictResult(FrontMatterStatus.Present, requestsParent, generations, null, runId); } private static bool ReadBool(YamlMappingNode root, string key) @@ -282,6 +303,18 @@ private static bool ReadBool(YamlMappingNode root, string key) return string.Equals(scalar.Value, "true", StringComparison.OrdinalIgnoreCase); } + /// + /// W7 (AB#3281): lenient scalar read used by the polling-side + /// . Returns null for any shape that isn't a + /// non-empty scalar so a bad front-matter doesn't crash a hot path. + /// + private static string? ReadString(YamlMappingNode root, string key) + { + if (!root.Children.TryGetValue(new YamlScalarNode(key), out var node)) return null; + if (node is not YamlScalarNode scalar) return null; + return string.IsNullOrEmpty(scalar.Value) ? null : scalar.Value; + } + private static IReadOnlyDictionary ReadIntMap(YamlMappingNode root, string key) { var empty = new Dictionary(StringComparer.Ordinal); @@ -374,30 +407,41 @@ public static FrontMatterReplacement ReplaceSnapshotPreservingTail( } var tail = body[match.Length..]; - var rewritten = SerialiseFrontMatter(strict.RequestsParentChange, newAncestorPlanGenerations) + tail; + var rewritten = SerialiseFrontMatter(strict.RequestsParentChange, newAncestorPlanGenerations, strict.RunId) + tail; return new FrontMatterReplacement.Replaced(rewritten); } /// /// Emit the canonical front-matter block: opening fence, deterministic - /// key order (requests_parent_change first, then - /// ancestor_plan_generations with sorted keys), closing fence. - /// Output line endings are \n. The closing --- is emitted - /// WITHOUT a trailing newline — the body tail (appended by the caller) - /// carries the line ending that originally followed the closing fence, - /// so a CRLF body's tail round-trips byte-exactly and a body whose tail - /// is empty (front-matter only, no body) ends with exactly one \n - /// after the closing fence. + /// key order (requests_parent_change, optional run_id, + /// then ancestor_plan_generations with sorted keys), closing + /// fence. Output line endings are \n. The closing --- + /// is emitted WITHOUT a trailing newline — the body tail (appended + /// by the caller) carries the line ending that originally followed + /// the closing fence, so a CRLF body's tail round-trips byte-exactly + /// and a body whose tail is empty (front-matter only, no body) ends + /// with exactly one \n after the closing fence. /// private static string SerialiseFrontMatter( bool requestsParentChange, - IReadOnlyDictionary generations) + IReadOnlyDictionary generations, + string? runId = null) { var sb = new System.Text.StringBuilder(); sb.Append("---\n"); sb.Append("requests_parent_change: "); sb.Append(requestsParentChange ? "true" : "false"); sb.Append('\n'); + // W7 (AB#3281): emit run_id as a top-level key alongside + // requests_parent_change, only when the lineage is known. Omitting + // the key on legacy callers keeps round-trip byte-identical for + // historical plan PRs. + if (!string.IsNullOrEmpty(runId)) + { + sb.Append("run_id: "); + sb.Append(runId); + sb.Append('\n'); + } sb.Append("ancestor_plan_generations:\n"); foreach (var kvp in generations.OrderBy(kv => kv.Key, StringComparer.Ordinal)) { diff --git a/src/Polyphony/Commands/PrBodyMarker.cs b/src/Polyphony/Commands/PrBodyMarker.cs new file mode 100644 index 0000000..fd48e4c --- /dev/null +++ b/src/Polyphony/Commands/PrBodyMarker.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; + +namespace Polyphony.Commands; + +/// +/// W6 (AB#3280): hidden run-id marker prepended to the *first* line +/// of non-plan PR bodies (impl, MG, evidence, feature). Plan PRs use +/// YAML front-matter for the same purpose (see W7). +/// +/// Canonical form: +/// +/// <!-- polyphony:run_id=01HZK7Y9ABCDEF0123456789AB --> +/// +/// +/// The marker MUST be prepended (not appended) because ADO +/// truncates PR descriptions at 4000 characters — a trailing marker +/// would silently disappear on long bodies. Reader semantics are +/// shared with : case-insensitive, +/// anchored to the first non-whitespace line, single-attribute +/// (run_id). +/// +/// This is a sibling helper to ; +/// they intentionally use different element names +/// (polyphony:agent-comment for comments, +/// polyphony:run_id for bodies) so a reader can't +/// accidentally cross the streams. +/// +internal static class PrBodyMarker +{ + /// + /// Recognize the run-id marker shape on the first non-whitespace + /// line of a PR body. + /// + private static readonly Regex MarkerRegex = new( + @"^\s*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + /// + /// Build a canonical marker string. Use this rather than + /// hand-formatting so reader parsing stays in lockstep. + /// + public static string Format(string runId) + { + if (string.IsNullOrEmpty(runId)) + throw new ArgumentException("runId is required", nameof(runId)); + return $""; + } + + /// + /// Ensure starts with a run-id marker for + /// . Idempotent: returns the body + /// unchanged when it already starts with any run-id marker (even + /// one belonging to a different lineage — overwriting an existing + /// marker would lie about who opened the PR; the caller is + /// responsible for whatever foreign-PR check is appropriate, see + /// W9). Returns the body unchanged when + /// is null/empty/whitespace so legacy callers that don't yet + /// supply a run id continue to work. + /// + public static string EnsureRunIdPrefix(string body, string? runId) + { + if (string.IsNullOrWhiteSpace(runId)) return body; + if (body is null) return Format(runId!); + if (MarkerRegex.IsMatch(body)) return body; + return Format(runId!) + Environment.NewLine + body; + } + + /// + /// Extract a run id from the first non-whitespace line of + /// . Returns null when no marker is + /// present, when the body is null/empty, or when the marker is + /// malformed. Used by readers (state observers, foreign-PR + /// detection in W9) that need to ground a PR observation in a + /// lineage when the journal is silent. + /// + public static string? TryParseRunId(string? body) + { + if (string.IsNullOrEmpty(body)) return null; + var match = MarkerRegex.Match(body); + if (!match.Success) return null; + var runId = match.Groups["run_id"].Value; + return string.IsNullOrEmpty(runId) ? null : runId; + } +} diff --git a/src/Polyphony/Commands/PrCommands.CreateFeatureAdo.cs b/src/Polyphony/Commands/PrCommands.CreateFeatureAdo.cs index d90ef4a..93a1b98 100644 --- a/src/Polyphony/Commands/PrCommands.CreateFeatureAdo.cs +++ b/src/Polyphony/Commands/PrCommands.CreateFeatureAdo.cs @@ -190,9 +190,11 @@ public async Task CreateFeatureAdo( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolvePrTitleAsync(rootId, innerCt).ConfigureAwait(false) : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? await BuildPrBodyAsync(rootId, headBranch, baseBranch, innerCt).ConfigureAwait(false) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); try { diff --git a/src/Polyphony/Commands/PrCommands.MergeEvidencePr.cs b/src/Polyphony/Commands/PrCommands.MergeEvidencePr.cs index ae63c3c..62749ae 100644 --- a/src/Polyphony/Commands/PrCommands.MergeEvidencePr.cs +++ b/src/Polyphony/Commands/PrCommands.MergeEvidencePr.cs @@ -107,6 +107,55 @@ public async Task MergeEvidencePr( var slug = $"{ghRepo.Owner}/{ghRepo.Name}"; + // W9 (AB#3282): pull the PR's poll data once so we can + // ground the foreign-PR check against the W6 hidden + // marker AND derive the (head,base) target needed for + // the journal fallback. Evidence PRs are passed by + // number alone, so the head/base aren't on hand from + // the caller — fetch them here. + try + { + var pollData = await gh.GetPullRequestPollDataAsync(slug, prNumber, innerCt).ConfigureAwait(false); + if (pollData is not null + && !string.IsNullOrEmpty(_runContext.RunId) + && !_runContext.HasManualLineage) + { + var jt = BranchPairJournalTarget( + pollData.HeadRefName ?? string.Empty, + pollData.BaseRefName ?? string.Empty); + var d = await PrLineageGuard.CheckAsync( + pollData.Body, _runContext.RunId, + _runContext.HasManualLineage, + bodyHasFrontMatter: false, + _journalStore, + "pr_open_evidence_pr", + jt, innerCt).ConfigureAwait(false); + if (!d.Allowed) + { + payload = new PrMergeEvidencePrPayload + { + PrNumber = prNumber, + PrUrl = prUrl, + RepoSlug = slug, + Repository = slug, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + AlreadyMerged = false, + Error = "foreign_lineage: " + d.Reason, + }; + EmitMergeEvidenceError(prNumber, prUrl, "foreign_lineage: " + d.Reason, slug); + return ExitCodes.RoutingFailure; + } + } + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + // Fail open: a body-fetch failure must not block the + // merge. Same posture as MergeShared.CheckGhMergeLineageAsync. + } + try { var result = await gh.MergePullRequestAsync( diff --git a/src/Polyphony/Commands/PrCommands.MergeImplPr.cs b/src/Polyphony/Commands/PrCommands.MergeImplPr.cs index 10b0dca..f47ac5a 100644 --- a/src/Polyphony/Commands/PrCommands.MergeImplPr.cs +++ b/src/Polyphony/Commands/PrCommands.MergeImplPr.cs @@ -240,6 +240,23 @@ private async Task MergeImplPrBodyAsync( } var openPr = resolution.OpenPr!; + // W9 (AB#3282): refuse foreign PRs. The (head, base) pair + // matched, but a foreign run could have opened a PR with the + // same branches if the local journal was wiped and the prior + // branches survived. Check W6 hidden marker / W9 journal row + // before mutating the platform. + var lineageReason = await CheckGhMergeLineageAsync( + slug, openPr.Number, bodyHasFrontMatter: false, + journalAction: "pr_open_impl_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + ct).ConfigureAwait(false); + if (lineageReason is not null) + { + EmitMergeImplError(rootId, itemId, mgPath, method, deleteBranchBool, + "foreign_lineage: " + lineageReason, headBranch, baseBranch); + return ExitCodes.RoutingFailure; + } + var mergeMatch = string.IsNullOrEmpty(matchHeadCommit) ? null : matchHeadCommit; var result = await gh.MergePullRequestAsync( slug, openPr.Number, mergeMethod, admin, deleteBranchBool, mergeMatch, ct: ct).ConfigureAwait(false); diff --git a/src/Polyphony/Commands/PrCommands.MergeMergeGroupPr.cs b/src/Polyphony/Commands/PrCommands.MergeMergeGroupPr.cs index e656364..87029a3 100644 --- a/src/Polyphony/Commands/PrCommands.MergeMergeGroupPr.cs +++ b/src/Polyphony/Commands/PrCommands.MergeMergeGroupPr.cs @@ -154,6 +154,33 @@ public async Task MergeMergeGroupPr( } var openPr = resolution.OpenPr!; + // W9 (AB#3282): refuse foreign PRs (see MergeImplPr). + var mgLineageReason = await CheckGhMergeLineageAsync( + slug, openPr.Number, bodyHasFrontMatter: false, + journalAction: "pr_open_mg_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + innerCt).ConfigureAwait(false); + if (mgLineageReason is not null) + { + payload = new PrMergeMergeGroupPrPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + HeadBranch = headBranch, + BaseBranch = baseBranch, + RepoSlug = slug, + PrNumber = openPr.Number, + Method = MgMethod, + DeleteBranch = MgDeleteBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + AlreadyMerged = false, + Error = "foreign_lineage: " + mgLineageReason, + }; + EmitMergeMgError(rootId, mgPath, "foreign_lineage: " + mgLineageReason, headBranch, baseBranch); + return ExitCodes.RoutingFailure; + } var mergeMatch = string.IsNullOrEmpty(matchHeadCommit) ? null : matchHeadCommit; var result = await gh.MergePullRequestAsync( slug, openPr.Number, GhMergeMethod.Merge, admin, MgDeleteBranch, mergeMatch, ct: innerCt).ConfigureAwait(false); diff --git a/src/Polyphony/Commands/PrCommands.MergePlanPr.cs b/src/Polyphony/Commands/PrCommands.MergePlanPr.cs index 68e0d23..2ab27c4 100644 --- a/src/Polyphony/Commands/PrCommands.MergePlanPr.cs +++ b/src/Polyphony/Commands/PrCommands.MergePlanPr.cs @@ -387,6 +387,26 @@ private async Task MergePlanPrUnderLockAsync( isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); + // ── 6a. Foreign-lineage refusal (W9, AB#3282). The irreversibility + // firewall: a head_ref / base_ref match proves we have the right + // branch pair, NOT that THIS lineage opened the PR. A foreign PR + // (operator-opened, leftover from a prior run, opened by a + // parallel manual lineage) must not be merged by the driver. + var lineage = await PrLineageGuard.CheckAsync( + body: poll.Body, + currentRunId: _runContext.RunId, + isManualLineage: _runContext.HasManualLineage, + bodyHasFrontMatter: true, + journal: _journalStore, + journalAction: "pr_open_plan_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + ct).ConfigureAwait(false); + if (!lineage.Allowed) + return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "foreign_lineage", + lineage.Reason, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, + prState: poll.State); + // ── 6b. Stale-generation refusal (P6). Only meaningful for OPEN // PRs — for MERGED PRs the merge already happened and we'd just // be running the recovery path. Root plans skip the check (no diff --git a/src/Polyphony/Commands/PrCommands.MergeShared.cs b/src/Polyphony/Commands/PrCommands.MergeShared.cs index 5ee09a1..22e30fc 100644 --- a/src/Polyphony/Commands/PrCommands.MergeShared.cs +++ b/src/Polyphony/Commands/PrCommands.MergeShared.cs @@ -244,4 +244,108 @@ private static bool TryParseMethod(string raw, out GhMergeMethod method, out str return false; } } + + /// + /// W9 (AB#3282): wrap with the body + /// fetch the merge verbs need. + /// returns only branch/number summaries, so the body — required + /// for both the W6 hidden marker and the W7 plan-PR front-matter + /// — lives behind an extra gh pr view call. Centralised + /// here so all four GitHub merge-* verbs share one foreign-PR + /// posture and one error envelope shape. + /// + /// + /// null when the lineage check allows the merge to proceed; + /// a structured refusal reason otherwise (callers translate the + /// reason into their verb-specific error envelope and exit code). + /// + internal async Task CheckGhMergeLineageAsync( + string repoSlug, + int prNumber, + bool bodyHasFrontMatter, + string journalAction, + string journalTarget, + CancellationToken ct) + { + if (string.IsNullOrEmpty(_runContext.RunId) || _runContext.HasManualLineage) + { + return null; + } + + string? body = null; + try + { + var poll = await gh.GetPullRequestPollDataAsync(repoSlug, prNumber, ct).ConfigureAwait(false); + body = poll?.Body; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + // Fail open: a body-fetch failure must not block the merge. + // The W6 marker is a defense-in-depth check; the journal + // is the next line of defense — keep going. + } + + var decision = await PrLineageGuard.CheckAsync( + body: body, + currentRunId: _runContext.RunId, + isManualLineage: _runContext.HasManualLineage, + bodyHasFrontMatter: bodyHasFrontMatter, + journal: _journalStore, + journalAction: journalAction, + journalTarget: journalTarget, + ct).ConfigureAwait(false); + return decision.Allowed ? null : decision.Reason; + } + + /// + /// W10 (AB#3291) ADO analog of . + /// Used by both the ADO merge verbs and the ADO open verbs at the + /// reused-existing-PR branch. Body comes from + /// . + /// + internal async Task CheckAdoPrLineageAsync( + string organization, + string project, + string repository, + int prNumber, + bool bodyHasFrontMatter, + string journalAction, + string journalTarget, + CancellationToken ct) + { + if (string.IsNullOrEmpty(_runContext.RunId) || _runContext.HasManualLineage) + { + return null; + } + + if (ado is null) + { + return null; + } + + string? body = null; + try + { + var poll = await ado.GetPullRequestPollDataAsync( + organization, project, repository, prNumber, ct).ConfigureAwait(false); + body = poll?.Body; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + // Fail open — same posture as the GH analog. + } + + var decision = await PrLineageGuard.CheckAsync( + body: body, + currentRunId: _runContext.RunId, + isManualLineage: _runContext.HasManualLineage, + bodyHasFrontMatter: bodyHasFrontMatter, + journal: _journalStore, + journalAction: journalAction, + journalTarget: journalTarget, + ct).ConfigureAwait(false); + return decision.Allowed ? null : decision.Reason; + } } diff --git a/src/Polyphony/Commands/PrCommands.OpenEvidenceAdo.cs b/src/Polyphony/Commands/PrCommands.OpenEvidenceAdo.cs index 1b160f3..abf2128 100644 --- a/src/Polyphony/Commands/PrCommands.OpenEvidenceAdo.cs +++ b/src/Polyphony/Commands/PrCommands.OpenEvidenceAdo.cs @@ -196,9 +196,11 @@ internal async Task OpenEvidenceAdoCoreAsync( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolveEvidencePrTitleAsync(workItem, ct).ConfigureAwait(false) : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultEvidenceBody(workItem, effectiveRoot, headBranch, resolvedBase) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); try { @@ -255,6 +257,19 @@ internal async Task OpenEvidenceAdoCoreAsync( if (existing is not null) { + // W10 (AB#3291): refuse foreign-lineage adoption. + var lineageReason = await CheckAdoPrLineageAsync( + organization, project, repository, existing.PullRequestId, + bodyHasFrontMatter: false, + journalAction: "pr_open_evidence_pr", + journalTarget: BranchPairJournalTarget(headBranch, resolvedBase), + ct).ConfigureAwait(false); + if (lineageReason is not null) + { + return EvidenceAdoOutcome.Failure(headBranch, resolvedBase, + "foreign_lineage", "foreign_lineage: " + lineageReason); + } + return new EvidenceAdoOutcome( PrNumber: existing.PullRequestId, PrUrl: BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), diff --git a/src/Polyphony/Commands/PrCommands.OpenEvidencePr.cs b/src/Polyphony/Commands/PrCommands.OpenEvidencePr.cs index 4683101..218096a 100644 --- a/src/Polyphony/Commands/PrCommands.OpenEvidencePr.cs +++ b/src/Polyphony/Commands/PrCommands.OpenEvidencePr.cs @@ -206,9 +206,11 @@ public async Task OpenEvidencePr( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolveEvidencePrTitleAsync(workItem, innerCt).ConfigureAwait(false) : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultEvidenceBody(workItem, effectiveRoot, headBranch, resolvedBase) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); var existing = await gh.ListPullRequestsAsync( slug, @@ -217,6 +219,34 @@ public async Task OpenEvidencePr( if (existing.Count > 0) { var found = existing[0]; + + // W10 (AB#3291): refuse foreign-lineage adoption. + var lineageReason = await CheckGhMergeLineageAsync( + slug, found.Number, bodyHasFrontMatter: false, + journalAction: "pr_open_evidence_pr", + journalTarget: BranchPairJournalTarget(headBranch, resolvedBase), + innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new PrOpenEvidencePrPayload + { + WorkItemId = workItem, + RootId = effectiveRoot, + HeadBranch = headBranch, + BaseBranch = resolvedBase, + RepoSlug = slug, + PrNumber = found.Number, + PrUrl = found.Url ?? "", + Title = prTitle, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitEvidenceError(workItem, effectiveRoot, payload.Error, headBranch: headBranch, baseBranch: resolvedBase); + return ExitCodes.RoutingFailure; + } + var result = new PrOpenEvidenceResult { PrNumber = found.Number, diff --git a/src/Polyphony/Commands/PrCommands.OpenImplAdo.cs b/src/Polyphony/Commands/PrCommands.OpenImplAdo.cs index 9f6a276..9867b90 100644 --- a/src/Polyphony/Commands/PrCommands.OpenImplAdo.cs +++ b/src/Polyphony/Commands/PrCommands.OpenImplAdo.cs @@ -195,9 +195,11 @@ internal async Task OpenImplAdoCoreAsync( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolveImplPrTitleAsync(itemId, ct).ConfigureAwait(false) : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultImplBody(rootId, itemId, path.Canonical, headBranch, baseBranch) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); try { @@ -255,6 +257,19 @@ internal async Task OpenImplAdoCoreAsync( if (existing is not null) { + // W10 (AB#3291): refuse foreign-lineage adoption. + var lineageReason = await CheckAdoPrLineageAsync( + organization, project, repository, existing.PullRequestId, + bodyHasFrontMatter: false, + journalAction: "pr_open_impl_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + ct).ConfigureAwait(false); + if (lineageReason is not null) + { + return ImplAdoOutcome.Failure(headBranch, baseBranch, + "foreign_lineage", "foreign_lineage: " + lineageReason); + } + return new ImplAdoOutcome( PrNumber: existing.PullRequestId, PrUrl: BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), diff --git a/src/Polyphony/Commands/PrCommands.OpenImplPr.cs b/src/Polyphony/Commands/PrCommands.OpenImplPr.cs index ffa7da3..eac23f8 100644 --- a/src/Polyphony/Commands/PrCommands.OpenImplPr.cs +++ b/src/Polyphony/Commands/PrCommands.OpenImplPr.cs @@ -196,9 +196,13 @@ public async Task OpenImplPr( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolveImplPrTitleAsync(itemId, innerCt).ConfigureAwait(false) : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultImplBody(rootId, itemId, path.Canonical, headBranch, baseBranch) : body; + // W6 (AB#3280): stamp the run-id marker on the first + // line so cross-machine readers can ground the PR in + // a lineage when the local journal is silent. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); var existing = await gh.ListPullRequestsAsync( slug, @@ -207,6 +211,38 @@ public async Task OpenImplPr( if (existing.Count > 0) { var found = existing[0]; + + // W10 (AB#3291): refuse to adopt a foreign-lineage PR + // even if its head/base match — a leftover from a prior + // run that shares the canonical branch names must not + // be silently claimed by the current lineage. + var lineageReason = await CheckGhMergeLineageAsync( + slug, found.Number, bodyHasFrontMatter: false, + journalAction: "pr_open_impl_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new PrOpenImplPrPayload + { + RootId = rootId, + ItemId = itemId, + MergeGroupPath = path.Canonical, + HeadBranch = headBranch, + BaseBranch = baseBranch, + RepoSlug = slug, + PrNumber = found.Number, + PrUrl = found.Url ?? "", + Title = prTitle, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitImplError(rootId, itemId, mgPath, payload.Error, headBranch: headBranch, baseBranch: baseBranch); + return ExitCodes.RoutingFailure; + } + var result = new PrOpenImplResult { PrNumber = found.Number, diff --git a/src/Polyphony/Commands/PrCommands.OpenMergeGroupAdo.cs b/src/Polyphony/Commands/PrCommands.OpenMergeGroupAdo.cs index 613a1b4..234d518 100644 --- a/src/Polyphony/Commands/PrCommands.OpenMergeGroupAdo.cs +++ b/src/Polyphony/Commands/PrCommands.OpenMergeGroupAdo.cs @@ -127,9 +127,11 @@ async Task ExecuteAsync(CancellationToken innerCt) var prTitle = string.IsNullOrWhiteSpace(title) ? $"merge group {path.Canonical} for root #{rootId}" : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultMgAdoBody(rootId, path.Canonical, headBranch, baseBranch) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); try { @@ -181,6 +183,20 @@ async Task ExecuteAsync(CancellationToken innerCt) if (existing is not null) { + // W10 (AB#3291): refuse foreign-lineage adoption. + var lineageReason = await CheckAdoPrLineageAsync( + organization, project, repository, existing.PullRequestId, + bodyHasFrontMatter: false, + journalAction: "pr_open_mg_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "foreign_lineage", "foreign_lineage: " + lineageReason, headBranch, baseBranch); + return ExitCodes.Success; + } + EmitOpenMgAdo(new PrOpenMergeGroupAdoResult { RootId = rootId, diff --git a/src/Polyphony/Commands/PrCommands.OpenMergeGroupPr.cs b/src/Polyphony/Commands/PrCommands.OpenMergeGroupPr.cs index 25b6a90..3503213 100644 --- a/src/Polyphony/Commands/PrCommands.OpenMergeGroupPr.cs +++ b/src/Polyphony/Commands/PrCommands.OpenMergeGroupPr.cs @@ -125,9 +125,11 @@ public async Task OpenMergeGroupPr( var prTitle = string.IsNullOrWhiteSpace(title) ? $"merge group {path.Canonical} for root #{rootId}" : title; - var prBody = string.IsNullOrWhiteSpace(body) + var rawBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultMgBody(rootId, path.Canonical, headBranch, baseBranch) : body; + // W6 (AB#3280): stamp the run-id marker on the first line. + var prBody = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); var existing = await gh.ListPullRequestsAsync( slug, @@ -136,6 +138,34 @@ public async Task OpenMergeGroupPr( if (existing.Count > 0) { var found = existing[0]; + + // W10 (AB#3291): refuse foreign-lineage adoption. + var lineageReason = await CheckGhMergeLineageAsync( + slug, found.Number, bodyHasFrontMatter: false, + journalAction: "pr_open_mg_pr", + journalTarget: BranchPairJournalTarget(headBranch, baseBranch), + innerCt).ConfigureAwait(false); + if (lineageReason is not null) + { + payload = new PrOpenMergeGroupPrPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + HeadBranch = headBranch, + BaseBranch = baseBranch, + RepoSlug = slug, + PrNumber = found.Number, + PrUrl = found.Url ?? "", + Title = prTitle, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + Error = "foreign_lineage: " + lineageReason, + }; + EmitMgError(rootId, mgPath, payload.Error, headBranch: headBranch, baseBranch: baseBranch); + return ExitCodes.RoutingFailure; + } + var result = new PrOpenMergeGroupResult { PrNumber = found.Number, diff --git a/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs index 53a8694..8531ec1 100644 --- a/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs +++ b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs @@ -312,7 +312,7 @@ private async Task OpenPlanAdoBodyAsync( var summaryBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultPlanBodySummary(rootId, itemId, isRootPlan, headBranch, baseBranch) : body; - var fullBody = BuildPlanPrBody(snapshot, summaryBody); + var fullBody = BuildPlanPrBody(snapshot, summaryBody, _runContext.RunId); try { diff --git a/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs b/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs index 69261b2..56be464 100644 --- a/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs +++ b/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs @@ -299,7 +299,7 @@ private async Task OpenPlanPrBodyAsync( var summaryBody = string.IsNullOrWhiteSpace(body) ? BuildDefaultPlanBodySummary(rootId, itemId, isRootPlan, headBranch, baseBranch) : body; - var fullBody = BuildPlanPrBody(snapshot, summaryBody); + var fullBody = BuildPlanPrBody(snapshot, summaryBody, _runContext.RunId); // ── 4. Reuse check: existing open PR? ───────────────────────── var existing = await gh.ListPullRequestsAsync( @@ -522,16 +522,27 @@ private static string BuildStaleMessage( /// Render the PR body with the well-known YAML front-matter at the /// top, followed by a blank line, then the human-readable summary. /// Format pinned to match what can - /// parse back out (and what the Phase 3 ADR specified). Both keys - /// are always emitted to keep the front-matter shape stable. + /// parse back out (and what the Phase 3 ADR specified). All recognised + /// keys are emitted in canonical order so the front-matter shape stays + /// stable across runs. /// private static string BuildPlanPrBody( IReadOnlyDictionary snapshot, - string summary) + string summary, + string? runId = null) { var sb = new StringBuilder(); sb.Append("---\n"); sb.Append("requests_parent_change: false\n"); + // W7 (AB#3281): emit run_id alongside requests_parent_change when + // the lineage is known so plan-PR observers can ground the PR in + // a lineage when the local journal is silent. Omitted for legacy + // callers (null/empty) to keep body bytes stable for hand-written + // plans that pre-date the stamping rollout. + if (!string.IsNullOrEmpty(runId)) + { + sb.Append("run_id: ").Append(runId).Append('\n'); + } if (snapshot.Count == 0) { sb.Append("ancestor_plan_generations: {}\n"); diff --git a/src/Polyphony/Commands/PrCommands.cs b/src/Polyphony/Commands/PrCommands.cs index df58626..5b211da 100644 --- a/src/Polyphony/Commands/PrCommands.cs +++ b/src/Polyphony/Commands/PrCommands.cs @@ -42,10 +42,15 @@ public sealed partial class PrCommands( Polyphony.Sdlc.Observers.RepoIdentityResolver repoIdentityResolver, RunContext runContext, JournaledActionDecorator decorator, - IAdoClient? ado = null) + IAdoClient? ado = null, + IJournalStore? journalStore = null) { private readonly RunContext _runContext = runContext; private readonly JournaledActionDecorator _journalDecorator = decorator; + // W9 (AB#3282): the merge-* verbs consult the journal to ground + // foreign-PR refusal when the body stamp is absent. Optional so + // GitHub-only test fixtures that don't wire a journal still work. + private readonly IJournalStore _journalStore = journalStore ?? new NullJournalStore(); private static readonly Regex PullUrlRegex = new(@"/pull/(\d+)", RegexOptions.Compiled); @@ -154,7 +159,9 @@ public async Task CreateFeaturePr( var prTitle = string.IsNullOrWhiteSpace(title) ? await ResolvePrTitleAsync(workItem, innerCt).ConfigureAwait(false) : title; - var body = await BuildPrBodyAsync(workItem, featureBranch, targetBranch, innerCt).ConfigureAwait(false); + var rawBody = await BuildPrBodyAsync(workItem, featureBranch, targetBranch, innerCt).ConfigureAwait(false); + // W6 (AB#3280): stamp the run-id marker on the first line. + var body = PrBodyMarker.EnsureRunIdPrefix(rawBody, _runContext.RunId); var existing = await gh.ListPullRequestsAsync( slug, diff --git a/src/Polyphony/Commands/PrLineageGuard.cs b/src/Polyphony/Commands/PrLineageGuard.cs new file mode 100644 index 0000000..09fabcd --- /dev/null +++ b/src/Polyphony/Commands/PrLineageGuard.cs @@ -0,0 +1,122 @@ +using Polyphony.Journal; + +namespace Polyphony.Commands; + +/// +/// W9 (AB#3282): pre-merge "is this PR ours?" check. The merge verbs +/// (pr merge-plan-pr, merge-impl-pr, merge-mg-pr, +/// merge-evidence-pr) currently merge whatever PR number +/// resolves on the expected branch without checking whether *this* +/// lineage opened it. That is the irreversibility firewall this +/// helper closes: a foreign PR (operator-opened, leftover-from-prior- +/// run, opened by a parallel manual lineage) must NOT be merged by +/// an automated driver. +/// +/// Two corroborating sources of "ours-ness", in order: +/// +/// Body marker. Non-plan PRs carry a hidden +/// polyphony:run_id=... HTML comment (see +/// ); plan PRs carry the same value in +/// their YAML front-matter run_id key. The body-side check +/// is the cross-machine ground truth — it survives even when the +/// local journal is empty (fresh checkout, machine swap, lost +/// tmpdir). +/// Journal. A pr_open_* action row with matching +/// run_id and target proves this lineage opened the PR. +/// Used as the fallback when the marker has been stripped by an +/// operator edit OR the PR pre-dates the W6/W7 stamping rollout. +/// Skipped silently when the journal store is the null store or +/// the current lineage is a manual_* fallback — neither +/// case can distinguish "we didn't open it" from "we have no idea". +/// +/// +/// Posture: refuse on mismatch. Returning a structured +/// rather than throwing keeps the callers' +/// routing-style envelope semantics intact (the verb emits an error +/// envelope and exits 0 with a non-success error code). +/// +internal static class PrLineageGuard +{ + /// + /// Outcome of the lineage check. Allow-cases differ only in + /// observability (which source corroborated the decision); refuse + /// carries a structured reason for the verb's error envelope. + /// + public sealed record Decision(bool Allowed, string Reason, string Source); + + /// + /// Inspect for a body-side stamp (marker + /// or plan-PR front-matter), optionally back-stop with the + /// journal, and decide whether this PR belongs to + /// . + /// distinguishes plan-PR bodies (parse front-matter) from + /// non-plan PR bodies (parse ). + /// + public static async Task CheckAsync( + string? body, + string? currentRunId, + bool isManualLineage, + bool bodyHasFrontMatter, + IJournalStore journal, + string journalAction, + string journalTarget, + CancellationToken ct) + { + // Manual lineage can't ground anything — fall through to allow. + // Same posture as DetectState's W3 short-circuit: a manual_* + // run id is "we don't actually know who we are", and refusing + // every merge under it would brick local-dev verb usage. + if (string.IsNullOrEmpty(currentRunId) || isManualLineage) + { + return new Decision(true, "Lineage check skipped: no run id or manual lineage.", "skipped"); + } + + var bodyRunId = bodyHasFrontMatter + ? PlanPrFrontMatter.Parse(body ?? string.Empty).RunId + : PrBodyMarker.TryParseRunId(body); + + if (string.Equals(bodyRunId, currentRunId, StringComparison.Ordinal)) + { + return new Decision(true, "Body stamp matches current run id.", "body"); + } + + if (!string.IsNullOrEmpty(bodyRunId)) + { + // Stamp exists and is foreign: refuse without consulting the + // journal. A mismatched body stamp is a stronger signal than + // journal silence — someone else owns this PR. + return new Decision( + false, + $"PR body carries run_id='{bodyRunId}' which differs from current run_id='{currentRunId}'. Refusing to merge a foreign-lineage PR.", + "body"); + } + + // No body stamp. Fall back to the journal: did THIS lineage + // open this PR? A NullJournalStore can't answer; treat that + // as "we don't know" and allow (no regression from pre-W9 + // behaviour for unstamped legacy PRs in null-store envs). + if (journal is NullJournalStore) + { + return new Decision(true, "Body stamp absent and journal store is null; lineage check inconclusive, allowing.", "inconclusive"); + } + + var rows = await journal.QueryAsync( + new JournalQuery + { + RunId = currentRunId, + Action = journalAction, + }, + ct).ConfigureAwait(false); + var ours = rows.Any(r => string.Equals(r.Target, journalTarget, StringComparison.Ordinal) + && r.Outcome == JournalOutcome.Success); + if (ours) + { + return new Decision(true, "Journal has a successful open-row for this PR target under the current run id.", "journal"); + } + + return new Decision( + false, + $"No body stamp and no journal evidence that run_id='{currentRunId}' opened a PR with target='{journalTarget}'. Refusing to merge a foreign-lineage PR.", + "journal"); + } +} diff --git a/src/Polyphony/Commands/ReconcileCommand.cs b/src/Polyphony/Commands/ReconcileCommand.cs new file mode 100644 index 0000000..26cf0ec --- /dev/null +++ b/src/Polyphony/Commands/ReconcileCommand.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Models; +using Twig.Domain.Interfaces; + +namespace Polyphony.Commands; + +/// +/// W13 (AB#3294): polyphony reconcile --root R. Surfaces the +/// drift report under the same the +/// reset pipeline uses, then optionally folds chosen findings back into +/// the current lineage so the next pass sees them as in-scope. +/// +/// Two side-effect flags, both opt-in and both require +/// --execute to mutate state: +/// +/// +/// --accept-external — for every +/// finding, +/// synthesize a reconcile_accept_external journal row that +/// records the observed actual state under the current run id. The +/// existing CurrentExpectedState projection picks up the new +/// effect on the next run, so future drift treats the resource as +/// consistent. +/// +/// +/// --adopt KIND:ID — single-shot ownership transfer for an +/// orphan resource. Writes a reconcile_adopt entry under the +/// current run id with + +/// , so the +/// OwnedResources projection includes it on the next pass. +/// +/// +/// +/// --repair-external from the original B3 sketch is +/// deliberately deferred — repairing externals means platform-side +/// rollback that needs its own policy surface. +/// +[VerbGroup("")] +public sealed class ReconcileCommand( + IJournalStore store, + IWorkItemRepository repository, + JournalDriftAnalyzer analyzer, + RunContext runContext) +{ + private readonly IJournalStore _store = store; + private readonly IWorkItemRepository _repository = repository; + private readonly JournalDriftAnalyzer _analyzer = analyzer; + private readonly RunContext _runContext = runContext; + + /// + /// Report drift against a root, optionally folding findings back + /// into the current lineage. + /// + /// Root work item ID. + /// + /// When true, every external_mutation finding will be (or + /// would be, in dry-run) absorbed into the current lineage. + /// + /// + /// Optional KIND:ID selector identifying a single orphan + /// resource to attach to the current lineage. + /// + /// + /// When false (default), report planned actions without mutating + /// the journal. --accept-external and --adopt are + /// inert in dry-run mode. + /// + /// Cancellation token. + [Command("reconcile")] + [VerbResult(typeof(ReconcileResult))] + public async Task Run( + int root = RequiredInput.MissingInt, + bool acceptExternal = false, + string adopt = "", + bool execute = false, + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("reconcile", + ("--root", root == RequiredInput.MissingInt)) is { } halt) + return halt; + + if (root <= 0) + { + EmitError(root, "root must be positive"); + return ExitCodes.RoutingFailure; + } + + string? adoptKind = null; + string? adoptId = null; + if (!string.IsNullOrWhiteSpace(adopt)) + { + var separator = adopt.IndexOf(':', StringComparison.Ordinal); + if (separator <= 0 || separator == adopt.Length - 1) + { + EmitError(root, $"--adopt must be of the form KIND:ID (got '{adopt}')"); + return ExitCodes.ConfigError; + } + adoptKind = adopt[..separator]; + adoptId = adopt[(separator + 1)..]; + } + + DriftResult drift; + try + { + var item = await _repository.GetByIdAsync(root, ct).ConfigureAwait(false); + if (item is null) + { + EmitError(root, $"Work item {root} not found"); + return ExitCodes.CacheError; + } + + var entries = await _store.QueryAsync(new JournalQuery { RootId = root }, ct).ConfigureAwait(false); + var analysis = await _analyzer.AnalyzeAsync(root, entries, ct).ConfigureAwait(false); + drift = analysis.Result; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + EmitError(root, ex.Message); + return ExitCodes.CacheError; + } + + var accepted = new List(); + var adopted = new List(); + + if (acceptExternal) + { + foreach (var finding in drift.Findings) + { + if (!string.Equals(finding.Classification, DriftClassifications.ExternalMutation, StringComparison.Ordinal)) + continue; + accepted.Add(ToReconciled(finding, "reconcile_accept_external")); + } + } + + if (adoptKind is not null && adoptId is not null) + { + var match = drift.Findings.FirstOrDefault(f => + string.Equals(f.Kind, adoptKind, StringComparison.Ordinal) && + string.Equals(f.Id, adoptId, StringComparison.Ordinal)); + if (match is null) + { + EmitError(root, $"--adopt {adopt}: no drift finding matched (kind/id not in current drift report)"); + return ExitCodes.RoutingFailure; + } + adopted.Add(ToReconciled(match, "reconcile_adopt")); + } + + if (execute) + { + try + { + foreach (var entry in accepted) + await RecordSynthesizedAsync(root, entry, ResourceMutation.Changed, ct).ConfigureAwait(false); + foreach (var entry in adopted) + await RecordSynthesizedAsync(root, entry, ResourceMutation.NoChangedExternalAlreadyPresent, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + EmitResult(new ReconcileResult + { + Root = root, + RunId = _runContext.RunId, + Executed = true, + AcceptExternal = acceptExternal, + AdoptTarget = string.IsNullOrWhiteSpace(adopt) ? null : adopt, + Drift = drift, + AcceptedExternal = accepted, + Adopted = adopted, + Success = false, + Error = $"Journal write failed: {ex.Message}", + }); + return ExitCodes.RoutingFailure; + } + } + + EmitResult(new ReconcileResult + { + Root = root, + RunId = _runContext.RunId, + Executed = execute, + AcceptExternal = acceptExternal, + AdoptTarget = string.IsNullOrWhiteSpace(adopt) ? null : adopt, + Drift = drift, + AcceptedExternal = accepted, + Adopted = adopted, + Success = true, + }); + return ExitCodes.Success; + } + + private async Task RecordSynthesizedAsync(int root, ReconciledFinding finding, ResourceMutation mutation, CancellationToken ct) + { + var startedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var attributes = new JsonObject + { + ["target_state"] = finding.ActualState ?? finding.ExpectedState, + ["reconcile_classification"] = finding.Classification, + }; + var payload = new JsonObject + { + ["resource_kind"] = finding.Kind, + ["resource_id"] = finding.Id, + ["expected_state"] = finding.ExpectedState, + ["actual_state"] = finding.ActualState, + ["classification"] = finding.Classification, + ["reconcile_action"] = finding.Action, + }; + var entryId = await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = _runContext.RunId, + RootId = root, + Action = finding.Action, + Target = $"{finding.Kind}:{finding.Id}", + StartedAt = startedAt, + PayloadJson = payload.ToJsonString(), + }, ct).ConfigureAwait(false); + + var effect = new JournalResourceEffect + { + Kind = finding.Kind, + Id = finding.Id, + Intent = ResourceIntent.EnsurePresent, + Mutation = mutation, + PolyphonyOwned = true, + Attributes = attributes, + }; + await _store.RecordEndAsync( + entryId, + JournalOutcome.Success, + errorCode: null, + errorMessage: null, + payloadJson: payload.ToJsonString(), + effects: new[] { effect }, + ct).ConfigureAwait(false); + } + + private static ReconciledFinding ToReconciled(DriftFinding finding, string action) => new() + { + Kind = finding.Kind, + Id = finding.Id, + Classification = finding.Classification, + ExpectedState = finding.ExpectedState, + ActualState = finding.ActualState, + Action = action, + }; + + private void EmitError(int root, string message) + { + EmitResult(new ReconcileResult + { + Root = root, + RunId = _runContext.RunId, + Executed = false, + AcceptExternal = false, + AdoptTarget = null, + Drift = new DriftResult + { + Status = "error", + RootId = root, + Findings = Array.Empty(), + Summary = new DriftSummary { Consistent = 0, ExternalDelete = 0, ExternalMutation = 0, ExternalCreate = 0 }, + ResetTargets = Array.Empty(), + }, + AcceptedExternal = Array.Empty(), + Adopted = Array.Empty(), + Success = false, + Error = message, + }); + } + + private static void EmitResult(ReconcileResult result) + => Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.ReconcileResult)); +} diff --git a/src/Polyphony/Commands/ResetCommands.Root.cs b/src/Polyphony/Commands/ResetCommands.Root.cs index 8da4866..81ceeda 100644 --- a/src/Polyphony/Commands/ResetCommands.Root.cs +++ b/src/Polyphony/Commands/ResetCommands.Root.cs @@ -119,10 +119,147 @@ private async Task ResetRootCoreAsync( }; } + // W12 (AB#3293): regardless of pipeline outcome, attempt to + // tombstone every active lineage for the root. Tombstoning is + // strictly local journal bookkeeping; it does not depend on + // remote/twig cleanup actually succeeding. Run it best-effort + // — a journal hiccup here must not flip a successful reset + // into a failure (the watermark already advanced). + var lineageResult = await RunLineageRetireAsync(root, execute, ct).ConfigureAwait(false); + var stepsCompleted = result.StepsCompleted.ToList(); + var stepsFailed = result.StepsFailed.ToList(); + if (lineageResult.Success) + stepsCompleted.Add("lineages"); + else + stepsFailed.Add("lineages"); + result = result with + { + Lineages = lineageResult, + StepsCompleted = stepsCompleted, + StepsFailed = stepsFailed, + }; + Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.ResetRootResult)); return ExitCodes.Success; } + /// + /// W12 (AB#3293): Retire every active (non-tombstoned) lineage + /// for . Dry-run reports what WOULD be + /// retired without writing; execute mode writes + /// retired_at + reason to each row. + /// + /// Reset writes a tombstone, not a delete, so future runs + /// can see what lineages existed (for diagnostics, attach, + /// reconcile) while routing verbs treat the retired rows as + /// foreign. + /// + private async Task RunLineageRetireAsync(int root, bool execute, CancellationToken ct) + { + const string Reason = "reset root"; + if (_journalStore is NullJournalStore) + { + return new Polyphony.Models.LineageRetireResult + { + Root = root, + RunId = null, + AllActive = true, + Executed = execute, + Reason = Reason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = true, + }; + } + + IReadOnlyList lineages; + try + { + lineages = await _journalStore.GetLineagesAsync(root, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + return new Polyphony.Models.LineageRetireResult + { + Root = root, + RunId = null, + AllActive = true, + Executed = execute, + Reason = Reason, + RetiredRunIds = [], + SkippedRunIds = [], + Success = false, + Error = $"Failed to enumerate lineages: {ex.Message}", + }; + } + + var active = lineages.Where(l => l.RetiredAt is null).ToList(); + var alreadyRetired = lineages + .Where(l => l.RetiredAt is not null) + .Select(l => l.RunId) + .ToList(); + if (active.Count == 0) + { + return new Polyphony.Models.LineageRetireResult + { + Root = root, + RunId = null, + AllActive = true, + Executed = execute, + Reason = Reason, + RetiredRunIds = [], + SkippedRunIds = alreadyRetired, + Success = true, + }; + } + + var retired = new List(); + if (execute) + { + foreach (var lineage in active) + { + try + { + var didRetire = await _journalStore.RetireLineageAsync( + lineage.RunId, root, Reason, ct).ConfigureAwait(false); + if (didRetire) retired.Add(lineage.RunId); + } + catch (Exception ex) + { + return new Polyphony.Models.LineageRetireResult + { + Root = root, + RunId = null, + AllActive = true, + Executed = true, + Reason = Reason, + RetiredRunIds = retired, + SkippedRunIds = alreadyRetired, + Success = false, + Error = $"Failed to retire '{lineage.RunId}': {ex.Message}", + }; + } + } + } + else + { + // Dry-run: report what WOULD be retired. + retired.AddRange(active.Select(l => l.RunId)); + } + + return new Polyphony.Models.LineageRetireResult + { + Root = root, + RunId = null, + AllActive = true, + Executed = execute, + Reason = Reason, + RetiredRunIds = retired, + SkippedRunIds = alreadyRetired, + Success = true, + }; + } + private async Task ResetRootPatternCoreAsync( int root, bool execute, diff --git a/src/Polyphony/Commands/ResetCommands.cs b/src/Polyphony/Commands/ResetCommands.cs index dad7a75..f3f01c8 100644 --- a/src/Polyphony/Commands/ResetCommands.cs +++ b/src/Polyphony/Commands/ResetCommands.cs @@ -52,7 +52,8 @@ public sealed partial class ResetCommands( Polyphony.Routing.HierarchyWalker walker, RunContext? runContext = null, JournaledActionDecorator? journalDecorator = null, - ProjectionResetExecutor? projectionResetExecutor = null) + ProjectionResetExecutor? projectionResetExecutor = null, + IJournalStore? journalStore = null) { private readonly ITwigClient _twig = twig; private readonly IGitClient _git = git; @@ -62,6 +63,7 @@ public sealed partial class ResetCommands( private readonly ProjectionResetExecutor? _projectionResetExecutor = projectionResetExecutor; private readonly RunContext _runContext = JournalCommandSupport.ResolveRunContext(runContext); private readonly JournaledActionDecorator _journalDecorator = JournalCommandSupport.ResolveDecorator(journalDecorator); + private readonly IJournalStore _journalStore = journalStore ?? JournalCommandSupport.NullStore; /// /// Canonical root-scoped branch prefix set for ref classes whose diff --git a/src/Polyphony/Commands/StateCommands.NextReady.cs b/src/Polyphony/Commands/StateCommands.NextReady.cs index 4b59d57..151f9aa 100644 --- a/src/Polyphony/Commands/StateCommands.NextReady.cs +++ b/src/Polyphony/Commands/StateCommands.NextReady.cs @@ -132,6 +132,20 @@ private async Task NextReadyCore( return ExitCodes.ConfigError; } + // W8: refuse to trust the polyphony:facets=* tag when the + // journal shows this lineage never stamped it but a prior + // lineage did. Defends fresh-checkout / cross-machine / reset + // scenarios where the tag survived but the run that produced + // it did not. See JournalLineageGrounding for the matrix. + if (overrideFacets is not null + && await JournalLineageGrounding.IsTagForeignAsync( + _journalStore, _runContext, workItem, "plan_seed_children", ct).ConfigureAwait(false)) + { + await Console.Error.WriteLineAsync( + $"warning: polyphony:facets tag on item {workItem} appears foreign to current lineage '{_runContext.RunId}' — ignoring for routing.").ConfigureAwait(false); + overrideFacets = null; + } + var resolved = RequirementInputResolver.Resolve(typeConfig, children.Count, overrideFacets); var derivation = RequirementSetDeriver.Derive( @@ -1057,6 +1071,16 @@ private async Task ComputeChildSnapshotAsync( }; } + // W8: same foreign-tag refusal applied to the child rollup + // path so a parent's disposition is not poisoned by a + // child's stale facets tag. + if (overrideFacets is not null + && await JournalLineageGrounding.IsTagForeignAsync( + _journalStore, _runContext, child.Id, "plan_seed_children", ct).ConfigureAwait(false)) + { + overrideFacets = null; + } + var resolved = RequirementInputResolver.Resolve(typeConfig, grandchildren.Count, overrideFacets); var derivation = RequirementSetDeriver.Derive( resolved.Facets, diff --git a/src/Polyphony/Commands/StateCommands.cs b/src/Polyphony/Commands/StateCommands.cs index 302a729..de5bd1a 100644 --- a/src/Polyphony/Commands/StateCommands.cs +++ b/src/Polyphony/Commands/StateCommands.cs @@ -32,8 +32,12 @@ public sealed partial class StateCommands( IProcessRunner runner, IWorkItemRepository repository, ProcessConfig processConfig, - PlanObserver planObserver) + PlanObserver planObserver, + Polyphony.Journal.RunContext? runContext = null, + Polyphony.Journal.IJournalStore? journalStore = null) { + private readonly Polyphony.Journal.RunContext _runContext = runContext ?? new Polyphony.Journal.RunContext(); + private readonly Polyphony.Journal.IJournalStore? _journalStore = journalStore; private const string DotnetExe = "dotnet"; /// diff --git a/src/Polyphony/ExitCodes.cs b/src/Polyphony/ExitCodes.cs index ab38e69..10f2215 100644 --- a/src/Polyphony/ExitCodes.cs +++ b/src/Polyphony/ExitCodes.cs @@ -31,4 +31,12 @@ public static class ExitCodes /// One or more critical health checks failed (polyphony health). /// public const int HealthCheckFailed = 4; + + /// + /// W5 (AB#3279): A mutating verb refused to run because POLYPHONY_RUN_ID + /// is unset (the lineage fell back to a process-scoped manual_* + /// id). The verb writes no journal rows and emits a structured + /// error envelope explaining how to set the env var. + /// + public const int MissingRunIdLineage = 5; } diff --git a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs index 177adb0..f2ad9f1 100644 --- a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs +++ b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs @@ -52,7 +52,14 @@ public static IServiceCollection AddPolyphonyServices( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + // W5 (AB#3279): production decorator runs with the manual-lineage + // guard ENGAGED. Test fixtures construct decorators directly + // without the guard for back-compat — the W5 guard is opt-in at + // construction time so this is the only code path that enables it. + services.AddSingleton(sp => + new JournaledActionDecorator( + sp.GetRequiredService(), + failClosedOnManualLineage: true)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Polyphony/Journal/JournalLineage.cs b/src/Polyphony/Journal/JournalLineage.cs new file mode 100644 index 0000000..3014498 --- /dev/null +++ b/src/Polyphony/Journal/JournalLineage.cs @@ -0,0 +1,36 @@ +namespace Polyphony.Journal; + +/// +/// W11 (AB#3292): a recorded lineage row — a (run_id, root_id) pair +/// that performed at least one journaled action under this checkout. +/// Used by routing, reset, reconcile, and cross-machine attach to +/// answer "which lineages have ever owned this root?" without +/// scanning the entire actions table. +/// +/// +/// is set by polyphony lineage retire +/// (or by polyphony reset apex's lineage-retire step) to +/// tombstone a lineage. Retired lineages remain queryable for +/// observability but never anchor an "ours" decision. +/// +/// +public sealed record JournalLineage +{ + public required string RunId { get; init; } + public required int RootId { get; init; } + + /// Unix epoch milliseconds at first observation. + public required long CreatedAt { get; init; } + + /// Unix epoch milliseconds at retirement, or null if active. + public long? RetiredAt { get; init; } + + /// Operator-supplied retirement reason, or null. + public string? RetiredReason { get; init; } + + /// Hostname at first observation, or null if unknown. + public string? CreatedByHost { get; init; } + + /// OS username at first observation, or null if unknown. + public string? CreatedByUser { get; init; } +} diff --git a/src/Polyphony/Journal/JournalSchema.cs b/src/Polyphony/Journal/JournalSchema.cs index da71422..952491f 100644 --- a/src/Polyphony/Journal/JournalSchema.cs +++ b/src/Polyphony/Journal/JournalSchema.cs @@ -2,7 +2,7 @@ namespace Polyphony.Journal; public static class JournalSchema { - public const int CurrentVersion = 2; + public const int CurrentVersion = 3; public const string EnableWalModePragma = "PRAGMA journal_mode=WAL;"; public const string EnableForeignKeysPragma = "PRAGMA foreign_keys=ON;"; @@ -53,6 +53,28 @@ FOREIGN KEY(journal_entry_id) REFERENCES actions(id) ON DELETE public const string CreateJournalEffectsKindIdIndex = "CREATE INDEX IF NOT EXISTS idx_journal_effects_kind_id ON journal_effects(kind, resource_id);"; public const string CreateJournalEffectsOwnershipIndex = "CREATE INDEX IF NOT EXISTS idx_journal_effects_owned_kind ON journal_effects(polyphony_owned, kind);"; + /// + /// W11 (AB#3292): one row per (run_id, root_id) pair that has ever + /// performed a journaled action under this checkout. Auto-inserted + /// by on first observation; + /// retired (tombstoned, not deleted) by + /// polyphony lineage retire and by the reset apex pipeline. + /// + public const string CreateJournalLineagesTable = """ + CREATE TABLE IF NOT EXISTS journal_lineages ( + run_id TEXT NOT NULL, + root_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + retired_at INTEGER, + retired_reason TEXT, + created_by_host TEXT, + created_by_user TEXT, + PRIMARY KEY (run_id, root_id) + ); + """; + + public const string CreateJournalLineagesRootIndex = "CREATE INDEX IF NOT EXISTS idx_journal_lineages_root ON journal_lineages(root_id);"; + public const string CreateSchemaVersionTable = """ CREATE TABLE IF NOT EXISTS schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -62,7 +84,7 @@ version INTEGER NOT NULL public const string EnsureSchemaVersionRow = """ INSERT INTO schema_version(id, version) - VALUES (1, 2) + VALUES (1, 3) ON CONFLICT(id) DO UPDATE SET version = excluded.version WHERE schema_version.version < excluded.version; """; @@ -71,6 +93,7 @@ ON CONFLICT(id) DO UPDATE SET version = excluded.version [ CreateActionsTable, CreateJournalEffectsTable, + CreateJournalLineagesTable, CreateWorkItemIndex, CreateRootIndex, CreateRunIndex, @@ -79,6 +102,7 @@ ON CONFLICT(id) DO UPDATE SET version = excluded.version CreateJournalEffectsEntryIndex, CreateJournalEffectsKindIdIndex, CreateJournalEffectsOwnershipIndex, + CreateJournalLineagesRootIndex, CreateSchemaVersionTable, EnsureSchemaVersionRow, ]; diff --git a/src/Polyphony/Journal/JournalStore.cs b/src/Polyphony/Journal/JournalStore.cs index 6df6b60..06e9a5d 100644 --- a/src/Polyphony/Journal/JournalStore.cs +++ b/src/Polyphony/Journal/JournalStore.cs @@ -20,6 +20,26 @@ Task RecordEndAsync( CancellationToken ct); Task> QueryAsync(JournalQuery query, CancellationToken ct); Task ExportAsync(string destinationPath, CancellationToken ct); + + /// + /// W11 (AB#3292): record a (run_id, root_id) lineage row. Idempotent — + /// repeated calls for the same pair are no-ops. The first observation + /// wins for created_at / host / user. + /// + Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct); + + /// + /// W11 (AB#3292): all lineage rows for the given root, ordered by + /// created_at ascending. Returns retired and active rows alike; + /// callers filter via . + /// + Task> GetLineagesAsync(int rootId, CancellationToken ct); + + /// + /// W12 (AB#3293): tombstone a lineage. Sets retired_at / + /// retired_reason. Returns true if a row was updated. + /// + Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct); } public sealed class JournalStore : IJournalStore @@ -59,7 +79,24 @@ INSERT INTO actions(run_id, root_id, work_item_id, action, target, started_at, p command.Parameters.AddWithValue("$payloadJson", (object?)entry.PayloadJson ?? DBNull.Value); var scalar = await command.ExecuteScalarAsync(ct).ConfigureAwait(false); - return Convert.ToInt64(scalar, System.Globalization.CultureInfo.InvariantCulture); + var insertedId = Convert.ToInt64(scalar, System.Globalization.CultureInfo.InvariantCulture); + + // W11 (AB#3292): opportunistically record the lineage. Cheap + // INSERT OR IGNORE — first observation wins. Only when we + // actually have a root id; lineage is meaningless without it. + if (entry.RootId is { } rootIdForLineage) + { + await RecordLineageInternalAsync( + connection, + entry.RunId, + rootIdForLineage, + Environment.MachineName, + Environment.UserName, + entry.StartedAt, + ct).ConfigureAwait(false); + } + + return insertedId; } public async Task RecordEndAsync( @@ -225,6 +262,99 @@ public async Task ExportAsync(string destinationPath, CancellationToken ct) sourceConnection.BackupDatabase(destinationConnection); } + public async Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + if (rootId <= 0) throw new ArgumentOutOfRangeException(nameof(rootId), "rootId must be positive."); + + await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); + await RecordLineageInternalAsync( + connection, + runId, + rootId, + host, + user, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ct).ConfigureAwait(false); + } + + public async Task> GetLineagesAsync(int rootId, CancellationToken ct) + { + if (rootId <= 0) throw new ArgumentOutOfRangeException(nameof(rootId), "rootId must be positive."); + + await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT run_id, root_id, created_at, retired_at, retired_reason, created_by_host, created_by_user + FROM journal_lineages + WHERE root_id = $rootId + ORDER BY created_at ASC, run_id ASC; + """; + command.Parameters.AddWithValue("$rootId", rootId); + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + results.Add(new JournalLineage + { + RunId = reader.GetString(0), + RootId = reader.GetInt32(1), + CreatedAt = reader.GetInt64(2), + RetiredAt = reader.IsDBNull(3) ? null : reader.GetInt64(3), + RetiredReason = reader.IsDBNull(4) ? null : reader.GetString(4), + CreatedByHost = reader.IsDBNull(5) ? null : reader.GetString(5), + CreatedByUser = reader.IsDBNull(6) ? null : reader.GetString(6), + }); + } + return results; + } + + public async Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + if (rootId <= 0) throw new ArgumentOutOfRangeException(nameof(rootId), "rootId must be positive."); + + await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE journal_lineages + SET retired_at = $retiredAt, + retired_reason = COALESCE($reason, retired_reason) + WHERE run_id = $runId AND root_id = $rootId AND retired_at IS NULL; + """; + command.Parameters.AddWithValue("$retiredAt", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$reason", (object?)reason ?? DBNull.Value); + command.Parameters.AddWithValue("$runId", runId); + command.Parameters.AddWithValue("$rootId", rootId); + var rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + return rowsAffected > 0; + } + + private static async Task RecordLineageInternalAsync( + SqliteConnection connection, + string runId, + int rootId, + string? host, + string? user, + long createdAtUnixMs, + CancellationToken ct) + { + await using var command = connection.CreateCommand(); + // INSERT OR IGNORE: first observation wins. Retirement is a + // separate UPDATE path; never collapse an existing row. + command.CommandText = """ + INSERT OR IGNORE INTO journal_lineages(run_id, root_id, created_at, created_by_host, created_by_user) + VALUES ($runId, $rootId, $createdAt, $host, $user); + """; + command.Parameters.AddWithValue("$runId", runId); + command.Parameters.AddWithValue("$rootId", rootId); + command.Parameters.AddWithValue("$createdAt", createdAtUnixMs); + command.Parameters.AddWithValue("$host", (object?)host ?? DBNull.Value); + command.Parameters.AddWithValue("$user", (object?)user ?? DBNull.Value); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + private async Task OpenConnectionAsync(CancellationToken ct) { var directory = Path.GetDirectoryName(_databasePath); diff --git a/src/Polyphony/Journal/JournaledActionDecorator.cs b/src/Polyphony/Journal/JournaledActionDecorator.cs index 8b322fe..1633b81 100644 --- a/src/Polyphony/Journal/JournaledActionDecorator.cs +++ b/src/Polyphony/Journal/JournaledActionDecorator.cs @@ -1,14 +1,50 @@ namespace Polyphony.Journal; +using System.Text.Json; + /// /// Manual journaling wrapper for state-mutating verbs. /// ConsoleAppFramework does not expose a first-class decorator hook for command methods, /// so Phase 1A keeps the wrapper explicit and AOT-safe: verbs call this helper directly /// when they opt into in Phase 1B. /// -public sealed class JournaledActionDecorator(IJournalStore store) +public sealed class JournaledActionDecorator { - private readonly IJournalStore _store = store; + private readonly IJournalStore _store; + private readonly bool _failClosedOnManualLineage; + + /// + /// Default ctor — keeps the W5 fail-closed guard OFF so existing test + /// fixtures that construct decorators without an env-stamped run id + /// continue to work. The production DI registration constructs with + /// = true. + /// + public JournaledActionDecorator(IJournalStore store) + : this(store, failClosedOnManualLineage: false) + { + } + + /// + /// W5 (AB#3279) ctor: when + /// is true, the decorator refuses to invoke any mutating action + /// whose invocation carries a + /// run id, writes no journal rows, and returns + /// after emitting a + /// structured error envelope to stderr. + /// + public JournaledActionDecorator(IJournalStore store, bool failClosedOnManualLineage) + { + ArgumentNullException.ThrowIfNull(store); + _store = store; + _failClosedOnManualLineage = failClosedOnManualLineage; + } + + /// + /// True when this decorator's W5 mutation guard is engaged. Surfaced + /// for test assertions and for diagnostics surfaces that want to + /// communicate the active posture. + /// + public bool WithManualLineageGuard => _failClosedOnManualLineage; public async Task RunWithAsync( JournaledActionInvocation invocation, @@ -21,6 +57,20 @@ public async Task RunWithAsync( ArgumentNullException.ThrowIfNull(invocation); ArgumentNullException.ThrowIfNull(action); + // ── W5 (AB#3279) fail-closed mutation guard. ────────────────────── + // Refuse to mutate when the run id fell back to a manual_* shape + // (POLYPHONY_RUN_ID was unset). Writes ZERO journal rows so the + // refusal does not pollute the journal of whichever real lineage + // the launcher would later stamp. Diagnostic verbs (journal, + // policy, health, validate-config) don't go through this + // decorator and are unaffected. + if (_failClosedOnManualLineage + && invocation.RunId.StartsWith(RunContext.ManualLineagePrefix, StringComparison.Ordinal)) + { + EmitManualLineageRefusal(invocation); + return ExitCodes.MissingRunIdLineage; + } + var actionId = await _store.RecordStartAsync( new JournalEntryStart { @@ -66,4 +116,31 @@ await _store.RecordEndAsync( ExitCodes.Success => JournalOutcome.Success, _ => JournalOutcome.Failure, }; + + private static void EmitManualLineageRefusal(JournaledActionInvocation invocation) + { + var envelope = new ManualLineageRefusal + { + Error = "missing_run_id_lineage", + Verb = invocation.Action, + Target = invocation.Target, + EnvVar = RunContext.RunIdEnvironmentVariable, + Message = "Refusing to mutate without POLYPHONY_RUN_ID. The launcher (Invoke-PolyphonySdlc.ps1) mints a ULID and exports it; ad-hoc `polyphony` invocations must export POLYPHONY_RUN_ID first (use a launcher-minted ULID to resume an existing run, or mint a new one for a fresh lineage). Diagnostic verbs (journal, policy, health, validate-config) do not require the env var.", + }; + Console.Error.WriteLine(JsonSerializer.Serialize( + envelope, PolyphonyJsonContext.Default.ManualLineageRefusal)); + } +} + +/// +/// W5 (AB#3279): structured envelope written to stderr when the +/// fail-closed mutation guard refuses an invocation. +/// +public sealed record ManualLineageRefusal +{ + public required string Error { get; init; } + public required string Verb { get; init; } + public required string Target { get; init; } + public required string EnvVar { get; init; } + public required string Message { get; init; } } diff --git a/src/Polyphony/Journal/NullJournalStore.cs b/src/Polyphony/Journal/NullJournalStore.cs index 87498a7..e885973 100644 --- a/src/Polyphony/Journal/NullJournalStore.cs +++ b/src/Polyphony/Journal/NullJournalStore.cs @@ -20,4 +20,13 @@ public Task> QueryAsync(JournalQuery query, Cancella => Task.FromResult>([]); public Task ExportAsync(string destinationPath, CancellationToken ct) => Task.CompletedTask; + + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) + => Task.CompletedTask; + + public Task> GetLineagesAsync(int rootId, CancellationToken ct) + => Task.FromResult>([]); + + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) + => Task.FromResult(false); } diff --git a/src/Polyphony/Journal/Payloads/PlanSeedChildrenPayload.cs b/src/Polyphony/Journal/Payloads/PlanSeedChildrenPayload.cs index b8d9340..5202ee6 100644 --- a/src/Polyphony/Journal/Payloads/PlanSeedChildrenPayload.cs +++ b/src/Polyphony/Journal/Payloads/PlanSeedChildrenPayload.cs @@ -14,4 +14,15 @@ public sealed record PlanSeedChildrenPayload public required bool FacetsTagMutated { get; init; } public required bool Succeeded { get; init; } public required bool WasMutated { get; init; } + + /// + /// W4 (AB#3278): explicit lifecycle fact that distinguishes + /// merged_unseeded from complete in journal-grounded + /// plan detect-state. True iff the seeder succeeded AND the + /// parent's polyphony:planned tag is now present (whether + /// newly mutated or already there from a prior run). This is the + /// only journal fact detect-state consults to declare a + /// plan "complete" without re-reading the parent's tag. + /// + public required bool PlanningCompleted { get; init; } } diff --git a/src/Polyphony/Journal/RunContext.cs b/src/Polyphony/Journal/RunContext.cs index 88bf551..885d466 100644 --- a/src/Polyphony/Journal/RunContext.cs +++ b/src/Polyphony/Journal/RunContext.cs @@ -2,13 +2,34 @@ namespace Polyphony.Journal; /// /// Resolves the conductor run id used to correlate journal rows with the -/// workflow event log. When no workflow-supplied run id is present, a -/// process-scoped fallback keeps ad-hoc CLI invocations journalable. +/// workflow event log. The launcher (W1) exports +/// so every nested polyphony +/// subprocess sees the same lineage; when the variable is unset (ad-hoc +/// CLI use, isolated unit tests), a process-scoped manual_* +/// fallback keeps the journal write path runnable. The manual_ +/// prefix is deliberate so downstream code (W5 mutation guard, manifest +/// init's mint-real-ULID branch) can distinguish a launcher-stamped +/// lineage from an unsanctioned one. /// public sealed class RunContext { public const string RunIdEnvironmentVariable = "POLYPHONY_RUN_ID"; - private static readonly string FallbackRunId = $"manual_{Guid.NewGuid():N}"; + + /// + /// Prefix used by the unstamped-process fallback. Tests, manifest + /// init, and the W5 mutation guard all key on this prefix. + /// + public const string ManualLineagePrefix = "manual_"; + + /// Symbolic source values surfaced through . + public static class Sources + { + public const string Environment = "env"; + public const string ManualFallback = "manual_fallback"; + public const string Explicit = "explicit"; + } + + private static readonly string FallbackRunId = $"{ManualLineagePrefix}{Guid.NewGuid():N}"; public RunContext() : this(Environment.GetEnvironmentVariable) @@ -19,16 +40,42 @@ internal RunContext(string runId) { ArgumentException.ThrowIfNullOrWhiteSpace(runId); RunId = runId; + RunIdSource = Sources.Explicit; } internal RunContext(Func getEnvironmentVariable) { ArgumentNullException.ThrowIfNull(getEnvironmentVariable); var configured = getEnvironmentVariable(RunIdEnvironmentVariable); - RunId = string.IsNullOrWhiteSpace(configured) - ? FallbackRunId - : configured; + if (string.IsNullOrWhiteSpace(configured)) + { + RunId = FallbackRunId; + RunIdSource = Sources.ManualFallback; + } + else + { + RunId = configured; + RunIdSource = Sources.Environment; + } } public string RunId { get; } + + /// + /// How was resolved. One of + /// , , + /// or (test-supplied). + /// + public string RunIdSource { get; } + + /// + /// True when falls back to the unstamped + /// manual_* shape — i.e., the launcher did not export + /// POLYPHONY_RUN_ID. Consumers writing lineage to durable + /// state (manifest, journal grounding effects) should mint a real + /// ULID via in this case rather than + /// persisting the ephemeral fallback. + /// + public bool HasManualLineage => RunIdSource == Sources.ManualFallback + || (RunId is not null && RunId.StartsWith(ManualLineagePrefix, StringComparison.Ordinal)); } diff --git a/src/Polyphony/Journal/RunIdMint.cs b/src/Polyphony/Journal/RunIdMint.cs new file mode 100644 index 0000000..df02a21 --- /dev/null +++ b/src/Polyphony/Journal/RunIdMint.cs @@ -0,0 +1,111 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace Polyphony.Journal; + +/// +/// Mints ULID-shaped run ids without taking a dependency on an external +/// ULID package. Produces 26-character Crockford-base32 strings of the +/// canonical shape {timestamp:10}{random:16} per the ULID spec +/// (): 48-bit big-endian +/// millisecond timestamp followed by 80 bits of cryptographic randomness. +/// +/// AOT-safe — no reflection, no allocations beyond the returned string. +/// +public static class RunIdMint +{ + /// The canonical ULID length in characters. + public const int UlidLength = 26; + + // Crockford base32 alphabet — excludes I, L, O, U to dodge visual + // ambiguity. Order is part of the ULID spec. + private const string CrockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + /// + /// Returns a fresh ULID-shaped run id seeded from the current UTC + /// timestamp and . + /// + public static string NewRunId() => NewRunId(DateTimeOffset.UtcNow); + + /// + /// Returns a fresh ULID-shaped run id seeded from the supplied + /// timestamp. The 80 random bits still come from + /// . + /// + public static string NewRunId(DateTimeOffset timestamp) + { + Span random = stackalloc byte[10]; + RandomNumberGenerator.Fill(random); + return Compose(timestamp, random); + } + + internal static string Compose(DateTimeOffset timestamp, ReadOnlySpan randomness) + { + if (randomness.Length != 10) + { + throw new ArgumentException("ULID randomness must be exactly 10 bytes (80 bits).", nameof(randomness)); + } + + var unixMs = timestamp.ToUnixTimeMilliseconds(); + if (unixMs < 0) + { + throw new ArgumentOutOfRangeException(nameof(timestamp), "Timestamp predates the Unix epoch; ULID time component is unsigned."); + } + + // ULID byte layout: 6 bytes timestamp (big-endian, top bits zeroed) + 10 bytes random. + Span bytes = stackalloc byte[16]; + Span tsScratch = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(tsScratch, (ulong)unixMs); + tsScratch[2..8].CopyTo(bytes); + randomness.CopyTo(bytes[6..]); + + return EncodeCrockford(bytes); + } + + /// + /// True when is a 26-character Crockford-base32 + /// string. Useful for validating values arriving from the launcher or + /// from a manifest field. + /// + public static bool IsWellFormed(string? value) + { + if (value is null || value.Length != UlidLength) + { + return false; + } + + foreach (var c in value) + { + if (CrockfordAlphabet.IndexOf(char.ToUpperInvariant(c)) < 0) + { + return false; + } + } + + return true; + } + + private static string EncodeCrockford(ReadOnlySpan source) + { + // 128 bits / 5 bits per char = 25.6 → ULID uses 26 chars, with the + // top 2 bits of the first base32 symbol fixed at 0. We compute by + // packing into a single 128-bit value carried as two ulongs. + var hi = BinaryPrimitives.ReadUInt64BigEndian(source[..8]); + var lo = BinaryPrimitives.ReadUInt64BigEndian(source[8..]); + + Span result = stackalloc char[UlidLength]; + + // Emit from the LSB upward, then reverse. + for (var i = UlidLength - 1; i >= 0; i--) + { + var index = (int)(lo & 0x1F); + result[i] = CrockfordAlphabet[index]; + + // Shift the 128-bit value right by 5 bits. + lo = (lo >> 5) | (hi << 59); + hi >>= 5; + } + + return new string(result); + } +} diff --git a/src/Polyphony/Manifest/RunManifest.cs b/src/Polyphony/Manifest/RunManifest.cs index 2fb844a..251c5e5 100644 --- a/src/Polyphony/Manifest/RunManifest.cs +++ b/src/Polyphony/Manifest/RunManifest.cs @@ -1,14 +1,16 @@ namespace Polyphony.Manifest; /// -/// The on-disk run manifest at .polyphony/run.yaml. Schema 1 per -/// the Rev 4 branch-model ADR (docs/decisions/branch-model.md § -/// Run manifest). DTO uses primitive types; domain-typed conversions -/// happen at the validator / consumer boundary. +/// The on-disk run manifest at .polyphony/run.yaml. Schema 2 per +/// the post-AB#3275 lineage primitive (W2): same shape as schema 1 with +/// one additive field, , carrying the ULID that +/// stamps every lineage-scoped action emitted under this run. The Rev 4 +/// branch-model ADR (docs/decisions/branch-model.md § Run +/// manifest) still governs structure; only the schema version moves. /// /// Field grouping (normative shape): /// -/// Identity: , , , , , . +/// Identity: , , , , , , . /// Topology (hashed): . is the SHA-256 over the canonicalized form. /// Plan generations (cross-cutting bookkeeping): . /// Operational/audit (NOT hashed): , , , . @@ -16,12 +18,23 @@ namespace Polyphony.Manifest; /// public sealed class RunManifest { - /// Manifest schema version. Always 1 for current builds. - public int Schema { get; set; } = 1; + /// Manifest schema version. 2 for newly written manifests; legacy on-disk files may still be 1 (loader tolerates). + public int Schema { get; set; } = RunManifestValidator.CurrentSchema; /// The run's root (focus) work-item id. public int RootId { get; set; } + /// + /// Stable lineage identifier (ULID, 26 Crockford-base32 chars) that + /// stamps every action and effect emitted under this run. Required + /// for schema 2; legacy schema 1 manifests may load with this value + /// unset, in which case records a + /// non-fatal warning. The launcher exports the same value as + /// POLYPHONY_RUN_ID so every nested polyphony + /// subprocess sees the same lineage. See AB#3275 / AB#3276 / W1-W2. + /// + public string? RunId { get; set; } + /// /// Platform-qualified project identifier (e.g. /// dev.azure.com/dangreen-msft/Twig) used to disambiguate diff --git a/src/Polyphony/Manifest/RunManifestValidator.cs b/src/Polyphony/Manifest/RunManifestValidator.cs index 72a4cb3..8221f8d 100644 --- a/src/Polyphony/Manifest/RunManifestValidator.cs +++ b/src/Polyphony/Manifest/RunManifestValidator.cs @@ -1,4 +1,5 @@ using Polyphony.Branching; +using Polyphony.Journal; namespace Polyphony.Manifest; @@ -11,9 +12,32 @@ namespace Polyphony.Manifest; public static class RunManifestValidator { /// - /// The supported manifest schema version. + /// The current manifest schema version emitted by new writes + /// (post-W2). Schema 1 manifests are still loadable (legacy); + /// schema 2 adds the field. /// - public const int SupportedSchema = 1; + public const int CurrentSchema = 2; + + /// + /// Lowest schema version the loader will accept. Anything below + /// this is a hard error (no migration path). + /// + public const int MinSupportedSchema = 1; + + /// + /// Highest schema version the loader will accept (alias for + /// ). Kept distinct so a future read-only + /// transition window between two schema versions is expressible + /// without renaming. + /// + public const int MaxSupportedSchema = CurrentSchema; + + /// + /// Back-compat alias for the value most callers historically read. + /// Prefer for writes and the + /// Min/Max constants for range checks. + /// + public const int SupportedSchema = CurrentSchema; /// /// The supported branch-model version (matches Rev 4 of the ADR). @@ -29,9 +53,9 @@ public static IReadOnlyList Validate(RunManifest manifest) var issues = new List(); - if (manifest.Schema != SupportedSchema) + if (manifest.Schema < MinSupportedSchema || manifest.Schema > MaxSupportedSchema) { - issues.Add($"schema must be {SupportedSchema} (got {manifest.Schema})."); + issues.Add($"schema must be in [{MinSupportedSchema},{MaxSupportedSchema}] (got {manifest.Schema})."); } if (manifest.BranchModelVersion != SupportedBranchModelVersion) @@ -44,6 +68,11 @@ public static IReadOnlyList Validate(RunManifest manifest) issues.Add($"root_id must be positive (got {manifest.RootId})."); } + if (manifest.RunId is not null && !RunIdMint.IsWellFormed(manifest.RunId)) + { + issues.Add($"run_id '{manifest.RunId}' is not a 26-character Crockford-base32 ULID."); + } + if (string.IsNullOrWhiteSpace(manifest.PlatformProject)) { issues.Add("platform_project must be non-empty."); @@ -69,6 +98,29 @@ public static IReadOnlyList Validate(RunManifest manifest) return issues; } + /// + /// Returns non-fatal warnings (e.g., legacy schema 1 missing a + /// ) that should be surfaced to + /// operators without blocking the load. Always pair with + /// : warnings DO NOT subsume issues. + /// + public static IReadOnlyList CollectWarnings(RunManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + + var warnings = new List(); + if (manifest.Schema >= 2 && string.IsNullOrEmpty(manifest.RunId)) + { + warnings.Add($"schema {manifest.Schema} manifest is missing run_id; new writers should populate it (W2)."); + } + else if (manifest.Schema < 2 && string.IsNullOrEmpty(manifest.RunId)) + { + warnings.Add("legacy schema 1 manifest predates the run_id primitive (W2); resume across processes is best-effort until a fresh init runs."); + } + + return warnings; + } + /// Throwing variant — used by the loader. public static void ValidateOrThrow(RunManifest manifest, string sourcePath = "") { diff --git a/src/Polyphony/Models/LineageAttachResult.cs b/src/Polyphony/Models/LineageAttachResult.cs new file mode 100644 index 0000000..12f949e --- /dev/null +++ b/src/Polyphony/Models/LineageAttachResult.cs @@ -0,0 +1,36 @@ +namespace Polyphony.Models; + +/// +/// W14 (AB#3295): result envelope for polyphony lineage attach. +/// On a fresh checkout (no local journal but a root with remote stamped +/// artifacts), the operator can opt-in to a lineage stub so subsequent +/// adoption checks treat the local lineage as current rather than +/// minting a parallel one. +/// +public sealed record LineageAttachResult +{ + /// Root work-item ID the verb was scoped to. + public required int Root { get; init; } + + /// Run id of the lineage being stubbed locally. + public required string RunId { get; init; } + + /// True when the verb actually wrote the stub. + public required bool Executed { get; init; } + + /// Operator-supplied reason (free-form); null when omitted. + public string? Reason { get; init; } + + /// + /// True when a matching journal_lineages row already existed + /// (active or retired) before this invocation. Idempotent surface + /// for the reset / attach handshake. + /// + public required bool AlreadyExisted { get; init; } + + /// True when the verb completed without error. + public required bool Success { get; init; } + + /// Error message when is false. + public string? Error { get; init; } +} diff --git a/src/Polyphony/Models/LineageRetireResult.cs b/src/Polyphony/Models/LineageRetireResult.cs new file mode 100644 index 0000000..e6a0129 --- /dev/null +++ b/src/Polyphony/Models/LineageRetireResult.cs @@ -0,0 +1,48 @@ +namespace Polyphony.Models; + +/// +/// W12 (AB#3293): result envelope for polyphony lineage retire. +/// Reset's tombstone-not-delete posture means future runs can still see +/// what lineages used to exist (for diagnostics, attach, reconcile) while +/// routing verbs treat the retired rows as foreign. +/// +public sealed record LineageRetireResult +{ + /// Root work-item ID the verb was scoped to. + public required int Root { get; init; } + + /// + /// Specific run id requested by the caller, or null when + /// --all-active was used. + /// + public string? RunId { get; init; } + + /// True when --all-active was passed. + public required bool AllActive { get; init; } + + /// True when the verb actually attempted the retirement (vs. dry-run). + public required bool Executed { get; init; } + + /// Operator-supplied reason (free-form); null when omitted. + public string? Reason { get; init; } + + /// + /// Concrete (run_id, root_id) pairs that this invocation tombstoned. + /// Empty when dry-run, when the lineage was already retired, or + /// when no matching lineage existed. + /// + public required IReadOnlyList RetiredRunIds { get; init; } + + /// + /// Lineages the verb considered but did not retire — already + /// retired, or not matching the run-id filter. Useful for the + /// reset pipeline's audit log. + /// + public required IReadOnlyList SkippedRunIds { get; init; } + + /// True when the verb completed without error. + public required bool Success { get; init; } + + /// Error message when is false. + public string? Error { get; init; } +} diff --git a/src/Polyphony/Models/LineageStatusResult.cs b/src/Polyphony/Models/LineageStatusResult.cs new file mode 100644 index 0000000..e4856aa --- /dev/null +++ b/src/Polyphony/Models/LineageStatusResult.cs @@ -0,0 +1,135 @@ +namespace Polyphony.Models; + +/// +/// W15 (AB#3285): result envelope for the +/// polyphony lineage status --root diagnostic. Pure read-only +/// JSON; carries no side-effects. Designed for: +/// +/// Operators triaging a stuck run ("which lineage am I in? +/// does the journal still know about me?"). +/// The forthcoming polyphony reconcile verb (W13) +/// as a pre-flight check before any superseding observations are +/// appended. +/// The cross-machine attach UX (W14) — same data, used to +/// refuse parallel-lineage minting. +/// +/// +public sealed record LineageStatusResult +{ + /// Root work-item ID the report was scoped to. + public required int Root { get; init; } + + /// + /// Current run id as resolved from + /// at verb invocation. Carries a manual_* sentinel when no + /// POLYPHONY_RUN_ID was exported by the launcher. + /// + public required string CurrentRunId { get; init; } + + /// + /// True when is a manual_* + /// fallback (no real lineage in play). Diagnostic only — the + /// verb still reports lineages observed in the journal so the + /// operator can see which prior lineages exist. + /// + public required bool CurrentLineageIsManual { get; init; } + + /// + /// Path to the resolved run manifest, or null when no + /// manifest exists yet. When non-null but unreadable, see + /// . + /// + public string? ManifestPath { get; init; } + + /// + /// as recorded + /// in the on-disk run manifest (W2). Mismatch with + /// is a partition signal. + /// + public string? ManifestRunId { get; init; } + + /// Set when the manifest exists but failed to parse. + public string? ManifestError { get; init; } + + /// + /// Path of the journal SQLite database the verb consulted. + /// null for the in-memory NullJournalStore. + /// + public string? JournalPath { get; init; } + + /// + /// Every distinct run id observed in the journal for this root. + /// Stable order (lexicographic) so diff-friendly across runs. + /// + public required IReadOnlyList Lineages { get; init; } + + /// + /// True when the current run id appears in + /// — i.e. THIS lineage has at least one journal row for this + /// root. + /// + public required bool CurrentLineageHasRows { get; init; } + + /// + /// True when the journal contains rows for OTHER lineages but + /// nothing for the current lineage. The classic + /// "fresh-checkout / wiped-tmpdir / cross-machine" partition + /// signal — without this, an operator could assume the journal + /// is clean when in fact it's only this checkout's view that is + /// blank. + /// + public required bool LooksPartitioned { get; init; } + + /// + /// Free-form human-readable headline summarising the verdict. + /// Stable wording per state so dashboards can match on it. + /// + public required string Verdict { get; init; } +} + +/// +/// One distinct lineage observed in the journal for a given root. +/// +public sealed record LineageObservation +{ + /// Distinct run_id column value from the journal. + public required string RunId { get; init; } + + /// Count of actions rows with this run id under this root. + public required int RowCount { get; init; } + + /// + /// Wall-clock timestamp of the earliest action row for this + /// lineage under this root (ms-since-epoch). Useful for spotting + /// "yesterday's stuck run". + /// + public long? FirstSeenAt { get; init; } + + /// + /// Wall-clock timestamp of the latest action row (ms-since-epoch). + /// Combined with bounds the lineage's + /// activity window. + /// + public long? LastSeenAt { get; init; } + + /// + /// True when this row's run id equals the current run id. Lets + /// callers highlight the "you are here" row in renderings. + /// + public required bool IsCurrent { get; init; } + + /// + /// W12 (AB#3293): unix-ms tombstone timestamp from the + /// journal_lineages table, or null when the lineage + /// is still active. Routing verbs treat tombstoned rows as + /// foreign even when they belong to lineages that produced rows + /// in actions. + /// + public long? RetiredAt { get; init; } + + /// + /// W12 (AB#3293): operator-supplied retirement reason, or + /// null when none was provided / the lineage is active. + /// + public string? RetiredReason { get; init; } +} diff --git a/src/Polyphony/Models/ManifestInitResult.cs b/src/Polyphony/Models/ManifestInitResult.cs index 996c448..ea03ec4 100644 --- a/src/Polyphony/Models/ManifestInitResult.cs +++ b/src/Polyphony/Models/ManifestInitResult.cs @@ -18,6 +18,23 @@ public sealed record ManifestInitResult /// The recorded creator (workflow author / operator). public required string CreatedBy { get; init; } + /// + /// The lineage ULID stamped on the manifest. Resolved from + /// POLYPHONY_RUN_ID when set; otherwise minted at init. The + /// launcher exports the same value so every nested + /// polyphony subprocess sees the same lineage. See + /// AB#3275 / AB#3276 / W1-W2. + /// + public string? RunId { get; init; } + + /// + /// How was sourced: "env" when the + /// launcher's POLYPHONY_RUN_ID was honoured, "minted" + /// when init fell back to a fresh ULID, or null on error + /// envelopes where the value was never resolved. + /// + public string? RunIdSource { get; init; } + /// The topology hash of the empty merge-group set. public required string TopologyHash { get; init; } diff --git a/src/Polyphony/Models/PlanDetectStateResult.cs b/src/Polyphony/Models/PlanDetectStateResult.cs index 734ef07..a0820dd 100644 --- a/src/Polyphony/Models/PlanDetectStateResult.cs +++ b/src/Polyphony/Models/PlanDetectStateResult.cs @@ -76,6 +76,21 @@ public sealed record PlanDetectStateResult /// Error message on failure; null on success. public string? Error { get; init; } + + /// + /// Provenance of the determination, surfaced for the + /// W3 / W4 journal-first rollout. One of: + /// + /// journal — state derived from journal grounding for the + /// current run lineage (no journal rows for this item under the + /// active POLYPHONY_RUN_ID = not_started). + /// archaeology — state derived from PR / branch / tag + /// observation (the pre-W3 behavior). + /// none — state derived without consulting either source + /// (input validation errors). + /// + /// + public string? LineageAnchor { get; init; } } /// diff --git a/src/Polyphony/Models/PrPollStatusResult.cs b/src/Polyphony/Models/PrPollStatusResult.cs index e44a9f6..628e9b9 100644 --- a/src/Polyphony/Models/PrPollStatusResult.cs +++ b/src/Polyphony/Models/PrPollStatusResult.cs @@ -179,6 +179,18 @@ public sealed record PrPollMetadata /// Map of ancestor item id (or "root") to plan_generation snapshot recorded at branch creation. Empty for non-plan PRs. public required IReadOnlyDictionary AncestorPlanGenerations { get; init; } + + /// + /// W7 (AB#3281): the originating run's lineage id, parsed from the + /// run_id: key in plan-PR YAML front-matter. Null when the + /// front-matter is absent or the key is missing — both are + /// acceptable on legacy plan PRs that pre-date the stamping + /// rollout. Plan-PR observers consult this to ground a plan PR in + /// a lineage when the local journal is silent (the plan-PR + /// analogue of for + /// non-plan PRs). + /// + public string? RunId { get; init; } } /// diff --git a/src/Polyphony/Models/ReconcileResult.cs b/src/Polyphony/Models/ReconcileResult.cs new file mode 100644 index 0000000..d34057e --- /dev/null +++ b/src/Polyphony/Models/ReconcileResult.cs @@ -0,0 +1,66 @@ +namespace Polyphony.Models; + +/// +/// W13 (AB#3294): result envelope for polyphony reconcile --root. +/// The verb is a thin lens over with optional +/// side-effects that write synthesized journal entries under the current +/// lineage so the existing OwnedResources / CurrentExpectedState +/// projections treat reconciled resources as in-scope on the next pass. +/// +public sealed record ReconcileResult +{ + /// Root work-item ID the verb was scoped to. + public required int Root { get; init; } + + /// Run id of the synthesized adopt/accept rows (always current lineage). + public required string RunId { get; init; } + + /// True when the verb actually mutated the journal. + public required bool Executed { get; init; } + + /// True when --accept-external was passed. + public required bool AcceptExternal { get; init; } + + /// + /// Optional KIND:ID selector from --adopt. null + /// when the operator did not request ownership transfer. + /// + public string? AdoptTarget { get; init; } + + /// Full drift report — surfaces every finding for transparency. + public required DriftResult Drift { get; init; } + + /// + /// Findings that --accept-external would (or did) absorb into + /// the current lineage by recording a reconcile_accept_external + /// journal row. Always populated, regardless of --execute. + /// + public required IReadOnlyList AcceptedExternal { get; init; } + + /// + /// Findings that --adopt would (or did) attach to the current + /// lineage via a synthesized reconcile_adopt row. Empty unless + /// --adopt matched a finding. + /// + public required IReadOnlyList Adopted { get; init; } + + /// True when the verb completed without error. + public required bool Success { get; init; } + + /// Error message when is false. + public string? Error { get; init; } +} + +/// +/// Compact description of a single drift finding that reconcile +/// has acted on (or would act on, in dry-run mode). +/// +public sealed record ReconciledFinding +{ + public required string Kind { get; init; } + public required string Id { get; init; } + public required string Classification { get; init; } + public required string ExpectedState { get; init; } + public string? ActualState { get; init; } + public required string Action { get; init; } +} diff --git a/src/Polyphony/Models/ResetRootResult.cs b/src/Polyphony/Models/ResetRootResult.cs index 6de7e52..31faa2e 100644 --- a/src/Polyphony/Models/ResetRootResult.cs +++ b/src/Polyphony/Models/ResetRootResult.cs @@ -22,5 +22,14 @@ public sealed record ResetRootResult public ResetManifestResult? Manifest { get; init; } public ResetStateResult? State { get; init; } public bool StateSkipped { get; init; } + + /// + /// W12 (AB#3293): outcome of the journal-lineage retire step. + /// Always populated (with RetiredRunIds = []) when reset + /// runs to success; null when the pipeline halted before + /// the lineages step or when the journal is the null store. + /// + public Polyphony.Models.LineageRetireResult? Lineages { get; init; } + public string? Error { get; init; } } diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index 5917ec1..219155f 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -51,6 +51,15 @@ namespace Polyphony; [JsonSerializable(typeof(TagMutationPayload))] [JsonSerializable(typeof(LockMutationPayload))] [JsonSerializable(typeof(ManifestMutationPayload))] +[JsonSerializable(typeof(ManualLineageRefusal))] +[JsonSerializable(typeof(LineageStatusResult))] +[JsonSerializable(typeof(LineageObservation))] +[JsonSerializable(typeof(LineageObservation[]))] +[JsonSerializable(typeof(LineageRetireResult))] +[JsonSerializable(typeof(LineageAttachResult))] +[JsonSerializable(typeof(ReconcileResult))] +[JsonSerializable(typeof(ReconciledFinding))] +[JsonSerializable(typeof(ReconciledFinding[]))] [JsonSerializable(typeof(PlanWritePlanPayload))] [JsonSerializable(typeof(PlanCommitAndPushPayload))] [JsonSerializable(typeof(PlanSeedChildrenPayload))] diff --git a/src/Polyphony/Program.cs b/src/Polyphony/Program.cs index 7230456..ee7b80c 100644 --- a/src/Polyphony/Program.cs +++ b/src/Polyphony/Program.cs @@ -49,6 +49,8 @@ app.Add("agent"); app.Add("research"); app.Add("reset"); +app.Add("lineage"); +app.Add(); // Set of registered top-level verb names + verb-group prefixes. Used by the // Layer 2 unknown-verb pre-check below — CAF v5 treats an unknown verb the @@ -60,7 +62,7 @@ "validate", "validate-config", "hierarchy", "journal", "health", "status", "plan", "policy", "guidance", "branch", "state", "pr", "scope", "root", "requirements", "merge-group", "manifest", "lock", "worktree", "worklist", "edges", "agent", - "research", "reset", + "research", "reset", "reconcile", "lineage", // Built-ins / pass-throughs handled by CAF itself. "--help", "-h", "--version", }; diff --git a/tests/Polyphony.Tests/Commands/BranchLineageGuardTests.cs b/tests/Polyphony.Tests/Commands/BranchLineageGuardTests.cs new file mode 100644 index 0000000..0a677d5 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/BranchLineageGuardTests.cs @@ -0,0 +1,185 @@ +using Polyphony.Commands; +using Polyphony.Journal; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W10 (AB#3291): unit coverage for . +/// Mirrors the matrix that 's tests use: +/// every "unknown" signal short-circuits to allow; only positive +/// foreign-ownership evidence refuses. +/// +public sealed class BranchLineageGuardTests +{ + [Fact] + public async Task CheckAsync_NoRunId_Allows() + { + var decision = await BranchLineageGuard.CheckAsync( + journal: new NullJournalStore(), + currentRunId: null, + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("no run id"); + } + + [Fact] + public async Task CheckAsync_ManualLineage_Allows() + { + var decision = await BranchLineageGuard.CheckAsync( + journal: new NullJournalStore(), + currentRunId: "01JCTEST", + isManualLineage: true, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("manual lineage"); + } + + [Fact] + public async Task CheckAsync_NullJournalStore_Allows() + { + var decision = await BranchLineageGuard.CheckAsync( + journal: new NullJournalStore(), + currentRunId: "01JCTEST", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("no journal store"); + } + + [Fact] + public async Task CheckAsync_JournalSilent_Allows() + { + using var fixture = new JournalFixture(); + var decision = await BranchLineageGuard.CheckAsync( + journal: fixture.Store, + currentRunId: "01JCFRESH", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("Journal silent"); + } + + [Fact] + public async Task CheckAsync_PriorRowFromCurrentRun_Allows() + { + using var fixture = new JournalFixture(); + await RecordEnsureAsync(fixture.Store, "01JCOURS", "impl/100-200"); + + var decision = await BranchLineageGuard.CheckAsync( + journal: fixture.Store, + currentRunId: "01JCOURS", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("current run id"); + } + + [Fact] + public async Task CheckAsync_PriorRowFromForeignRun_Refuses() + { + using var fixture = new JournalFixture(); + await RecordEnsureAsync(fixture.Store, "01JCFOREIGN", "impl/100-200"); + + var decision = await BranchLineageGuard.CheckAsync( + journal: fixture.Store, + currentRunId: "01JCMINE", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeFalse(); + decision.Reason.ShouldContain("01JCFOREIGN"); + decision.Reason.ShouldContain("01JCMINE"); + decision.Reason.ShouldContain("foreign-lineage"); + } + + [Fact] + public async Task CheckAsync_PriorRowFromBothCurrentAndForeign_Allows() + { + using var fixture = new JournalFixture(); + await RecordEnsureAsync(fixture.Store, "01JCFOREIGN", "impl/100-200"); + await RecordEnsureAsync(fixture.Store, "01JCMINE", "impl/100-200"); + + var decision = await BranchLineageGuard.CheckAsync( + journal: fixture.Store, + currentRunId: "01JCMINE", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("current run id"); + } + + [Fact] + public async Task CheckAsync_RowForDifferentTarget_DoesNotInterfere() + { + using var fixture = new JournalFixture(); + await RecordEnsureAsync(fixture.Store, "01JCFOREIGN", "impl/100-999"); + + var decision = await BranchLineageGuard.CheckAsync( + journal: fixture.Store, + currentRunId: "01JCMINE", + isManualLineage: false, + journalAction: "branch_ensure_impl", + journalTarget: "impl/100-200", + CancellationToken.None); + + decision.Allowed.ShouldBeTrue(); + decision.Reason.ShouldContain("Journal silent"); + } + + private static async Task RecordEnsureAsync(JournalStore store, string runId, string target) + { + var actionId = await store.RecordStartAsync( + new JournalEntryStart + { + RunId = runId, + RootId = 100, + WorkItemId = 200, + Action = "branch_ensure_impl", + Target = target, + StartedAt = 1_800_000_000_000, + }, + CancellationToken.None); + await store.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, null, CancellationToken.None); + } + + private sealed class JournalFixture : IDisposable + { + private readonly string _tempDir; + public JournalStore Store { get; } + + public JournalFixture() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-branch-lineage-guard-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + Store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs index 3c15d5b..7ae7fd4 100644 --- a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs +++ b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs @@ -2282,6 +2282,10 @@ private sealed class ThrowingJournalStore(string? queryError = null, string? exp public Task> QueryAsync(JournalQuery query, CancellationToken ct) => throw new InvalidOperationException(queryError ?? "query failed"); public Task ExportAsync(string destinationPath, CancellationToken ct) => throw new InvalidOperationException(exportError ?? "export failed"); + + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); } private sealed class StubResourceObserver(ResourceObservationBatch batch) : IResourceObserver diff --git a/tests/Polyphony.Tests/Commands/LineageAttachTests.cs b/tests/Polyphony.Tests/Commands/LineageAttachTests.cs new file mode 100644 index 0000000..15dd700 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/LineageAttachTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Journal; +using Polyphony.Models; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W14 (AB#3295): polyphony lineage attach — cross-machine +/// on-ramp. Verifies dry-run vs execute, idempotent re-attach, +/// retirement refusal, and the NullJournalStore short-circuit. +/// +public sealed class LineageAttachTests : CommandTestBase +{ + private readonly string _tempDir; + private readonly JournalStore _store; + + public LineageAttachTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-lineage-attach-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task Attach_RootMissing_ReturnsRequiredInputHalt() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => command.Attach(runId: "01JCYYYYYYYYYYYYYYYYYYYYYY")); + + exitCode.ShouldNotBe(ExitCodes.Success); + output.ShouldContain("--root"); + } + + [Fact] + public async Task Attach_RunIdMissing_ReturnsRequiredInputHalt() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => command.Attach(root: 7700)); + + exitCode.ShouldNotBe(ExitCodes.Success); + output.ShouldContain("--run-id"); + } + + [Fact] + public async Task Attach_DryRun_DoesNotWrite() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: 7701, runId: "01JCZZZZZZZZZZZZZZZZZZZZZZ", reason: "fresh checkout")); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.Success); + result.Success.ShouldBeTrue(); + result.Executed.ShouldBeFalse(); + result.AlreadyExisted.ShouldBeFalse(); + result.Reason.ShouldBe("fresh checkout"); + + var lineages = await _store.GetLineagesAsync(7701, CancellationToken.None); + lineages.ShouldBeEmpty(); + } + + [Fact] + public async Task Attach_Execute_WritesStub() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: 7702, runId: "01JCAAAAAAAAAAAAAAAAAAAAAA", execute: true)); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.Success); + result.Executed.ShouldBeTrue(); + result.AlreadyExisted.ShouldBeFalse(); + + var lineages = await _store.GetLineagesAsync(7702, CancellationToken.None); + lineages.ShouldHaveSingleItem(); + lineages[0].RunId.ShouldBe("01JCAAAAAAAAAAAAAAAAAAAAAA"); + lineages[0].RetiredAt.ShouldBeNull(); + } + + [Fact] + public async Task Attach_AlreadyExists_Idempotent() + { + await _store.RecordLineageAsync("01JCBBBBBBBBBBBBBBBBBBBBBB", 7703, "host", "user", CancellationToken.None); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: 7703, runId: "01JCBBBBBBBBBBBBBBBBBBBBBB", execute: true)); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.Success); + result.AlreadyExisted.ShouldBeTrue(); + + var lineages = await _store.GetLineagesAsync(7703, CancellationToken.None); + lineages.ShouldHaveSingleItem(); // no duplicate row + } + + [Fact] + public async Task Attach_Retired_RefusesWithRoutingFailure() + { + await _store.RecordLineageAsync("01JCCCCCCCCCCCCCCCCCCCCCCC", 7704, "host", "user", CancellationToken.None); + await _store.RetireLineageAsync("01JCCCCCCCCCCCCCCCCCCCCCCC", 7704, "test retire", CancellationToken.None); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: 7704, runId: "01JCCCCCCCCCCCCCCCCCCCCCCC", execute: true)); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.RoutingFailure); + result.Success.ShouldBeFalse(); + result.AlreadyExisted.ShouldBeTrue(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("retired"); + } + + [Fact] + public async Task Attach_NullJournalStore_ShortCircuitsSuccess() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), new NullJournalStore()); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: 7705, runId: "01JCDDDDDDDDDDDDDDDDDDDDDD", execute: true)); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.Success); + result.Success.ShouldBeTrue(); + } + + [Fact] + public async Task Attach_NegativeRoot_ReturnsConfigError() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Attach(root: -1, runId: "01JCEEEEEEEEEEEEEEEEEEEEEE")); + var result = Deserialize(output); + + exitCode.ShouldBe(ExitCodes.ConfigError); + result.Success.ShouldBeFalse(); + } + + private static LineageAttachResult Deserialize(string output) + => JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.LineageAttachResult)!; +} diff --git a/tests/Polyphony.Tests/Commands/LineageRetireTests.cs b/tests/Polyphony.Tests/Commands/LineageRetireTests.cs new file mode 100644 index 0000000..a716547 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/LineageRetireTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Journal; +using Polyphony.Models; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W12 (AB#3293): polyphony lineage retire + the +/// reset-pipeline integration that calls it with +/// --all-active --reason "reset root". +/// +public sealed class LineageRetireTests : CommandTestBase +{ + private readonly string _tempDir; + private readonly JournalStore _store; + + public LineageRetireTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-lineage-retire-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task Retire_RunIdAndAllActiveBoth_ConfigError() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 1, runId: "x", allActive: true, execute: true)); + + exitCode.ShouldBe(ExitCodes.ConfigError); + var result = Deserialize(output); + result.Success.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("mutually exclusive"); + } + + [Fact] + public async Task Retire_NeitherRunIdNorAllActive_ConfigError() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 1, execute: true)); + + exitCode.ShouldBe(ExitCodes.ConfigError); + var result = Deserialize(output); + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task Retire_AllActive_DryRun_ReportsButDoesNotMutate() + { + await RecordLineageAsync("01JCWWWWWWWWWWWWWWWWWWWWWW", 7777); + await RecordLineageAsync("01JCXXXXXXXXXXXXXXXXXXXXXX", 7777); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 7777, allActive: true, execute: false)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Success.ShouldBeTrue(); + result.Executed.ShouldBeFalse(); + result.RetiredRunIds.ShouldBe( + new[] { "01JCWWWWWWWWWWWWWWWWWWWWWW", "01JCXXXXXXXXXXXXXXXXXXXXXX" }, + ignoreOrder: true); + + // No mutation: GetLineagesAsync still returns both as active. + var post = await _store.GetLineagesAsync(7777, CancellationToken.None); + post.Count.ShouldBe(2); + post.All(l => l.RetiredAt is null).ShouldBeTrue(); + } + + [Fact] + public async Task Retire_AllActive_Execute_TombstonesEveryActiveLineage() + { + await RecordLineageAsync("01JCAAAAAAAAAAAAAAAAAAAAAA", 8888); + await RecordLineageAsync("01JCBBBBBBBBBBBBBBBBBBBBBB", 8888); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 8888, allActive: true, reason: "cleanup", execute: true)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Success.ShouldBeTrue(); + result.Executed.ShouldBeTrue(); + result.RetiredRunIds.Count.ShouldBe(2); + + var post = await _store.GetLineagesAsync(8888, CancellationToken.None); + post.All(l => l.RetiredAt is not null).ShouldBeTrue(); + post.All(l => l.RetiredReason == "cleanup").ShouldBeTrue(); + } + + [Fact] + public async Task Retire_AllActive_SkipsAlreadyRetiredLineages() + { + await RecordLineageAsync("01JCYYYYYYYYYYYYYYYYYYYYYY", 9999); + await RecordLineageAsync("01JCZZZZZZZZZZZZZZZZZZZZZZ", 9999); + // Tombstone the first one ahead of time. + await _store.RetireLineageAsync("01JCYYYYYYYYYYYYYYYYYYYYYY", 9999, "earlier", CancellationToken.None); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 9999, allActive: true, execute: true)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Success.ShouldBeTrue(); + result.RetiredRunIds.ShouldBe(new[] { "01JCZZZZZZZZZZZZZZZZZZZZZZ" }); + result.SkippedRunIds.ShouldContain("01JCYYYYYYYYYYYYYYYYYYYYYY"); + + // Original tombstone preserved (reason unchanged). + var earlier = (await _store.GetLineagesAsync(9999, CancellationToken.None)) + .Single(l => l.RunId == "01JCYYYYYYYYYYYYYYYYYYYYYY"); + earlier.RetiredReason.ShouldBe("earlier"); + } + + [Fact] + public async Task Retire_SpecificRunId_TombstonesJustThatLineage() + { + await RecordLineageAsync("01JCKEEPKEEPKEEPKEEPKEEPKE", 12000); + await RecordLineageAsync("01JCDROPDROPDROPDROPDROPDR", 12000); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 12000, runId: "01JCDROPDROPDROPDROPDROPDR", execute: true)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Success.ShouldBeTrue(); + result.RetiredRunIds.ShouldBe(new[] { "01JCDROPDROPDROPDROPDROPDR" }); + + var lineages = await _store.GetLineagesAsync(12000, CancellationToken.None); + var keep = lineages.Single(l => l.RunId == "01JCKEEPKEEPKEEPKEEPKEEPKE"); + keep.RetiredAt.ShouldBeNull(); + var drop = lineages.Single(l => l.RunId == "01JCDROPDROPDROPDROPDROPDR"); + drop.RetiredAt.ShouldNotBeNull(); + } + + [Fact] + public async Task Retire_SpecificRunId_MissingLineage_SuccessWithEmptyRetired() + { + // No lineages recorded for this root. + var command = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Retire(root: 13000, runId: "01JCMISSINGMISSINGMISSINGMI", execute: true)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Success.ShouldBeTrue(); + result.RetiredRunIds.ShouldBeEmpty(); + } + + [Fact] + public async Task Status_AfterRetire_SurfacesRetiredAtAndReason() + { + // Use the auto-record path so we have an action row backing the lineage. + await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "01JCRETIRETIRETIRETIRETIRT", + RootId = 14000, + WorkItemId = 14000, + Action = "plan_seed_children", + Target = "wi:14000", + StartedAt = 1_800_000_000_000, + }, + CancellationToken.None); + await _store.RetireLineageAsync("01JCRETIRETIRETIRETIRETIRT", 14000, "operator wipe", CancellationToken.None); + + var statusCommand = new LineageCommands(JournalTestSupport.CreateRunContext("manual_test"), _store); + var (_, statusOutput) = await CaptureConsoleAsync( + () => statusCommand.Status(root: 14000, manifestPath: "")); + var status = JsonSerializer.Deserialize(statusOutput, PolyphonyJsonContext.Default.LineageStatusResult); + status.ShouldNotBeNull(); + var observation = status.Lineages.Single(l => l.RunId == "01JCRETIRETIRETIRETIRETIRT"); + observation.RetiredAt.ShouldNotBeNull(); + observation.RetiredReason.ShouldBe("operator wipe"); + } + + private Task RecordLineageAsync(string runId, int root) + => _store.RecordLineageAsync(runId, root, host: "test-host", user: "test-user", CancellationToken.None); + + private static LineageRetireResult Deserialize(string output) + { + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.LineageRetireResult); + result.ShouldNotBeNull(); + return result; + } + + public override void Dispose() + { + base.Dispose(); + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ } + } +} diff --git a/tests/Polyphony.Tests/Commands/LineageStatusTests.cs b/tests/Polyphony.Tests/Commands/LineageStatusTests.cs new file mode 100644 index 0000000..ed23585 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/LineageStatusTests.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Journal; +using Polyphony.Manifest; +using Polyphony.Models; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W15 (AB#3285): polyphony lineage status --root. +/// +public sealed class LineageStatusTests : CommandTestBase +{ + private readonly string _tempDir; + private readonly string _manifestPath; + private readonly JournalStore _store; + + public LineageStatusTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-lineage-status-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + _manifestPath = Path.Combine(_tempDir, ".polyphony", "run.yaml"); + } + + [Fact] + public async Task Status_NoManifestAndEmptyJournal_NoJournalHistory() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("run-fresh"), _store); + + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Status(root: 5001, manifestPath: _manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.Root.ShouldBe(5001); + result.CurrentRunId.ShouldBe("run-fresh"); + result.CurrentLineageHasRows.ShouldBeFalse(); + result.LooksPartitioned.ShouldBeFalse(); + result.Lineages.Count.ShouldBe(0); + result.ManifestRunId.ShouldBeNull(); + result.ManifestError.ShouldBeNull(); + result.Verdict.ShouldStartWith("no_journal_history"); + } + + [Fact] + public async Task Status_CurrentLineageHasRows_VerdictIsActive() + { + await AppendRowAsync(runId: "run-active", root: 5010, action: "branch_ensure_feature"); + + WriteManifest(rootId: 5010, runId: "run-active"); + var command = new LineageCommands(JournalTestSupport.CreateRunContext("run-active"), _store); + + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Status(root: 5010, manifestPath: _manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.CurrentLineageHasRows.ShouldBeTrue(); + result.LooksPartitioned.ShouldBeFalse(); + result.Lineages.Count.ShouldBe(1); + result.Lineages[0].RunId.ShouldBe("run-active"); + result.Lineages[0].IsCurrent.ShouldBeTrue(); + result.Lineages[0].RowCount.ShouldBe(1); + result.ManifestRunId.ShouldBe("run-active"); + result.Verdict.ShouldStartWith("current_lineage_active"); + } + + [Fact] + public async Task Status_PriorLineageOnlyAndFreshCurrent_LooksPartitioned() + { + await AppendRowAsync(runId: "run-old", root: 5020, action: "branch_ensure_feature"); + + WriteManifest(rootId: 5020, runId: "run-old"); + var command = new LineageCommands(JournalTestSupport.CreateRunContext("run-new"), _store); + + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Status(root: 5020, manifestPath: _manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.CurrentLineageHasRows.ShouldBeFalse(); + result.LooksPartitioned.ShouldBeTrue(); + result.Lineages.Count.ShouldBe(1); + result.Lineages[0].RunId.ShouldBe("run-old"); + result.Lineages[0].IsCurrent.ShouldBeFalse(); + result.Verdict.ShouldStartWith("journal_partitioned"); + result.Verdict.ShouldContain("run-new"); + } + + [Fact] + public async Task Status_ManualLineage_VerdictIsManual() + { + // RunContext.HasManualLineage falls back to the `manual_` prefix + // check, so an explicit run id starting with `manual_` is the + // simplest way to simulate "launcher didn't export POLYPHONY_RUN_ID". + var command = new LineageCommands( + JournalTestSupport.CreateRunContext("manual_no_env"), + _store); + + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Status(root: 5030, manifestPath: _manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.CurrentLineageIsManual.ShouldBeTrue(); + result.Verdict.ShouldStartWith("current_lineage_manual"); + } + + [Fact] + public async Task Status_ManifestRunIdMismatch_VerdictCalledOut() + { + await AppendRowAsync(runId: "run-current", root: 5040, action: "branch_ensure_feature"); + WriteManifest(rootId: 5040, runId: "run-other"); + + var command = new LineageCommands(JournalTestSupport.CreateRunContext("run-current"), _store); + + var (exitCode, output) = await CaptureConsoleAsync( + () => command.Status(root: 5040, manifestPath: _manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = Deserialize(output); + result.ManifestRunId.ShouldBe("run-other"); + result.CurrentRunId.ShouldBe("run-current"); + result.Verdict.ShouldStartWith("manifest_lineage_mismatch"); + } + + [Fact] + public async Task Status_RequiresRoot_HaltsWithoutFlag() + { + var command = new LineageCommands(JournalTestSupport.CreateRunContext("run-x"), _store); + var (exitCode, _) = await CaptureConsoleAsync(() => command.Status(manifestPath: _manifestPath)); + exitCode.ShouldNotBe(ExitCodes.Success); + } + + private async Task AppendRowAsync(string runId, int root, string action) + { + var id = await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = runId, + RootId = root, + WorkItemId = root, + Action = action, + Target = $"feature/{root}", + StartedAt = 1_700_000_000_000, + }, + CancellationToken.None); + await _store.RecordEndAsync(id, JournalOutcome.Success, null, null, null, null, CancellationToken.None); + } + + private void WriteManifest(int rootId, string runId) + { + var manifest = new RunManifest + { + RootId = rootId, + RunId = runId, + PlatformProject = "dev.azure.com/test/Test", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + CreatedBy = "test", + BranchModelVersion = 1, + }; + RunManifestStore.Save(_manifestPath, manifest); + } + + private static LineageStatusResult Deserialize(string output) + { + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.LineageStatusResult); + result.ShouldNotBeNull(); + return result; + } + + public override void Dispose() + { + base.Dispose(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + // Best-effort cleanup; SQLite can hold a transient handle on Windows. + } + } +} diff --git a/tests/Polyphony.Tests/Commands/PlanCommandsDetectStateJournalGroundingTests.cs b/tests/Polyphony.Tests/Commands/PlanCommandsDetectStateJournalGroundingTests.cs new file mode 100644 index 0000000..726dbe1 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PlanCommandsDetectStateJournalGroundingTests.cs @@ -0,0 +1,533 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; +using Polyphony.Routing; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.Stubs; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W3+W4 (AB#3277/AB#3278): journal-grounded plan detect-state. +/// Covers all eight B1 walk-through cases. The journal anchors the answer +/// to "did THIS lineage open/merge/finish-planning for this item?"; ADO/git +/// supply live status only for journal-grounded objects. Tests pin one +/// case each. +/// +public sealed class PlanCommandsDetectStateJournalGroundingTests : CommandTestBase, IDisposable +{ + private const int RootId = 1000; + private const int ChildId = 2000; + private const string ChildPlanBranch = "plan/1000-2000"; + private const int JournalPrNumber = 555; + private const string CurrentRunId = "01JZ7P0X9KZBKQR4N3FVMT8YWA"; + private const string PriorRunId = "01JOLDRUNXXXXXXXXXXXXXXXX1"; + + private readonly string tempCommonDir; + private readonly string localManifestPath; + + public PlanCommandsDetectStateJournalGroundingTests() + { + this.tempCommonDir = Path.Combine(Path.GetTempPath(), $"polytest-j-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.tempCommonDir); + var manifestDir = Path.Combine(this.tempCommonDir, "polyphony", RootId.ToString()); + Directory.CreateDirectory(manifestDir); + this.localManifestPath = Path.Combine(manifestDir, "run.yaml"); + } + + public override void Dispose() + { + try { if (Directory.Exists(this.tempCommonDir)) Directory.Delete(this.tempCommonDir, recursive: true); } catch { } + base.Dispose(); + } + + // ─── Test infrastructure ────────────────────────────────────────────── + + private sealed class StubJournalStore : IJournalStore + { + private readonly IReadOnlyList _entries; + private readonly Exception? _throwOnQuery; + + public StubJournalStore(IReadOnlyList entries, Exception? throwOnQuery = null) + { + _entries = entries; + _throwOnQuery = throwOnQuery; + } + + public string DatabasePath => "stub.db"; + public Task RecordStartAsync(JournalEntryStart entry, CancellationToken ct) => Task.FromResult(0L); + public Task RecordEndAsync(long actionId, JournalOutcome outcome, string? errorCode, string? errorMessage, string? payloadJson, IReadOnlyList? effects, CancellationToken ct) => Task.CompletedTask; + public Task> QueryAsync(JournalQuery query, CancellationToken ct) + { + if (_throwOnQuery is not null) throw _throwOnQuery; + var matched = _entries + .Where(e => query.RunId is null || e.RunId == query.RunId) + .Where(e => !query.RootId.HasValue || e.RootId == query.RootId) + .Where(e => !query.WorkItemId.HasValue || e.WorkItemId == query.WorkItemId) + .ToList(); + return Task.FromResult>(matched); + } + public Task ExportAsync(string destinationPath, CancellationToken ct) => Task.CompletedTask; + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); + } + + private (PlanCommands Command, FakeProcessRunner Runner) CreateCommand( + IJournalStore journalStore, RunContext runContext) + { + var runner = new FakeProcessRunner(); + runner.WhenExact("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], + new ProcessResult(0, this.tempCommonDir + "\n", "")); + var twig = new TwigClient(runner); + var walker = new HierarchyWalker(Config, Repository); + var git = new GitClient(runner); + var gh = new GhClient(runner); + return (new PlanCommands( + walker, Repository, Config, twig, git, gh, new ThrowingAdoClient(), + new FakePostconditionVerifier(), + new Polyphony.Infrastructure.Paths.PolyphonyStatePaths(git), + new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), + new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), + runContext: runContext, + journalDecorator: null, + journalStore: journalStore), + runner); + } + + private static PlanDetectStateResult Parse(string output) => + JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PlanDetectStateResult)!; + + private static RunContext LauncherRunContext(string runId) + { + // The internal RunContext(string) ctor stamps Source=Explicit, which + // is what a launcher-supplied run-id looks like (HasManualLineage=false). + var ctorInfo = typeof(RunContext).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + binder: null, [typeof(string)], modifiers: null)!; + return (RunContext)ctorInfo.Invoke([runId]); + } + + // ─── Journal-entry factories ────────────────────────────────────────── + + private static JournalEntry Entry(long id, string runId, string action, string? payloadJson = null, + JournalOutcome outcome = JournalOutcome.Success) => + new() + { + Id = id, + RunId = runId, + RootId = RootId, + WorkItemId = ChildId, + Action = action, + Target = $"workitem:{ChildId}", + StartedAt = 1_700_000_000 + id, + FinishedAt = 1_700_000_000 + id + 1, + Outcome = outcome, + PayloadJson = payloadJson, + }; + + private static string OpenPlanPrPayload(int prNumber) => + JsonSerializer.Serialize(new PrOpenPlanPrPayload + { + RootId = RootId, + ItemId = ChildId, + ParentItemId = RootId, + ItemKey = $"{ChildId}", + IsRootPlan = false, + HeadBranch = ChildPlanBranch, + BaseBranch = "feature/1000", + PrNumber = prNumber, + PrUrl = $"https://github.com/acme/repo/pull/{prNumber}", + ResultAction = "opened", + Succeeded = true, + WasMutated = true, + Stale = false, + }, PolyphonyJsonContext.Default.PrOpenPlanPrPayload); + + private static string MergePlanPrPayload(int prNumber) => + JsonSerializer.Serialize(new PrMergePlanPrPayload + { + RootId = RootId, + ItemId = ChildId, + ParentItemId = RootId, + PrNumber = prNumber, + ItemKey = $"{ChildId}", + IsRootPlan = false, + HeadBranch = ChildPlanBranch, + BaseBranch = "feature/1000", + ManifestBranch = "feature/1000", + PrUrl = $"https://github.com/acme/repo/pull/{prNumber}", + ResultAction = "merged", + Succeeded = true, + WasMutated = true, + AlreadyMerged = false, + }, PolyphonyJsonContext.Default.PrMergePlanPrPayload); + + private static string SeedChildrenPayload(bool planningCompleted) => + JsonSerializer.Serialize(new PlanSeedChildrenPayload + { + WorkItemId = ChildId, + ChildCount = 1, + SeededItems = [], + ReusedItems = [], + Errors = [], + Warnings = [], + PlannedTagMutated = planningCompleted, + PlannedTagAlreadyPresent = false, + RootFacets = [], + FacetsTagMutated = false, + Succeeded = true, + WasMutated = true, + PlanningCompleted = planningCompleted, + }, PolyphonyJsonContext.Default.PlanSeedChildrenPayload); + + // ─── Process stubs (used by W4 paths that DO poll a journal-anchored PR) ─ + + private static void StubRemoteUrl(FakeProcessRunner runner, string url = "https://github.com/acme/repo.git") + => runner.WhenExact("git", ["remote", "get-url", "origin"], new ProcessResult(0, url + "\n", "")); + + private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) + => runner.WhenExact("git", ["ls-remote", "--heads", "origin", $"refs/heads/{branch}"], + new ProcessResult(0, exists ? $"abc123\trefs/heads/{branch}\n" : "", "")); + + private static void StubPrListEmpty(FakeProcessRunner runner) + => runner.WhenStartsWith("gh", ["pr", "list"], new ProcessResult(0, "[]", "")); + + private static void StubPrPoll(FakeProcessRunner runner, int prNumber, string state, string body = "") + { + var bodyJson = JsonEncodedText.Encode(body).Value; + var json = $$""" + { + "number": {{prNumber}}, + "state": "{{state}}", + "reviewDecision": "REVIEW_REQUIRED", + "mergeable": "MERGEABLE", + "headRefName": "{{ChildPlanBranch}}", + "headRefOid": "abc123", + "baseRefName": "feature/1000", + "mergedAt": null, + "mergeCommit": null, + "body": "{{bodyJson}}", + "reviews": [] + } + """; + runner.WhenStartsWith("gh", ["pr", "view", prNumber.ToString()], new ProcessResult(0, json, "")); + } + + private static void StubPrPollNotFound(FakeProcessRunner runner, int prNumber) + => runner.WhenStartsWith("gh", ["pr", "view", prNumber.ToString()], + new ProcessResult(1, "", "no pull request found")); + + private void WriteManifest(IDictionary planGenerations) + { + var manifest = new Polyphony.Manifest.RunManifest + { + Schema = 1, + RootId = RootId, + PlatformProject = "github.com/acme/repo", + CreatedAt = DateTime.UtcNow, + CreatedBy = "test", + BranchModelVersion = 1, + PlanGenerations = new Dictionary(planGenerations, StringComparer.Ordinal), + }; + Polyphony.Manifest.RunManifestStore.Save(this.localManifestPath, manifest); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 1 / 7: no journal rows → not_started (THE bug fix). + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task NoJournalRows_ReturnsNotStarted_WithLineageAnchorJournal() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([]), + LauncherRunContext(CurrentRunId)); + + // Critical: do NOT stub any PR / branch / tag calls. If the short- + // circuit doesn't fire, the verb would try them and fail. + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("journal"); + result.BranchExistsOnOrigin.ShouldBeFalse(); + } + + [Fact] + public async Task PriorLineageJournalRows_DoNotCount_ReturnsNotStarted() + { + // Rows exist but under a DIFFERENT run id → equivalent to "no rows + // for current lineage". This is the residue-from-prior-run case. + var (cmd, _) = CreateCommand( + new StubJournalStore([Entry(1, PriorRunId, "plan_write_plan")]), + LauncherRunContext(CurrentRunId)); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 2 / 8: journal opened PR; ADO confirms OPEN → awaiting_review. + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalOpenedPr_AdoOpen_ReturnsAwaitingReview_PollsJournalPrNumber() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber))]), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: true); + StubPrPoll(runner, JournalPrNumber, "OPEN"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("awaiting_review"); + result.PrNumber.ShouldBe(JournalPrNumber); + result.LineageAnchor.ShouldBe("journal"); + + // The verb must consult ONLY the journal-anchored PR number — no + // archaeological `gh pr list` should have happened. + runner.Invocations.ShouldNotContain(i => i.Executable == "gh" && i.Arguments.Contains("list")); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 2 variant: journal opened PR; stale generation flag. + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalOpenedPr_AdoOpen_StaleGeneration_ReturnsStaleGeneration() + { + // Manifest plan-generations: root has advanced past the snapshot. + WriteManifest(new Dictionary { ["root"] = 2 }); + + // PR body carries front-matter that points at snapshot gen 1. + var prBody = "---\nancestor_plan_generations:\n root: 1\n---\nbody"; + + var (cmd, runner) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber))]), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: true); + StubPrPoll(runner, JournalPrNumber, "OPEN", body: prBody); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("stale_generation"); + result.LineageAnchor.ShouldBe("journal"); + result.StaleAncestors.ShouldContain("root: snapshot=1, manifest=2"); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 3 / 4: journal says merged but NOT planning-completed. + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalMerged_NoPlanningCompleted_ReturnsMergedUnseeded() + { + var (cmd, _) = CreateCommand( + new StubJournalStore([ + Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber)), + Entry(2, CurrentRunId, "pr_merge_plan_pr", MergePlanPrPayload(JournalPrNumber)), + ]), + LauncherRunContext(CurrentRunId)); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("merged_unseeded"); + result.PrNumber.ShouldBe(JournalPrNumber); + result.PrState.ShouldBe("MERGED"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 5: journal says planning completed → complete (no + // parent-change overlay because no children). + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalPlanningCompleted_ReturnsComplete() + { + WriteManifest(new Dictionary { [ChildId.ToString()] = 1 }); + + var (cmd, runner) = CreateCommand( + new StubJournalStore([ + Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber)), + Entry(2, CurrentRunId, "pr_merge_plan_pr", MergePlanPrPayload(JournalPrNumber)), + Entry(3, CurrentRunId, "plan_seed_children", SeedChildrenPayload(planningCompleted: true)), + ]), + LauncherRunContext(CurrentRunId)); + + // Child-PR overlay needs origin + twig (no children → empty list). + StubRemoteUrl(runner); + runner.WhenExact("twig", ["show", ChildId.ToString(), "--tree", "--output", "json"], + new ProcessResult(0, $$"""{"id":{{ChildId}},"title":"Item","children":[]}""", "")); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("complete"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 6: journal opened PR; ADO ABANDONED/CLOSED. + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalOpenedPr_AdoClosed_BranchExists_ReturnsClosedUnmerged() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber))]), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: true); + StubPrPoll(runner, JournalPrNumber, "CLOSED"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("closed_unmerged"); + result.PrState.ShouldBe("CLOSED"); + result.LineageAnchor.ShouldBe("journal"); + } + + [Fact] + public async Task JournalOpenedPr_AdoClosed_BranchGone_ReturnsNotStarted() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber))]), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: false); + StubPrPoll(runner, JournalPrNumber, "CLOSED"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // B1 case 4 variant: journal opened PR; ADO MERGED (crash recovery — no + // pr_merge_plan_* row was written). + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalOpenedPr_AdoMerged_NoMergeRow_ReturnsMergedUnseeded() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "pr_open_plan_pr", OpenPlanPrPayload(JournalPrNumber))]), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: true); + StubPrPoll(runner, JournalPrNumber, "MERGED"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("merged_unseeded"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Journal rows exist but no PR-open row yet (e.g. only plan_write_plan). + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JournalRowsButNoPrOpen_ReturnsNotStarted() + { + var (cmd, _) = CreateCommand( + new StubJournalStore([Entry(1, CurrentRunId, "plan_write_plan")]), + LauncherRunContext(CurrentRunId)); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("journal"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Fall-back posture: manual lineage and journal-throw both fall through + // to archaeology so the legacy verb still works. + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ManualLineage_SkipsJournalGrounding_FallsThroughToArchaeology() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([]), + new RunContext(_ => null)); // unstamped → manual_ fallback + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: false); + StubPrListEmpty(runner); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("archaeology"); + } + + [Fact] + public async Task JournalThrows_FailsOpenToArchaeology() + { + var (cmd, runner) = CreateCommand( + new StubJournalStore([], throwOnQuery: new InvalidOperationException("simulated journal failure")), + LauncherRunContext(CurrentRunId)); + + StubRemoteUrl(runner); + StubLsRemote(runner, ChildPlanBranch, exists: false); + StubPrListEmpty(runner); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.DetectState(rootId: RootId, itemId: ChildId)); + + exit.ShouldBe(0); + var result = Parse(output); + result.State.ShouldBe("not_started"); + result.LineageAnchor.ShouldBe("archaeology"); + } +} diff --git a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterReplaceTests.cs b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterReplaceTests.cs index 0c57e00..b61dd97 100644 --- a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterReplaceTests.cs +++ b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterReplaceTests.cs @@ -200,4 +200,36 @@ public void RejectsNullSnapshot() Should.Throw(() => PlanPrFrontMatter.ReplaceSnapshotPreservingTail(body, null!)); } + + [Fact] + public void RunIdPreserved_ThroughSnapshotReplacement() + { + // W7 (AB#3281): the restack-remedy verb rewrites the snapshot + // but must preserve the run_id stamp — otherwise the rebase + // would silently strip the lineage tag and the downstream + // foreign-PR check loses its evidence. + var body = "---\nrequests_parent_change: true\nrun_id: 01HZK7Y9ABCDEF0123456789AB\nancestor_plan_generations:\n root: 1\n---\n\nbody"; + var result = PlanPrFrontMatter.ReplaceSnapshotPreservingTail( + body, Snapshot(("root", 2))); + + var replaced = result.ShouldBeOfType(); + replaced.NewBody.ShouldContain("run_id: 01HZK7Y9ABCDEF0123456789AB"); + var roundTrip = PlanPrFrontMatter.ParseStrict(replaced.NewBody); + roundTrip.Status.ShouldBe(FrontMatterStatus.Present); + roundTrip.RunId.ShouldBe("01HZK7Y9ABCDEF0123456789AB"); + } + + [Fact] + public void RunIdAbsent_RewriteDoesNotInventOne() + { + // Legacy body without run_id: rewrite must not synthesise one out + // of thin air — only the lineage that was already stamped at PR + // open time is canonical. + var body = "---\nrequests_parent_change: false\nancestor_plan_generations:\n root: 1\n---\n\nbody"; + var result = PlanPrFrontMatter.ReplaceSnapshotPreservingTail( + body, Snapshot(("root", 2))); + + var replaced = result.ShouldBeOfType(); + replaced.NewBody.ShouldNotContain("run_id:"); + } } diff --git a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterStrictTests.cs b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterStrictTests.cs index b81f4d5..83fb634 100644 --- a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterStrictTests.cs +++ b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterStrictTests.cs @@ -194,4 +194,53 @@ public void ExplicitYamlBoolTag_IsAccepted() r.Status.ShouldBe(FrontMatterStatus.Present); r.RequestsParentChange.ShouldBeTrue(); } + + [Fact] + public void RunId_AbsentKey_RoundTripsAsNull() + { + // W7 (AB#3281): legacy plan-PR bodies stamped pre-run_id rollout + // do not carry the key. Strict parse must accept that shape and + // surface RunId == null so the validator does not block them. + var body = "---\nrequests_parent_change: false\nancestor_plan_generations:\n root: 1\n---"; + var r = PlanPrFrontMatter.ParseStrict(body); + + r.Status.ShouldBe(FrontMatterStatus.Present); + r.RunId.ShouldBeNull(); + } + + [Fact] + public void RunId_ValidScalar_IsRoundTripped() + { + // W7 (AB#3281): well-formed lineage stamp must arrive intact at + // the call site so a stale-fence reviewer can refuse the merge + // when the lineage doesn't match the journal's current run. + var body = "---\nrequests_parent_change: false\nrun_id: 01HZK7Y9ABCDEF0123456789AB\nancestor_plan_generations: {}\n---"; + var r = PlanPrFrontMatter.ParseStrict(body); + + r.Status.ShouldBe(FrontMatterStatus.Present); + r.RunId.ShouldBe("01HZK7Y9ABCDEF0123456789AB"); + } + + [Fact] + public void RunId_EmptyScalar_IsMalformed() + { + // An author who clears run_id has produced a worse signal than + // omitting it — block the merge so they can either fix or remove. + var body = "---\nrequests_parent_change: false\nrun_id: \"\"\nancestor_plan_generations: {}\n---"; + var r = PlanPrFrontMatter.ParseStrict(body); + + r.Status.ShouldBe(FrontMatterStatus.Malformed); + r.ErrorDetail!.ShouldContain("run_id"); + } + + [Fact] + public void RunId_NonScalarMapping_IsMalformed() + { + // Shape error: someone tried to nest a structure under run_id. + var body = "---\nrun_id:\n nested: oops\n---"; + var r = PlanPrFrontMatter.ParseStrict(body); + + r.Status.ShouldBe(FrontMatterStatus.Malformed); + r.ErrorDetail!.ShouldContain("run_id"); + } } diff --git a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterTests.cs b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterTests.cs index 62efb37..37ae42b 100644 --- a/tests/Polyphony.Tests/Commands/PlanPrFrontMatterTests.cs +++ b/tests/Polyphony.Tests/Commands/PlanPrFrontMatterTests.cs @@ -123,4 +123,34 @@ public void Parse_CrlfLineEndings_HandledCorrectly() meta.RequestsParentChange.ShouldBeTrue(); meta.AncestorPlanGenerations["root"].ShouldBe(1); } + + [Fact] + public void Parse_RunIdAbsent_ReturnsNull() + { + // W7 (AB#3281): lenient parser must tolerate legacy bodies that + // pre-date the stamping rollout; absence is the common case. + var body = "---\nrequests_parent_change: false\nancestor_plan_generations:\n root: 1\n---\n"; + var meta = PlanPrFrontMatter.Parse(body); + meta.RunId.ShouldBeNull(); + } + + [Fact] + public void Parse_RunIdValid_RoundTrips() + { + // W7 (AB#3281): the polling hot path surfaces RunId so callers + // can correlate the PR with the run that authored it. + var body = "---\nrequests_parent_change: false\nrun_id: 01HZK7Y9ABCDEF0123456789AB\nancestor_plan_generations: {}\n---\n"; + var meta = PlanPrFrontMatter.Parse(body); + meta.RunId.ShouldBe("01HZK7Y9ABCDEF0123456789AB"); + } + + [Fact] + public void Parse_RunIdMalformedMapping_ReturnsNull() + { + // Lenient: a nested-mapping shape is degenerate but the polling + // path should never crash. Surface null and let upstream decide. + var body = "---\nrun_id:\n nested: oops\n---\n"; + var meta = PlanPrFrontMatter.Parse(body); + meta.RunId.ShouldBeNull(); + } } diff --git a/tests/Polyphony.Tests/Commands/PrBodyMarkerTests.cs b/tests/Polyphony.Tests/Commands/PrBodyMarkerTests.cs new file mode 100644 index 0000000..521cb18 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrBodyMarkerTests.cs @@ -0,0 +1,124 @@ +using System; +using Polyphony.Commands; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W6 (AB#3280): unit tests for the PrBodyMarker helper. Round-trip +/// tests against the real PR-opening verbs live in the per-verb test +/// classes (PrCommandsOpenImplPr*, PrCommandsCreateFeatureAdo*, etc.); +/// this class covers the helper itself in isolation. +/// +public sealed class PrBodyMarkerTests +{ + private const string SampleRunId = "01HZK7Y9ABCDEF0123456789AB"; + private const string SecondRunId = "01HZK8AAABCDEF0123456789AB"; + + [Fact] + public void Format_Canonical_EmitsHtmlComment() + { + PrBodyMarker.Format(SampleRunId) + .ShouldBe($""); + } + + [Fact] + public void Format_EmptyRunId_Throws() + { + Should.Throw(() => PrBodyMarker.Format("")); + } + + [Fact] + public void EnsureRunIdPrefix_EmptyRunId_ReturnsBodyUnchanged() + { + var body = "## Summary\n\nDoing the thing."; + PrBodyMarker.EnsureRunIdPrefix(body, null).ShouldBe(body); + PrBodyMarker.EnsureRunIdPrefix(body, "").ShouldBe(body); + PrBodyMarker.EnsureRunIdPrefix(body, " ").ShouldBe(body); + } + + [Fact] + public void EnsureRunIdPrefix_NoExistingMarker_PrependsMarkerAndNewline() + { + var body = "## Summary\n\nDoing the thing."; + var result = PrBodyMarker.EnsureRunIdPrefix(body, SampleRunId); + result.ShouldStartWith($""); + result.ShouldEndWith(body); + result.Length.ShouldBe(body.Length + PrBodyMarker.Format(SampleRunId).Length + Environment.NewLine.Length); + } + + [Fact] + public void EnsureRunIdPrefix_AlreadyHasMarker_ReturnsUnchanged() + { + var body = PrBodyMarker.Format(SampleRunId) + "\n## Summary\n"; + PrBodyMarker.EnsureRunIdPrefix(body, SampleRunId).ShouldBe(body); + } + + [Fact] + public void EnsureRunIdPrefix_AlreadyHasForeignMarker_DoesNotOverwrite() + { + // The body already claims a different lineage. Refusing to + // overwrite is the correct posture — W9 owns the foreign-PR + // detection question; the body-marker writer must not lie. + var body = PrBodyMarker.Format(SecondRunId) + "\n## Summary\n"; + PrBodyMarker.EnsureRunIdPrefix(body, SampleRunId).ShouldBe(body); + } + + [Fact] + public void EnsureRunIdPrefix_NullBody_ReturnsJustTheMarker() + { + // Defensive: callers occasionally pass a null body when the + // upstream default-body resolver was skipped on an error path. + // Returning just the marker keeps the stamp present without + // crashing. + PrBodyMarker.EnsureRunIdPrefix(null!, SampleRunId) + .ShouldBe(PrBodyMarker.Format(SampleRunId)); + } + + [Fact] + public void TryParseRunId_Empty_ReturnsNull() + { + PrBodyMarker.TryParseRunId(null).ShouldBeNull(); + PrBodyMarker.TryParseRunId("").ShouldBeNull(); + } + + [Fact] + public void TryParseRunId_NoMarker_ReturnsNull() + { + PrBodyMarker.TryParseRunId("## Summary\n\nNo marker here.").ShouldBeNull(); + } + + [Fact] + public void TryParseRunId_StampedBody_ReturnsRunId() + { + var body = PrBodyMarker.EnsureRunIdPrefix("## Summary\n", SampleRunId); + PrBodyMarker.TryParseRunId(body).ShouldBe(SampleRunId); + } + + [Fact] + public void TryParseRunId_CaseInsensitive_Recognized() + { + var body = $"\n## Summary"; + PrBodyMarker.TryParseRunId(body).ShouldBe(SampleRunId); + } + + [Fact] + public void TryParseRunId_TolerantOfWhitespace() + { + var body = $" \nbody"; + PrBodyMarker.TryParseRunId(body).ShouldBe(SampleRunId); + } + + [Fact] + public void RoundTrip_EnsurePlusParse_Idempotent() + { + var body = "## Summary\n\nbody"; + var stamped = PrBodyMarker.EnsureRunIdPrefix(body, SampleRunId); + PrBodyMarker.TryParseRunId(stamped).ShouldBe(SampleRunId); + // Second ensure call on a stamped body is a no-op. + PrBodyMarker.EnsureRunIdPrefix(stamped, SampleRunId).ShouldBe(stamped); + // After ensure, parsing still recovers the original id. + PrBodyMarker.TryParseRunId(PrBodyMarker.EnsureRunIdPrefix(stamped, SampleRunId)).ShouldBe(SampleRunId); + } +} diff --git a/tests/Polyphony.Tests/Commands/PrCommandsCreateFeatureAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsCreateFeatureAdoTests.cs index 5f9c967..1056ca8 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsCreateFeatureAdoTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsCreateFeatureAdoTests.cs @@ -416,7 +416,9 @@ public async Task CreateFeatureAdo_ExplicitBody_OverridesFallback() await CaptureConsoleAsync( () => cmd.CreateFeatureAdo(Org, Project, Repo, rootId: 100, body: "explicit body content")); - ado.LastCreateDescription.ShouldBe("explicit body content"); + // W6 (AB#3280): explicit-body callers still get the run-id + // marker prefix; the explicit body is preserved verbatim after. + ado.LastCreateDescription.ShouldBe("" + Environment.NewLine + "explicit body content"); } // ─── PR URL synthesis when ADO returns empty Url ───────────────────── diff --git a/tests/Polyphony.Tests/Commands/PrCommandsOpenImplPrTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenImplPrTests.cs index 4dd644d..f19c2af 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsOpenImplPrTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenImplPrTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using System.Text.Json; using Polyphony.Commands; using Polyphony.Infrastructure.Processes; @@ -205,4 +207,35 @@ public async Task OpenImplPr_JsonContract_PreservesSnakeCaseKeys() output.ShouldContain("\"mg_path\""); output.ShouldContain("\"created\""); } + + [Fact] + public async Task OpenImplPr_HappyPath_StampsRunIdMarkerOnFirstLineOfBody() + { + // W6 (AB#3280): the body sent to `gh pr create` must start with + // the marker so cross-machine + // readers (and post-merge journal-rehydration) can ground the + // PR in the originating lineage when the local journal is + // silent. Test fixture uses RunId="test-run". + var (cmd, runner) = CreateCommand(); + StubLsRemoteHas(runner, "refs/heads/impl/100-200", exists: true); + StubLsRemoteHas(runner, "refs/heads/mg/100_core", exists: true); + StubGitRemoteOrigin(runner, "https://github.com/PolyphonyRequiem/polyphony.git"); + StubTwigShowTree(runner, 200, "Add login form"); + StubPrListEmpty(runner); + StubPrCreate(runner, "https://github.com/PolyphonyRequiem/polyphony/pull/200"); + + var (exit, _) = await CaptureConsoleAsync( + () => cmd.OpenImplPr(rootId: 100, itemId: 200, mgPath: "core")); + exit.ShouldBe(ExitCodes.Success); + + var create = runner.Invocations.Single(i => + i.Executable == "gh" && i.Arguments.Count >= 2 + && i.Arguments[0] == "pr" && i.Arguments[1] == "create"); + var bodyIdx = create.Arguments.ToList().IndexOf("--body"); + bodyIdx.ShouldBeGreaterThan(-1); + var body = create.Arguments[bodyIdx + 1]; + body.ShouldStartWith(""); + PrBodyMarker.TryParseRunId(body).ShouldBe("test-run"); + } } + diff --git a/tests/Polyphony.Tests/Commands/PrCommandsOpenMergeGroupAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenMergeGroupAdoTests.cs index c79923a..b275e02 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsOpenMergeGroupAdoTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenMergeGroupAdoTests.cs @@ -563,7 +563,8 @@ public async Task OpenMgAdo_ExplicitBody_OverridesFallback() await CaptureConsoleAsync( () => cmd.OpenMergeGroupAdo(Org, Project, Repo, rootId: 100, mgPath: "core", body: "explicit body content")); - ado.LastCreateDescription.ShouldBe("explicit body content"); + // W6 (AB#3280): explicit-body callers still get the run-id marker prefix. + ado.LastCreateDescription.ShouldBe("" + Environment.NewLine + "explicit body content"); } // ─── PR URL synthesis when ADO returns empty Url ───────────────────── diff --git a/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs index 9d6da7f..2fa71ee 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs @@ -292,6 +292,10 @@ public async Task OpenPlanAdo_DescendantWithSnapshot_CreatesNewPrWithFrontMatter ado.LastCreateDescription.ShouldContain("ancestor_plan_generations:"); ado.LastCreateDescription.ShouldContain("\"5678\": 4"); ado.LastCreateDescription.ShouldContain("root: 2"); + // W7 (AB#3281): ADO-side default body must stamp the current + // run's lineage just like the GitHub-side path so foreign-PR + // detection has parity across platforms. + ado.LastCreateDescription.ShouldContain("run_id: test-run"); } // ─── Reuse with matching snapshot ──────────────────────────────────── diff --git a/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanPrTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanPrTests.cs index 00f56e6..043fef2 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanPrTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanPrTests.cs @@ -399,6 +399,9 @@ public async Task OpenPlanPr_PrCreateBodyContainsFrontMatter() body.ShouldContain("ancestor_plan_generations:"); body.ShouldContain("\"5678\": 4"); body.ShouldContain("root: 2"); + // W7 (AB#3281): default body must stamp the current run's lineage + // so foreign-PR detection can ground refusal decisions. + body.ShouldContain("run_id: test-run"); // Round-trip: feed the emitted body back through PlanPrFrontMatter. var roundTripped = PlanPrFrontMatter.Parse(body); @@ -406,6 +409,7 @@ public async Task OpenPlanPr_PrCreateBodyContainsFrontMatter() roundTripped.AncestorPlanGenerations.Count.ShouldBe(2); roundTripped.AncestorPlanGenerations["5678"].ShouldBe(4); roundTripped.AncestorPlanGenerations["root"].ShouldBe(2); + roundTripped.RunId.ShouldBe("test-run"); } // ─── Reuse semantics ───────────────────────────────────────────────── diff --git a/tests/Polyphony.Tests/Commands/PrLineageGuardTests.cs b/tests/Polyphony.Tests/Commands/PrLineageGuardTests.cs new file mode 100644 index 0000000..d50db9c --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrLineageGuardTests.cs @@ -0,0 +1,113 @@ +using Polyphony.Commands; +using Polyphony.Journal; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W9 (AB#3282): unit tests for the foreign-PR firewall. Exercise +/// the helper directly so the merge-verb integration tests don't +/// have to enumerate every body/journal combination — the verbs +/// stack one well-known case on top of this helper's matrix. +/// +public sealed class PrLineageGuardTests +{ + private const string CurrentRunId = "01HZK7CURRENT0000000000000"; + private const string OtherRunId = "01HZK7OTHER00000000000000A"; + + private static IJournalStore NullJournal() => new NullJournalStore(); + + [Fact] + public async Task ManualLineage_AllowsWithSkipSource() + { + // A `manual_*` lineage is a "we don't actually know who we are" + // marker; refusing every merge under it would brick local dev. + var d = await PrLineageGuard.CheckAsync( + body: null, currentRunId: CurrentRunId, + isManualLineage: true, bodyHasFrontMatter: false, + journal: NullJournal(), + journalAction: "pr_open_impl_pr", + journalTarget: "branchpair:impl/1-2→mg/1_x", + ct: default); + d.Allowed.ShouldBeTrue(); + d.Source.ShouldBe("skipped"); + } + + [Fact] + public async Task BodyMarker_Matches_AllowsWithBodySource() + { + var body = $"\nImpl PR body."; + var d = await PrLineageGuard.CheckAsync( + body, CurrentRunId, isManualLineage: false, + bodyHasFrontMatter: false, NullJournal(), + "pr_open_impl_pr", + "branchpair:impl/1-2→mg/1_x", default); + d.Allowed.ShouldBeTrue(); + d.Source.ShouldBe("body"); + } + + [Fact] + public async Task BodyMarker_MismatchedRunId_RefusesWithBodySource() + { + // The exact W9 firewall case: another lineage opened this PR; + // we must not merge it even if the journal happens to be silent. + var body = $"\nImpl PR body."; + var d = await PrLineageGuard.CheckAsync( + body, CurrentRunId, isManualLineage: false, + bodyHasFrontMatter: false, NullJournal(), + "pr_open_impl_pr", + "branchpair:impl/1-2→mg/1_x", default); + d.Allowed.ShouldBeFalse(); + d.Source.ShouldBe("body"); + d.Reason.ShouldContain("foreign-lineage"); + d.Reason.ShouldContain(OtherRunId); + } + + [Fact] + public async Task PlanFrontMatter_Matches_AllowsWithBodySource() + { + var body = + "---\nrequests_parent_change: false\nrun_id: " + CurrentRunId + + "\nancestor_plan_generations: {}\n---\n\n## Plan body"; + var d = await PrLineageGuard.CheckAsync( + body, CurrentRunId, isManualLineage: false, + bodyHasFrontMatter: true, NullJournal(), + "pr_open_plan_pr", + "branchpair:plan/1-2→plan/1", default); + d.Allowed.ShouldBeTrue(); + d.Source.ShouldBe("body"); + } + + [Fact] + public async Task PlanFrontMatter_MismatchedRunId_Refuses() + { + var body = + "---\nrequests_parent_change: false\nrun_id: " + OtherRunId + + "\nancestor_plan_generations: {}\n---\n\n## Plan body"; + var d = await PrLineageGuard.CheckAsync( + body, CurrentRunId, isManualLineage: false, + bodyHasFrontMatter: true, NullJournal(), + "pr_open_plan_pr", + "branchpair:plan/1-2→plan/1", default); + d.Allowed.ShouldBeFalse(); + d.Source.ShouldBe("body"); + } + + [Fact] + public async Task NoBodyStamp_NullJournalStore_AllowsAsInconclusive() + { + // Without a journal AND without a stamp, we can't refuse without + // false-positiving every PR that pre-dates the W6 rollout. + // Default to allow so the W9 rollout doesn't brick existing + // PRs the moment it ships. + var d = await PrLineageGuard.CheckAsync( + body: "no marker here, just a body.", + CurrentRunId, isManualLineage: false, + bodyHasFrontMatter: false, NullJournal(), + "pr_open_impl_pr", + "branchpair:impl/1-2→mg/1_x", default); + d.Allowed.ShouldBeTrue(); + d.Source.ShouldBe("inconclusive"); + } +} diff --git a/tests/Polyphony.Tests/Commands/PrOpenEvidenceTests.cs b/tests/Polyphony.Tests/Commands/PrOpenEvidenceTests.cs index cfb7f87..e3cfd0a 100644 --- a/tests/Polyphony.Tests/Commands/PrOpenEvidenceTests.cs +++ b/tests/Polyphony.Tests/Commands/PrOpenEvidenceTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text.Json; using Polyphony.Commands; using Polyphony.Infrastructure.Processes; @@ -206,10 +207,17 @@ public async Task OpenEvidencePr_TitleAndBodySuppliedExplicitly_OverrideTwigComp result.Title.ShouldBe("Explicit evidence title"); // The explicit body must have made it onto the gh pr create call. + // W6 (AB#3280): explicit-body callers get the run-id marker + // prefix prepended; the explicit body content is preserved verbatim + // after the marker. var createInvocation = runner.Invocations .First(i => i.Executable == "gh" && i.Arguments.Count >= 2 && i.Arguments[0] == "pr" && i.Arguments[1] == "create"); - createInvocation.Arguments.ShouldContain("Explicit body content"); + var bodyIdx = createInvocation.Arguments.ToList().IndexOf("--body"); + bodyIdx.ShouldBeGreaterThan(-1); + var sentBody = createInvocation.Arguments[bodyIdx + 1]; + sentBody.ShouldStartWith(""); + sentBody.ShouldContain("Explicit body content"); } [Fact] diff --git a/tests/Polyphony.Tests/Commands/ReconcileCommandTests.cs b/tests/Polyphony.Tests/Commands/ReconcileCommandTests.cs new file mode 100644 index 0000000..74f5729 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/ReconcileCommandTests.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Journal.Observers; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W13 (AB#3294): coverage for polyphony reconcile — dry-run +/// report, --accept-external journal synthesis, --adopt single-resource +/// ownership transfer, and refusal paths. +/// +public sealed class ReconcileCommandTests : CommandTestBase +{ + private readonly string _tempDir; + private readonly JournalStore _store; + + public ReconcileCommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-reconcile-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task Reconcile_RootMissing_ReturnsRequiredInputHalt() + { + var command = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => command.Run()); + + exitCode.ShouldNotBe(ExitCodes.Success); + output.ShouldContain("--root"); + } + + [Fact] + public async Task Reconcile_RootNotFound_ReturnsCacheError() + { + var command = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => command.Run(99_999)); + + exitCode.ShouldBe(ExitCodes.CacheError); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + result.Success.ShouldBeFalse(); + result.Error.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task Reconcile_DryRun_ReportsDriftWithoutMutating() + { + await SeedAsync(new WorkItemBuilder().WithId(4001).WithType("Epic").WithTitle("Root").Build()); + await SeedEntryAsync(rootId: 4001, effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/4001", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + + var observer = new FakeResourceObserver(new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/4001", + Exists = true, + MatchesExpectedState = false, + ActualState = "def456", + }, + ], + DiscoveredResources = [], + }); + var command = CreateCommand(observer); + + var (exitCode, output) = await CaptureConsoleAsync(() => command.Run(4001)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.Success); + result.Success.ShouldBeTrue(); + result.Executed.ShouldBeFalse(); + result.AcceptExternal.ShouldBeFalse(); + result.AdoptTarget.ShouldBeNull(); + result.Drift.Findings.ShouldHaveSingleItem(); + result.Drift.Findings[0].Classification.ShouldBe(DriftClassifications.ExternalMutation); + result.AcceptedExternal.ShouldBeEmpty(); + result.Adopted.ShouldBeEmpty(); + + var entries = await _store.QueryAsync(new JournalQuery { RootId = 4001 }, CancellationToken.None); + entries.Count.ShouldBe(1); // only the seeded fixture row, no reconcile rows + } + + [Fact] + public async Task Reconcile_AcceptExternal_DryRun_ListsPlannedAcceptsButDoesNotWrite() + { + await SeedAsync(new WorkItemBuilder().WithId(4002).WithType("Epic").Build()); + await SeedEntryAsync(rootId: 4002, effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/4002", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + + var observer = new FakeResourceObserver(new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/4002", + Exists = true, + MatchesExpectedState = false, + ActualState = "zzz999", + }, + ], + DiscoveredResources = [], + }); + var command = CreateCommand(observer); + + var (exitCode, output) = await CaptureConsoleAsync(() => command.Run(4002, acceptExternal: true)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.Success); + result.AcceptExternal.ShouldBeTrue(); + result.Executed.ShouldBeFalse(); + result.AcceptedExternal.ShouldHaveSingleItem(); + result.AcceptedExternal[0].Action.ShouldBe("reconcile_accept_external"); + result.AcceptedExternal[0].Id.ShouldBe("feature/4002"); + + var entries = await _store.QueryAsync(new JournalQuery { RootId = 4002 }, CancellationToken.None); + entries.Count.ShouldBe(1); // dry-run wrote nothing + } + + [Fact] + public async Task Reconcile_AcceptExternal_Execute_WritesSynthesizedRowsThatProjectionPicksUp() + { + await SeedAsync(new WorkItemBuilder().WithId(4003).WithType("Epic").Build()); + await SeedEntryAsync(rootId: 4003, effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/4003", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + + var observer = new FakeResourceObserver(new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/4003", + Exists = true, + MatchesExpectedState = false, + ActualState = "actual-sha", + }, + ], + DiscoveredResources = [], + }); + var command = CreateCommand(observer); + + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Run(4003, acceptExternal: true, execute: true)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.Success); + result.Executed.ShouldBeTrue(); + result.AcceptedExternal.ShouldHaveSingleItem(); + + var entries = await _store.QueryAsync(new JournalQuery { RootId = 4003 }, CancellationToken.None); + entries.Count.ShouldBe(2); + var reconcileRow = entries.Single(e => e.Action == "reconcile_accept_external"); + reconcileRow.Outcome.ShouldBe(JournalOutcome.Success); + reconcileRow.Effects.ShouldHaveSingleItem(); + reconcileRow.Effects[0].Kind.ShouldBe(ResourceKind.GitBranch); + reconcileRow.Effects[0].Id.ShouldBe("feature/4003"); + reconcileRow.Effects[0].PolyphonyOwned.ShouldBeTrue(); + } + + [Fact] + public async Task Reconcile_AdoptUnknownResource_RefusesWithRoutingFailure() + { + await SeedAsync(new WorkItemBuilder().WithId(4004).WithType("Epic").Build()); + var command = CreateCommand(); + + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Run(4004, adopt: "git_branch:does-not-exist", execute: true)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.RoutingFailure); + result.Success.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("--adopt"); + } + + [Fact] + public async Task Reconcile_AdoptMalformed_ReturnsConfigError() + { + await SeedAsync(new WorkItemBuilder().WithId(4005).WithType("Epic").Build()); + var command = CreateCommand(); + + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Run(4005, adopt: "no-colon-here")); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.ConfigError); + result.Success.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("KIND:ID"); + } + + [Fact] + public async Task Reconcile_AdoptOrphan_Execute_MarksResourceOwnedUnderCurrentLineage() + { + await SeedAsync(new WorkItemBuilder().WithId(4006).WithType("Epic").Build()); + // Seed a separate expected resource so the observer leg runs; + // the orphan is then surfaced via DiscoveredResources alongside it. + await SeedEntryAsync(rootId: 4006, effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "plan/4006", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + var observer = new FakeResourceObserver(new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "plan/4006", + Exists = true, + MatchesExpectedState = true, + ActualState = "consistent", + }, + ], + DiscoveredResources = [ + new DiscoveredResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/4006", + ActualState = "present", + MatchesPolyphonyPattern = true, + }, + ], + }); + var command = CreateCommand(observer); + var runContext = new RunContext(); + // Re-create command with a known RunContext we can inspect. + command = new ReconcileCommand(_store, Repository, new JournalDriftAnalyzer([observer]), runContext); + + var (exitCode, output) = await CaptureConsoleAsync(() => + command.Run(4006, adopt: $"{ResourceKind.GitBranch}:feature/4006", execute: true)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.ReconcileResult)!; + + exitCode.ShouldBe(ExitCodes.Success); + result.Executed.ShouldBeTrue(); + result.Adopted.ShouldHaveSingleItem(); + result.Adopted[0].Action.ShouldBe("reconcile_adopt"); + result.Adopted[0].Classification.ShouldBe(DriftClassifications.ExternalCreatePolyphonyNamed); + + var entries = await _store.QueryAsync(new JournalQuery { RootId = 4006 }, CancellationToken.None); + var adoptRow = entries.Single(e => e.Action == "reconcile_adopt"); + adoptRow.RunId.ShouldBe(runContext.RunId); + adoptRow.Outcome.ShouldBe(JournalOutcome.Success); + adoptRow.Effects.ShouldHaveSingleItem(); + adoptRow.Effects[0].PolyphonyOwned.ShouldBeTrue(); + } + + private ReconcileCommand CreateCommand(params IResourceObserver[] observers) + => new(_store, Repository, new JournalDriftAnalyzer(observers), new RunContext()); + + private async Task SeedEntryAsync(int rootId, IReadOnlyList? effects = null) + { + var actionId = await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "run-reconcile", + RootId = rootId, + Action = "fixture", + Target = $"root:{rootId}", + StartedAt = 1_700_000_000_000, + }, + CancellationToken.None); + await _store.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, effects, CancellationToken.None); + } + + private sealed class FakeResourceObserver(ResourceObservationBatch batch) : IResourceObserver + { + public string Kind => batch.Kind; + public bool CanObserve => true; + public string? DeferredReason => null; + public Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + => Task.FromResult(batch); + } +} diff --git a/tests/Polyphony.Tests/Commands/ResetRootProjectionCommandTests.cs b/tests/Polyphony.Tests/Commands/ResetRootProjectionCommandTests.cs index 5eff50c..082f1c2 100644 --- a/tests/Polyphony.Tests/Commands/ResetRootProjectionCommandTests.cs +++ b/tests/Polyphony.Tests/Commands/ResetRootProjectionCommandTests.cs @@ -92,6 +92,9 @@ private sealed class FakeJournalStore(IReadOnlyList entries) : IJo public Task RecordEndAsync(long actionId, JournalOutcome outcome, string? errorCode, string? errorMessage, string? payloadJson, IReadOnlyList? effects, CancellationToken ct) => throw new NotSupportedException(); public Task> QueryAsync(JournalQuery query, CancellationToken ct) => Task.FromResult(entries); public Task ExportAsync(string destinationPath, CancellationToken ct) => throw new NotSupportedException(); + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); } private sealed class MutableBranchWorld(bool exists, bool matchesExpected, string actualState) diff --git a/tests/Polyphony.Tests/Commands/StateNextReadyJournalLineageGroundingTests.cs b/tests/Polyphony.Tests/Commands/StateNextReadyJournalLineageGroundingTests.cs new file mode 100644 index 0000000..048f508 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/StateNextReadyJournalLineageGroundingTests.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Configuration; +using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Sdlc; +using Polyphony.Sdlc.Observers; +using Polyphony.Tagging; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.Stubs; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W8 (epic #3165): journal-grounded refusal of foreign +/// polyphony:facets=* tags in . +/// +/// +/// The override-applied / no-tag / malformed-tag cases are covered by +/// . This file pins the +/// W8-specific behaviour: when a real is +/// wired AND the current lineage has no plan_seed_children row +/// for the item but another lineage does, the verb refuses to trust +/// the tag and falls back to the type-config default. Conversely, when +/// the current lineage DID record the seeding, the tag is trusted. +/// +public sealed class StateNextReadyJournalLineageGroundingTests : CommandTestBase +{ + private const int RootId = 4801; + private const string OriginUrl = "https://github.com/acme/repo.git"; + + private readonly string _tempDir; + private readonly JournalStore _store; + + public StateNextReadyJournalLineageGroundingTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-state-w8-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task NextReady_FacetsTag_StampedByForeignLineage_IsIgnored() + { + // Pre-seed the journal with a plan_seed_children row stamped by + // a PRIOR run (run-old). The current lineage (run-current) has + // no row for the item. W8 must refuse to honour the tag. + await AppendRowAsync(runId: "run-old", workItemId: RootId, action: "plan_seed_children"); + + var item = new WorkItemBuilder() + .WithId(RootId).WithType("Issue").WithTitle("Root 4801").WithState("Doing") + .WithTags($"polyphony;{PolyphonyTags.FacetsPrefix}={Facet.Plannable}") + .Build(); + await SeedAsync(item); + var runner = new FakeProcessRunner(); + BindBaseline(runner); + + var cmd = CreateCommand(runner, currentRunId: "run-current"); + var (exit, output) = await CaptureConsoleAsync(() => cmd.NextReady(workItem: RootId)); + exit.ShouldBe(ExitCodes.Success); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StateNextReadyResult)!; + result.Status.ShouldNotBe("error"); + + // With the foreign tag refused, the resolver falls back to the + // type-config default — implementation_merged returns to the + // requirement set (Issue type defaults to plannable+implementable). + var kinds = result.Requirements.Select(r => r.Kind).ToHashSet(); + kinds.ShouldContain(RequirementKind.ImplementationMerged); + result.ResolvedInputs.Facets.ShouldContain(Facet.Implementable); + } + + [Fact] + public async Task NextReady_FacetsTag_StampedByCurrentLineage_IsHonoured() + { + // Current lineage recorded the seeding — the tag is ours. + await AppendRowAsync(runId: "run-current", workItemId: RootId, action: "plan_seed_children"); + + var item = new WorkItemBuilder() + .WithId(RootId).WithType("Issue").WithTitle("Root 4801").WithState("Doing") + .WithTags($"polyphony;{PolyphonyTags.FacetsPrefix}={Facet.Plannable}") + .Build(); + await SeedAsync(item); + var runner = new FakeProcessRunner(); + BindBaseline(runner); + + var cmd = CreateCommand(runner, currentRunId: "run-current"); + var (exit, output) = await CaptureConsoleAsync(() => cmd.NextReady(workItem: RootId)); + exit.ShouldBe(ExitCodes.Success); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StateNextReadyResult)!; + result.Status.ShouldNotBe("error"); + + // Tag honoured → narrowed to plannable only, implementation_merged + // drops out (same expectation as StateNextReadyRootFacetsTests' + // happy-path assertion). + var kinds = result.Requirements.Select(r => r.Kind).ToHashSet(); + kinds.ShouldNotContain(RequirementKind.ImplementationMerged); + result.ResolvedInputs.Facets.ShouldBe([Facet.Plannable]); + } + + [Fact] + public async Task NextReady_FacetsTag_ManualLineage_TagAlwaysTrusted() + { + // Same prior-lineage row as the refuse-case, but the current + // RunContext is a manual_* fallback — we can't ground anything, + // so the tag must be trusted (status quo). + await AppendRowAsync(runId: "run-old", workItemId: RootId, action: "plan_seed_children"); + + var item = new WorkItemBuilder() + .WithId(RootId).WithType("Issue").WithTitle("Root 4801").WithState("Doing") + .WithTags($"polyphony;{PolyphonyTags.FacetsPrefix}={Facet.Plannable}") + .Build(); + await SeedAsync(item); + var runner = new FakeProcessRunner(); + BindBaseline(runner); + + var cmd = CreateCommand(runner, currentRunId: "manual_no_env"); + var (exit, output) = await CaptureConsoleAsync(() => cmd.NextReady(workItem: RootId)); + exit.ShouldBe(ExitCodes.Success); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StateNextReadyResult)!; + var kinds = result.Requirements.Select(r => r.Kind).ToHashSet(); + kinds.ShouldNotContain(RequirementKind.ImplementationMerged); + } + + private StateCommands CreateCommand(FakeProcessRunner runner, string currentRunId) + { + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + var planObserver = new PlanObserver(git, gh, new ThrowingAdoClient(), twig, new RepoIdentityResolver(git)); + return new StateCommands(twig, git, gh, runner, Repository, Config, planObserver, + JournalTestSupport.CreateRunContext(currentRunId), _store); + } + + private static void BindBaseline(FakeProcessRunner runner) + { + runner.WhenExact("git", ["remote", "get-url", "origin"], + new ProcessResult(0, OriginUrl + "\n", "")); + runner.WhenStartsWith("git", ["ls-remote"], new ProcessResult(0, "", "")); + runner.WhenStartsWith("gh", ["pr", "list"], new ProcessResult(0, "[]", "")); + runner.WhenStartsWith("twig", ["show"], new ProcessResult(0, + $$"""{"id":{{RootId}},"title":"Root","tags":""}""", "")); + } + + private async Task AppendRowAsync(string runId, int workItemId, string action) + { + var id = await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = runId, + RootId = workItemId, + WorkItemId = workItemId, + Action = action, + Target = $"workitem:{workItemId}", + StartedAt = 1_700_000_000_000, + }, + CancellationToken.None); + await _store.RecordEndAsync(id, JournalOutcome.Success, null, null, null, null, CancellationToken.None); + } + + public override void Dispose() + { + base.Dispose(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* best-effort */ } + } +} diff --git a/tests/Polyphony.Tests/Commands/W10ForeignLineageAdoptionTests.cs b/tests/Polyphony.Tests/Commands/W10ForeignLineageAdoptionTests.cs new file mode 100644 index 0000000..4544305 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/W10ForeignLineageAdoptionTests.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Configuration; +using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Routing; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Twig.Domain.Interfaces; +using Twig.Domain.Services; +using Twig.Infrastructure.Persistence; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// W10 (AB#3291): end-to-end coverage that the lineage check fires +/// on the adoption path. One representative ensure-* verb and one +/// representative open-* verb prove the wiring; +/// and cover the matrix at unit level. +/// +public sealed class W10ForeignLineageAdoptionTests : CommandTestBase, IDisposable +{ + private readonly string _tempDir; + private readonly JournalStore _journal; + + public W10ForeignLineageAdoptionTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-w10-foreign-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _journal = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + public override void Dispose() + { + base.Dispose(); + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ } + } + + private async Task RecordForeignEnsureAsync(string foreignRunId, string target, string action) + { + var actionId = await _journal.RecordStartAsync( + new JournalEntryStart + { + RunId = foreignRunId, + RootId = 100, + WorkItemId = 200, + Action = action, + Target = target, + StartedAt = 1_800_000_000_000, + }, + CancellationToken.None); + await _journal.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, null, CancellationToken.None); + } + + [Fact] + public async Task EnsureImpl_BranchExistsRemotelyOwnedByForeignLineage_Refuses() + { + const string branch = "impl/100-200"; + await RecordForeignEnsureAsync("01JCFOREIGN", branch, "branch_ensure_impl"); + + var runner = new FakeProcessRunner(); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", branch], + new ProcessResult(0, $"abc123\trefs/heads/{branch}\n", "")); + runner.WhenExact("git", ["rev-parse", "--verify", $"refs/heads/{branch}"], + new ProcessResult(1, "", "fatal: needed a single revision")); + + var cmd = CreateBranchCommands(runner, runId: "01JCMINE"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.EnsureImpl(rootId: 100, itemId: 200, mgPath: "core")); + + exit.ShouldBe(ExitCodes.RoutingFailure); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.BranchEnsureImplResult)!; + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("foreign_lineage"); + result.Error.ShouldContain("01JCFOREIGN"); + } + + [Fact] + public async Task EnsureImpl_BranchExistsButOwnedByCurrentLineage_Allows() + { + const string branch = "impl/100-200"; + const string baseBranch = "mg/100_core"; + await RecordForeignEnsureAsync("01JCMINE", branch, "branch_ensure_impl"); + + var runner = new FakeProcessRunner(); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", branch], + new ProcessResult(0, $"abc123\trefs/heads/{branch}\n", "")); + runner.WhenExact("git", ["rev-parse", "--verify", $"refs/heads/{branch}"], + new ProcessResult(1, "", "fatal: needed a single revision")); + runner.WhenExact("git", ["fetch", "origin", branch], + new ProcessResult(0, "", "")); + runner.WhenExact("git", ["checkout", "--track", $"origin/{branch}"], + new ProcessResult(0, "", "")); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", baseBranch], + new ProcessResult(0, $"def456\trefs/heads/{baseBranch}\n", "")); + runner.WhenExact("git", ["rev-parse", $"refs/heads/{branch}"], + new ProcessResult(0, "abc123\n", "")); + runner.WhenExact("git", ["rev-parse", "--abbrev-ref", "HEAD"], + new ProcessResult(0, $"{branch}\n", "")); + + var cmd = CreateBranchCommands(runner, runId: "01JCMINE"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.EnsureImpl(rootId: 100, itemId: 200, mgPath: "core")); + + exit.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.BranchEnsureImplResult)!; + result.Error.ShouldBeNull(); + result.Action.ShouldBe("checked_out"); + } + + [Fact] + public async Task OpenImplPr_ReusesExistingPrOwnedByForeignLineage_Refuses() + { + const string headBranch = "impl/100-200"; + const string baseBranch = "mg/100_core"; + var pairTarget = $"{headBranch}->{baseBranch}"; + await RecordForeignEnsureAsync("01JCFOREIGN", pairTarget, "pr_open_impl_pr"); + + var runner = new FakeProcessRunner(); + runner.WhenExact("git", ["remote", "get-url", "origin"], + new ProcessResult(0, "git@github.com:org/repo.git\n", "")); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", $"refs/heads/{headBranch}"], + new ProcessResult(0, $"abc123\trefs/heads/{headBranch}\n", "")); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", $"refs/heads/{baseBranch}"], + new ProcessResult(0, $"def456\trefs/heads/{baseBranch}\n", "")); + runner.WhenStartsWith("twig", ["show"], new ProcessResult(0, """{"title":"x","id":200}""", "")); + runner.WhenStartsWith("gh", ["pr", "list"], + new ProcessResult(0, $$"""[{"number":42,"url":"https://github.com/org/repo/pull/42","headRefName":"{{headBranch}}"}]""", "")); + // gh pr view returns body WITHOUT the run-id marker so the journal + // is the corroborating source; the journal has a foreign row. + runner.WhenStartsWith("gh", ["pr", "view", "42"], + new ProcessResult(0, """{"body":"## Some PR\n\nNo marker.","title":"x"}""", "")); + + var cmd = CreatePrCommands(runner, runId: "01JCMINE"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenImplPr(rootId: 100, itemId: 200, mgPath: "core")); + + exit.ShouldBe(ExitCodes.RoutingFailure); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PrOpenImplResult)!; + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("foreign_lineage"); + } + + private BranchCommands CreateBranchCommands(FakeProcessRunner runner, string runId) + { + var twig = new TwigClient(runner); + var config = new ProcessConfigBuilder() + .WithType("Issue", ["plannable", "implementable"], new Dictionary()) + .Build(); + var store = new SqliteCacheStore("Data Source=:memory:"); + var repo = new SqliteWorkItemRepository(store, new WorkItemMapper()); + var walker = new HierarchyWalker(config, repo); + var validator = new TransitionValidator(config); + var git = new GitClient(runner); + var gh = new GhClient(runner); + return new BranchCommands( + twig, walker, repo, validator, git, config, + new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), + new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), + JournalTestSupport.CreateRunContext(runId), + JournalTestSupport.CreateDecorator(), + _journal); + } + + private PrCommands CreatePrCommands(FakeProcessRunner runner, string runId) + { + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + return new PrCommands( + git, gh, twig, Repository, Config, + new Polyphony.Locking.RunLockStore(), + new Polyphony.Locking.RunLockPathResolver(git), + new Polyphony.Infrastructure.Paths.PolyphonyStatePaths(git), + new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), + JournalTestSupport.CreateRunContext(runId), + JournalTestSupport.CreateDecorator(), + journalStore: _journal); + } +} diff --git a/tests/Polyphony.Tests/Journal/JournalEffectContractTests.cs b/tests/Polyphony.Tests/Journal/JournalEffectContractTests.cs index 2813a63..4b77d77 100644 --- a/tests/Polyphony.Tests/Journal/JournalEffectContractTests.cs +++ b/tests/Polyphony.Tests/Journal/JournalEffectContractTests.cs @@ -153,6 +153,7 @@ public void EmittedEffects_RespectOwnershipInvariants() FacetsTagMutated = true, Succeeded = true, WasMutated = true, + PlanningCompleted = true, }), InvokeSelector(typeof(PlanCommands), "SelectPlanRebaseStaleDescendantEffects", new PlanRebaseStaleDescendantPayload { diff --git a/tests/Polyphony.Tests/Journal/JournalLineageStoreTests.cs b/tests/Polyphony.Tests/Journal/JournalLineageStoreTests.cs new file mode 100644 index 0000000..49ef89e --- /dev/null +++ b/tests/Polyphony.Tests/Journal/JournalLineageStoreTests.cs @@ -0,0 +1,164 @@ +using Polyphony.Journal; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Journal; + +/// +/// W11 (AB#3292): the journal_lineages table plus the +/// / +/// / +/// trio that anchor +/// W12 (reset retire), W13 (reconcile), and W14 (cross-machine attach). +/// +public sealed class JournalLineageStoreTests : IDisposable +{ + private readonly string _tempDir; + private readonly JournalStore _store; + + public JournalLineageStoreTests() + { + SQLitePCL.Batteries.Init(); + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-journal-lineage-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task RecordStartAsync_AutoRecordsLineage_WhenRootIdPresent() + { + var startedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "01JC1234567890ABCDEFGHJKMN", + RootId = 4242, + WorkItemId = 4242, + Action = "plan_seed_children", + Target = "wi:4242", + StartedAt = startedAt, + }, + CancellationToken.None); + + var lineages = await _store.GetLineagesAsync(4242, CancellationToken.None); + lineages.Count.ShouldBe(1); + var lineage = lineages[0]; + lineage.RunId.ShouldBe("01JC1234567890ABCDEFGHJKMN"); + lineage.RootId.ShouldBe(4242); + lineage.CreatedAt.ShouldBe(startedAt); + lineage.RetiredAt.ShouldBeNull(); + lineage.RetiredReason.ShouldBeNull(); + // We auto-populate host/user from Environment. + lineage.CreatedByHost.ShouldNotBeNullOrEmpty(); + lineage.CreatedByUser.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task RecordStartAsync_NoRootId_DoesNotInsertLineageRow() + { + await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "01JCNNNNNNNNNNNNNNNNNNNNNN", + RootId = null, + WorkItemId = 999, + Action = "diagnostic", + Target = "wi:999", + StartedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }, + CancellationToken.None); + + // GetLineagesAsync queries by rootId so we can't easily probe + // for "no row at all" — assert by querying both candidate roots. + (await _store.GetLineagesAsync(999, CancellationToken.None)).ShouldBeEmpty(); + } + + [Fact] + public async Task RecordStartAsync_RepeatedSameRunAndRoot_IdempotentlyKeepsFirstObservation() + { + var first = 1_000_000L; + var second = 2_000_000L; + await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "01JCAAAAAAAAAAAAAAAAAAAAAA", + RootId = 100, + Action = "first", + Target = "wi:100", + StartedAt = first, + }, + CancellationToken.None); + await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "01JCAAAAAAAAAAAAAAAAAAAAAA", + RootId = 100, + Action = "second", + Target = "wi:100", + StartedAt = second, + }, + CancellationToken.None); + + var lineages = await _store.GetLineagesAsync(100, CancellationToken.None); + lineages.Count.ShouldBe(1); + lineages[0].CreatedAt.ShouldBe(first); + } + + [Fact] + public async Task GetLineagesAsync_ReturnsAllLineagesForRoot_OrderedByCreatedAt() + { + await _store.RecordLineageAsync("01JCBBBBBBBBBBBBBBBBBBBBBB", 200, host: "h1", user: "u1", CancellationToken.None); + await Task.Delay(5); + await _store.RecordLineageAsync("01JCCCCCCCCCCCCCCCCCCCCCCC", 200, host: "h2", user: "u2", CancellationToken.None); + + var lineages = await _store.GetLineagesAsync(200, CancellationToken.None); + lineages.Count.ShouldBe(2); + lineages[0].RunId.ShouldBe("01JCBBBBBBBBBBBBBBBBBBBBBB"); + lineages[1].RunId.ShouldBe("01JCCCCCCCCCCCCCCCCCCCCCCC"); + lineages[0].CreatedByHost.ShouldBe("h1"); + lineages[0].CreatedByUser.ShouldBe("u1"); + } + + [Fact] + public async Task RetireLineageAsync_StampsRetiredAtAndReason_AndReturnsTrue() + { + await _store.RecordLineageAsync("01JCRETIREDRETIREDRETIREDR", 300, host: null, user: null, CancellationToken.None); + + var retired = await _store.RetireLineageAsync("01JCRETIREDRETIREDRETIREDR", 300, reason: "reset apex", CancellationToken.None); + retired.ShouldBeTrue(); + + var lineages = await _store.GetLineagesAsync(300, CancellationToken.None); + lineages.Count.ShouldBe(1); + lineages[0].RetiredAt.ShouldNotBeNull(); + lineages[0].RetiredReason.ShouldBe("reset apex"); + } + + [Fact] + public async Task RetireLineageAsync_AlreadyRetired_ReturnsFalseAndPreservesOriginalRetirement() + { + await _store.RecordLineageAsync("01JCDOUBLERETIREDDOUBLERETI", 400, host: null, user: null, CancellationToken.None); + var firstReason = "first reason"; + (await _store.RetireLineageAsync("01JCDOUBLERETIREDDOUBLERETI", 400, firstReason, CancellationToken.None)).ShouldBeTrue(); + var firstSnapshot = (await _store.GetLineagesAsync(400, CancellationToken.None))[0]; + + await Task.Delay(5); + var second = await _store.RetireLineageAsync("01JCDOUBLERETIREDDOUBLERETI", 400, reason: "second reason", CancellationToken.None); + second.ShouldBeFalse(); + + var afterSecond = (await _store.GetLineagesAsync(400, CancellationToken.None))[0]; + afterSecond.RetiredAt.ShouldBe(firstSnapshot.RetiredAt); + afterSecond.RetiredReason.ShouldBe(firstReason); + } + + [Fact] + public async Task RetireLineageAsync_NonexistentLineage_ReturnsFalse() + { + var retired = await _store.RetireLineageAsync("01JCMISSINGMISSINGMISSINGMI", 500, reason: null, CancellationToken.None); + retired.ShouldBeFalse(); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ } + } +} diff --git a/tests/Polyphony.Tests/Journal/JournalStoreTests.cs b/tests/Polyphony.Tests/Journal/JournalStoreTests.cs index b596a5a..7374477 100644 --- a/tests/Polyphony.Tests/Journal/JournalStoreTests.cs +++ b/tests/Polyphony.Tests/Journal/JournalStoreTests.cs @@ -40,6 +40,7 @@ public async Task QueryAsync_CreatesSchema_AndSchemaVersionRow() objectNames.ShouldContain("actions"); objectNames.ShouldContain("journal_effects"); + objectNames.ShouldContain("journal_lineages"); objectNames.ShouldContain("schema_version"); objectNames.ShouldContain("idx_actions_work_item"); objectNames.ShouldContain("idx_actions_root"); @@ -49,6 +50,7 @@ public async Task QueryAsync_CreatesSchema_AndSchemaVersionRow() objectNames.ShouldContain("idx_journal_effects_entry"); objectNames.ShouldContain("idx_journal_effects_kind_id"); objectNames.ShouldContain("idx_journal_effects_owned_kind"); + objectNames.ShouldContain("idx_journal_lineages_root"); await using var versionCommand = connection.CreateCommand(); versionCommand.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; diff --git a/tests/Polyphony.Tests/Journal/JournaledActionDecoratorManualLineageGuardTests.cs b/tests/Polyphony.Tests/Journal/JournaledActionDecoratorManualLineageGuardTests.cs new file mode 100644 index 0000000..1a5cb20 --- /dev/null +++ b/tests/Polyphony.Tests/Journal/JournaledActionDecoratorManualLineageGuardTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Polyphony; +using Polyphony.Journal; +using Polyphony.Tests.Commands; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Journal; + +/// +/// W5 (AB#3279): the fail-closed mutation guard on JournaledActionDecorator +/// refuses to invoke any mutating action when the run id falls back to the +/// `manual_*` synthetic shape (POLYPHONY_RUN_ID was unset). The guard +/// writes ZERO journal rows so the refusal doesn't pollute whichever real +/// lineage the launcher would later stamp. +/// +public sealed class JournaledActionDecoratorManualLineageGuardTests +{ + private const string SampleManualRunId = "manual_abcdef0123456789abcdef0123456789"; + private const string SampleUlidRunId = "01HZK7Y9ABCDEF0123456789AB"; + + [Fact] + public async Task RunWithAsync_GuardEngaged_ManualLineage_RefusesAndReturnsExitCode5() + { + var store = new RecordingJournalStore(); + var decorator = new JournaledActionDecorator(store, failClosedOnManualLineage: true); + var actionFired = false; + + int exitCode; + var stderrCapture = new StringWriter(); + await ConsoleTestLock.AsyncLock.WaitAsync(); + var priorErr = Console.Error; + Console.SetError(stderrCapture); + try + { + exitCode = await decorator.RunWithAsync( + new JournaledActionInvocation + { + RunId = SampleManualRunId, + RootId = 1234, + WorkItemId = 5678, + Action = "branch.ensure_plan", + Target = "plan/1234", + }, + _ => + { + actionFired = true; + return Task.FromResult(ExitCodes.Success); + }); + } + finally + { + Console.SetError(priorErr); + ConsoleTestLock.AsyncLock.Release(); + } + + exitCode.ShouldBe(ExitCodes.MissingRunIdLineage); + ExitCodes.MissingRunIdLineage.ShouldBe(5); + actionFired.ShouldBeFalse(); + store.Starts.Count.ShouldBe(0); + store.Ends.Count.ShouldBe(0); + + var stderr = stderrCapture.ToString().Trim(); + stderr.ShouldNotBeEmpty(); + var envelope = JsonSerializer.Deserialize( + stderr, PolyphonyJsonContext.Default.ManualLineageRefusal); + envelope.ShouldNotBeNull(); + envelope!.Error.ShouldBe("missing_run_id_lineage"); + envelope.Verb.ShouldBe("branch.ensure_plan"); + envelope.Target.ShouldBe("plan/1234"); + envelope.EnvVar.ShouldBe("POLYPHONY_RUN_ID"); + envelope.Message.ShouldContain("POLYPHONY_RUN_ID"); + envelope.Message.ShouldContain("Invoke-PolyphonySdlc.ps1"); + } + + [Fact] + public async Task RunWithAsync_GuardEngaged_RealUlidRunId_AllowsActionAndJournals() + { + var store = new RecordingJournalStore(); + var decorator = new JournaledActionDecorator(store, failClosedOnManualLineage: true); + + var exitCode = await decorator.RunWithAsync( + new JournaledActionInvocation + { + RunId = SampleUlidRunId, + RootId = 1234, + Action = "branch.ensure_plan", + Target = "plan/1234", + }, + _ => Task.FromResult(ExitCodes.Success)); + + exitCode.ShouldBe(ExitCodes.Success); + store.Starts.Count.ShouldBe(1); + store.Ends.Count.ShouldBe(1); + store.Starts[0].RunId.ShouldBe(SampleUlidRunId); + store.Ends[0].Outcome.ShouldBe(JournalOutcome.Success); + } + + [Fact] + public async Task RunWithAsync_DefaultCtor_ManualLineage_DoesNotRefuse() + { + // The default ctor (used by 7 existing test fixtures) leaves the + // guard disengaged so tests that construct decorators without an + // env-stamped run id continue to work. + var store = new RecordingJournalStore(); + var decorator = new JournaledActionDecorator(store); + + decorator.WithManualLineageGuard.ShouldBeFalse(); + + var exitCode = await decorator.RunWithAsync( + new JournaledActionInvocation + { + RunId = SampleManualRunId, + Action = "branch.ensure_plan", + Target = "plan/1234", + }, + _ => Task.FromResult(ExitCodes.Success)); + + exitCode.ShouldBe(ExitCodes.Success); + store.Starts.Count.ShouldBe(1); + } + + [Fact] + public void Constructor_GuardFlag_ExposedViaWithManualLineageGuardProperty() + { + var store = new RecordingJournalStore(); + new JournaledActionDecorator(store, failClosedOnManualLineage: true).WithManualLineageGuard.ShouldBeTrue(); + new JournaledActionDecorator(store, failClosedOnManualLineage: false).WithManualLineageGuard.ShouldBeFalse(); + new JournaledActionDecorator(store).WithManualLineageGuard.ShouldBeFalse(); + } + + [Fact] + public async Task RunWithAsync_GuardEngaged_ManualLineage_DoesNotWriteAnyJournalRow() + { + // Belt-and-suspenders: the refusal contract is that the launcher + // can re-run the same invocation under a real lineage and the + // journal must look pristine — no orphan start row, no failure + // row from the refused attempt. + var store = new RecordingJournalStore(); + var decorator = new JournaledActionDecorator(store, failClosedOnManualLineage: true); + + var priorErr = Console.Error; + await ConsoleTestLock.AsyncLock.WaitAsync(); + Console.SetError(TextWriter.Null); + try + { + await decorator.RunWithAsync( + new JournaledActionInvocation + { + RunId = SampleManualRunId, + Action = "branch.ensure_plan", + Target = "plan/1234", + }, + _ => Task.FromResult(ExitCodes.Success)); + } + finally + { + Console.SetError(priorErr); + ConsoleTestLock.AsyncLock.Release(); + } + + store.Starts.Count.ShouldBe(0); + store.Ends.Count.ShouldBe(0); + + // Second invocation under real lineage now journals as normal. + await decorator.RunWithAsync( + new JournaledActionInvocation + { + RunId = SampleUlidRunId, + Action = "branch.ensure_plan", + Target = "plan/1234", + }, + _ => Task.FromResult(ExitCodes.Success)); + + store.Starts.Count.ShouldBe(1); + store.Starts[0].RunId.ShouldBe(SampleUlidRunId); + } + + private sealed class RecordingJournalStore : IJournalStore + { + public List Starts { get; } = new(); + public List<(long ActionId, JournalOutcome Outcome)> Ends { get; } = new(); + public string DatabasePath => ":memory:"; + + public Task RecordStartAsync(JournalEntryStart entry, CancellationToken ct) + { + Starts.Add(entry); + return Task.FromResult((long)Starts.Count); + } + + public Task RecordEndAsync( + long actionId, + JournalOutcome outcome, + string? errorCode, + string? errorMessage, + string? payloadJson, + IReadOnlyList? effects, + CancellationToken ct) + { + Ends.Add((actionId, outcome)); + return Task.CompletedTask; + } + + public Task> QueryAsync(JournalQuery query, CancellationToken ct) + => Task.FromResult>(Array.Empty()); + + public Task ExportAsync(string destinationPath, CancellationToken ct) => Task.CompletedTask; + + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); + } +} diff --git a/tests/Polyphony.Tests/Journal/Reset/ProjectionResetExecutorTests.cs b/tests/Polyphony.Tests/Journal/Reset/ProjectionResetExecutorTests.cs index c3ef4cd..f5c710e 100644 --- a/tests/Polyphony.Tests/Journal/Reset/ProjectionResetExecutorTests.cs +++ b/tests/Polyphony.Tests/Journal/Reset/ProjectionResetExecutorTests.cs @@ -146,6 +146,9 @@ private sealed class FakeJournalStore(IReadOnlyList entries) : IJo public Task RecordEndAsync(long actionId, JournalOutcome outcome, string? errorCode, string? errorMessage, string? payloadJson, IReadOnlyList? effects, CancellationToken ct) => throw new NotSupportedException(); public Task> QueryAsync(JournalQuery query, CancellationToken ct) => Task.FromResult(entries); public Task ExportAsync(string destinationPath, CancellationToken ct) => throw new NotSupportedException(); + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); } private sealed class MutableBranchWorld(bool exists, bool matchesExpected, string actualState) diff --git a/tests/Polyphony.Tests/Journal/RunContextLineageSourceTests.cs b/tests/Polyphony.Tests/Journal/RunContextLineageSourceTests.cs new file mode 100644 index 0000000..84ca831 --- /dev/null +++ b/tests/Polyphony.Tests/Journal/RunContextLineageSourceTests.cs @@ -0,0 +1,54 @@ +using Polyphony.Journal; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Journal; + +/// +/// Verifies the post-W2 lineage-source contract on +/// : env-set → env, env-unset → +/// manual_fallback with the canonical prefix preserved. +/// +public sealed class RunContextLineageSourceTests +{ + [Fact] + public void EnvSet_ReportsEnvironmentSource() + { + var ctx = new RunContext(_ => "01HQABCD0123456789MNPQRSTV"); + ctx.RunIdSource.ShouldBe(RunContext.Sources.Environment); + ctx.HasManualLineage.ShouldBeFalse(); + ctx.RunId.ShouldBe("01HQABCD0123456789MNPQRSTV"); + } + + [Fact] + public void EnvUnset_FallsBackToManualPrefix() + { + var ctx = new RunContext(_ => null); + ctx.RunIdSource.ShouldBe(RunContext.Sources.ManualFallback); + ctx.HasManualLineage.ShouldBeTrue(); + ctx.RunId.ShouldStartWith(RunContext.ManualLineagePrefix); + } + + [Fact] + public void EnvBlank_TreatedAsUnset() + { + var ctx = new RunContext(_ => " "); + ctx.RunIdSource.ShouldBe(RunContext.Sources.ManualFallback); + ctx.HasManualLineage.ShouldBeTrue(); + } + + [Fact] + public void ExplicitConstructor_SetsExplicitSource() + { + var ctx = new RunContext("explicit_test_value"); + ctx.RunIdSource.ShouldBe(RunContext.Sources.Explicit); + ctx.RunId.ShouldBe("explicit_test_value"); + } + + [Fact] + public void HasManualLineage_KeysOnPrefix_EvenForExplicit() + { + var ctx = new RunContext($"{RunContext.ManualLineagePrefix}imported_from_legacy"); + ctx.HasManualLineage.ShouldBeTrue(); + } +} diff --git a/tests/Polyphony.Tests/Journal/RunIdMintTests.cs b/tests/Polyphony.Tests/Journal/RunIdMintTests.cs new file mode 100644 index 0000000..184abb6 --- /dev/null +++ b/tests/Polyphony.Tests/Journal/RunIdMintTests.cs @@ -0,0 +1,82 @@ +using Polyphony.Journal; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Journal; + +/// +/// Smoke tests for the ULID mint that backs the lineage primitive (W2). +/// +public sealed class RunIdMintTests +{ + [Fact] + public void NewRunId_ProducesCanonicalLength() + { + var id = RunIdMint.NewRunId(); + id.Length.ShouldBe(RunIdMint.UlidLength); + } + + [Fact] + public void NewRunId_UsesCrockfordAlphabetOnly() + { + var id = RunIdMint.NewRunId(); + const string alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + foreach (var c in id) + { + alphabet.ShouldContain(c); + } + } + + [Fact] + public void NewRunId_UniqueAcrossManyDraws() + { + var set = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < 1_000; i++) + { + set.Add(RunIdMint.NewRunId()).ShouldBeTrue("ULID collision within 1k samples — randomness is broken."); + } + } + + [Fact] + public void NewRunId_FixedTimestamp_HasLexicographicPrefixOrdering() + { + var t0 = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); + var t1 = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_500); + + for (var trial = 0; trial < 100; trial++) + { + var a = RunIdMint.NewRunId(t0); + var b = RunIdMint.NewRunId(t1); + string.CompareOrdinal(a, b).ShouldBeLessThan(0, + $"Later timestamp should sort after earlier (a={a}, b={b})."); + } + } + + [Fact] + public void Compose_ProducesStableEncoding_ForKnownInputs() + { + // 1234567890 ms past epoch, zero-randomness — deterministic. + var t = DateTimeOffset.FromUnixTimeMilliseconds(1_234_567_890L); + ReadOnlySpan randomness = stackalloc byte[10]; + var encoded = RunIdMint.Compose(t, randomness); + encoded.Length.ShouldBe(RunIdMint.UlidLength); + encoded.ShouldEndWith(new string('0', 16)); + } + + [Fact] + public void IsWellFormed_AcceptsFreshMint() + { + RunIdMint.IsWellFormed(RunIdMint.NewRunId()).ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-ulid")] + [InlineData("manual_deadbeef")] + [InlineData("01ARZ3NDEKTSV4RRFFQ69G5FAI")] // contains 'I' — excluded from Crockford + public void IsWellFormed_RejectsMalformed(string? value) + { + RunIdMint.IsWellFormed(value).ShouldBeFalse(); + } +} diff --git a/tests/Polyphony.Tests/Manifest/RunManifestSchemaV2Tests.cs b/tests/Polyphony.Tests/Manifest/RunManifestSchemaV2Tests.cs new file mode 100644 index 0000000..2f627bf --- /dev/null +++ b/tests/Polyphony.Tests/Manifest/RunManifestSchemaV2Tests.cs @@ -0,0 +1,112 @@ +using Polyphony.Journal; +using Polyphony.Manifest; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Manifest; + +/// +/// Verifies the W2 schema-v2 additions on : +/// round-trips through +/// ; the validator accepts the schema +/// range [1, 2] and rejects out-of-range values; legacy schema-1 +/// manifests load without RunId; warnings surface the legacy gap. +/// +public sealed class RunManifestSchemaV2Tests : IDisposable +{ + private readonly string tempDir; + + public RunManifestSchemaV2Tests() + { + this.tempDir = Path.Combine(Path.GetTempPath(), "polyphony-manifest-v2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.tempDir); + } + + public void Dispose() + { + try { Directory.Delete(this.tempDir, recursive: true); } catch { /* best effort */ } + } + + private string PathOf(string name) => Path.Combine(this.tempDir, name); + + private static RunManifest GoodBaseline(int schema, string? runId) => new() + { + Schema = schema, + RootId = 1234, + RunId = runId, + PlatformProject = "dev.azure.com/org/project", + CreatedAt = new DateTime(2026, 5, 6, 15, 30, 0, DateTimeKind.Utc), + CreatedBy = "dangreen", + BranchModelVersion = 1, + }; + + [Fact] + public void V2_WithRunId_RoundTripsThroughStore() + { + var ulid = RunIdMint.NewRunId(); + var path = PathOf("run.yaml"); + var manifest = GoodBaseline(schema: 2, runId: ulid); + RunManifestStore.Save(path, manifest); + + var loaded = RunManifestStore.LoadOrThrow(path); + loaded.Schema.ShouldBe(2); + loaded.RunId.ShouldBe(ulid); + } + + [Fact] + public void V1_LegacyWithoutRunId_LoadsCleanly_AndNotErrored() + { + var path = PathOf("legacy.yaml"); + var manifest = GoodBaseline(schema: 1, runId: null); + RunManifestStore.Save(path, manifest); + + var loaded = RunManifestStore.LoadOrThrow(path); + loaded.Schema.ShouldBe(1); + loaded.RunId.ShouldBeNull(); + + // CollectWarnings surfaces the legacy gap without making it fatal. + var warnings = RunManifestValidator.CollectWarnings(loaded); + warnings.ShouldContain(w => w.Contains("legacy schema 1")); + } + + [Fact] + public void V2_WithoutRunId_Warns_ButDoesNotError() + { + var manifest = GoodBaseline(schema: 2, runId: null); + RunManifestValidator.Validate(manifest).ShouldBeEmpty(); + RunManifestValidator.CollectWarnings(manifest).ShouldContain(w => w.Contains("schema 2") && w.Contains("missing run_id")); + } + + [Fact] + public void Schema_OutOfRange_IsRejected() + { + var manifest = GoodBaseline(schema: 0, runId: null); + RunManifestValidator.Validate(manifest).ShouldContain(s => s.Contains("schema must be in")); + + manifest.Schema = 99; + RunManifestValidator.Validate(manifest).ShouldContain(s => s.Contains("schema must be in")); + } + + [Fact] + public void RunId_MalformedUlid_IsRejected() + { + var manifest = GoodBaseline(schema: 2, runId: "not-a-real-ulid"); + RunManifestValidator.Validate(manifest).ShouldContain(s => s.Contains("run_id")); + } + + [Fact] + public void RunId_WellFormedUlid_PassesValidation() + { + var manifest = GoodBaseline(schema: 2, runId: RunIdMint.NewRunId()); + RunManifestValidator.Validate(manifest).ShouldBeEmpty(); + } + + [Fact] + public void CurrentSchema_Constant_IsTwo() + { + // Locking the value so a future bump is intentional, not accidental. + RunManifestValidator.CurrentSchema.ShouldBe(2); + RunManifestValidator.MaxSupportedSchema.ShouldBe(2); + RunManifestValidator.MinSupportedSchema.ShouldBe(1); + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs b/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs index a024f28..b21f27e 100644 --- a/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs +++ b/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs @@ -20,5 +20,10 @@ public Task> QueryAsync(JournalQuery query, Cancella => Task.FromResult>([]); public Task ExportAsync(string destinationPath, CancellationToken ct) => Task.CompletedTask; + + public Task RecordLineageAsync(string runId, int rootId, string? host, string? user, CancellationToken ct) => Task.CompletedTask; + public Task> GetLineagesAsync(int rootId, CancellationToken ct) + => Task.FromResult>([]); + public Task RetireLineageAsync(string runId, int rootId, string? reason, CancellationToken ct) => Task.FromResult(false); } }