Public/Start-EnvkAppSequence.ps1


#Requires -Version 5.1
<#
.SYNOPSIS
    Sequential app launch loop: resolves, validates, launches, and maximizes each app
    in configuration order, returning a structured per-app result array.
 
.DESCRIPTION
    Start-EnvkAppSequence is the core value delivery of Phase 6. It composes all prior
    building blocks (Resolve-AppConfig, Resolve-EnvkExecutablePath, Invoke-WindowMaximize)
    into an end-to-end sequential launch function suitable for PS 5.1 (and used as
    fallback in PS 7+ parallel mode from Phase 7).
 
    Execution flow:
      1. Use pre-resolved Apps if provided; otherwise call Resolve-AppConfig.
      2. Read windowTimeoutSeconds from $Config.settings (default 120 if absent).
      3. Set $script:TimeoutMs for Invoke-WindowMaximize (module-scope variable).
      4. Start total stopwatch for summary timing.
      5. For each app (sequential foreach):
           a. Start per-app stopwatch.
           b. Resolve-EnvkExecutablePath for just-in-time path validation.
              - Valid=$false -> Skipped result, continue.
           c. Get-AppProcessIds by ProcessName + path to check if already running.
              - Returns PIDs -> AlreadyRunning result, call Invoke-WindowMaximize with existing PIDs, continue.
           d. Launch:
              - UWP (path -like 'shell:AppsFolder\*'): Start-Process without -PassThru.
                Poll Get-Process by ProcessName until found or $script:TimeoutMs elapsed.
              - Win32: Start-Process -PassThru to capture PID directly.
              - If Start-Process throws -> Failed result, continue.
           e. Grace period: Start-Sleep -Milliseconds $script:GracePeriodMs.
           f. Invoke-WindowMaximize with resolved PID.
              - Success=$false -> WARNING log; Status is still 'Launched'.
           g. Add Launched result.
      6. Emit summary log line.
      7. Return results array.
 
    Result object shape (per app):
      AppName [string] -- Display name from catalog key
      Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped'
      ElapsedMs [int] -- Milliseconds for this app (launch + maximize)
      MaximizeResult [PSCustomObject] -- From Invoke-WindowMaximize, or $null on Failed/Skipped
      ErrorMessage [string] -- Non-null on Failed or Skipped; $null on success
 
    Summary log line format (CONTEXT.md):
      "Launch complete: N launched, N already running, N failed (AppName). Total: N apps, Ns"
      Skipped count included only if > 0.
 
.PARAMETER Config
    The parsed config PSCustomObject returned by Get-EnvkConfig (ConvertFrom-Json output).
    Used to call Resolve-AppConfig and to read settings.windowTimeoutSeconds.
 
.PARAMETER EnvironmentName
    The resolved environment name from Select-EnvkEnvironment. Passed directly to Resolve-AppConfig.
 
.PARAMETER Apps
    Optional pre-resolved app list. When provided, the internal Resolve-AppConfig call
    is skipped and this array is used directly. Used by Invoke-EnvkStartup to
    avoid resolving the app list twice when it has already called Resolve-AppConfig.
 
.PARAMETER EnableDebug
    When set, passed through to Invoke-WindowMaximize for detailed UIAutomation logging.
 
.OUTPUTS
    [PSCustomObject[]]
    Array of per-app result objects. One element per app in the resolved config order.
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
 
    TIMING: $script:TimeoutMs is set at the start of each invocation from
    $Config.settings.windowTimeoutSeconds (default 120s). This overwrites the
    value set by Invoke-WindowMaximize.ps1 at import time. Sequential execution
    means there is no race condition on this shared module-scope variable.
 
    GRACE PERIOD: $script:GracePeriodMs = 2000 is set at file scope. Tests override
    it to 0 inside InModuleScope to avoid real sleeps. Same pattern as PollIntervalMs
    in Invoke-WindowMaximize.ps1.
#>


# PSUseOutputTypeCorrectly: the unary comma operator forces a PowerShell Object[] wrapper
# around the List.ToArray() result to prevent single-element unboxing through the pipeline.
# The element type is always PSCustomObject; the Object[] container is an implementation detail.
# PSUseShouldProcessForStateChangingFunctions: Start-EnvkAppSequence is an orchestration function
# that delegates all state changes to Start-Process (which supports -WhatIf natively) and to
# Invoke-WindowMaximize. Adding SupportsShouldProcess at this level would leave $launchedPid
# null in -WhatIf mode and break the post-launch window maximization path.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
param ()

# ---------------------------------------------------------------------------
# Script-scope configuration -- overridable in tests via InModuleScope.
# ---------------------------------------------------------------------------
$script:GracePeriodMs = 2000

