param( [Parameter(Mandatory = $true)] [string]$WorkerName, [Parameter(Mandatory = $true)] [string]$WorkerType, [string]$PayloadBase64, [string]$PayloadBase64Path, [string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'), [int]$MaxRestarts = 5, [int]$RestartDelaySeconds = 10 ) $ErrorActionPreference = 'Stop' [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace = $Host.Runspace # region Path setup $workerRoot = Join-Path -Path $DataRoot -ChildPath $WorkerType $instanceRoot = Join-Path -Path $workerRoot -ChildPath $WorkerName New-Item -ItemType Directory -Path $instanceRoot -Force | Out-Null $logsRoot = Join-Path -Path $instanceRoot -ChildPath 'logs' $stateRoot = Join-Path -Path $instanceRoot -ChildPath 'state' New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null $logPath = Join-Path -Path $logsRoot -ChildPath 'worker.log' $metaPath = Join-Path -Path $stateRoot -ChildPath 'worker-info.json' $commandPath = Join-Path -Path $stateRoot -ChildPath 'commands.txt' $payloadPath = Join-Path -Path $stateRoot -ChildPath "payload.ps1" # endregion # region Logging $logStream = [System.IO.FileStream]::new( $logPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite ) $logWriter = [System.IO.StreamWriter]::new($logStream, [System.Text.Encoding]::UTF8) $logWriter.AutoFlush = $true if (-not ("UnifiedWorkers.WorkerLogSink" -as [type])) { Add-Type -Namespace UnifiedWorkers -Name WorkerLogSink -MemberDefinition @" using System; using System.Diagnostics; using System.IO; public sealed class WorkerLogSink { private readonly TextWriter _writer; private readonly string _prefix; private readonly object _sync = new object(); public WorkerLogSink(TextWriter writer, string prefix) { if (writer == null) { throw new ArgumentNullException(\"writer\"); } if (prefix == null) { throw new ArgumentNullException(\"prefix\"); } _writer = writer; _prefix = prefix; } public void OnData(object sender, DataReceivedEventArgs e) { if (e == null || string.IsNullOrEmpty(e.Data)) { return; } lock (_sync) { var timestamp = DateTime.UtcNow.ToString("u"); var line = string.Format("[{0} {1}] {2}", _prefix, timestamp, e.Data); _writer.WriteLine(line); _writer.Flush(); } } } "@ } function Write-LogLine { param( [string]$Prefix, [string]$Message ) if (-not $logWriter) { return } $timestamp = (Get-Date).ToString('u') $logWriter.WriteLine("[{0} {1}] {2}" -f $Prefix, $timestamp, $Message) } function Write-ControllerLog { param([string]$Message) Write-LogLine -Prefix 'CTRL' -Message $Message } # endregion # region Helpers function Resolve-PayloadBase64 { if ($PayloadBase64) { return $PayloadBase64 } if ($PayloadBase64Path) { if (-not (Test-Path $PayloadBase64Path)) { throw "Payload file '$PayloadBase64Path' not found." } return (Get-Content -Path $PayloadBase64Path -Raw) } throw "No payload data provided to controller." } function Write-Metadata { param( [string]$Status, [nullable[int]]$WorkerPid = $null, [nullable[int]]$ControllerPid = $PID, [int]$Restarts = 0, [nullable[int]]$LastExitCode = $null ) $payload = [pscustomobject]@{ WorkerName = $WorkerName WorkerType = $WorkerType Status = $Status ControllerPid = $ControllerPid WorkerPid = $WorkerPid Restarts = $Restarts LastExitCode = $LastExitCode LogPath = $logPath CommandPath = $commandPath PayloadPath = $payloadPath UpdatedAtUtc = (Get-Date).ToUniversalTime() } $payload | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8 } function Get-PendingCommands { if (-not (Test-Path $commandPath)) { return @() } try { $lines = Get-Content -Path $commandPath -ErrorAction Stop Remove-Item -Path $commandPath -Force -ErrorAction SilentlyContinue return $lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } } catch { return @() } } # endregion # record initial state before launching worker Write-Metadata -Status 'initializing' -WorkerPid $null -ControllerPid $PID -Restarts 0 $resolvedPayloadBase64 = Resolve-PayloadBase64 $PayloadBase64 = $resolvedPayloadBase64 try { # Write payload script to disk $payloadBytes = [Convert]::FromBase64String($resolvedPayloadBase64) [IO.File]::WriteAllBytes($payloadPath, $payloadBytes) Write-ControllerLog "Payload written to $payloadPath" } catch { Write-Error "Unable to write payload: $($_.Exception.Message)" exit 1 } $restartCount = 0 $controllerPid = $PID while ($restartCount -le $MaxRestarts) { try { # Initialize worker process $psi = [System.Diagnostics.ProcessStartInfo]::new() $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue if ($pwsh) { $psi.FileName = $pwsh.Source } else { $psi.FileName = (Get-Command powershell -ErrorAction Stop).Source } $psi.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$payloadPath`"" $psi.UseShellExecute = $false $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $workerProcess = New-Object System.Diagnostics.Process $workerProcess.StartInfo = $psi if (-not $workerProcess.Start()) { throw "Failed to start worker process." } Write-ControllerLog "Worker process started with PID $($workerProcess.Id)" Write-Metadata -Status 'running' -WorkerPid $workerProcess.Id -ControllerPid $controllerPid -Restarts $restartCount $stdoutSink = [UnifiedWorkers.WorkerLogSink]::new($logWriter, 'OUT') $stderrSink = [UnifiedWorkers.WorkerLogSink]::new($logWriter, 'ERR') $outputHandler = [System.Diagnostics.DataReceivedEventHandler]$stdoutSink.OnData $errorHandler = [System.Diagnostics.DataReceivedEventHandler]$stderrSink.OnData $workerProcess.add_OutputDataReceived($outputHandler) $workerProcess.add_ErrorDataReceived($errorHandler) $workerProcess.BeginOutputReadLine() $workerProcess.BeginErrorReadLine() while (-not $workerProcess.HasExited) { $commands = Get-PendingCommands foreach ($command in $commands) { $trimmed = $command.Trim() if (-not $trimmed) { continue } Write-ControllerLog "Received command '$trimmed'" try { $workerProcess.StandardInput.WriteLine($trimmed) $workerProcess.StandardInput.Flush() } catch { Write-ControllerLog "Failed to forward command '$trimmed': $($_.Exception.Message)" } if ($trimmed -ieq 'quit') { Write-ControllerLog "Quit command issued. Waiting for worker to exit." } } Start-Sleep -Milliseconds 500 } $exitCode = $workerProcess.ExitCode Write-ControllerLog "Worker exited with code $exitCode" Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode if ($exitCode -eq 0) { break } } catch { Write-ControllerLog "Controller error: $($_.Exception.Message)" } $restartCount++ if ($restartCount -gt $MaxRestarts) { Write-ControllerLog "Maximum restart attempts reached. Controller stopping." break } Write-ControllerLog "Restarting worker in $RestartDelaySeconds seconds (attempt $restartCount of $MaxRestarts)." Start-Sleep -Seconds $RestartDelaySeconds } Write-Metadata -Status 'inactive' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount Write-ControllerLog "Controller exiting." if ($logWriter) { $logWriter.Dispose() } if ($logStream) { $logStream.Dispose() }