Files
MBN_STOCK_WEBVIEW/scripts/Invoke-LegacyPgmParityRun.ps1

1269 lines
58 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateSet('StaticAudit', 'DryPreflight', 'Initialize', 'Inspect', 'RunStage', 'RunNext')]
[string] $Action = 'StaticAudit',
[string] $EvidenceDirectory = '',
[ValidateSet('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')]
[string] $Workflow = 'Core5001',
[string] $Stage = '',
[int] $RouteIndex = -1,
[int] $PageIndex = -1,
[int] $DebugPort = 9339,
[int] $ExpectedPgmPort = 30001,
[int] $StageTimeoutSeconds = 900,
[string] $ExpectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity',
[string] $ExpectedPgmExecutable = '',
[string] $ExpectedAppWindowTitle = '',
[string] $ExpectedPgmWindowTitle = 'PGM',
[string] $ExpectedNetworkWindowTitle = '',
[string] $OriginalCutsDirectory = '',
[string] $NodeExecutable = ''
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($ExpectedNetworkWindowTitle)) {
$ExpectedNetworkWindowTitle = [Text.Encoding]::Unicode.GetString(
[Convert]::FromBase64String('JLG40szGbNAgAKi6yLIw0cG5IAA9zA=='))
}
if ([string]::IsNullOrWhiteSpace($ExpectedAppWindowTitle)) {
$ExpectedAppWindowTitle = [Text.Encoding]::Unicode.GetString(
[Convert]::FromBase64String('VgAtAFMAdABvAGMAawAgAJ3JjK0VyPS8ocGczdzCpMJc0SAAZgBvAHIAIADkuXzHvawcyFQAVgAgACgAMgA2AC4AMAAzAC4AMgA2ACkA'))
}
$workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ([string]::IsNullOrWhiteSpace($OriginalCutsDirectory)) {
$OriginalCutsDirectory = [IO.Path]::GetFullPath((Join-Path $workspaceRoot `
'..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'))
} else {
$OriginalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory)
}
if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable) -and
-not [string]::IsNullOrWhiteSpace($env:MBN_STOCK_PGM_EXECUTABLE)) {
$ExpectedPgmExecutable = [IO.Path]::GetFullPath($env:MBN_STOCK_PGM_EXECUTABLE)
} elseif (-not [string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) {
$ExpectedPgmExecutable = [IO.Path]::GetFullPath($ExpectedPgmExecutable)
}
$script:Utf8NoBom = [Text.UTF8Encoding]::new($false)
$script:TargetUrl = 'https://legacy-parity.mbn.local/index.html'
$script:NodeStageScript = Join-Path $PSScriptRoot 'LegacyPgmParityStage.mjs'
$script:RouteCodes = @(
'5001', '5074',
'5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024',
'5077', '5082', '5083', '5084', '5085', '5088', '6067', '8035',
'5011', '8003', '5037', '8001', '5026', '5087', '5029', '8032',
'5032', '5076', '5079', '5080', '5081', '5025', '6001'
)
$script:S6001AssetRelativePaths = @(
[Text.Encoding]::Unicode.GetString(
[Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAD8yCDHMK5tAGUAcgBnAGUALgBwAG4AZwA=')),
[Text.Encoding]::Unicode.GetString(
[Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAAzADUANwA1ADIAOQAxADMAXwBsAC4AagBwAGcA'))
)
function Write-NewText {
param(
[Parameter(Mandatory = $true)] [string] $Path,
[Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text
)
$fullPath = [IO.Path]::GetFullPath($Path)
$stream = [IO.File]::Open($fullPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
try {
$writer = [IO.StreamWriter]::new($stream, $script:Utf8NoBom)
try { $writer.Write($Text) } finally { $writer.Dispose() }
} finally {
if ($null -ne $stream) { $stream.Dispose() }
}
}
function Write-NewJson {
param(
[Parameter(Mandatory = $true)] [string] $Path,
[Parameter(Mandatory = $true)] [object] $Value
)
Write-NewText -Path $Path -Text (($Value | ConvertTo-Json -Depth 40) + [Environment]::NewLine)
}
function Get-CanonicalJson {
param([Parameter(Mandatory = $true)] [object] $Value)
return ($Value | ConvertTo-Json -Depth 40 -Compress)
}
function Get-ResolvedNodeExecutable {
if (-not [string]::IsNullOrWhiteSpace($NodeExecutable)) {
$resolved = [IO.Path]::GetFullPath($NodeExecutable)
if (-not [IO.File]::Exists($resolved)) { throw "Node executable does not exist: $resolved" }
return $resolved
}
$command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $command) { return [IO.Path]::GetFullPath($command.Source) }
$bundled = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
if ([IO.File]::Exists($bundled)) { return [IO.Path]::GetFullPath($bundled) }
throw 'Node was not found. Supply -NodeExecutable with an absolute node.exe path.'
}
function Get-FileIdentity {
param([Parameter(Mandatory = $true)] [string] $Path)
$fullPath = [IO.Path]::GetFullPath($Path)
if (-not [IO.File]::Exists($fullPath)) { throw "Pinned file does not exist: $fullPath" }
$file = [IO.FileInfo]::new($fullPath)
return [ordered]@{
path = $fullPath
length = $file.Length
lastWriteUtc = $file.LastWriteTimeUtc.ToString('o')
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash
}
}
function Initialize-NativeTypes {
if ('CodexLegacyParityNative20260722' -as [type]) { return }
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public static class CodexLegacyParityNative20260722
{
public delegate bool WindowCallback(IntPtr window, IntPtr parameter);
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter);
[DllImport("user32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr parameter);
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern int GetClassName(IntPtr window, StringBuilder text, int count);
[DllImport("user32.dll", SetLastError=true)]
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll", SetLastError=true)]
private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam,
IntPtr lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result);
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam,
StringBuilder lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll", EntryPoint="GetWindowLongPtrW", SetLastError=true)]
private static extern IntPtr GetWindowLongPtr64(IntPtr window, int index);
[DllImport("user32.dll", EntryPoint="GetWindowLongW", SetLastError=true)]
private static extern int GetWindowLong32(IntPtr window, int index);
[DllImport("user32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter,
int x, int y, int width, int height, uint flags);
public static bool IsTopMost(IntPtr window)
{
const int GwlExStyle = -20;
const long WsExTopMost = 0x00000008L;
long style = IntPtr.Size == 8
? GetWindowLongPtr64(window, GwlExStyle).ToInt64()
: GetWindowLong32(window, GwlExStyle);
return (style & WsExTopMost) != 0;
}
public static bool SetTopMostForCapture(IntPtr window, bool topMost)
{
IntPtr insertAfter = topMost ? new IntPtr(-1) : new IntPtr(-2);
const uint SwpNoSize = 0x0001;
const uint SwpNoMove = 0x0002;
const uint SwpNoActivate = 0x0010;
const uint SwpShowWindow = 0x0040;
return SetWindowPos(window, insertAfter, 0, 0, 0, 0,
SwpNoSize | SwpNoMove | SwpNoActivate | SwpShowWindow);
}
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)]
private static extern int GetPackageFullName(IntPtr process, ref uint packageFullNameLength,
StringBuilder packageFullName);
public static string GetProcessPackageFullName(IntPtr process)
{
const int AppmodelErrorNoPackage = 15700;
const int ErrorInsufficientBuffer = 122;
uint length = 0;
int first = GetPackageFullName(process, ref length, null);
if (first == AppmodelErrorNoPackage) return null;
if (first != ErrorInsufficientBuffer || length == 0)
throw new InvalidOperationException("GetPackageFullName length query failed: " + first);
var buffer = new StringBuilder((int)length);
int second = GetPackageFullName(process, ref length, buffer);
if (second != 0) throw new InvalidOperationException("GetPackageFullName failed: " + second);
return buffer.ToString();
}
public static IntPtr[] FindTopLevelWindows(uint processId, string exactTitle)
{
var matches = new List<IntPtr>();
if (!EnumWindows(delegate(IntPtr window, IntPtr parameter) {
uint owner;
if (GetWindowThreadProcessId(window, out owner) == 0 || owner != processId) return true;
var title = new StringBuilder(1024);
GetWindowText(window, title, title.Capacity);
if (String.Equals(title.ToString(), exactTitle, StringComparison.Ordinal)) matches.Add(window);
return true;
}, IntPtr.Zero)) throw new InvalidOperationException("Top-level window enumeration failed.");
return matches.ToArray();
}
public static string GetWindowTitle(IntPtr window)
{
var title = new StringBuilder(1024);
GetWindowText(window, title, title.Capacity);
return title.ToString();
}
public static bool GetVisible(IntPtr window) { return IsWindowVisible(window); }
private static IntPtr FindUniqueRichEdit(IntPtr parent)
{
var matches = new List<IntPtr>();
if (!EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) {
var className = new StringBuilder(256);
if (GetClassName(window, className, className.Capacity) > 0 &&
String.Equals(className.ToString(), "RichEdit20W", StringComparison.Ordinal)) matches.Add(window);
return true;
}, IntPtr.Zero)) throw new InvalidOperationException("Network child enumeration failed.");
if (matches.Count != 1)
throw new InvalidOperationException("Expected exactly one Network Monitoring RichEdit20W; found " + matches.Count + ".");
return matches[0];
}
private static string ReadTextOnce(IntPtr window)
{
const uint WmGetText = 0x000D, WmGetTextLength = 0x000E, Flags = 0x0003;
UIntPtr lengthResult;
if (SendMessageTimeout(window, WmGetTextLength, UIntPtr.Zero, IntPtr.Zero, Flags, 5000,
out lengthResult) == IntPtr.Zero) throw new InvalidOperationException("Network length read timed out.");
long length = checked((long)lengthResult.ToUInt64());
if (length < 0 || length > 10000000) throw new InvalidOperationException("Network text length is unsafe.");
var text = new StringBuilder(checked((int)length + 65537));
UIntPtr copied;
if (SendMessageTimeout(window, WmGetText, new UIntPtr((uint)text.Capacity), text, Flags, 5000,
out copied) == IntPtr.Zero || copied.ToUInt64() != (ulong)text.Length)
throw new InvalidOperationException("Network text copy failed.");
return text.ToString();
}
public static string ReadStableNetworkText(IntPtr parent)
{
IntPtr edit = FindUniqueRichEdit(parent);
string first = ReadTextOnce(edit);
string second = ReadTextOnce(edit);
if (!String.Equals(first, second, StringComparison.Ordinal))
throw new InvalidOperationException("Network text changed during the read.");
return first;
}
}
'@
}
function Get-ExactWindowDescriptor {
param(
[Parameter(Mandatory = $true)] [int] $ProcessIdValue,
[Parameter(Mandatory = $true)] [string] $ExactTitle,
[bool] $RequireVisible
)
$matches = @([CodexLegacyParityNative20260722]::FindTopLevelWindows([uint32]$ProcessIdValue, $ExactTitle))
if ($matches.Count -ne 1) {
throw "Expected one '$ExactTitle' window owned by PID $ProcessIdValue; found $($matches.Count)."
}
$handle = [IntPtr]$matches[0]
$visible = [CodexLegacyParityNative20260722]::GetVisible($handle)
if ($RequireVisible -and -not $visible) { throw "Pinned '$ExactTitle' window is not visible." }
return [ordered]@{
handle = $handle.ToInt64().ToString([Globalization.CultureInfo]::InvariantCulture)
title = [CodexLegacyParityNative20260722]::GetWindowTitle($handle)
visible = $visible
}
}
function Get-ProcessPathSafe {
param([Parameter(Mandatory = $true)] [Diagnostics.Process] $Process)
try { return $Process.Path } catch { return $null }
}
function Find-UniqueProcessByPath {
param([Parameter(Mandatory = $true)] [string] $ExpectedPath)
$fullPath = [IO.Path]::GetFullPath($ExpectedPath)
$matches = @()
foreach ($candidate in @(Get-Process -ErrorAction Stop)) {
$candidatePath = Get-ProcessPathSafe -Process $candidate
if ($null -ne $candidatePath -and [string]::Equals(
[IO.Path]::GetFullPath($candidatePath), $fullPath, [StringComparison]::OrdinalIgnoreCase)) {
$matches += $candidate
}
}
if ($matches.Count -ne 1) { throw "Expected exactly one process at '$fullPath'; found $($matches.Count)." }
return $matches[0]
}
function Get-ProcessDescriptor {
param(
[Parameter(Mandatory = $true)] [Diagnostics.Process] $Process,
[string] $WindowTitle = '',
[bool] $RequireVisibleWindow = $false
)
$path = Get-ProcessPathSafe -Process $Process
if ([string]::IsNullOrWhiteSpace($path)) { throw "PID $($Process.Id) has no readable executable path." }
$packageFullName = [CodexLegacyParityNative20260722]::GetProcessPackageFullName($Process.Handle)
$descriptor = [ordered]@{
processId = $Process.Id
startUtc = $Process.StartTime.ToUniversalTime().ToString('o')
executable = Get-FileIdentity -Path $path
packageFullName = $packageFullName
}
if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) {
$descriptor.window = Get-ExactWindowDescriptor -ProcessIdValue $Process.Id -ExactTitle $WindowTitle -RequireVisible $RequireVisibleWindow
}
return $descriptor
}
function Get-ListenerDescriptor {
param(
[Parameter(Mandatory = $true)] [int] $Port,
[int] $ExpectedOwner = -1
)
$listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop)
if ($listeners.Count -eq 0) { throw "No TCP listener is present on port $Port." }
$owners = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)
if ($owners.Count -ne 1) { throw "TCP port $Port has more than one listener owner." }
if ($ExpectedOwner -ge 0 -and $owners[0] -ne $ExpectedOwner) {
throw "TCP port $Port is owned by PID $($owners[0]), expected $ExpectedOwner."
}
$endpoints = @($listeners | ForEach-Object {
'{0}:{1}|{2}|{3}' -f $_.LocalAddress, $_.LocalPort, $_.State, $_.OwningProcess
} | Sort-Object)
return [ordered]@{ port = $Port; ownerProcessId = [int]$owners[0]; endpoints = $endpoints }
}
function Get-EstablishedPgmConnection {
param(
[Parameter(Mandatory = $true)] [int] $AppProcessId,
[Parameter(Mandatory = $true)] [int] $PgmProcessId,
[Parameter(Mandatory = $true)] [int] $PgmPort
)
$connections = @(Get-NetTCPConnection -State Established -RemotePort $PgmPort -ErrorAction Stop |
Where-Object { $_.OwningProcess -eq $AppProcessId })
if ($connections.Count -ne 1) {
throw "Expected one established app-to-PGM connection owned by PID $AppProcessId; found $($connections.Count)."
}
$connection = $connections[0]
$localAddress = $null
$remoteAddress = $null
if (-not [Net.IPAddress]::TryParse([string]$connection.LocalAddress, [ref]$localAddress) -or
-not [Net.IPAddress]::TryParse([string]$connection.RemoteAddress, [ref]$remoteAddress) -or
-not [Net.IPAddress]::IsLoopback($localAddress) -or
-not [Net.IPAddress]::IsLoopback($remoteAddress)) {
throw 'The app-to-PGM connection is not entirely loopback.'
}
$reciprocal = @(Get-NetTCPConnection -State Established -LocalPort $PgmPort `
-RemotePort $connection.LocalPort -ErrorAction Stop |
Where-Object {
$_.OwningProcess -eq $PgmProcessId -and
[string]$_.LocalAddress -ceq [string]$connection.RemoteAddress -and
[string]$_.RemoteAddress -ceq [string]$connection.LocalAddress
})
if ($reciprocal.Count -ne 1) {
throw "Expected one reciprocal PGM-to-app connection owned by PID $PgmProcessId; found $($reciprocal.Count)."
}
$pgmSide = $reciprocal[0]
return [ordered]@{
appSide = [ordered]@{
ownerProcessId = $connection.OwningProcess
localAddress = $connection.LocalAddress
localPort = $connection.LocalPort
remoteAddress = $connection.RemoteAddress
remotePort = $connection.RemotePort
state = [string]$connection.State
}
pgmSide = [ordered]@{
ownerProcessId = $pgmSide.OwningProcess
localAddress = $pgmSide.LocalAddress
localPort = $pgmSide.LocalPort
remoteAddress = $pgmSide.RemoteAddress
remotePort = $pgmSide.RemotePort
state = [string]$pgmSide.State
}
}
}
function Get-CdpTargetDescriptor {
param([Parameter(Mandatory = $true)] [int] $Port)
$targets = @(Invoke-RestMethod -Uri "http://127.0.0.1:$Port/json/list" -Method Get -TimeoutSec 10)
$pages = @($targets | Where-Object { $_.type -ceq 'page' -and $_.url -ceq $script:TargetUrl })
if ($pages.Count -ne 1) { throw "Expected exactly one CDP parity target; found $($pages.Count)." }
$target = $pages[0]
if ([string]::IsNullOrWhiteSpace([string]$target.id) -or
[string]::IsNullOrWhiteSpace([string]$target.webSocketDebuggerUrl)) {
throw 'The parity CDP target is missing an ID or WebSocket endpoint.'
}
$endpoint = [Uri]::new([string]$target.webSocketDebuggerUrl)
if ($endpoint.Scheme -cne 'ws' -or $endpoint.Host -cne '127.0.0.1' -or
$endpoint.Port -ne $Port -or $endpoint.AbsolutePath -cne "/devtools/page/$($target.id)" -or
-not [string]::IsNullOrEmpty($endpoint.Query) -or -not [string]::IsNullOrEmpty($endpoint.Fragment)) {
throw 'The parity CDP WebSocket endpoint identity is not exact.'
}
return [ordered]@{
id = [string]$target.id
type = [string]$target.type
title = [string]$target.title
url = [string]$target.url
webSocketDebuggerUrl = [string]$target.webSocketDebuggerUrl
}
}
function Get-CutInventory {
param(
[Parameter(Mandatory = $true)] [string] $SourceDirectory,
[Parameter(Mandatory = $true)] [string] $RuntimeDirectory
)
$sourceRoot = [IO.Path]::GetFullPath($SourceDirectory)
$runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory)
$items = @()
foreach ($code in $script:RouteCodes) {
$sourcePath = Join-Path $sourceRoot "$code.t2s"
$runtimePath = Join-Path $runtimeRoot "$code.t2s"
$source = Get-FileIdentity -Path $sourcePath
$runtime = Get-FileIdentity -Path $runtimePath
if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) {
throw "Runtime cut $code does not exactly match the authoritative legacy cut."
}
$items += [ordered]@{
code = $code
sourcePath = $source.path
runtimePath = $runtime.path
length = $runtime.length
sha256 = $runtime.sha256
}
}
return $items
}
function Get-AssetInventory {
param(
[Parameter(Mandatory = $true)] [string] $SourceDirectory,
[Parameter(Mandatory = $true)] [string] $RuntimeDirectory
)
$sourceRoot = [IO.Path]::GetFullPath($SourceDirectory)
$runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory)
$items = @()
foreach ($relativePath in $script:S6001AssetRelativePaths) {
$source = Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath)
$runtime = Get-FileIdentity -Path (Join-Path $runtimeRoot $relativePath)
if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) {
throw "Runtime asset $relativePath does not exactly match the authoritative legacy asset."
}
$items += [ordered]@{
relativePath = $relativePath
sourcePath = $source.path
runtimePath = $runtime.path
length = $runtime.length
sha256 = $runtime.sha256
}
}
return $items
}
function Get-CurrentPins {
Initialize-NativeTypes
if ($DebugPort -le 0 -or $DebugPort -gt 65535) { throw "Invalid CDP port: $DebugPort" }
if ($ExpectedPgmPort -le 0 -or $ExpectedPgmPort -gt 65535) { throw "Invalid PGM port: $ExpectedPgmPort" }
if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) {
throw 'Pass -ExpectedPgmExecutable or set MBN_STOCK_PGM_EXECUTABLE for runtime checks.'
}
$packages = @(Get-AppxPackage -Name $ExpectedPackageName -ErrorAction Stop |
Where-Object { $_.Name -ceq $ExpectedPackageName })
if ($packages.Count -ne 1) { throw "Expected one registered package '$ExpectedPackageName'; found $($packages.Count)." }
$package = $packages[0]
if ($package.IsDevelopmentMode -ne $true -or [string]$package.Architecture -cne 'X64') {
throw 'Only the x64 loose Debug development package is authorized for this run.'
}
$installLocation = [IO.Path]::GetFullPath($package.InstallLocation)
$workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$debugPackageRoot = [IO.Path]::GetFullPath((Join-Path $workspaceRoot `
'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug'))
$debugPrefix = $debugPackageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $installLocation.StartsWith($debugPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Package InstallLocation is outside the workspace x64 Debug boundary: $installLocation"
}
$debugRelative = $installLocation.Substring($debugPrefix.Length)
$debugSegments = @($debugRelative.Split([IO.Path]::DirectorySeparatorChar) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($debugSegments.Count -ne 3 -or
$debugSegments[0] -notmatch '^net[0-9]+\.[0-9]+-windows[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -or
$debugSegments[1] -cne 'win-x64' -or $debugSegments[2] -cne 'AppX') {
throw "Package InstallLocation is not an exact Debug target-framework/win-x64/AppX path: $installLocation"
}
$appExecutable = Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'
if (-not [IO.File]::Exists($appExecutable)) { throw "Package app executable was not found under $installLocation." }
$appProcess = Find-UniqueProcessByPath -ExpectedPath $appExecutable
$pgmProcess = Find-UniqueProcessByPath -ExpectedPath $ExpectedPgmExecutable
$app = Get-ProcessDescriptor -Process $appProcess -WindowTitle $ExpectedAppWindowTitle -RequireVisibleWindow $true
$pgm = Get-ProcessDescriptor -Process $pgmProcess -WindowTitle $ExpectedPgmWindowTitle -RequireVisibleWindow $true
if ($app.packageFullName -cne $package.PackageFullName) {
throw "App PID $($app.processId) does not carry the registered package identity."
}
$pgm.networkWindow = Get-ExactWindowDescriptor -ProcessIdValue $pgmProcess.Id `
-ExactTitle $ExpectedNetworkWindowTitle -RequireVisible $false
$pgmListener = Get-ListenerDescriptor -Port $ExpectedPgmPort -ExpectedOwner $pgmProcess.Id
$appConnection = Get-EstablishedPgmConnection -AppProcessId $appProcess.Id `
-PgmProcessId $pgmProcess.Id -PgmPort $ExpectedPgmPort
$cdpListener = Get-ListenerDescriptor -Port $DebugPort
$cdpOwnerProcess = Get-Process -Id $cdpListener.ownerProcessId -ErrorAction Stop
$cdpOwner = Get-ProcessDescriptor -Process $cdpOwnerProcess
$cdpTarget = Get-CdpTargetDescriptor -Port $DebugPort
$runtimeFiles = @()
foreach ($relative in @(
'MBN_STOCK_WEBVIEW.LegacyParityApp.exe',
'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
'MBN_STOCK_WEBVIEW.Playout.dll')) {
$runtimeFiles += Get-FileIdentity -Path (Join-Path $installLocation $relative)
}
$nodePath = Get-ResolvedNodeExecutable
$cutInventory = Get-CutInventory -SourceDirectory $OriginalCutsDirectory `
-RuntimeDirectory (Join-Path $installLocation 'Cuts')
$assetInventory = Get-AssetInventory -SourceDirectory $OriginalCutsDirectory `
-RuntimeDirectory (Join-Path $installLocation 'Cuts')
return [ordered]@{
schemaVersion = 1
package = [ordered]@{
name = [string]$package.Name
packageFullName = [string]$package.PackageFullName
packageFamilyName = [string]$package.PackageFamilyName
publisher = [string]$package.Publisher
version = $package.Version.ToString()
architecture = [string]$package.Architecture
isDevelopmentMode = [bool]$package.IsDevelopmentMode
installLocation = $installLocation
debugPackageRoot = $debugPackageRoot
}
app = $app
pgm = $pgm
pgmListener = $pgmListener
appToPgmConnection = $appConnection
cdp = [ordered]@{
listener = $cdpListener
owner = $cdpOwner
target = $cdpTarget
}
runtimeFiles = $runtimeFiles
cutInventory = $cutInventory
assetInventory = $assetInventory
tools = [ordered]@{
wrapper = Get-FileIdentity -Path $PSCommandPath
node = Get-FileIdentity -Path $nodePath
nodeStage = Get-FileIdentity -Path $script:NodeStageScript
}
}
}
function Assert-PinsExact {
param([Parameter(Mandatory = $true)] [object] $Pinned)
$current = Get-CurrentPins
$expectedJson = Get-CanonicalJson -Value $Pinned
$currentJson = Get-CanonicalJson -Value $current
if ($currentJson -cne $expectedJson) {
throw 'A pinned package, process, executable hash, window, listener, connection, CDP target, cut, or tool identity changed.'
}
return $current
}
function Get-NetworkCounts {
param([Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text)
$lines = @($Text -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
return [ordered]@{
helloRequest = @($lines | Where-Object { $_ -match '\[R\]\s+HELLO\b' }).Count
helloSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+HELLO\b' }).Count
loadSceneRequest = @($lines | Where-Object { $_ -match '\[R\]\s+LOAD_SCENE\b' }).Count
loadSceneSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+LOAD_SCENE\b' }).Count
prepareRequest = @($lines | Where-Object { $_ -match '\[R\]\s+SCENE_PREPARE\b' }).Count
prepareSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+SCENE_PREPARE\b' }).Count
playRequest = @($lines | Where-Object { $_ -match '\[R\]\s+PLAY\b' }).Count
playSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+PLAY\b' }).Count
scenePlayed = @($lines | Where-Object { $_ -match '\[A\]\s+SCENE_PLAYED\b' }).Count
unloadRequest = @($lines | Where-Object { $_ -match '\[R\]\s+UNLOAD_SCENE\b' }).Count
unloadSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+UNLOAD_SCENE\b' }).Count
failure = @($lines | Where-Object { $_ -match '\bFAILURE\b' }).Count
error = @($lines | Where-Object { $_ -match '\bERROR\b' }).Count
}
}
function Read-StableNetworkText {
param([Parameter(Mandatory = $true)] [IntPtr] $NetworkWindow)
$lastError = $null
for ($attempt = 1; $attempt -le 8; $attempt += 1) {
try { return [CodexLegacyParityNative20260722]::ReadStableNetworkText($NetworkWindow) }
catch {
$lastError = $_
Start-Sleep -Milliseconds 125
}
}
throw $lastError
}
function Capture-WindowImage {
param(
[Parameter(Mandatory = $true)] [IntPtr] $Window,
[Parameter(Mandatory = $true)] [string] $Path
)
if ([IO.File]::Exists($Path)) { throw "Evidence image already exists: $Path" }
$rect = New-Object CodexLegacyParityNative20260722+Rect
if (-not [CodexLegacyParityNative20260722]::GetWindowRect($Window, [ref]$rect)) {
throw 'Window rectangle could not be read.'
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -lt 100 -or $height -lt 100 -or $width -gt 8000 -or $height -gt 5000) {
throw "Window rectangle is unsafe: ${width}x${height}."
}
# CopyFromScreen is required for the DirectX PGM surface, but it otherwise
# records an unrelated foreground window when the target is occluded. Raise
# only this exact pinned window without activating it, allow one repaint,
# capture, and restore its original top-most state in a finally block.
$wasTopMost = [CodexLegacyParityNative20260722]::IsTopMost($Window)
if (-not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $true)) {
throw 'The exact evidence window could not be raised for an unobscured capture.'
}
Start-Sleep -Milliseconds 250
$bitmap = $null
$graphics = $null
try {
$bitmap = [Drawing.Bitmap]::new($width, $height)
$graphics = [Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size)
$bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png)
} finally {
if ($null -ne $graphics) { $graphics.Dispose() }
if ($null -ne $bitmap) { $bitmap.Dispose() }
if (-not $wasTopMost -and
-not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $false)) {
throw 'The evidence window top-most state could not be restored.'
}
}
}
function Get-NetworkDelta {
param(
[Parameter(Mandatory = $true)] [string] $Current,
[string] $PreviousPath = ''
)
if ([string]::IsNullOrWhiteSpace($PreviousPath)) {
return [ordered]@{ anchorMatched = $null; anchorLength = 0; delta = $Current }
}
$previousFullPath = [IO.Path]::GetFullPath($PreviousPath)
if (-not [IO.File]::Exists($previousFullPath)) { throw "Previous Network evidence is missing: $previousFullPath" }
$previous = [IO.File]::ReadAllText($previousFullPath, [Text.Encoding]::UTF8)
if ($previous.Length -eq 0) {
return [ordered]@{ anchorMatched = $true; anchorLength = 0; delta = $Current }
}
$anchorLength = [Math]::Min(4096, $previous.Length)
$anchor = $previous.Substring($previous.Length - $anchorLength)
$first = $Current.IndexOf($anchor, [StringComparison]::Ordinal)
$last = $Current.LastIndexOf($anchor, [StringComparison]::Ordinal)
if ($first -lt 0 -or $first -ne $last) {
throw 'The rolling Network Monitoring anchor is missing or non-unique.'
}
return [ordered]@{
anchorMatched = $true
anchorLength = $anchorLength
delta = $Current.Substring($first + $anchorLength)
}
}
function Capture-ReadOnlyEvidence {
param(
[Parameter(Mandatory = $true)] [object] $Pins,
[Parameter(Mandatory = $true)] [string] $OutputDirectory,
[string] $PreviousNetworkPath = ''
)
Initialize-NativeTypes
$directory = [IO.Path]::GetFullPath($OutputDirectory)
if (-not [IO.Directory]::Exists($directory)) { throw "Evidence stage directory is missing: $directory" }
$pgmImage = Join-Path $directory 'pgm-screen.png'
$appImage = Join-Path $directory 'app-screen.png'
$networkPath = Join-Path $directory 'network-full.txt'
$networkDeltaPath = Join-Path $directory 'network-delta.txt'
$summaryPath = Join-Path $directory 'capture.json'
$pgmWindow = [IntPtr]::new([long]$Pins.pgm.window.handle)
$appWindow = [IntPtr]::new([long]$Pins.app.window.handle)
$networkWindow = [IntPtr]::new([long]$Pins.pgm.networkWindow.handle)
# Capture the PGM frame first. Capture-WindowImage temporarily changes only
# the pinned window's z-order; it never focuses, restores, clicks, or sends a
# command to either application.
Capture-WindowImage -Window $pgmWindow -Path $pgmImage
Capture-WindowImage -Window $appWindow -Path $appImage
$networkText = Read-StableNetworkText -NetworkWindow $networkWindow
Write-NewText -Path $networkPath -Text $networkText
$deltaResult = Get-NetworkDelta -Current $networkText -PreviousPath $PreviousNetworkPath
Write-NewText -Path $networkDeltaPath -Text $deltaResult.delta
$summary = [ordered]@{
schemaVersion = 1
capturedAtUtc = [DateTime]::UtcNow.ToString('o')
previousNetworkPath = $(if ([string]::IsNullOrWhiteSpace($PreviousNetworkPath)) { $null } else { [IO.Path]::GetFullPath($PreviousNetworkPath) })
anchorMatched = $deltaResult.anchorMatched
anchorLength = $deltaResult.anchorLength
fullCharacters = $networkText.Length
deltaCharacters = $deltaResult.delta.Length
counts = Get-NetworkCounts -Text $deltaResult.delta
pgmImagePath = $pgmImage
appImagePath = $appImage
networkFullPath = $networkPath
networkDeltaPath = $networkDeltaPath
}
Write-NewJson -Path $summaryPath -Value $summary
return $summary
}
function Get-WorkflowStages {
param([Parameter(Mandatory = $true)] [string] $Name)
if ($Name -ceq 'Recovery8001') {
return @([pscustomobject]@{ action = 'take-out-recovery-8001'; routeIndex = 19; pageIndex = $null })
}
if ($Name -ceq 'Recovery5077Page2') {
return @([pscustomobject]@{ action = 'take-out-recovery-5077-page2'; routeIndex = $null; pageIndex = 2 })
}
if ($Name -ceq 'ThirtyRoutes20To29') {
$resume = @([pscustomobject]@{ action = 'resume-30-manifest'; routeIndex = $null; pageIndex = $null })
for ($index = 20; $index -lt 30; $index += 1) {
foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) {
$resume += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
}
}
return $resume
}
$items = @([pscustomobject]@{ action = 'bootstrap'; routeIndex = $null; pageIndex = $null })
switch ($Name) {
'Core5001' {
foreach ($actionName in @('setup-5001-5074', 'take-in-5001', 'next-5074', 'page-next-5074', 'take-out-5074')) {
$items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null }
}
}
'ThirtyRoutes' {
foreach ($actionName in @('build-30-base', 'build-30-comparison', 'build-30-financial', 'build-30-manual')) {
$items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null }
}
for ($index = 0; $index -lt 30; $index += 1) {
foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) {
$items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
}
}
}
'PagedBoundary' {
$items += [pscustomobject]@{ action = 'build-paged'; routeIndex = $null; pageIndex = $null }
$items += [pscustomobject]@{ action = 'take-in-5077'; routeIndex = $null; pageIndex = $null }
for ($page = 1; $page -le 19; $page += 1) {
$items += [pscustomobject]@{ action = 'page-next-5077'; routeIndex = $null; pageIndex = $page }
}
$items += [pscustomobject]@{ action = 'next-5088'; routeIndex = $null; pageIndex = $null }
$items += [pscustomobject]@{ action = 'take-out-5088'; routeIndex = $null; pageIndex = $null }
}
'S6001AssetIndependent' {
$items += [pscustomobject]@{ action = 'build-s6001-asset-independent'; routeIndex = $null; pageIndex = $null }
for ($index = 0; $index -lt 4; $index += 1) {
foreach ($actionName in @('activate-s6001', 'take-in-s6001', 'wait-s6001', 'take-out-s6001')) {
$items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
}
}
}
default { throw "Unknown workflow: $Name" }
}
return $items
}
function Get-SuccessfulStageRecords {
param([Parameter(Mandatory = $true)] [string] $Directory)
$records = @()
foreach ($child in @(Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction Stop |
Where-Object { $_.Name -match '^(?!0000-)\d{4}-' } | Sort-Object Name)) {
$successPath = Join-Path $child.FullName 'SUCCESS.json'
if ([IO.File]::Exists($successPath)) {
$records += (Get-Content -LiteralPath $successPath -Raw -Encoding UTF8 | ConvertFrom-Json)
}
}
return $records
}
function Assert-CompletedStageSequence {
param(
[Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $Records,
[Parameter(Mandatory = $true)] [object[]] $ExpectedStages
)
if ($Records.Count -gt $ExpectedStages.Count) { throw 'Evidence contains more successful stages than the workflow.' }
for ($index = 0; $index -lt $Records.Count; $index += 1) {
$record = $Records[$index]
$expected = $ExpectedStages[$index]
$recordRoute = if ($null -ne $record.stage.routeIndex) { [int]$record.stage.routeIndex } else { $null }
$recordPage = if ($null -ne $record.stage.pageIndex) { [int]$record.stage.pageIndex } else { $null }
if ([int]$record.sequence -ne ($index + 1) -or
[string]$record.stage.action -cne [string]$expected.action -or
$recordRoute -ne $expected.routeIndex -or $recordPage -ne $expected.pageIndex) {
throw "Successful evidence sequence is not exact at index $index."
}
}
}
function Get-PreviousNetworkPath {
param([Parameter(Mandatory = $true)] [string] $Directory)
$candidates = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'network-full.txt' -ErrorAction Stop |
Sort-Object FullName)
if ($candidates.Count -eq 0) { return '' }
return $candidates[-1].FullName
}
function New-StopLatch {
param(
[Parameter(Mandatory = $true)] [string] $Directory,
[Parameter(Mandatory = $true)] [string] $Reason,
[string] $StageDirectory = '',
[object] $Details = $null
)
$path = Join-Path $Directory 'STOPPED.json'
if ([IO.File]::Exists($path)) { return }
$record = [ordered]@{
schemaVersion = 1
stoppedAtUtc = [DateTime]::UtcNow.ToString('o')
reason = $Reason
stageDirectory = $(if ([string]::IsNullOrWhiteSpace($StageDirectory)) { $null } else { $StageDirectory })
details = $Details
rule = 'No later playout command may be dispatched from this run directory.'
}
try { Write-NewJson -Path $path -Value $record } catch { if (-not [IO.File]::Exists($path)) { throw } }
}
function ConvertTo-NodeArgumentString {
param([Parameter(Mandatory = $true)] [object[]] $Values)
$quoted = foreach ($value in $Values) {
$text = [string]$value
if ($text.Contains('"') -or $text.Contains([char]0)) {
throw 'A Node stage argument contains a forbidden character.'
}
'"' + $text + '"'
}
return ($quoted -join ' ')
}
function Invoke-NodeStage {
param(
[Parameter(Mandatory = $true)] [object] $Manifest,
[Parameter(Mandatory = $true)] [object] $ExpectedStage,
[Parameter(Mandatory = $true)] [string] $OutputDirectory
)
$stdoutPath = Join-Path $OutputDirectory 'node-stdout.json'
$stderrPath = Join-Path $OutputDirectory 'node-stderr.json'
$nodePath = [string]$Manifest.pins.tools.node.path
$nodeScriptPath = [string]$Manifest.pins.tools.nodeStage.path
$arguments = @($nodeScriptPath, '--port', [string]$Manifest.debugPort, '--action', [string]$ExpectedStage.action)
if ($null -ne $ExpectedStage.routeIndex) { $arguments += @('--route-index', [string]$ExpectedStage.routeIndex) }
if ($null -ne $ExpectedStage.pageIndex) { $arguments += @('--page-index', [string]$ExpectedStage.pageIndex) }
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $nodePath
$start.WorkingDirectory = $PSScriptRoot
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$start.Arguments = ConvertTo-NodeArgumentString -Values $arguments
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
try {
if (-not $process.Start()) { throw 'The Node stage process did not start.' }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$exited = $process.WaitForExit($StageTimeoutSeconds * 1000)
if (-not $exited) {
$terminated = $false
try {
$process.Kill()
$terminated = $process.WaitForExit(5000)
} catch { }
if ($terminated) {
$process.WaitForExit()
Write-NewText -Path $stdoutPath -Text $stdoutTask.GetAwaiter().GetResult()
Write-NewText -Path $stderrPath -Text $stderrTask.GetAwaiter().GetResult()
}
return [ordered]@{
ok = $false
timedOut = $true
exitCode = $null
parsed = $null
error = "Node stage exceeded $StageTimeoutSeconds seconds. Outcome is unknown."
stdoutPath = $stdoutPath
stderrPath = $stderrPath
}
}
$process.WaitForExit()
$exitCode = [int]$process.ExitCode
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
Write-NewText -Path $stdoutPath -Text $stdout
Write-NewText -Path $stderrPath -Text $stderr
$stdout = $stdout.Trim()
$stderr = $stderr.Trim()
$parsed = $null
$parseError = $null
try {
$candidate = if (-not [string]::IsNullOrWhiteSpace($stdout)) { $stdout } else { $stderr }
if ([string]::IsNullOrWhiteSpace($candidate)) { throw 'Node stage returned no JSON.' }
$parsed = $candidate | ConvertFrom-Json
} catch { $parseError = $_.Exception.Message }
$ok = $exitCode -eq 0 -and $null -ne $parsed -and $parsed.ok -eq $true
return [ordered]@{
ok = $ok
timedOut = $false
exitCode = $exitCode
parsed = $parsed
error = $parseError
stdoutPath = $stdoutPath
stderrPath = $stderrPath
}
} finally {
$process.Dispose()
}
}
function Invoke-StaticAudit {
$nodePath = Get-ResolvedNodeExecutable
if (-not [IO.File]::Exists($script:NodeStageScript)) { throw "Node stage script is missing: $script:NodeStageScript" }
$tokens = $null
$errors = $null
[Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$errors) | Out-Null
if ($errors.Count -ne 0) { throw "PowerShell parser reported $($errors.Count) error(s): $($errors[0].Message)" }
$nodeCheckOutput = @(& $nodePath --check $script:NodeStageScript 2>&1)
if ($LASTEXITCODE -ne 0) { throw "Node syntax check failed: $($nodeCheckOutput -join [Environment]::NewLine)" }
$nodeSource = [IO.File]::ReadAllText($script:NodeStageScript, [Text.Encoding]::UTF8)
foreach ($required in @('OUTCOME_UNKNOWN', 'TARGET_IDENTITY_MISMATCH', 'take-in-30-fast', 'page-next-5077', 'take-out-recovery-5077-page2', 'build-s6001-asset-independent', 'take-in-s6001')) {
if (-not $nodeSource.Contains($required)) { throw "Node stage script is missing required safety token: $required" }
}
$sourceRoot = [IO.Path]::GetFullPath($OriginalCutsDirectory)
$cuts = @()
foreach ($code in $script:RouteCodes) {
$path = Join-Path $sourceRoot "$code.t2s"
$cuts += Get-FileIdentity -Path $path
}
$assets = @()
foreach ($relativePath in $script:S6001AssetRelativePaths) {
$assets += Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath)
}
$workflowCounts = [ordered]@{}
foreach ($name in @('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')) {
$workflowCounts[$name] = @(Get-WorkflowStages -Name $name).Count
}
return [ordered]@{
ok = $true
audit = 'static-only'
auditedAtUtc = [DateTime]::UtcNow.ToString('o')
wrapper = Get-FileIdentity -Path $PSCommandPath
node = Get-FileIdentity -Path $nodePath
nodeStage = Get-FileIdentity -Path $script:NodeStageScript
authoritativeCutCount = $cuts.Count
# OrderedDictionary keys are not projected as ordinary properties by
# Select-Object in Windows PowerShell 5.1. Preserve the validated
# identity dictionaries directly so the evidence cannot silently emit
# null paths or hashes.
authoritativeCutHashes = @($cuts)
authoritativeAssetCount = $assets.Count
authoritativeAssetHashes = @($assets)
workflowStageCounts = $workflowCounts
runtimeProcessesQueried = $false
cdpAttached = $false
nativeMessagesSent = 0
}
}
function Resolve-EvidenceDirectory {
if ([string]::IsNullOrWhiteSpace($EvidenceDirectory)) {
throw "-EvidenceDirectory is required for action '$Action'."
}
return [IO.Path]::GetFullPath($EvidenceDirectory)
}
function Invoke-InitializeRun {
$directory = Resolve-EvidenceDirectory
if ([IO.Directory]::Exists($directory) -or [IO.File]::Exists($directory)) {
throw "Initialization never overwrites an existing evidence path: $directory"
}
$pins = Get-CurrentPins
[IO.Directory]::CreateDirectory($directory) | Out-Null
$baselineDirectory = Join-Path $directory '0000-baseline'
[IO.Directory]::CreateDirectory($baselineDirectory) | Out-Null
$manifest = [ordered]@{
schemaVersion = 1
runId = [IO.Path]::GetFileName($directory)
initializedAtUtc = [DateTime]::UtcNow.ToString('o')
workflow = $Workflow
debugPort = $DebugPort
targetUrl = $script:TargetUrl
discovery = [ordered]@{
packageName = $ExpectedPackageName
pgmExecutable = [string]$pins.pgm.executable.path
appWindowTitle = [string]$pins.app.window.title
pgmWindowTitle = [string]$pins.pgm.window.title
networkWindowTitle = [string]$pins.pgm.networkWindow.title
originalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory)
nodeExecutable = [string]$pins.tools.node.path
pgmPort = [int]$pins.pgmListener.port
}
rules = @(
'One Node invocation dispatches at most one user-visible playout command.',
'No playout command is retried after timeout, OutcomeUnknown, or identity mismatch.',
'STOPPED.json permanently blocks later commands in this evidence directory.',
'Playlist rows are built in memory; no named playlist fixture is loaded.'
)
expectedStages = Get-WorkflowStages -Name $Workflow
pins = $pins
baselineNetworkPath = Join-Path $baselineDirectory 'network-full.txt'
}
Write-NewJson -Path (Join-Path $directory 'manifest.json') -Value $manifest
try {
$capture = Capture-ReadOnlyEvidence -Pins $pins -OutputDirectory $baselineDirectory
Assert-PinsExact -Pinned $pins | Out-Null
Write-NewJson -Path (Join-Path $baselineDirectory 'SUCCESS.json') -Value ([ordered]@{
initializedAtUtc = [DateTime]::UtcNow.ToString('o')
capture = $capture
})
} catch {
New-StopLatch -Directory $directory -Reason 'Initialization evidence or post-capture identity validation failed.' `
-StageDirectory $baselineDirectory -Details $_.Exception.Message
throw
}
return $manifest
}
function Read-RunManifest {
param([Parameter(Mandatory = $true)] [string] $Directory)
$manifestPath = Join-Path $Directory 'manifest.json'
if (-not [IO.File]::Exists($manifestPath)) { throw "Run manifest is missing: $manifestPath" }
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1 -or $manifest.targetUrl -cne $script:TargetUrl -or
$null -eq $manifest.discovery) {
throw 'Run manifest schema or target identity is unsupported.'
}
return $manifest
}
function Use-ManifestDiscoverySettings {
param([Parameter(Mandatory = $true)] [object] $Manifest)
$script:DebugPort = [int]$Manifest.debugPort
$script:ExpectedPgmPort = [int]$Manifest.discovery.pgmPort
$script:ExpectedPackageName = [string]$Manifest.discovery.packageName
$script:ExpectedPgmExecutable = [string]$Manifest.discovery.pgmExecutable
$script:ExpectedAppWindowTitle = [string]$Manifest.discovery.appWindowTitle
$script:ExpectedPgmWindowTitle = [string]$Manifest.discovery.pgmWindowTitle
$script:ExpectedNetworkWindowTitle = [string]$Manifest.discovery.networkWindowTitle
$script:OriginalCutsDirectory = [string]$Manifest.discovery.originalCutsDirectory
$script:NodeExecutable = [string]$Manifest.discovery.nodeExecutable
}
function Invoke-InspectRun {
$directory = Resolve-EvidenceDirectory
$manifest = Read-RunManifest -Directory $directory
Use-ManifestDiscoverySettings -Manifest $manifest
$current = Assert-PinsExact -Pinned $manifest.pins
$completed = @(Get-SuccessfulStageRecords -Directory $directory)
$expected = @(Get-WorkflowStages -Name ([string]$manifest.workflow))
Assert-CompletedStageSequence -Records $completed -ExpectedStages $expected
return [ordered]@{
ok = $true
action = 'Inspect'
stopped = [IO.File]::Exists((Join-Path $directory 'STOPPED.json'))
completedStageCount = $completed.Count
totalStageCount = $expected.Count
nextStage = $(if ($completed.Count -lt $expected.Count) { $expected[$completed.Count] } else { $null })
pins = $current
inspectedAtUtc = [DateTime]::UtcNow.ToString('o')
nativeMessagesSent = 0
}
}
function Invoke-RunStage {
param([bool] $UseExpectedStage = $false)
$directory = Resolve-EvidenceDirectory
$stopPath = Join-Path $directory 'STOPPED.json'
if ([IO.File]::Exists($stopPath)) {
throw "This evidence run is permanently stopped. Inspect: $stopPath"
}
$manifest = Read-RunManifest -Directory $directory
Use-ManifestDiscoverySettings -Manifest $manifest
if (-not $UseExpectedStage -and [string]::IsNullOrWhiteSpace($Stage)) { throw '-Stage is required for RunStage.' }
$lockPath = Join-Path $directory 'RUNNING.lock'
$lockStream = $null
try {
$lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
} catch {
throw "Another stage is running, or an interrupted lock requires inspection: $lockPath"
}
try {
$completed = @(Get-SuccessfulStageRecords -Directory $directory)
$expectedStages = @(Get-WorkflowStages -Name ([string]$manifest.workflow))
Assert-CompletedStageSequence -Records $completed -ExpectedStages $expectedStages
if ($completed.Count -ge $expectedStages.Count) { throw 'All workflow stages are already complete.' }
$expected = $expectedStages[$completed.Count]
if (-not $UseExpectedStage) {
$requestedRoute = if ($RouteIndex -ge 0) { $RouteIndex } else { $null }
$requestedPage = if ($PageIndex -ge 0) { $PageIndex } else { $null }
if ($Stage -cne $expected.action -or $requestedRoute -ne $expected.routeIndex -or $requestedPage -ne $expected.pageIndex) {
throw "Out-of-order stage request. Expected action=$($expected.action), routeIndex=$($expected.routeIndex), pageIndex=$($expected.pageIndex)."
}
}
$sequence = $completed.Count + 1
$safeStage = $expected.action -replace '[^a-z0-9-]', '-'
$stageDirectory = Join-Path $directory ('{0:D4}-{1}-{2}' -f $sequence, $safeStage, [Guid]::NewGuid().ToString('N').Substring(0, 8))
[IO.Directory]::CreateDirectory($stageDirectory) | Out-Null
Write-NewJson -Path (Join-Path $stageDirectory 'REQUEST.json') -Value ([ordered]@{
sequence = $sequence
requestedAtUtc = [DateTime]::UtcNow.ToString('o')
stage = $expected
})
try {
$beforePins = Assert-PinsExact -Pinned $manifest.pins
Write-NewJson -Path (Join-Path $stageDirectory 'pins-before.json') -Value $beforePins
} catch {
New-StopLatch -Directory $directory -Reason 'Pinned runtime identity changed before stage dispatch.' `
-StageDirectory $stageDirectory -Details $_.Exception.Message
throw
}
$nodeResult = Invoke-NodeStage -Manifest $manifest -ExpectedStage $expected -OutputDirectory $stageDirectory
Write-NewJson -Path (Join-Path $stageDirectory 'node-result.json') -Value $nodeResult
$postIdentityError = $null
try {
$afterPins = Assert-PinsExact -Pinned $manifest.pins
Write-NewJson -Path (Join-Path $stageDirectory 'pins-after.json') -Value $afterPins
} catch { $postIdentityError = $_.Exception.Message }
$capture = $null
$captureError = $null
if ([string]::IsNullOrWhiteSpace($postIdentityError)) {
try {
$previousNetworkPath = Get-PreviousNetworkPath -Directory $directory
# Exclude the current stage file if an earlier partial capture somehow exists.
if ($previousNetworkPath -like "$stageDirectory*") {
$previousCandidates = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter 'network-full.txt' |
Where-Object { $_.FullName -notlike "$stageDirectory*" } | Sort-Object FullName)
$previousNetworkPath = if ($previousCandidates.Count -gt 0) { $previousCandidates[-1].FullName } else { '' }
}
$capture = Capture-ReadOnlyEvidence -Pins $manifest.pins -OutputDirectory $stageDirectory `
-PreviousNetworkPath $previousNetworkPath
$afterCapturePins = Assert-PinsExact -Pinned $manifest.pins
Write-NewJson -Path (Join-Path $stageDirectory 'pins-after-capture.json') -Value $afterCapturePins
} catch { $captureError = $_.Exception.Message }
} else {
$captureError = 'Capture was skipped because the pinned runtime identity had already changed.'
}
$failureReasons = @()
if (-not $nodeResult.ok) { $failureReasons += 'Node one-shot stage failed, timed out, or returned invalid JSON.' }
if (-not [string]::IsNullOrWhiteSpace($postIdentityError)) { $failureReasons += 'Pinned runtime identity changed after stage execution.' }
if (-not [string]::IsNullOrWhiteSpace($captureError)) { $failureReasons += 'Read-only evidence capture or rolling Network anchor validation failed.' }
if ($failureReasons.Count -gt 0) {
$failure = [ordered]@{
failedAtUtc = [DateTime]::UtcNow.ToString('o')
reasons = $failureReasons
node = $nodeResult
postIdentityError = $postIdentityError
captureError = $captureError
capture = $capture
}
Write-NewJson -Path (Join-Path $stageDirectory 'FAILURE.json') -Value $failure
New-StopLatch -Directory $directory -Reason ($failureReasons -join ' ') `
-StageDirectory $stageDirectory -Details $failure
throw "Stage failed and the run is now stopped: $($failureReasons -join ' ')"
}
$success = [ordered]@{
schemaVersion = 1
sequence = $sequence
completedAtUtc = [DateTime]::UtcNow.ToString('o')
stage = $expected
node = $nodeResult.parsed
capture = $capture
}
Write-NewJson -Path (Join-Path $stageDirectory 'SUCCESS.json') -Value $success
return $success
} finally {
if ($null -ne $lockStream) { $lockStream.Dispose() }
if ([IO.File]::Exists($lockPath)) { [IO.File]::Delete($lockPath) }
}
}
switch ($Action) {
'StaticAudit' {
Invoke-StaticAudit | ConvertTo-Json -Depth 40 -Compress
}
'DryPreflight' {
$pins = Get-CurrentPins
[ordered]@{
ok = $true
action = 'DryPreflight'
inspectedAtUtc = [DateTime]::UtcNow.ToString('o')
pins = $pins
evidenceWritten = $false
cdpAttached = $false
nativeMessagesSent = 0
} | ConvertTo-Json -Depth 40 -Compress
}
'Initialize' {
Invoke-InitializeRun | ConvertTo-Json -Depth 40 -Compress
}
'Inspect' {
Invoke-InspectRun | ConvertTo-Json -Depth 40 -Compress
}
'RunStage' {
Invoke-RunStage -UseExpectedStage $false | ConvertTo-Json -Depth 40 -Compress
}
'RunNext' {
Invoke-RunStage -UseExpectedStage $true | ConvertTo-Json -Depth 40 -Compress
}
default { throw "Unsupported action: $Action" }
}