workflows/default/systems/ui/modules/ProcessAPI.psm1
|
<# .SYNOPSIS Process management API module .DESCRIPTION Provides process listing, output streaming, stop/kill operations, whisper messaging, and process launching. Extracted from server.ps1 for modularity. #> $script:Config = @{ ProcessesDir = $null BotRoot = $null ControlDir = $null } Import-Module (Join-Path $PSScriptRoot "..\..\runtime\modules\ConsoleSequenceSanitizer.psm1") function Update-ActivityEventFields { param( [Parameter(Mandatory)] [object]$Event ) if ($Event.PSObject.Properties['message']) { $Event.message = ConvertTo-SanitizedConsoleText $Event.message } return $Event } function Initialize-ProcessAPI { param( [Parameter(Mandatory)] [string]$ProcessesDir, [Parameter(Mandatory)] [string]$BotRoot, [Parameter(Mandatory)] [string]$ControlDir ) $script:Config.ProcessesDir = $ProcessesDir $script:Config.BotRoot = $BotRoot $script:Config.ControlDir = $ControlDir } function Get-ProcessList { param( [string]$FilterType, [string]$FilterStatus ) $processesDir = $script:Config.ProcessesDir $processList = @() $processFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue $now = [DateTime]::UtcNow foreach ($pf in $processFiles) { try { $proc = Get-Content $pf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json # TTL cleanup: remove failed/stopped processes older than 5 minutes if ($proc.status -in @('failed', 'stopped') -and $proc.failed_at) { $failedTime = [DateTime]::Parse($proc.failed_at) if (($now - $failedTime).TotalMinutes -gt 5) { Remove-Item $pf.FullName -Force -ErrorAction SilentlyContinue # Also remove activity and whisper files $actFile = Join-Path $processesDir "$($proc.id).activity.jsonl" $whisperFile = Join-Path $processesDir "$($proc.id).whisper.jsonl" $stopFile = Join-Path $processesDir "$($proc.id).stop" Remove-Item $actFile -Force -ErrorAction SilentlyContinue Remove-Item $whisperFile -Force -ErrorAction SilentlyContinue Remove-Item $stopFile -Force -ErrorAction SilentlyContinue continue } } # Detect dead PIDs for running/starting processes if ($proc.status -in @('running', 'starting') -and $proc.pid) { $isAlive = $null -ne (Get-Process -Id $proc.pid -ErrorAction SilentlyContinue) if (-not $isAlive) { $proc.status = 'stopped' $proc.failed_at = $now.ToString("o") $proc | Add-Member -NotePropertyName 'error' -NotePropertyValue "Process terminated unexpectedly" -Force $proc = Update-ProcessHeartbeatFields -Process $proc $proc | ConvertTo-Json -Depth 10 | Set-Content -Path $pf.FullName -Force -ErrorAction Stop # Write activity log so the PROCESSES tab output shows what happened $actFile = Join-Path $processesDir "$($proc.id).activity.jsonl" $event = @{ timestamp = $now.ToString("o"); type = "text"; message = "Process terminated unexpectedly (PID $($proc.pid) no longer alive)" } | ConvertTo-Json -Compress Add-Content -Path $actFile -Value $event -ErrorAction SilentlyContinue } } $processList += (Update-ProcessHeartbeatFields -Process $proc) } catch { Write-BotLog -Level Debug -Message "Logging operation failed" -Exception $_ } } # Apply query filters if present if ($FilterType) { $processList = @($processList | Where-Object { $_.type -eq $FilterType }) } if ($FilterStatus) { $processList = @($processList | Where-Object { $_.status -eq $FilterStatus }) } return @{ processes = @($processList) } } function Get-ProcessOutput { param( [Parameter(Mandatory)] [string]$ProcessId, [int]$Position = 0, [int]$Tail = 50 ) $processesDir = $script:Config.ProcessesDir $activityFile = Join-Path $processesDir "$ProcessId.activity.jsonl" if ($Tail -le 0) { $Tail = 50 } if (Test-Path $activityFile) { try { $fs = [System.IO.FileStream]::new($activityFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) $sr = [System.IO.StreamReader]::new($fs, [System.Text.Encoding]::UTF8) $allText = $sr.ReadToEnd() $sr.Close(); $fs.Close() $allLines = @($allText -split "`n" | Where-Object { $_.Trim() }) $totalLines = $allLines.Count $events = @() $startIdx = if ($Position -gt 0) { $Position } else { [Math]::Max(0, $totalLines - $Tail) } for ($li = $startIdx; $li -lt $totalLines; $li++) { try { $events += (Update-ActivityEventFields -Event ($allLines[$li] | ConvertFrom-Json)) } catch { Write-BotLog -Level Debug -Message "Malformed JSONL line in activity log" -Exception $_ } } return @{ events = @($events) position = $totalLines total = $totalLines } } catch { return @{ events = @(); position = 0; error = "$_" } } } else { return @{ events = @(); position = 0 } } } function Stop-ProcessById { param( [Parameter(Mandatory)] [string]$ProcessId ) $processesDir = $script:Config.ProcessesDir $stopFile = Join-Path $processesDir "$ProcessId.stop" "stop" | Set-Content -Path $stopFile -Force Write-Status "Stop signal sent to process $ProcessId" -Type Info return @{ success = $true; process_id = $ProcessId; message = "Stop signal sent" } } function Stop-ManagedProcessById { param( [Parameter(Mandatory)] [string]$ProcessId ) $processesDir = $script:Config.ProcessesDir $procFile = Join-Path $processesDir "$ProcessId.json" if (Test-Path $procFile) { try { $procData = Get-Content $procFile -Raw | ConvertFrom-Json $pid = $procData.pid if ($pid) { Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue } # Update process registry $procData.status = "stopped" $procData | Add-Member -NotePropertyName "failed_at" -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force $procData | ConvertTo-Json -Depth 10 | Set-Content -Path $procFile -Force -Encoding utf8NoBOM # Create stop signal file for cleanup $stopFile = Join-Path $processesDir "$ProcessId.stop" "stop" | Set-Content -Path $stopFile -Force Write-Status "Killed process $ProcessId (PID: $pid)" -Type Warn return @{ success = $true; process_id = $ProcessId; message = "Process killed (PID: $pid)" } } catch { return @{ success = $false; error = "Kill failed: $_" } } } else { return @{ _statusCode = 404; error = "Process not found: $ProcessId" } } } function Stop-ProcessByType { param( [Parameter(Mandatory)] [string]$Type ) $processesDir = $script:Config.ProcessesDir $stopped = @() $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json if ($pData.type -eq $Type -and ($pData.status -eq "running" -or $pData.status -eq "starting")) { $stopFile = Join-Path $processesDir "$($pData.id).stop" "stop" | Set-Content -Path $stopFile -Force $stopped += $pData.id } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } Write-Status "Stop signal sent to $($stopped.Count) $Type process(es)" -Type Info return @{ success = $true; stopped = $stopped; count = $stopped.Count } } function Stop-ManagedProcessByType { param( [Parameter(Mandatory)] [string]$Type ) $processesDir = $script:Config.ProcessesDir $killed = @() $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json if ($pData.type -eq $Type -and ($pData.status -eq "running" -or $pData.status -eq "starting")) { if ($pData.pid) { Stop-Process -Id $pData.pid -Force -ErrorAction SilentlyContinue } $pData.status = "stopped" $pData | Add-Member -NotePropertyName "failed_at" -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force $pData | ConvertTo-Json -Depth 10 | Set-Content -Path $pf.FullName -Force -Encoding utf8NoBOM $stopFile = Join-Path $processesDir "$($pData.id).stop" "stop" | Set-Content -Path $stopFile -Force $killed += $pData.id } } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to stop process" -Exception $_ } } Write-Status "Killed $($killed.Count) $Type process(es)" -Type Warn return @{ success = $true; killed = $killed; count = $killed.Count } } function Stop-AllManagedProcesses { $processesDir = $script:Config.ProcessesDir $killed = @() $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json if ($pData.status -eq "running" -or $pData.status -eq "starting") { if ($pData.pid) { Stop-Process -Id $pData.pid -Force -ErrorAction SilentlyContinue } $pData.status = "stopped" $pData | Add-Member -NotePropertyName "failed_at" -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force $pData | ConvertTo-Json -Depth 10 | Set-Content -Path $pf.FullName -Force -Encoding utf8NoBOM $stopFile = Join-Path $processesDir "$($pData.id).stop" "stop" | Set-Content -Path $stopFile -Force $killed += $pData.id } } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to stop process" -Exception $_ } } Write-Status "Killed all processes ($($killed.Count) total)" -Type Warn return @{ success = $true; killed = $killed; count = $killed.Count } } function Send-ProcessWhisper { param( [Parameter(Mandatory)] [string]$ProcessId, [Parameter(Mandatory)] [string]$Message, [string]$Priority = "normal" ) $processesDir = $script:Config.ProcessesDir $whisperFile = Join-Path $processesDir "$ProcessId.whisper.jsonl" $whisper = @{ instruction = $Message priority = $Priority timestamp = (Get-Date).ToUniversalTime().ToString("o") } | ConvertTo-Json -Compress Add-Content -Path $whisperFile -Value $whisper -Encoding utf8NoBOM Write-Status "Whisper sent to process $ProcessId" -Type Success return @{ success = $true; process_id = $ProcessId } } function Get-ProcessDetail { param( [Parameter(Mandatory)] [string]$ProcessId ) $processesDir = $script:Config.ProcessesDir $procFile = Join-Path $processesDir "$ProcessId.json" if (Test-Path $procFile) { return Update-ProcessHeartbeatFields -Process (Get-Content $procFile -Raw | ConvertFrom-Json) } else { return @{ _statusCode = 404; error = "Process not found: $ProcessId" } } } function Get-MaxConcurrent { $botRoot = $script:Config.BotRoot $controlDir = $script:Config.ControlDir $maxConcurrent = 1 $settingsPath = Join-Path $botRoot "settings\settings.default.json" $controlSettingsPath = Join-Path $controlDir "settings.json" foreach ($sp in @($controlSettingsPath, $settingsPath)) { if (Test-Path $sp) { try { $s = Get-Content $sp -Raw | ConvertFrom-Json if ($s.scoring -and $s.scoring.max_concurrent_scores -and [int]$s.scoring.max_concurrent_scores -gt $maxConcurrent) { $maxConcurrent = [int]$s.scoring.max_concurrent_scores } if ($s.execution -and $s.execution.max_concurrent -and [int]$s.execution.max_concurrent -gt $maxConcurrent) { $maxConcurrent = [int]$s.execution.max_concurrent } if ($maxConcurrent -gt 1) { break } } catch { Write-BotLog -Level Debug -Message "Failed to parse max_concurrent setting" -Exception $_ } } } return $maxConcurrent } function Start-ProcessLaunch { param( [Parameter(Mandatory)] [string]$Type, [string]$TaskId, [string]$Prompt, [bool]$Continue = $false, [string]$Description, [string]$Model, [string]$WorkflowName, [int]$Slot = -1 ) $processesDir = $script:Config.ProcessesDir $botRoot = $script:Config.BotRoot $controlDir = $script:Config.ControlDir # Auto-concurrent: when launching a workflow without an explicit slot, # check max_concurrent and delegate to Start-ConcurrentWorkflow if > 1. if ($Type -eq 'task-runner' -and $Slot -lt 0) { $maxConcurrent = Get-MaxConcurrent if ($maxConcurrent -gt 1) { return Start-ConcurrentWorkflow -WorkflowName $WorkflowName -Description $Description -MaxConcurrent $maxConcurrent } } $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1" if (-not (Test-Path $launcherPath)) { return @{ success = $false; error = "Launcher script not found" } } # Build arguments $launchArgs = @("-File", "`"$launcherPath`"", "-Type", $Type) if ($TaskId) { $launchArgs += @("-TaskId", $TaskId) } if ($Prompt) { $launchArgs += @("-Prompt", "`"$($Prompt -replace '"', '\"')`"") } if ($Continue) { $launchArgs += "-Continue" } if ($Description) { $launchArgs += @("-Description", "`"$($Description -replace '"', '\"')`"") } # Only pass -Model when explicitly provided; otherwise let launch-process.ps1 resolve from settings if ($Model) { $launchArgs += @("-Model", $Model) } if ($WorkflowName) { $launchArgs += @("-Workflow", $WorkflowName) } if ($Slot -ge 0) { $launchArgs += @("-Slot", $Slot) } # Check settings for debug/verbose $settingsFile = Join-Path $controlDir "ui-settings.json" if (Test-Path $settingsFile) { try { $uiSettings = Get-Content $settingsFile -Raw | ConvertFrom-Json if ([bool]$uiSettings.showDebug) { $launchArgs += "-ShowDebug" } if ([bool]$uiSettings.showVerbose) { $launchArgs += "-ShowVerbose" } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Launch as separate process $startParams = @{ ArgumentList = $launchArgs; PassThru = $true } if ($IsWindows) { $startParams.WindowStyle = 'Normal' } $proc = Start-Process pwsh @startParams # Wait briefly for process file to be created Start-Sleep -Milliseconds 500 # Find the process ID from the registry (most recent by started_at) $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending $launchedProcId = $null foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json if ($pData.pid -eq $proc.Id) { $launchedProcId = $pData.id break } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } $slotSegment = if ($Slot -ge 0) { ", Slot: $Slot" } else { "" } Write-Status "Launched $Type process (PID: $($proc.Id)$slotSegment)" -Type Success return @{ success = $true process_id = $launchedProcId pid = $proc.Id type = $Type model = $Model slot = $Slot } } function Start-ConcurrentWorkflow { param( [string]$WorkflowName, [string]$Description, [int]$MaxConcurrent = 1 ) $results = @() for ($slot = 0; $slot -lt $MaxConcurrent; $slot++) { $desc = if ($MaxConcurrent -gt 1) { "$Description (slot $slot)" } else { $Description } $result = Start-ProcessLaunch -Type 'task-runner' -Continue $true -Description $desc -WorkflowName $WorkflowName -Slot $slot $results += $result if ($slot -lt $MaxConcurrent - 1) { Start-Sleep -Milliseconds 300 } } return @{ success = $true slots_launched = $results.Count processes = $results } } Export-ModuleMember -Function @( 'Initialize-ProcessAPI', 'Get-ProcessList', 'Get-ProcessOutput', 'Stop-ProcessById', 'Stop-ManagedProcessById', 'Stop-ProcessByType', 'Stop-ManagedProcessByType', 'Stop-AllManagedProcesses', 'Send-ProcessWhisper', 'Get-ProcessDetail', 'Start-ProcessLaunch', 'Start-ConcurrentWorkflow' ) |