function Start-EnvkAppSequence {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject]$Config,

        [Parameter(Mandatory)]
        [string]$EnvironmentName,

        [Parameter()]
        [PSCustomObject[]]$Apps,

        [Parameter()]
        [switch]$EnableDebug
    )

    # ------------------------------------------------------------------
    # Step 1: Use pre-resolved apps if provided; otherwise resolve internally.
    # ------------------------------------------------------------------
    if ($null -ne $Apps -and $Apps.Count -gt 0) {
        $apps = $Apps
    } else {
        $apps = Resolve-AppConfig -Config $Config -EnvironmentName $EnvironmentName
    }

    # ------------------------------------------------------------------
    # Step 2: Read timeout from config.settings; default 120s.
    # ------------------------------------------------------------------
    $windowTimeoutSeconds = 120
    if ($null -ne $Config.PSObject.Properties['settings'] -and
        $null -ne $Config.settings.PSObject.Properties['windowTimeoutSeconds']) {
        $windowTimeoutSeconds = [int]$Config.settings.windowTimeoutSeconds
    }

    # ------------------------------------------------------------------
    # Step 3: Set module-scope TimeoutMs for Invoke-WindowMaximize.
    # Sequential execution: no race condition on this shared variable.
    # ------------------------------------------------------------------
    $script:TimeoutMs = $windowTimeoutSeconds * 1000

    # ------------------------------------------------------------------
    # Step 4: Start total stopwatch for summary timing.
    # ------------------------------------------------------------------
    $totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    # Accumulate results and status counters.
    $results        = [System.Collections.Generic.List[PSCustomObject]]::new()
    $launchedCount  = 0
    $alreadyCount   = 0
    $failedCount    = 0
    $skippedCount   = 0
    $failedNames    = [System.Collections.Generic.List[string]]::new()

    # ------------------------------------------------------------------
    # Step 5: Sequential per-app launch loop.
    # ------------------------------------------------------------------
    foreach ($app in $apps) {
        $appStopwatch = [System.Diagnostics.Stopwatch]::StartNew()

        try {
            # ----------------------------------------------------------
            # Step 5a: Just-in-time path validation.
            # ----------------------------------------------------------
            $pathResult = Resolve-EnvkExecutablePath -Path $app.Path

            if (-not $pathResult.Valid) {
                $errMsg = "Path validation failed for '$($app.Name)': $($pathResult.Reason)"
                Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: $errMsg"
                $appStopwatch.Stop()
                $results.Add([PSCustomObject]@{
                    AppName        = $app.Name
                    Status         = 'Skipped'
                    ElapsedMs      = [int]$appStopwatch.ElapsedMilliseconds
                    MaximizeResult = $null
                    ErrorMessage   = $errMsg
                })
                $skippedCount++
                continue
            }

            # ----------------------------------------------------------
            # Step 5b: Already-running detection.
            # ----------------------------------------------------------
            # Get-AppProcessIds filters by executable path before expanding descendants,
            # preventing false positives from process name collisions (e.g., the Claude
            # desktop app and the Claude Code VSCode extension both run processes named
            # 'claude'; only the path-matching PIDs should be passed to Invoke-WindowMaximize).
            # -RequireWindow excludes suspended UWP processes that have no visible window
            # (MainWindowHandle = IntPtr.Zero), preventing false-positive already-running
            # detection for apps that leave background processes after being closed.
            $existingPids = Get-AppProcessIds -ProcessName $app.ProcessName -ConfigPath $app.Path -Arguments $app.Arguments -RequireWindow

            if ($existingPids.Count -gt 0) {
                Write-EnvkLog -Level 'INFO' -Message "Start-EnvkAppSequence: '$($app.Name)' already running ($($existingPids.Count) PID(s)) -- skipping launch, maximizing existing window"
                $maxResult = Invoke-WindowMaximize -Name $app.Name -ProcessIds $existingPids -SkipMaximize:$app.SkipMaximize -EnableDebug:$EnableDebug
                $appStopwatch.Stop()
                $results.Add([PSCustomObject]@{
                    AppName        = $app.Name
                    Status         = 'AlreadyRunning'
                    ElapsedMs      = [int]$appStopwatch.ElapsedMilliseconds
                    MaximizeResult = $maxResult
                    ErrorMessage   = $null
                })
                $alreadyCount++
                continue
            }

            # ----------------------------------------------------------
            # Step 5c: Launch the application.
            # ----------------------------------------------------------
            $isUwp = $app.Path -like 'shell:AppsFolder\*'
            $launchedPid = $null

            $startParams = @{
                FilePath    = $app.Path
                ErrorAction = 'Stop'
            }

            if (-not [string]::IsNullOrEmpty($app.Arguments)) {
                $startParams['ArgumentList'] = $app.Arguments
            }

            if ($isUwp) {
                # UWP: Start-Process without -PassThru (returned PID would be the
                # intermediate shell process, not the real app process).
                # Assign to $null to prevent any pipeline output from being collected.
                $null = Start-Process @startParams

                # Grace period: wait for UWP app to initialize before polling.
                Start-Sleep -Milliseconds $script:GracePeriodMs

                # Poll using path-filtered Get-AppProcessIds (not raw Get-Process -Name) to
                # avoid capturing the wrong PID when another process shares the same name
                # (e.g., Claude Code VSCode extension running alongside Claude UWP desktop app).
                # Uses exponential backoff: first miss sleeps $script:InitialPollMs,
                # each subsequent miss doubles up to $script:MaxPollMs.
                # Do NOT pass -RequireWindow here -- the app may not yet have a visible window.
                $pollStart    = [System.Diagnostics.Stopwatch]::StartNew()
                $uwpPollInterval = $script:InitialPollMs
                $uwpAllPids = $null
                while ($null -eq $uwpAllPids -and $pollStart.ElapsedMilliseconds -lt $script:TimeoutMs) {
                    $polledPids = Get-AppProcessIds -ProcessName $app.ProcessName -ConfigPath $app.Path
                    if ($polledPids.Count -gt 0) {
                        # Keep ALL polled PIDs — UWP apps spawn sibling processes (not parent-child)
                        # and the window-owning PID may not be the first one returned. Passing all
                        # PIDs to Invoke-WindowMaximize via -ProcessIds ensures the window search
                        # covers every path-matching process.
                        $uwpAllPids  = $polledPids
                        $launchedPid = $polledPids[0]
                    }
                    else {
                        Start-Sleep -Milliseconds $uwpPollInterval
                        $uwpPollInterval = [Math]::Min($uwpPollInterval * $script:BackoffFactor, $script:MaxPollMs)
                    }
                }
                $pollStart.Stop()

                if ($null -eq $launchedPid) {
                    $errMsg = "UWP process '$($app.ProcessName)' did not appear within timeout for '$($app.Name)'"
                    Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: $errMsg"
                    # Use PID 0 as sentinel -- Invoke-WindowMaximize will timeout and return failure
                    $launchedPid = 0
                }
            }
            else {
                # Win32: Start-Process -PassThru captures the launched PID directly.
                $startParams['PassThru'] = $true
                $launchedProcess = Start-Process @startParams
                $launchedPid = $launchedProcess.Id

                # Grace period: wait for Win32 app window to initialize.
                Start-Sleep -Milliseconds $script:GracePeriodMs
            }

            # ----------------------------------------------------------
            # Step 5d: Window maximization.
            # ----------------------------------------------------------
            # UWP apps spawn sibling processes (not parent-child). If we only pass the first
            # PID via -ProcessId, descendant expansion won't reach the window-owning sibling.
            # Pass all path-matching PIDs via -ProcessIds so the window search covers all of them.
            if ($null -ne $uwpAllPids) {
                $maxResult = Invoke-WindowMaximize -Name $app.Name -ProcessIds $uwpAllPids -SkipMaximize:$app.SkipMaximize -EnableDebug:$EnableDebug
            }
            else {
                $maxResult = Invoke-WindowMaximize -Name $app.Name -ProcessId $launchedPid -SkipMaximize:$app.SkipMaximize -EnableDebug:$EnableDebug
            }

            if (-not $maxResult.Success) {
                Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: window maximize did not succeed for '$($app.Name)' (Strategy=$($maxResult.Strategy)) -- app still launched, continuing"
            }

            $appStopwatch.Stop()
            $results.Add([PSCustomObject]@{
                AppName        = $app.Name
                Status         = 'Launched'
                ElapsedMs      = [int]$appStopwatch.ElapsedMilliseconds
                MaximizeResult = $maxResult
                ErrorMessage   = $null
            })
            $launchedCount++
        }
        catch {
            # Safety net: catch unanticipated errors (including Start-Process throws).
            $errMsg = $_.Exception.Message
            Write-EnvkLog -Level 'ERROR' -Message "Start-EnvkAppSequence: failed to launch '$($app.Name)' -- $errMsg"
            $appStopwatch.Stop()
            $results.Add([PSCustomObject]@{
                AppName        = $app.Name
                Status         = 'Failed'
                ElapsedMs      = [int]$appStopwatch.ElapsedMilliseconds
                MaximizeResult = $null
                ErrorMessage   = $errMsg
            })
            $failedCount++
            $failedNames.Add($app.Name)
        }
    }

    # ------------------------------------------------------------------
    # Step 6: Emit summary log line.
    # Format: "Launch complete: N launched, N already running, N failed (Names). Total: N apps, Xs"
    # ------------------------------------------------------------------
    $totalStopwatch.Stop()
    $totalSecs   = [math]::Round($totalStopwatch.Elapsed.TotalSeconds, 1)
    $totalApps   = $results.Count

    $summaryParts = [System.Collections.Generic.List[string]]::new()
    $summaryParts.Add("$launchedCount launched")
    $summaryParts.Add("$alreadyCount already running")

    if ($failedCount -gt 0) {
        $failedNamesStr = $failedNames -join ', '
        $summaryParts.Add("$failedCount failed ($failedNamesStr)")
    }
    else {
        $summaryParts.Add('0 failed')
    }

    if ($skippedCount -gt 0) {
        $summaryParts.Add("$skippedCount skipped")
    }

    $summaryLine = "Launch complete: $($summaryParts -join ', '). Total: $totalApps apps, $($totalSecs)s"
    Write-EnvkLog -Level 'INFO' -Message $summaryLine

    # ------------------------------------------------------------------
    # Step 7: Return results array.
    # ------------------------------------------------------------------
    return , $results.ToArray()
}