From 839394f4448e3bb1379d3094bebb3baecbe93b39 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 28 Oct 2026 23:19:35 -0700 Subject: [PATCH] Fix bounded action process journaling Give action-only launchers a dedicated numbered journal and preserve exact qualified topologies for the final bounded parity actions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Invoke-MatchedActionSupplement.ps1 | 72 +++- Tools/windows/Invoke-UiCaptureWorker.ps1 | 374 ++++++++++++++---- .../Prepare-MatchedActionSupplement.ps1 | 131 +++++- Tools/windows/Start-MatchedActionSessions.ps1 | 20 + Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 47 ++- Tools/windows/UI-CAPTURE-SCENARIOS.md | 8 + Tools/windows/UiCaptureDriver.psm1 | 29 +- 7 files changed, 588 insertions(+), 93 deletions(-) create mode 100644 Tools/windows/Start-MatchedActionSessions.ps1 diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 94a11e99..923a6285 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -26,10 +26,14 @@ if ($head -cne $plan.sourceHead -or $tree -cne $plan.sourceTree -or $modulePath=Join-Path $repositoryRoot 'Tools\windows\UiCaptureDriver.psm1' $workerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-UiCaptureWorker.ps1' $stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' +$actionRunnerPath=$PSCommandPath +$actionSeedHelperPath=Join-Path $repositoryRoot 'Tools\windows\Start-MatchedActionSessions.ps1' foreach ($binding in @( @($modulePath,$plan.moduleSha256), @($workerPath,$plan.workerSha256), @($stubPath,$plan.stubDaemonSha256), + @($actionRunnerPath,$plan.actionRunnerSha256), + @($actionSeedHelperPath,$plan.actionSeedHelperSha256), @($plan.jobPath,$plan.jobSha256), @($plan.preflightReceiptPath,$plan.preflightReceiptSha256), @($plan.existingSceneManifestPath,$plan.existingSceneManifestSha256) @@ -51,6 +55,7 @@ $authorization=[ordered]@{ actionPlanSha256=$ActionPlanSha256.ToUpperInvariant() moduleSha256=$plan.moduleSha256 workerSha256=$plan.workerSha256 + actionRunnerSha256=$plan.actionRunnerSha256 jobSha256=$plan.jobSha256 preflightReceiptSha256=$plan.preflightReceiptSha256 pngAuthorized=$false @@ -59,6 +64,7 @@ $authorization=[ordered]@{ [long]$plan.authorizationBounds.requestedMaxMinutes).ToString('o') maxMinutes=[long]$plan.authorizationBounds.requestedMaxMinutes } +if ($plan.group -ceq 'FinalBoundedActions') {$authorization.pngAuthorized=$true} $authorizationPath=Join-Path $root 'action-authorization.json' if (Test-Path $authorizationPath) {throw 'Action supplement authorization already consumed.'} [IO.File]::WriteAllText($authorizationPath,($authorization|ConvertTo-Json -Depth 20), @@ -75,6 +81,8 @@ $fixturePath=Join-Path $root 'fixtures\fixture.json' $peerScript=Join-Path $root 'inputs\offline-peer-source.ps1' $runDirectory=Join-Path $root ('action-runs\' + [guid]::NewGuid().ToString('N')) $null=New-Item -ItemType Directory -Path $runDirectory +$journalPath=Join-Path $runDirectory 'process-journal' +$null=New-Item -ItemType Directory -Path $journalPath $outputDirectory=$output $workerLog=Join-Path $output 'action-worker.log' [IO.File]::WriteAllText($workerLog,'',[Text.UTF8Encoding]::new($false)) @@ -90,6 +98,19 @@ $transitionRehearsal=$false $fixtureHash=(Get-FileHash $fixturePath -Algorithm SHA256).Hash $fixtureSourceHash=$plan.fixture.canonicalSourceSha256 $fixtureMappingHash=$plan.fixture.mappingSha256 +$moduleHash=$plan.moduleSha256 +$workerHash=$plan.workerSha256 +$supervisorHash=$plan.actionRunnerSha256 +$selfProcess=[Diagnostics.Process]::GetCurrentProcess() +$selfIdentity=Get-CapProcessIdentity -Process $selfProcess ` + -ExpectedImagePath $selfProcess.MainModule.FileName -Role 'matched-action-runner' +$journalBinding=New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` + -LeaseId $authorization.authorizationId -JobSha256 $plan.jobSha256 ` + -ModuleSha256 $moduleHash -WorkerSha256 $workerHash ` + -SupervisorSha256 $supervisorHash -Identity $selfIdentity +$journalState=New-CapJournalState -Root $root -Directory $journalPath ` + -Binding $journalBinding -DeadlineUtc $leaseWindow.ExpiresUtc.UtcDateTime +Write-CapWorkerJournalSnapshot -Kind 'Bootstrap' $startedUtc=[DateTime]::UtcNow $primaryFailure=$null $independent=@() @@ -107,11 +128,34 @@ try { $zmxSeedReceipts.Count -ne [long]$plan.expectedReceiptCount) { throw 'Terminal action supplement receipt or provider count differs.' } - } else { + } elseif ($plan.group -ceq 'WireCompletion') { $receipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath + $receipt | Add-Member -NotePropertyName actionIds -NotePropertyValue @('W1') $receipt | Add-Member -NotePropertyName imageBindingSha256 ` -NotePropertyValue ([string]$plan.imageBindings.'s-goal-unwired-unselected') $actionSupplementReceipts.Add($receipt) + } else { + $wireReceipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath + $wireReceipt | Add-Member -NotePropertyName actionIds -NotePropertyValue @('W1') + $wireReceipt | Add-Member -NotePropertyName imageBindingSha256 ` + -NotePropertyValue ([string]$plan.imageBindings.'s-goal-unwired-unselected') + $actionSupplementReceipts.Add($wireReceipt) + $bindings=@{} + foreach ($property in $plan.imageBindings.PSObject.Properties) { + $bindings[$property.Name]=[string]$property.Value + } + $terminalScenes=@( + 's-tabs-two-agent-selected', + 's-tabs-split-right-focused', + 's-tabs-last-guard', + 's-tabs-many-first', + 's-tabs-hover-close' + ) + Invoke-CapMatchedScenes -Phase 'after' -SceneNames $terminalScenes ` + -SkipRestart -ActionSupplement -ImageBindings $bindings + if ($actionSupplementReceipts.Count -ne 6 -or $zmxSeedReceipts.Count -ne 5) { + throw 'Final bounded action or provider receipt count differs.' + } } } catch { $primaryFailure=$_ @@ -128,11 +172,25 @@ try { catch {$cleanupFailures.Add("Final seed cleanup failed: $($_.Exception.Message)")} } } + foreach ($seedRun in @($actionSeedRuns)) { + if (-not $seedRun.CleanupComplete) { + try {Close-CapMatchedActionSeeds -SeedRun $seedRun} + catch {$cleanupFailures.Add("Final action-seed cleanup failed: $($_.Exception.Message)")} + } + } $independent=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase) } | Select-Object ProcessId,Name,ExecutablePath) } +$terminalOutcome=if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and + $independent.Count -eq 0) {'COMPLETE'} else {'FAILED'} +try { + Write-CapWorkerJournalSnapshot -Kind 'Terminal' -Outcome $terminalOutcome +} catch { + if (-not $primaryFailure) {$primaryFailure=$_} + else {$cleanupFailures.Add("Final action journal publication failed: $($_.Exception.Message)")} +} $manifest=[ordered]@{ schemaVersion=1L status=$(if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and @@ -148,7 +206,14 @@ $manifest=[ordered]@{ receipts=@($actionSupplementReceipts) receiptCount=[long]$actionSupplementReceipts.Count providerReceipts=@($zmxSeedReceipts) + resultImages=@($images) processJournal=@($journalEntries) + numberedJournal=[ordered]@{ + binding=$journalState.Binding + directory=$journalPath + terminal=$journalState.Terminal + published=@($journalState.Published.ToArray()) + } cleanup=@($cleanupReceipts) cleanupFailures=@($cleanupFailures) independentSurvivors=@($independent) @@ -168,6 +233,8 @@ $release=[ordered]@{ group=$plan.group manifestSha256=(Get-FileHash $manifestPath -Algorithm SHA256).Hash receiptCount=[long]$actionSupplementReceipts.Count + journalPublicationCount=[long]$journalState.Published.Count + journalTerminalSha256=$(if ($journalState.Terminal) {$journalState.Terminal.sha256} else {$null}) cleanupFailures=[long]$cleanupFailures.Count independentSurvivors=[long]$independent.Count releasedUtc=[DateTime]::UtcNow.ToString('o') @@ -178,6 +245,9 @@ $releasePath=Join-Path $output 'action-release.json' if ($manifest.status -cne 'COMPLETE' -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') { throw "Action supplement failed: $($manifest.failures -join '; ') cleanup=$($cleanupFailures -join '; ') survivors=$($independent.Count)" } +if ($manifest.pngCount -ne [long]$plan.maximumResultPngs) { + throw "Action supplement result image count differs: expected=$($plan.maximumResultPngs) actual=$($manifest.pngCount)" +} [ordered]@{ group=$plan.group manifestPath=$manifestPath diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index a99d7b1e..5a98ed9c 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -39,6 +39,7 @@ $checkpoints = [Collections.Generic.List[object]]::new() $phaseTransitions = [Collections.Generic.List[object]]::new() $runs = [Collections.Generic.List[object]]::new() $seedRuns = [Collections.Generic.List[object]]::new() +$actionSeedRuns = [Collections.Generic.List[object]]::new() $applicationRuns = [Collections.Generic.List[object]]::new() $realRestarts = [Collections.Generic.List[object]]::new() $actionSupplementReceipts = [Collections.Generic.List[object]]::new() @@ -176,17 +177,24 @@ function Write-CapProcessJournal { } } if ($index -ge 0) { $journalEntries[$index] = $entry } else { $journalEntries.Add($entry) } - if (-not $actionSupplementMode) { - Write-CapWorkerJournalSnapshot -Kind 'Progress' - } + Write-CapWorkerJournalSnapshot -Kind 'Progress' } function Write-CapWorkerJournalSnapshot { param([ValidateSet('Bootstrap','Progress','Terminal')][string]$Kind, [string]$Outcome = 'CAPTURING') - if ($actionSupplementMode) {return} if ($null -eq $journalState) { throw 'Worker journal was not initialized before publication.' } - $progressManifest=if ($transitionRehearsal) { + $progressManifest=if ($actionSupplementMode) { + [pscustomobject][ordered]@{ + schemaVersion=1L + executionMode='MATCHED_ACTION_SUPPLEMENT' + evidenceClassification='BOUNDED_ACTION_EVIDENCE_NOT_STATIC_CAPTURE' + status=$Outcome + actionReceiptCount=[long]$actionSupplementReceipts.Count + providerReceiptCount=[long]$zmxSeedReceipts.Count + captures=0L + } + } elseif ($transitionRehearsal) { [pscustomobject][ordered]@{ schemaVersion=1L executionMode='LIVE_TRANSITION_REHEARSAL' @@ -533,6 +541,130 @@ function Start-CapOfflineSeed { $arguments[6] -cne 'echo Offline card fixture - no backend')) { throw 'The B job does not contain the exact seven-argument offline cmd-only zmx seed.' } + + function Install-CapQualifiedActionTopology { + param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)]$Topology) + if ((Get-FileHash -LiteralPath $Topology.preparedLayoutPath -Algorithm SHA256).Hash -cne + [string]$Topology.preparedLayoutSha256) { + throw "Prepared qualified layout changed: $($Topology.scene)" + } + $layoutPath=Join-Path $Roots.Root "$($Topology.loop).json" + Copy-Item -LiteralPath $Topology.preparedLayoutPath -Destination $layoutPath + $sessionRoot=Join-Path $Roots.Root 'shell-sessions' + $null=New-Item -ItemType Directory -Path $sessionRoot -Force + foreach ($session in @($Topology.sessions | Where-Object {-not $_.agent})) { + $source=Join-Path $Topology.shellSessionsPath $session.name + if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -cne + [string]$session.preparedSha256) { + throw "Prepared shell-session identity changed: $($session.name)" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $sessionRoot $session.name) + } + return [pscustomobject]@{ + layoutPath=$layoutPath + layoutSha256=(Get-FileHash -LiteralPath $layoutPath -Algorithm SHA256).Hash + sourceLayoutSha256=$Topology.sourceLayoutSha256 + sessionCount=[long]$Topology.sessions.Count + shellSessionCount=[long]@($Topology.sessions | Where-Object {-not $_.agent}).Count + } + } + + function Start-CapMatchedActionSeeds { + param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)]$Topology) + $sessions=@($Topology.sessions | ForEach-Object {[string]$_.name}) + if ($sessions.Count -lt 1 -or @($sessions | Sort-Object -Unique).Count -ne $sessions.Count) { + throw 'Qualified action topology has missing or duplicate sessions.' + } + $manifestPath=Join-Path $Roots.Peer 'action-seed-sessions.json' + $manifest=[pscustomobject]@{schemaVersion=1L;sessions=$sessions} + $manifestReference=Write-CapImmutableJson -Root $root -Path $manifestPath -Value $manifest + $zmxPath=Join-Path $root 'runtime\zmx.exe' + $zmxRoot=Assert-CapOwnedPath $Roots.Zmx + $working=Assert-CapOwnedPath (Join-Path $Roots.Peer 'action-seed-cwd') + $null=New-Item -ItemType Directory -Path $working -Force + $run=Start-CapJobLauncher -Root $root -OutputDirectory $working ` + -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( + '-NoProfile','-NonInteractive','-File',$actionSeedHelperPath, + '-ZmxPath',$zmxPath,'-SessionManifestPath',$manifestPath + ) -WorkingDirectory $working -Role 'matched-action-seed-launcher' -Environment @{ + ZMX_DIR=$zmxRoot + TEMP=$Roots.Temp + TMP=$Roots.Temp + USERPROFILE=$Roots.Profile + } + $run | Add-Member -NotePropertyMembers @{ + Phase=$Phase + SessionNames=$sessions + SessionManifestSha256=$manifestReference.sha256 + ActionSeedGroup=$true + Descendants=@() + SeedReceipt=$null + } + $runs.Add($run) + $actionSeedRuns.Add($run) + Write-CapProcessJournal -Identity $run.Identity -Phase $Phase + $target=Release-CapJobLauncher -Run $run -JournalState $journalState + $deadline=[DateTime]::UtcNow.AddSeconds(30) + if ($deadline -gt $leaseDeadlineUtc) {$deadline=$leaseDeadlineUtc} + $exit=Wait-CapJobTarget -Run $run -DeadlineUtc $deadline + if ($exit.exitCode -ne 0) {throw "Qualified action seed helper failed for $Phase."} + $cmdPath=Join-Path ([Environment]::SystemDirectory) 'cmd.exe' + $zmxPath=ConvertTo-CapWorkerImagePath $zmxPath + $readyDeadline=[DateTime]::UtcNow.AddSeconds(15) + if ($readyDeadline -gt $leaseDeadlineUtc.AddSeconds(-90)) {$readyDeadline=$leaseDeadlineUtc.AddSeconds(-90)} + $tree=@();$daemons=@();$cmdPeers=@() + do { + $tree=@(Get-CapJobMembers -Run $run) + foreach ($identity in $tree) { + if ($identity.ExpectedImagePath -ieq $zmxPath) {$identity.Role='zmx-daemon'} + elseif ($identity.ExpectedImagePath -ieq $cmdPath) {$identity.Role='offline-cmd-peer'} + } + $daemons=@($tree | Where-Object Role -eq 'zmx-daemon') + $cmdPeers=@($tree | Where-Object Role -eq 'offline-cmd-peer') + if ($daemons.Count -ge 1 -and $cmdPeers.Count -eq $sessions.Count) {break} + Assert-CapWorkerDeadline "qualified action seed readiness $Phase" + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $readyDeadline) + if ($daemons.Count -lt 1 -or $cmdPeers.Count -ne $sessions.Count) { + throw "Qualified action seeds differ: sessions=$($sessions.Count) daemon=$($daemons.Count) cmd=$($cmdPeers.Count)." + } + foreach ($identity in $tree) { + Write-CapProcessJournal -Identity $identity -Phase $Phase + if (-not @($descendantIdentities | Where-Object { + $_.Pid -eq $identity.Pid -and $_.CreationFileTimeUtc -eq $identity.CreationFileTimeUtc + }).Count) {$descendantIdentities.Add($identity)} + } + $run.Descendants=@($tree) + $receipt=[pscustomobject]@{ + phase=$Phase + sessions=$sessions + sessionCount=[long]$sessions.Count + launcherPid=[long]$run.Identity.Pid + launcherCreationFileTimeUtc=[long]$run.Identity.CreationFileTimeUtc + targetPid=[long]$target.pid + targetCreationFileTimeUtc=[long]$target.creationFileTimeUtc + jobId=$run.JobId + daemonCount=[long]$daemons.Count + cmdCount=[long]$cmdPeers.Count + sessionManifestSha256=$manifestReference.sha256 + cleanupStatus='PENDING' + outputProof=$null + } + $run.SeedReceipt=$receipt + $zmxSeedReceipts.Add($receipt) + return $run + } + + function Close-CapMatchedActionSeeds { + param([Parameter(Mandatory)]$SeedRun) + if ($SeedRun.CleanupComplete) {return $SeedRun.OutputProof} + $proof=Close-CapJobLauncher -Run $SeedRun + $SeedRun.SeedReceipt.cleanupStatus='CLOSED' + $SeedRun.SeedReceipt.outputProof=$proof + $cleanupReceipts.Add($proof) + return $proof + } $seedReceiptRoot = if ($isS) { $Roots.Root } else { Join-Path $runDirectory "phases\$Phase\normal" } $seedReceiptPath = Assert-CapOwnedPath (Join-Path $seedReceiptRoot 'zmx-seed.json') $null = New-Item -ItemType Directory -Path (Split-Path -Parent $seedReceiptPath) -Force @@ -2011,7 +2143,9 @@ function Get-CapMatchedProviderTopology { } }) return [pscustomobject]@{ - seedSession=$SeedRun.SessionName + seedSessions=$(if ($SeedRun.PSObject.Properties['SessionNames']) { + @($SeedRun.SessionNames) + } else {@($SeedRun.SessionName)}) seedJobId=$SeedRun.JobId mountedProviders=$providers providerCount=[long]$providers.Count @@ -2019,7 +2153,8 @@ function Get-CapMatchedProviderTopology { } function Invoke-CapMatchedTerminalInputChallenge { - param($AppRun,[int]$ExpectedPaneCount,[int]$FocusedIndex=0) + param($AppRun,[int]$ExpectedPaneCount,[int]$FocusedIndex=0, + [Parameter(Mandatory)][string]$FocusLabel) $panes=@(Get-CapMatchedPaneElements -AppRun $AppRun -ExpectedCount $ExpectedPaneCount) if ($FocusedIndex -lt 0 -or $FocusedIndex -ge $panes.Count) { throw 'Terminal input challenge focus index is outside the owned pane topology.' @@ -2034,37 +2169,42 @@ function Invoke-CapMatchedTerminalInputChallenge { $pattern=[Windows.Automation.TextPattern]$pane.GetCurrentPattern( [Windows.Automation.TextPattern]::Pattern) $before=[string]$pattern.DocumentRange.GetText(-1) - $marker='GC_SUPPLEMENT_INPUT_OK' - $command="echo $marker" + $nonce=[guid]::NewGuid().ToString('N').Substring(0,12) + $prefix="GC_${nonce}_" + $expectedArithmetic=323L + $command="powershell.exe -NoProfile -NonInteractive -Command `"`$v=17*19;Write-Output ('$prefix'+`$PID+'_'+`$v)`"" $characters=[GraphCodeUiaGateState]::SendUnicodeText($command) $enter=[GraphCodeUiaGateState]::SendKeyInput(0x0D,1) if ($characters -ne $command.Length -or $enter -ne 1) { throw 'Terminal input challenge native SendInput count differs.' } - $after=$null;$occurrences=0 + $after=$null;$match=$null $deadline=[DateTime]::UtcNow.AddSeconds(5) if ($deadline -gt $leaseDeadlineUtc.AddSeconds(-90)) {$deadline=$leaseDeadlineUtc.AddSeconds(-90)} do { $after=[string]$pattern.DocumentRange.GetText(-1) - $occurrences=[regex]::Matches($after,[regex]::Escape($marker)).Count - if ($occurrences -ge 2) {break} + $match=[regex]::Match($after,[regex]::Escape($prefix)+'(?[0-9]+)_(?[0-9]+)') + if ($match.Success) {break} Assert-CapWorkerDeadline 'terminal native input output';Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) - if ($occurrences -lt 2) { - throw 'Terminal input challenge did not observe both the shell command and shell-produced output.' + if (-not $match.Success -or [long]$match.Groups['pid'].Value -le 0 -or + [long]$match.Groups['value'].Value -ne $expectedArithmetic) { + throw 'Terminal input challenge did not observe shell-computed nonce PID and arithmetic output.' } return [pscustomobject]@{ - marker=$marker + focus=$FocusLabel + nonce=$nonce + shellPid=[long]$match.Groups['pid'].Value + arithmetic=[long]$match.Groups['value'].Value paneAutomationId=$pane.Current.AutomationId paneNativeWindowHandle=[long]$pane.Current.NativeWindowHandle focused=$true foreground=$true unicodeCharacters=[long]$characters enterEvents=1L - markerOccurrences=[long]$occurrences beforeTextLength=[long]$before.Length afterTextLength=[long]$after.Length - evidence='NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_OUTPUT' + evidence='NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_COMPUTED_NONCE_PID_ARITHMETIC' } } @@ -2072,6 +2212,7 @@ function Invoke-CapMatchedTerminalSceneSupplement { param($AppRun,[string]$Scene,$SeedRun,[int]$TotalTabs,[int]$SelectedTab,[string]$AgentTitle) $receipt=[ordered]@{ scene=$Scene + actionIds=@() imageBindingSha256=$null actions=[Collections.Generic.List[object]]::new() topologyBefore=$null @@ -2092,72 +2233,112 @@ function Invoke-CapMatchedTerminalSceneSupplement { } $receipt.topologyBefore=& $observeTabs $TotalTabs $SelectedTab switch ($Scene) { - 's-tabs-backend-idle' { - $receipt.nativeInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun -ExpectedPaneCount 1 - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-new-tab' - $TotalTabs=2;$SelectedTab=1 - $receipt.actions.Add([pscustomobject]@{action='New Tab';result='MOUNTED_SELECTED_SHELL'}) - } - 's-tabs-goal-needs-you' { - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-split-down' - $receipt.panes=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 2 -FocusedIndex 1) - $receipt.actions.Add([pscustomobject]@{action='Split Down';paneCount=2;focusedPane=1}) - } 's-tabs-two-agent-selected' { + $receipt.actionIds=@('W4') + $agentInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun ` + -ExpectedPaneCount 1 -FocusedIndex 0 -FocusLabel 'agent' $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' - $alternate=& $observeTabs 2 1 - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '0' -Name $AgentTitle - $restored=& $observeTabs 2 0 - $receipt.actions.Add([pscustomobject]@{action='Alternate tab selection';alternate=$alternate;restored=$restored}) - } - 's-tabs-two-shell-selected' { - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '0' -Name 'Shell' - $alternate=& $observeTabs 2 0 - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' - $restored=& $observeTabs 2 1 - $receipt.actions.Add([pscustomobject]@{action='Alternate tab selection';alternate=$alternate;restored=$restored}) + $SelectedTab=1 + $shellTopology=& $observeTabs 2 1 + $shellInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun ` + -ExpectedPaneCount 1 -FocusedIndex 0 -FocusLabel 'alternate-shell' + $receipt.nativeInput=[pscustomobject]@{agent=$agentInput;shell=$shellInput} + $receipt.actions.Add([pscustomobject]@{ + action='Explicit agent focus then alternate Shell focus' + shellTopology=$shellTopology + }) } 's-tabs-split-right-focused' { + $receipt.actionIds=@('W2','W4') $before=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 2 -FocusedIndex 1) + $rightInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun ` + -ExpectedPaneCount 2 -FocusedIndex 1 -FocusLabel 'right-pane' $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-split-down' $after=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 3 -FocusedIndex 2) + $downInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun ` + -ExpectedPaneCount 3 -FocusedIndex 2 -FocusLabel 'down-pane' $receipt.panes=$after - $receipt.actions.Add([pscustomobject]@{action='Split Right then Split Down';before=$before;after=$after}) + $receipt.nativeInput=[pscustomobject]@{right=$rightInput;down=$downInput} + $receipt.actions.Add([pscustomobject]@{action='Split Down';before=$before;after=$after + initialTopology='PERSISTED_QUALIFIED_SPLIT_RIGHT'}) } 's-tabs-many-first' { + $receipt.actionIds=@('W5') $roundTrip=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` -SelectedTab 0 -TargetIndex 11 -AgentTitle $AgentTitle ` -SourceCommit $job.binaries.after.source ` -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` -DpiBase ([long]$job.captureContract.dpiBase) - $receipt.actions.Add([pscustomobject]@{action='Many-first overflow selection';receipt=$roundTrip}) + $receipt.actions.Add([pscustomobject]@{action='Existing offscreen last selection';receipt=$roundTrip}) $SelectedTab=11 } - 's-tabs-overflow-last' { - $toFirst=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` - -SelectedTab 11 -TargetIndex 0 -AgentTitle $AgentTitle ` - -SourceCommit $job.binaries.after.source ` - -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` - -DpiBase ([long]$job.captureContract.dpiBase) - $back=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` - -SelectedTab 0 -TargetIndex 11 -AgentTitle $AgentTitle ` - -SourceCommit $job.binaries.after.source ` - -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` - -DpiBase ([long]$job.captureContract.dpiBase) - $receipt.actions.Add([pscustomobject]@{action='Overflow-last round trip';toFirst=$toFirst;back=$back}) - } 's-tabs-hover-close' { - $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-close-tab' -SubjectId '1' - $TotalTabs=1;$SelectedTab=0 - $receipt.actions.Add([pscustomobject]@{action='Per-tab hover close';closedIndex=1;remaining=1}) + $receipt.actionIds=@('W6') + $alternate=@($receipt.topologyBefore.visible | Where-Object index -EQ 1) + if ($alternate.Count -ne 1 -or $alternate[0].closeBounds.Count -ne 4) { + throw 'Real pointer hover lacks the exact alternate close target.' + } + $bounds=@($alternate[0].closeBounds) + $x=[int](($bounds[0]+$bounds[2])/2) + $y=[int](($bounds[1]+$bounds[3])/2) + $pointer=@([GraphCodeUiaGateState]::MovePointerScreen($x,$y)) + Start-Sleep -Milliseconds 250 + if ($pointer.Count -ne 4 -or $pointer[0] -ne $x -or $pointer[1] -ne $y -or + $pointer[3] -ne $AppRun.Run.Identity.Pid) { + throw 'Native pointer did not land on the owned alternate close target.' + } + $afterHover=& $observeTabs 2 0 + $hovered=@($afterHover.visible | Where-Object index -EQ 1) + if ($hovered.Count -ne 1 -or $hovered[0].closeBounds.Count -ne 4) { + throw 'Native pointer hover did not retain the owned close affordance.' + } + Save-CapSceneImage -AppRun $AppRun -SceneName 's-tabs-hover-close-action-result' ` + -ZoomEvidence $null -Phase 'action-result' + $receipt.actions.Add([pscustomobject]@{ + action='Real native pointer hover' + targetBounds=$bounds + requestedPoint=@($x,$y) + actualPoint=@($pointer[0],$pointer[1]) + targetWindowHandle=[long]$pointer[2] + targetProcessId=[long]$pointer[3] + resultingCloseBounds=@($hovered[0].closeBounds) + resultImage='action-result-s-tabs-hover-close-action-result.png' + }) } 's-tabs-last-guard' { + $receipt.actionIds=@('W3') $before=& $observeTabs 1 0 $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-close-tab' -SubjectId '0' + Start-Sleep -Milliseconds 250 + $form=[GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) + $result='REFUSED_TAB_RETAINED' + $confirmation=$null + if ($form -ne [IntPtr]::Zero) { + $formElement=[Windows.Automation.AutomationElement]::FromHandle($form) + $cancelCondition=[Windows.Automation.AndCondition]::new( + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::Button), + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::NameProperty,'Cancel')) + $cancel=$formElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$cancelCondition) + if ($null -eq $cancel -or -not $cancel.Current.IsEnabled) { + throw 'Final-tab destructive confirmation omitted its enabled Cancel action.' + } + $confirmation=[pscustomobject]@{ + windowHandle=[long]$form.ToInt64() + name=$formElement.Current.Name + cancelName=$cancel.Current.Name + } + $cancel.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() + $result='CONFIRMATION_CANCELLED_TAB_RETAINED' + Start-Sleep -Milliseconds 100 + } $after=& $observeTabs 1 0 $receipt.actualFinalTabCloseResult=[pscustomobject]@{ attempted=$true - result='REFUSED_TAB_RETAINED' + result=$result + confirmation=$confirmation before=$before after=$after } @@ -2547,6 +2728,14 @@ function Invoke-CapMatchedScenes { Assert-CapWorkerDeadline "matched scene $scene" if ([DateTime]::UtcNow -ge $leaseDeadlineUtc.AddSeconds(-90)) {throw 'Matched scenes reached the required cleanup reserve.'} $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory "phases\$Phase\$scene") + $qualifiedTopology=$null + $qualifiedInstall=$null + if ($ActionSupplement -and $plan.group -ceq 'FinalBoundedActions') { + $qualified=@($plan.qualifiedAuthority.topologies | Where-Object scene -CEQ $scene) + if ($qualified.Count -ne 1) {throw "Final bounded action topology missing or duplicated: $scene"} + $qualifiedTopology=$qualified[0] + $qualifiedInstall=Install-CapQualifiedActionTopology -Roots $roots -Topology $qualifiedTopology + } $pipe="gc-s-$([guid]::NewGuid().ToString('N'))" $peer=Start-CapOfflinePeer -Roots $roots -PipeName $pipe -Phase "$Phase-$scene" $app=$null;$seed=$null;$supplementReceipt=$null @@ -2562,7 +2751,11 @@ function Invoke-CapMatchedScenes { } if ($workspace) { $node=$fixture.graphs[0].nodes[$nodeIndex] - $seed=Start-CapOfflineSeed -Roots $roots -Phase "$Phase-$scene" -SessionName "graphcode-$($node.id)" + $seed=if ($qualifiedTopology) { + Start-CapMatchedActionSeeds -Roots $roots -Phase "$Phase-$scene" -Topology $qualifiedTopology + } else { + Start-CapOfflineSeed -Roots $roots -Phase "$Phase-$scene" -SessionName "graphcode-$($node.id)" + } } $app=Start-CapGraphCodeApp -Roots $roots -Phase $Phase -PipeName $pipe -SessionPrefix "s-$Phase" $app | Add-Member -NotePropertyMembers @{SActions=[Collections.Generic.List[object]]::new();SObservation=$null} @@ -2742,30 +2935,36 @@ function Invoke-CapMatchedScenes { } } else { $null=Invoke-CapSceneAction -AppRun $app -Action 'open-loop' -Name $node.title - $total=1;$selected=0;$title=switch ($nodeIndex) {0 {'Goal'};6 {'Loop'};7 {'Agent'};default {'Copilot CLI'}} + $title=switch ($nodeIndex) {0 {'Goal'};6 {'Loop'};7 {'Agent'};default {'Copilot CLI'}} $tabSelection=$null - $newTabs=if ($scene -cin @('s-tabs-many-first','s-tabs-overflow-last')) {11} - elseif ($scene -cin @('s-tabs-two-shell-selected','s-tabs-split-right-focused')) {2} - elseif ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close')) {1} else {0} - for ($i=0;$i -lt $newTabs;$i++) {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-new-tab';$total++;$selected=$total-1} - if ($scene -cin @('s-tabs-two-shell-selected','s-tabs-split-right-focused')) { - $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-close-tab' -SubjectId '0' - $total=2;$selected=0;$title='Shell' - $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' - $selected=1 - } - if ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close','s-tabs-many-first')) { - if ($total -eq 12) { - $tabSelection=Invoke-CapMatchedTabSelection -AppRun $app -TotalTabs $total ` - -SelectedTab $selected -TargetIndex 0 -AgentTitle $title ` - -SourceCommit $job.binaries.$Phase.source ` - -ExpectedDpi $tabExpectedDpi -DpiBase $tabDpiBase - } else { - $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '0' -Name $title + if ($qualifiedTopology) { + $total=[int]$qualifiedTopology.expectedTabs + $selected=[int]$qualifiedTopology.expectedSelected + } else { + $total=1;$selected=0 + $newTabs=if ($scene -cin @('s-tabs-many-first','s-tabs-overflow-last')) {11} + elseif ($scene -cin @('s-tabs-two-shell-selected','s-tabs-split-right-focused')) {2} + elseif ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close')) {1} else {0} + for ($i=0;$i -lt $newTabs;$i++) {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-new-tab';$total++;$selected=$total-1} + if ($scene -cin @('s-tabs-two-shell-selected','s-tabs-split-right-focused')) { + $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-close-tab' -SubjectId '0' + $total=2;$selected=0;$title='Shell' + $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' + $selected=1 + } + if ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close','s-tabs-many-first')) { + if ($total -eq 12) { + $tabSelection=Invoke-CapMatchedTabSelection -AppRun $app -TotalTabs $total ` + -SelectedTab $selected -TargetIndex 0 -AgentTitle $title ` + -SourceCommit $job.binaries.$Phase.source ` + -ExpectedDpi $tabExpectedDpi -DpiBase $tabDpiBase + } else { + $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '0' -Name $title + } + $selected=0 } - $selected=0 + if ($scene -ceq 's-tabs-split-right-focused') {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-split-right'} } - if ($scene -ceq 's-tabs-split-right-focused') {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-split-right'} $rows=@(Read-CapMatchedRows -AppRun $app) $tabs=Get-CapMatchedTabObservation -Rows $rows -ExpectedProcessId $app.Run.Identity.Pid ` -TotalTabs $total -SelectedTab $selected -AgentTitle $title ` @@ -2774,7 +2973,7 @@ function Invoke-CapMatchedScenes { $chromeControls=if ($focusedCapture) { @(Read-CapMatchedChromeControls -AppRun $app -ExpectedDpi $tabExpectedDpi -DpiBase $tabDpiBase) } else {@()} - if ($scene -ceq 's-tabs-hover-close') { + if ($scene -ceq 's-tabs-hover-close' -and -not $qualifiedTopology) { $tab=@($tabs.visible | Where-Object index -EQ 1)[0] Set-CapMatchedTabHover -AppRun $app -Bounds $tab.bounds } @@ -2785,7 +2984,10 @@ function Invoke-CapMatchedScenes { tabs=$tabs;panes=$panes;nodeId=$node.id;nodeState=$node.state chromeControls=$chromeControls tabSelection=$tabSelection - hover=if ($scene -ceq 's-tabs-hover-close') {'POSTED_WM_MOUSEMOVE_NOT_PHYSICAL_POINTER'} else {'NOT_REQUESTED'}} + qualifiedTopology=$qualifiedInstall + hover=if ($scene -ceq 's-tabs-hover-close' -and -not $qualifiedTopology) { + 'POSTED_WM_MOUSEMOVE_NOT_PHYSICAL_POINTER' + } else {'NOT_REQUESTED'}} if ($ActionSupplement) { if (-not $ImageBindings.ContainsKey($scene) -or [string]$ImageBindings[$scene] -cnotmatch '^[A-Fa-f0-9]{64}$') { @@ -2814,7 +3016,13 @@ function Invoke-CapMatchedScenes { } } finally { if ($app) {Close-CapGraphCodeApp -AppRun $app} - if ($seed) {Close-CapOfflineSeed -SeedRun $seed} + if ($seed) { + if ($seed.PSObject.Properties['ActionSeedGroup']) { + Close-CapMatchedActionSeeds -SeedRun $seed + } else { + Close-CapOfflineSeed -SeedRun $seed + } + } Close-CapPeer -PeerRun $peer -Label $pipe } } diff --git a/Tools/windows/Prepare-MatchedActionSupplement.ps1 b/Tools/windows/Prepare-MatchedActionSupplement.ps1 index 03706630..39eec803 100644 --- a/Tools/windows/Prepare-MatchedActionSupplement.ps1 +++ b/Tools/windows/Prepare-MatchedActionSupplement.ps1 @@ -3,9 +3,14 @@ param( [Parameter(Mandatory)][string]$OwnedRoot, [Parameter(Mandatory)][string]$FocusedPlanPath, [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$FocusedPlanSha256, - [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions')][string]$Group, + [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions','FinalBoundedActions')][string]$Group, [Parameter(Mandatory)][string]$ExistingSceneManifestPath, [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ExistingSceneManifestSha256, + [string]$QualifiedCaptureRoot = '', + [string]$QualifiedRunId = '', + [ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$QualifiedManifestSha256 = ('0' * 64), + [ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$QualifiedReleaseSha256 = ('0' * 64), + [ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$QualifiedTerminalJournalSha256 = ('0' * 64), [ValidateRange(3,30)][long]$AuthorizationMinutes = 20 ) $ErrorActionPreference='Stop' @@ -37,6 +42,15 @@ if ($head -cne $focused.sourceHead -or $tree -cne $focused.sourceTree -or } $expectedScenes=if ($Group -ceq 'WireCompletion') { @('s-goal-unwired-unselected') +} elseif ($Group -ceq 'FinalBoundedActions') { + @( + 's-goal-unwired-unselected', + 's-tabs-two-agent-selected', + 's-tabs-split-right-focused', + 's-tabs-last-guard', + 's-tabs-many-first', + 's-tabs-hover-close' + ) } else { @( 's-tabs-agent-unknown','s-tabs-backend-idle','s-tabs-goal-needs-you','s-tabs-loop-running', @@ -44,6 +58,99 @@ $expectedScenes=if ($Group -ceq 'WireCompletion') { 's-tabs-many-first','s-tabs-overflow-last','s-tabs-hover-close','s-tabs-last-guard' ) } +$qualifiedTopologies=@() +if ($Group -ceq 'FinalBoundedActions') { + $qualifiedRoot=[IO.Path]::GetFullPath($QualifiedCaptureRoot).TrimEnd('\') + if ($QualifiedRunId -cnotmatch '^[0-9a-f]{32}$') { + throw 'Final bounded actions require the exact qualified capture run ID.' + } + $qualifiedManifest=Join-Path $qualifiedRoot 'outputs\manifest.json' + $qualifiedRelease=Join-Path $qualifiedRoot 'outputs\focused-release.json' + $qualifiedJournal=Join-Path $qualifiedRoot "runs\$QualifiedRunId\process-journal\000370.json" + foreach ($binding in @( + @($qualifiedManifest,$QualifiedManifestSha256), + @($qualifiedRelease,$QualifiedReleaseSha256), + @($qualifiedJournal,$QualifiedTerminalJournalSha256) + )) { + if (-not (Test-Path -LiteralPath $binding[0] -PathType Leaf) -or + (Get-FileHash -LiteralPath $binding[0] -Algorithm SHA256).Hash -cne + ([string]$binding[1]).ToUpperInvariant()) { + throw "Qualified bounded-action authority differs: $($binding[0])" + } + } + $qualifiedInputs=Join-Path $root 'inputs\qualified-actions' + $null=New-Item -ItemType Directory -Path $qualifiedInputs -Force + $layoutSpecs=@( + [pscustomobject]@{scene='s-tabs-two-agent-selected';loop='20000000-0000-4000-8000-000000000001' + layoutSha256='5EA9171DFE71E8C9C140C7A05B1FEEE578174558A1A52013EC1ED9317DC71790' + expectedTabs=2L;expectedSelected=0L}, + [pscustomobject]@{scene='s-tabs-split-right-focused';loop='11111111-1111-4111-8111-000000000001' + layoutSha256='42D99B227C024BB36EF260E2BACC7B27AFC96D82DDC9B535246593174CCD32D5' + expectedTabs=3L;expectedSelected=2L}, + [pscustomobject]@{scene='s-tabs-last-guard';loop='11111111-1111-4111-8111-000000000001' + layoutSha256='289522E51B2F7B1F2695623115CAAFF80026C7B72A545B3E7EC5981BCD8AA70F' + expectedTabs=1L;expectedSelected=0L}, + [pscustomobject]@{scene='s-tabs-many-first';loop='11111111-1111-4111-8111-000000000001' + layoutSha256='386A5DB78AAEAA63DE0AA4E83A8150B4CDB022F3E73E18F119DCCAECEA0BF2EE' + expectedTabs=12L;expectedSelected=0L}, + [pscustomobject]@{scene='s-tabs-hover-close';loop='11111111-1111-4111-8111-000000000001' + layoutSha256='10EC1E1E144AEFC36E243DAF0299A40350EB1EAD64971CEB3A206767C63F93C5' + expectedTabs=2L;expectedSelected=0L} + ) + $sourceProject=Join-Path $qualifiedRoot 'fixtures\projects\Matched' + $targetProject=Join-Path $root 'fixtures\projects\Matched' + foreach ($spec in $layoutSpecs) { + $sourceDirectory=Join-Path $qualifiedRoot "runs\$QualifiedRunId\phases\after\$($spec.scene)" + $sourceLayout=Join-Path $sourceDirectory "$($spec.loop).json" + if ((Get-FileHash -LiteralPath $sourceLayout -Algorithm SHA256).Hash -cne $spec.layoutSha256) { + throw "Qualified topology layout differs: $($spec.scene)" + } + $layout=Get-Content -LiteralPath $sourceLayout -Raw | ConvertFrom-Json -DateKind String + if ($layout.schemaVersion -ne 2 -or $layout.project -cne $sourceProject -or + $layout.loop -cne $spec.loop -or $layout.tabs.Count -lt 1) { + throw "Qualified topology schema or source binding differs: $($spec.scene)" + } + $destination=Join-Path $qualifiedInputs $spec.scene + $sessionDestination=Join-Path $destination 'shell-sessions' + $null=New-Item -ItemType Directory -Path $sessionDestination -Force + Copy-Item -LiteralPath $sourceLayout -Destination (Join-Path $destination 'layout-source.json') + $layout.project=$targetProject + $rewrittenLayout=Join-Path $destination 'layout.json' + [IO.File]::WriteAllText($rewrittenLayout,($layout|ConvertTo-Json -Depth 20 -Compress), + [Text.UTF8Encoding]::new($false)) + $sessions=@() + foreach ($pane in @($layout.tabs.panes)) { + $sessionName="graphcode-$($pane.id)" + $sourceSession=Join-Path $sourceDirectory "shell-sessions\$sessionName" + if ($pane.agent) { + $sessions+= [pscustomobject]@{name=$sessionName;paneId=$pane.id;agent=$true + sourceSha256=$null;preparedSha256=$null} + continue + } + if (-not (Test-Path -LiteralPath $sourceSession -PathType Leaf) -or + [IO.File]::ReadAllText($sourceSession) -cne $spec.loop) { + throw "Qualified shell-session ownership differs: $sessionName" + } + $sessionHash=(Get-FileHash -LiteralPath $sourceSession -Algorithm SHA256).Hash + $preparedSession=Join-Path $sessionDestination $sessionName + Copy-Item -LiteralPath $sourceSession -Destination $preparedSession + $sessions+= [pscustomobject]@{name=$sessionName;paneId=$pane.id;agent=$false + sourceSha256=$sessionHash + preparedSha256=(Get-FileHash -LiteralPath $preparedSession -Algorithm SHA256).Hash} + } + $qualifiedTopologies+= [pscustomobject]@{ + scene=$spec.scene + loop=$spec.loop + sourceLayoutSha256=$spec.layoutSha256 + preparedLayoutPath=$rewrittenLayout + preparedLayoutSha256=(Get-FileHash -LiteralPath $rewrittenLayout -Algorithm SHA256).Hash + shellSessionsPath=$sessionDestination + sessions=$sessions + expectedTabs=$spec.expectedTabs + expectedSelected=$spec.expectedSelected + } + } +} $bindings=[ordered]@{} foreach ($scene in $expectedScenes) { $rows=@($scenes.scenes | Where-Object canonicalScene -CEQ $scene) @@ -53,6 +160,7 @@ foreach ($scene in $expectedScenes) { $bindings[$scene]=[string]$rows[0].sha256 } $stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' +$actionRunnerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-MatchedActionSupplement.ps1' $plan=[ordered]@{ schemaVersion=1L status='PREPARED_NO_AUTHORITY' @@ -70,6 +178,8 @@ $plan=[ordered]@{ moduleSha256=$focused.moduleSha256 workerSha256=$focused.workerSha256 supervisorSha256=$focused.supervisorSha256 + actionRunnerSha256=(Get-FileHash $actionRunnerPath -Algorithm SHA256).Hash + actionSeedHelperSha256=(Get-FileHash (Join-Path $repositoryRoot 'Tools\windows\Start-MatchedActionSessions.ps1') -Algorithm SHA256).Hash preflightReceiptPath=$focused.preflightReceiptPath preflightReceiptSha256=$focused.preflightReceiptSha256 fixture=$focused.fixture @@ -81,8 +191,26 @@ $plan=[ordered]@{ expectedScenes=$expectedScenes imageBindings=$bindings expectedReceiptCount=[long]$expectedScenes.Count + qualifiedAuthority=$(if ($Group -ceq 'FinalBoundedActions') { + [ordered]@{ + runId=$QualifiedRunId + manifestSha256=$QualifiedManifestSha256.ToUpperInvariant() + releaseSha256=$QualifiedReleaseSha256.ToUpperInvariant() + terminalJournalSha256=$QualifiedTerminalJournalSha256.ToUpperInvariant() + topologies=$qualifiedTopologies + } + } else {$null}) expectedActions=$(if ($Group -ceq 'WireCompletion') { @('Wire it up','target selection','Create edge','graph readback') + } elseif ($Group -ceq 'FinalBoundedActions') { + @( + 'W1 complete Wire once', + 'W2 Split Down from qualified persisted Split Right topology', + 'W3 final unsplit-tab close with destructive confirmation cancellation', + 'W4 native shell-computed nonce PID arithmetic across agent Shell right and Down focus', + 'W5 existing offscreen-last selection from qualified many-first topology', + 'W6 real native pointer hover over alternate close target' + ) } else { @('New Tab','Split Right','Split Down','alternate selection/focus','per-tab close', 'final-tab close','many-first','overflow-last','native input/output') @@ -99,6 +227,7 @@ $plan=[ordered]@{ survivorsExpected=0L } pngArtifacts=0L + maximumResultPngs=$(if ($Group -ceq 'FinalBoundedActions') {1L} else {0L}) processLaunches=0L } $planPath=Join-Path $root 'action-plan.json' diff --git a/Tools/windows/Start-MatchedActionSessions.ps1 b/Tools/windows/Start-MatchedActionSessions.ps1 new file mode 100644 index 00000000..f9d5767a --- /dev/null +++ b/Tools/windows/Start-MatchedActionSessions.ps1 @@ -0,0 +1,20 @@ +param( + [Parameter(Mandatory)][string]$ZmxPath, + [Parameter(Mandatory)][string]$SessionManifestPath +) +$ErrorActionPreference='Stop' +$manifest=Get-Content -LiteralPath $SessionManifestPath -Raw | ConvertFrom-Json -DateKind String +if ($manifest.schemaVersion -ne 1 -or $manifest.sessions.Count -lt 1 -or + @($manifest.sessions | Sort-Object -Unique).Count -ne $manifest.sessions.Count) { + throw 'Matched action session manifest is invalid.' +} +$cmd=Join-Path ([Environment]::SystemDirectory) 'cmd.exe' +foreach ($session in @($manifest.sessions)) { + if ([string]$session -cnotmatch '^graphcode-(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|s-after-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$') { + throw 'Matched action session identity is outside the frozen fixture namespace.' + } + & $ZmxPath run $session -d $cmd /Q /K "echo Offline action fixture $session" + if ($LASTEXITCODE -ne 0) { + throw "Pinned zmx failed to seed matched action session $session." + } +} diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index eba166ca..c97a625e 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -715,6 +715,9 @@ function Assert-CapRejects { $expected = @( 'GraphCodeUiaGateState|PostMessage|user32.dll|PostMessageA', 'GraphCodeUiaGateState|ScreenToClient|user32.dll|ScreenToClient', + 'GraphCodeUiaGateState|SetCursorPos|user32.dll|SetCursorPos', + 'GraphCodeUiaGateState|GetCursorPos|user32.dll|GetCursorPos', + 'GraphCodeUiaGateState|WindowFromPoint|user32.dll|WindowFromPoint', 'GraphCodeUiaGateState|SendKeyInputs|user32.dll|SendInput', 'GraphCodeUiaGateState|EnumWindows|user32.dll|EnumWindows', 'GraphCodeUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', @@ -754,8 +757,8 @@ function Assert-CapRejects { 'CapProcessJob|SetHandleInformation|kernel32.dll|SetHandleInformation' ) $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) - Assert-CapTest ($actual.Count -eq 39 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static39 export contract differs.' - return @{ subcases = 36; evidence = 'API export resolution only, no native window or GUI action.' } + Assert-CapTest ($actual.Count -eq 42 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static42 export contract differs.' + return @{ subcases = 39; evidence = 'API export resolution only, no native window or GUI action.' } } Invoke-CapTest 'legacy mutable seed journal proof is refused and exact numbered terminal proof is accepted' { $f = New-JournalTerminalFixture 'seed-reference' @@ -3001,6 +3004,7 @@ function Invoke-CapMatchedContracts { $authorizeSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Authorize-FocusedUiCapture.ps1')) $actionPrepareSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Prepare-MatchedActionSupplement.ps1')) $actionInvokeSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Invoke-MatchedActionSupplement.ps1')) + $actionSeedSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Start-MatchedActionSessions.ps1')) $splitDown=Get-CapActionSpec -Action 'workspace-split-down' Assert-CapTest ($supervisorSource.Contains("'FOCUSED_LIVE_CAPTURE'") -and $supervisorSource.Contains('FocusedAuthorizationPath') -and @@ -3013,14 +3017,22 @@ function Invoke-CapMatchedContracts { $workerSource.Contains('[GraphCodeUiaGateState]::PostKeyboard') -and $workerSource.Contains('function Invoke-CapMatchedTerminalSceneSupplement') -and $workerSource.Contains('function Invoke-CapWireCompletionSupplement') -and - $workerSource.Contains('NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_OUTPUT') -and + $workerSource.Contains('NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_COMPUTED_NONCE_PID_ARITHMETIC') -and $actionPrepareSource.Contains("status='PREPARED_NO_AUTHORITY'") -and + $actionPrepareSource.Contains('actionRunnerSha256=') -and + $actionPrepareSource.Contains("'FinalBoundedActions'") -and + $actionPrepareSource.Contains('qualifiedAuthority=') -and $actionPrepareSource.Contains("pngArtifacts=0L") -and $actionInvokeSource.Contains('-DefinitionsOnly') -and $actionInvokeSource.Contains('$actionSupplementMode=$true') -and + $actionInvokeSource.Contains('$journalState=New-CapJournalState') -and + $actionInvokeSource.Contains("Write-CapWorkerJournalSnapshot -Kind 'Bootstrap'") -and + $actionInvokeSource.Contains("Write-CapWorkerJournalSnapshot -Kind 'Terminal'") -and + $workerSource.Contains("'s-tabs-hover-close-action-result'") -and + $actionSeedSource.Contains('Pinned zmx failed to seed matched action session') -and $actionInvokeSource.Contains("'RELEASED_ZERO_SURVIVORS'") -and - $workerSource.Contains('if (-not $actionSupplementMode)') -and - $workerSource.Contains('if ($actionSupplementMode) {return}') -and + -not $workerSource.Contains('if (-not $actionSupplementMode)') -and + -not $workerSource.Contains('if ($actionSupplementMode) {return}') -and $splitDown.Name -ceq 'Split Down' -and $splitDown.AutomationId -ceq (Get-CapMatchedAutomationId 'workspace-split-down:control') -and $workerSource.Contains("-Action 'attention-action'") -and @@ -4622,7 +4634,8 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { 'Final journal still treats contained-job members as dependent on advisory parent metadata.' Assert-CapTest ($workerSource.Contains('checkpointCount') -and $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' - Assert-CapTest ($workerSource.Contains('$progressManifest=if ($transitionRehearsal)') -and + Assert-CapTest ($workerSource.Contains('$progressManifest=if ($actionSupplementMode)') -and + $workerSource.Contains('} elseif ($transitionRehearsal) {') -and $workerSource.Contains('phaseTransitionCount=[long]$phaseTransitions.Count') -and $workerSource.Contains('$transitionRehearsal -and $images.Count -eq 0 -and $checkpoints.Count -eq 36')) ` 'Transition journal progress or final completion still depends on the full image manifest.' @@ -5155,9 +5168,29 @@ function Invoke-CapJobOwnershipContract { $gatedMembers = @([CapProcessJob]::Members($run.Job)) Assert-CapTest ($gatedMembers.Count -eq 1 -and $gatedMembers[0] -eq $run.Identity.Pid -and [CapProcessJob]::Contains($run.Job, $run.Identity.Process.Handle)) 'Gated job does not contain exactly the held launcher.' + $null = Assert-CapRejects { + Release-CapJobLauncher -Run $run -JournalState $null + } 'valid numbered journal state' + $null = Assert-CapRejects { + Release-CapJobLauncher -Run $run -JournalState ([pscustomobject]@{ + Sequence = 1L + Directory = '' + }) + } 'valid numbered journal state' + Assert-CapTest (-not $run.GateReleased -and -not [IO.File]::Exists($run.TargetReceiptPath) -and + [CapProcessJob]::Contains($run.Job, $run.Identity.Process.Handle)) ` + 'Invalid action journal state released or displaced the contained launcher.' + $wrongEntry = [pscustomobject]@{ pid = [long]$run.Identity.Pid + creationFileTimeUtc = [long]$run.Identity.CreationFileTimeUtc; jobId = 'foreign' } + $null = Publish-CapJournalSnapshot $context.State Bootstrap ([pscustomobject]@{ entries = @($wrongEntry) }) + $null = Assert-CapRejects { + Release-CapJobLauncher -Run $run -JournalState $context.State + } 'does not bind the assigned launcher generation/job' + Assert-CapTest (-not $run.GateReleased -and -not [IO.File]::Exists($run.TargetReceiptPath)) ` + 'Foreign action journal publication released the contained launcher.' $entry = [pscustomobject]@{ pid = [long]$run.Identity.Pid creationFileTimeUtc = [long]$run.Identity.CreationFileTimeUtc; jobId = $run.JobId } - $null = Publish-CapJournalSnapshot $context.State Bootstrap ([pscustomobject]@{ entries = @($entry) }) + $null = Publish-CapJournalSnapshot $context.State Progress ([pscustomobject]@{ entries = @($entry) }) $targetReceipt = Release-CapJobLauncher -Run $run -JournalState $context.State Assert-CapTest ($targetReceipt.expectedImagePath -ieq $target.executable -and $targetReceipt.creationFileTimeUtc -gt 0) 'Normal target launch receipt is missing.' diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index 8bd29f06..1d8cdce9 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -105,6 +105,14 @@ same accumulated I/O clock with only its remaining budget, so post-copy CPU time cannot create a false `JournalUnavailable` and cannot grant another second of I/O. +Action-only supplements use their own dedicated numbered-journal directory and +binding before any contained launcher gate can release. Each launcher +registration publishes the exact held generation and job ID; null, unpublished, +foreign or empty-path states refuse before target launch. The action terminal +publication is written only after held cleanup and stream EOF collection, and +the sanitized action manifest retains its binding, publication chain and +terminal hash without changing the static-capture inventory. + The focused contract reproduces the C8R terminal scale with exactly 679 process entries and a numbered terminal larger than 1.48 MB, then injects more than one second of post-copy validation. It also retains explicit refusals for a copy diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 3592f7e5..06af5d6f 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -38,6 +38,12 @@ public static class GraphCodeUiaGateState { private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); [DllImport("user32.dll", SetLastError = true)] private static extern bool ScreenToClient(IntPtr window, ref Point point); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetCursorPos(out Point point); + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(Point point); [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); @@ -81,6 +87,15 @@ public static class GraphCodeUiaGateState { PostMessage(window, 0x0201, (UIntPtr)1, packed) && PostMessage(window, 0x0202, UIntPtr.Zero, packed); } + public static long[] MovePointerScreen(int screenX, int screenY) { + if (!SetCursorPos(screenX, screenY)) return new long[0]; + Point actual; + if (!GetCursorPos(out actual)) return new long[0]; + IntPtr target = WindowFromPoint(actual); + uint processId; + GetWindowThreadProcessId(target, out processId); + return new long[] { actual.X, actual.Y, target.ToInt64(), processId }; + } public static int SendUnicodeText(string text) { if (String.IsNullOrEmpty(text)) return 0; var inputs = new KeyInputRecord[text.Length * 2]; @@ -741,9 +756,21 @@ function Start-CapJobLauncher { function Release-CapJobLauncher { [CmdletBinding()] - param([Parameter(Mandatory)]$Run, [Parameter(Mandatory)]$JournalState) + param([Parameter(Mandatory)]$Run, [Parameter(Mandatory)][AllowNull()]$JournalState) if ($Run.GateReleased -or $Run.CleanupComplete -or -not [CapProcessJob]::Contains($Run.Job, $Run.Identity.Process.Handle)) { throw 'Invalid contained launcher gate state.' } + if ($null -eq $JournalState -or + $null -eq $JournalState.PSObject.Properties['Sequence'] -or + $null -eq $JournalState.PSObject.Properties['Root'] -or + $null -eq $JournalState.PSObject.Properties['Directory'] -or + $null -eq $JournalState.PSObject.Properties['Binding'] -or + $null -eq $JournalState.PSObject.Properties['DeadlineUtc'] -or + [string]::IsNullOrWhiteSpace([string]$JournalState.Root) -or + [string]::IsNullOrWhiteSpace([string]$JournalState.Directory) -or + -not [IO.Path]::IsPathFullyQualified([string]$JournalState.Root) -or + -not [IO.Path]::IsPathFullyQualified([string]$JournalState.Directory)) { + throw 'Launcher requires a valid numbered journal state before gate release.' + } if ($JournalState.Sequence -lt 1) { throw 'Launcher requires an immutable journal publication before gate release.' } $snapshot = Read-CapJournalObject $JournalState $JournalState.Sequence (New-CapJournalTransaction $JournalState) $entries = @($snapshot.Payload.data.entries | Where-Object {