Compare commits

...

7 Commits

Author SHA1 Message Date
Nathan
428d9bac6b remote controller working! 2025-12-02 16:23:31 -07:00
Nathan
e568e02cdf fix opt 6 exit not working
also continue fix attempt on sheepit not starting
2025-12-02 12:08:00 -07:00
18683b4ad0 attachment is clean 2025-12-01 15:52:35 -07:00
Nathan
b61baac09b attempting fix 2025-11-26 18:18:13 -07:00
Nathan
2aa9061114 begin persistence/attachment overhaul 2025-11-25 14:51:22 -07:00
Nathan
0b8f4f30fa fixed sheepit functionality for sheepit blender 5.0 update 2025-11-25 13:31:13 -07:00
Nathan
3ef8b09737 specst for networking 2025-11-19 13:30:17 -07:00
12 changed files with 29329 additions and 869 deletions

View File

@@ -1,4 +1,4 @@
<!-- Generated by SpecStory, Markdown v2.0.0 --> <!-- Generated by SpecStory, Markdown v2.1.0 -->
<!-- cursor Session f54ac1bf-82e6-4697-aed8-b2489222fc47 (2025-08-13 18:59Z) --> <!-- cursor Session f54ac1bf-82e6-4697-aed8-b2489222fc47 (2025-08-13 18:59Z) -->
@@ -42,7 +42,7 @@ Im going to inspect the `scripts/TalkingHeads Custom Render.js` file and sear
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "'seq'" in "scripts" • 2 matches</summary> <summary>Tool use: **grep** • Grep for "'seq'" in "scripts" • 2 matches</summary>
@@ -268,7 +268,7 @@ Im going to scan both scripts to confirm available utilities for filesystem c
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "exists|isdir|mkdir|readdir|stat" in "scripts" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "exists|isdir|mkdir|readdir|stat" in "scripts" • 0 matches</summary>
@@ -282,7 +282,7 @@ _No matches found_
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "path\.|Path\(|abspath\(|path\.join|path\.dirname|path\.basename|Path\.join" in "scripts" • 22 matches</summary> <summary>Tool use: **grep** • Grep for "path\.|Path\(|abspath\(|path\.join|path\.dirname|path\.basename|Path\.join" in "scripts" • 22 matches</summary>
@@ -341,7 +341,7 @@ I could derive the job name using path.stem(settings.blendfile), but the origina
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "jobname" • 2 matches</summary> <summary>Tool use: **grep** • Grep for "jobname" • 2 matches</summary>
@@ -2761,7 +2761,7 @@ Im looking to ensure the UI always displays auto values while ignoring any ma
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "use_automatic|use_auto|use_eval|use.*eval" in "scripts" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "use_automatic|use_auto|use_eval|use.*eval" in "scripts" • 0 matches</summary>

View File

@@ -1,4 +1,4 @@
<!-- Generated by SpecStory, Markdown v2.0.0 --> <!-- Generated by SpecStory, Markdown v2.1.0 -->
<!-- cursor Session a7f6e07f-fadd-406b-8735-387c0659fa23 (2025-11-03 19:27Z) --> <!-- cursor Session a7f6e07f-fadd-406b-8735-387c0659fa23 (2025-11-03 19:27Z) -->
@@ -1044,7 +1044,7 @@ To-do's from the plan have already been created. Do not create them again. Mark
_**Agent (model gpt-5-codex, mode Agent)**_ _**Agent (model gpt-5-codex, mode Agent)**_
<tool-use data-tool-type="generic" data-tool-name="todo_write"> <tool-use data-tool-type="task" data-tool-name="todo_write">
<details><summary>Tool use: **todo_write** • Todo List</summary> <details><summary>Tool use: **todo_write** • Todo List</summary>
- [-] Add render key prompt/state to unified_sheepit_launcher.ps1 - [-] Add render key prompt/state to unified_sheepit_launcher.ps1
@@ -1500,7 +1500,7 @@ The model made no changes to the file.
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "SheepItPath" in "unified_sheepit_launcher.ps1" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "SheepItPath" in "unified_sheepit_launcher.ps1" • 0 matches</summary>
@@ -1608,7 +1608,7 @@ Lint results
--- ---
<tool-use data-tool-type="generic" data-tool-name="todo_write"> <tool-use data-tool-type="task" data-tool-name="todo_write">
<details><summary>Tool use: **todo_write** • Todo List</summary> <details><summary>Tool use: **todo_write** • Todo List</summary>
- [x] Add render key prompt/state to unified_sheepit_launcher.ps1 - [x] Add render key prompt/state to unified_sheepit_launcher.ps1
@@ -1625,7 +1625,7 @@ Lint results
--- ---
<tool-use data-tool-type="generic" data-tool-name="todo_write"> <tool-use data-tool-type="task" data-tool-name="todo_write">
<details><summary>Tool use: **todo_write** • Todo List</summary> <details><summary>Tool use: **todo_write** • Todo List</summary>
- [x] Add render key prompt/state to unified_sheepit_launcher.ps1 - [x] Add render key prompt/state to unified_sheepit_launcher.ps1
@@ -1642,7 +1642,7 @@ Lint results
--- ---
<tool-use data-tool-type="generic" data-tool-name="todo_write"> <tool-use data-tool-type="task" data-tool-name="todo_write">
<details><summary>Tool use: **todo_write** • Todo List</summary> <details><summary>Tool use: **todo_write** • Todo List</summary>
- [x] Add render key prompt/state to unified_sheepit_launcher.ps1 - [x] Add render key prompt/state to unified_sheepit_launcher.ps1

View File

@@ -1,4 +1,4 @@
<!-- Generated by SpecStory, Markdown v2.0.0 --> <!-- Generated by SpecStory, Markdown v2.1.0 -->
<!-- cursor Session fe037dc9-a2ad-48b1-84ac-2bb4d322c421 (2025-11-06 17:28Z) --> <!-- cursor Session fe037dc9-a2ad-48b1-84ac-2bb4d322c421 (2025-11-06 17:28Z) -->
@@ -272,7 +272,7 @@ Im refining the plan to remove all daily path handling, carefully adjusting p
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "daily_path" in "scripts" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "daily_path" in "scripts" • 0 matches</summary>
@@ -291,7 +291,7 @@ _No matches found_
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "formatDaily" in "scripts" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "formatDaily" in "scripts" • 0 matches</summary>
@@ -888,7 +888,7 @@ _**Agent (model gpt-5-codex, mode Agent)**_
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "settings.render_output_path" • 8 matches</summary> <summary>Tool use: **grep** • Grep for "settings.render_output_path" • 8 matches</summary>
@@ -917,7 +917,7 @@ Output mode: content
--- ---
<tool-use data-tool-type="grep" data-tool-name="grep"> <tool-use data-tool-type="search" data-tool-name="grep">
<details> <details>
<summary>Tool use: **grep** • Grep for "render_output_path','\s*type" in "scripts" • 0 matches</summary> <summary>Tool use: **grep** • Grep for "render_output_path','\s*type" in "scripts" • 0 matches</summary>

View File

@@ -1,523 +0,0 @@
<!-- Generated by SpecStory, Markdown v2.0.0 -->
<!-- cursor Session 9522a0d8-694a-45d0-a000-ce16feaaf911 (2025-11-19 17:03Z) -->
# Error initializing SheepIt client (2025-11-19 17:03Z)
_**User (2025-11-19 17:03Z)**_
SheepIt client already present at C:\Users\nathan\AppData\Roaming\sheepit\sheepit-client.jar. Skipping download.
Starting SheepIt client...
Authenticating as RaincloudTheDragon via render key.
Error occurred during initialization of VM
Failed setting boot class path.
Connection to i9-13ks closed.
SSH session ended.
Press Enter to close:
---
_**Agent (model default, mode Agent)**_
---
The "Failed setting boot class path" error usually indicates a Java version mismatch or incompatible JVM arguments. Checking the SheepIt launcher script to see how it's launching the client.
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: unified_sheepit_launcher.ps1</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: unified_sheepit_launcher.bat</summary>
</details>
</tool-use>
---
---
The error "Failed setting boot class path" usually indicates a Java version mismatch or incompatible JVM arguments. Updating the launch command to be more compatible and add error handling:
---
<tool-use data-tool-type="write" data-tool-name="search_replace">
<details><summary>Tool use: **code_edit** • Edit file: unified_sheepit_launcher.ps1</summary>
**Chunk 1**
Lines added: 13, lines removed: 2
```diff
@@ -88,6 +88,17 @@
Write-Host &quot;Starting SheepIt client...&quot; -ForegroundColor Cyan
- ${userLine} Set-Location &#96;$sheepDir
- &amp; java -jar &#96;$jarPath -ui text --log-stdout --verbose -gpu OPTIX_0 -login &#39;${safeUser}&#39; -password &#39;${safeKey}&#39;
+
+ # Check Java version
+ try {
+ &#96;$javaVersion = java -version 2&gt;&amp;1 | Select-Object -First 1
+ Write-Host &quot;Java version: &#96;$javaVersion&quot; -ForegroundColor Gray
+ }
+ catch {
+ Write-Host &quot;Warning: Could not determine Java version&quot; -ForegroundColor Yellow
+ }
+
+ Set-Location &#96;$sheepDir
+ # Use -XX:+IgnoreUnrecognizedVMOptions to handle any incompatible JVM args
+ &amp; java -XX:+IgnoreUnrecognizedVMOptions -jar &#96;$jarPath -ui text --log-stdout --verbose -gpu OPTIX_0 -login &#39;${safeUser}&#39; -password &#39;${safeKey}&#39;
}
catch {
```
</details>
</tool-use>
---
---
Improving error handling to capture and display more details about Java errors:
---
<tool-use data-tool-type="write" data-tool-name="search_replace">
<details><summary>Tool use: **code_edit** • Edit file: unified_sheepit_launcher.ps1</summary>
**Chunk 1**
Lines added: 20, lines removed: 1
```diff
@@ -99,9 +99,28 @@
Set-Location &#96;$sheepDir
+
+ # Check for problematic environment variables
+ if (&#96;$env:JAVA_TOOL_OPTIONS) {
+ Write-Host &quot;Warning: JAVA_TOOL_OPTIONS is set: &#96;$env:JAVA_TOOL_OPTIONS&quot; -ForegroundColor Yellow
+ }
+
# Use -XX:+IgnoreUnrecognizedVMOptions to handle any incompatible JVM args
- &amp; java -XX:+IgnoreUnrecognizedVMOptions -jar &#96;$jarPath -ui text --log-stdout --verbose -gpu OPTIX_0 -login &#39;${safeUser}&#39; -password &#39;${safeKey}&#39;
+ # This flag helps with Java 9+ compatibility issues
+ &#96;$javaArgs = @(&#39;-XX:+IgnoreUnrecognizedVMOptions&#39;, &#39;-jar&#39;, &#96;$jarPath,
+ &#39;-ui&#39;, &#39;text&#39;, &#39;--log-stdout&#39;, &#39;--verbose&#39;,
+ &#39;-gpu&#39;, &#39;OPTIX_0&#39;, &#39;-login&#39;, &#39;${safeUser}&#39;, &#39;-password&#39;, &#39;${safeKey}&#39;)
+
+ try {
+ &amp; java @javaArgs
+ }
+ catch {
+ Write-Host (&#39;Java execution error: {0}&#39; -f &#96;$_.Exception.Message) -ForegroundColor Red
+ Write-Host &quot;If the error persists, try updating Java to a compatible version (Java 8 or Java 11+ recommended).&quot; -ForegroundColor Yellow
+ throw
+ }
}
catch {
Write-Host (&#39;Error: {0}&#39; -f &#96;$_.Exception.Message) -ForegroundColor Red
+ Write-Host (&#39;Stack trace: {0}&#39; -f &#96;$_.ScriptStackTrace) -ForegroundColor DarkRed
}
&quot;@
```
</details>
</tool-use>
---
---
Checking for linting errors:
---
<tool-use data-tool-type="generic" data-tool-name="read_lints">
<details>
<summary>Tool use: **read_lints** • Read lints for 1 file</summary>
Lint paths:
- `unified_sheepit_launcher.ps1`
Lint results
**No lint errors found**
</details>
</tool-use>
---
---
Updated the SheepIt launcher to address the "Failed setting boot class path" error. Changes:
1. **Added `-XX:+IgnoreUnrecognizedVMOptions` flag** — helps with Java 9+ compatibility issues that can cause boot class path errors
2. **Added Java version check** — displays the Java version in use for debugging
3. **Added environment variable check** — warns if `JAVA_TOOL_OPTIONS` is set (can cause conflicts)
4. **Improved error handling** — clearer error messages and diagnostics
The main fix is the `-XX:+IgnoreUnrecognizedVMOptions` flag, which tells the JVM to ignore unrecognized VM options. This often resolves boot class path errors when the JAR or environment includes incompatible arguments.
Try running the launcher again. If the error persists, it may indicate:
- An incompatible Java version on the remote machine (i9-13ks)
- System-level Java configuration issues
The script will now show the Java version being used, which will help diagnose further if needed.
---
_**User (2025-11-19 17:03Z)**_
I think it's an issue with the system itself. Can you ssh into the system and see if it can reach the internet?
SheepIt client already present at C:\Users\nathan\AppData\Roaming\sheepit\sheepit-client.jar. Skipping download.
Starting SheepIt client...
Java version: Error occurred during initialization of VM
Error occurred during initialization of VM
Failed setting boot class path.
Connection to i9-13ks closed.
SSH session ended.
Press Enter to close:
---
_**Agent (model default, mode Agent)**_
---
SSHing into i9-13ks to check internet connectivity and diagnose the Java issue. Since `java -version` also fails, this likely points to a system-level Java problem.
---
<tool-use data-tool-type="bash" data-tool-name="run_terminal_cmd">
<details><summary>Tool use: **command** • Run command: ssh -t -p 22146 i9-13ks "powershell -Command \"Write-Host 'Testing internet connectivity...' -ForegroundColor Cyan; Test-NetConnection -ComputerName google.com -Port 80 -InformationLevel Quiet; if ($?) { Write-Host 'Internet connection: OK' -ForegroundColor Green } else { Write-Host 'Internet connection: FAILED' -ForegroundColor Red }; Write-Host ''; Write-Host 'Testing DNS resolution...' -ForegroundColor Cyan; try { $result = Resolve-DnsName -Name google.com -ErrorAction Stop; Write-Host 'DNS resolution: OK' -ForegroundColor Green } catch { Write-Host 'DNS resolution: FAILED' -ForegroundColor Red }; Write-Host ''; Write-Host 'Testing HTTP connectivity...' -ForegroundColor Cyan; try { $response = Invoke-WebRequest -Uri 'https://www.google.com' -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop; Write-Host 'HTTP connectivity: OK' -ForegroundColor Green } catch { Write-Host \"HTTP connectivity: FAILED - $($_.Exception.Message)\" -ForegroundColor Red }\""</summary>
```bash
ssh -t -p 22146 i9-13ks "powershell -Command \"Write-Host 'Testing internet connectivity...' -ForegroundColor Cyan; Test-NetConnection -ComputerName google.com -Port 80 -InformationLevel Quiet; if ($?) { Write-Host 'Internet connection: OK' -ForegroundColor Green } else { Write-Host 'Internet connection: FAILED' -ForegroundColor Red }; Write-Host ''; Write-Host 'Testing DNS resolution...' -ForegroundColor Cyan; try { $result = Resolve-DnsName -Name google.com -ErrorAction Stop; Write-Host 'DNS resolution: OK' -ForegroundColor Green } catch { Write-Host 'DNS resolution: FAILED' -ForegroundColor Red }; Write-Host ''; Write-Host 'Testing HTTP connectivity...' -ForegroundColor Cyan; try { $response = Invoke-WebRequest -Uri 'https://www.google.com' -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop; Write-Host 'HTTP connectivity: OK' -ForegroundColor Green } catch { Write-Host \"HTTP connectivity: FAILED - $($_.Exception.Message)\" -ForegroundColor Red }\""
```
```
[?9001h[?1004h[?25l]0;Administrator: C:\WINDOWS\system32\conhost.exe[?25h\ : The term &#39;\&#39; is not recognized as the name of a cmdlet, function, script file, or operable program. Check the 
spelling of the name, or if a path was included, verify that the path is correct and try again.
At line:1 char:1
+ \ Write-Host Testing internet connectivity... -ForegroundColor Cyan
+ ~
+ CategoryInfo : ObjectNotFound: (\:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
[?9001l[?1004lConnection to i9-13ks closed.
True
Internet connection: OK
Testing DNS resolution...
DNS resolution: OK
Testing HTTP connectivity...
HTTP connectivity: OK
\: C:\Users\Nathan\AppData\Local\Temp\ps-script-f07861ff-1930-4e0c-a126-2a9512cba633.ps1:7
Line |
7 | . ctivity: FAILED - $($_.Exception.Message)\&quot; -ForegroundColor Red }\&quot;&quot;
| ~~~
| The term &#39;\&#39; is not recognized as a name of a cmdlet, function, script file, or executable program. Check the
| spelling of the name, or if a path was included, verify that the path is correct and try again.
```
</details>
</tool-use>
---
---
Internet connectivity is working. Checking the Java installation:
---
<tool-use data-tool-type="bash" data-tool-name="run_terminal_cmd">
<details><summary>Tool use: **command** • Run command: ssh -t -p 22146 i9-13ks "powershell -Command \"Write-Host 'Checking Java installation...' -ForegroundColor Cyan; Write-Host ''; Write-Host 'Java executable path:' -ForegroundColor Yellow; Get-Command java -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source; Write-Host ''; Write-Host 'JAVA_HOME environment variable:' -ForegroundColor Yellow; Write-Host \$env:JAVA_HOME; Write-Host ''; Write-Host 'JAVA_TOOL_OPTIONS:' -ForegroundColor Yellow; Write-Host \$env:JAVA_TOOL_OPTIONS; Write-Host ''; Write-Host 'PATH entries containing java:' -ForegroundColor Yellow; \$env:PATH -split ';' | Where-Object { \$_ -like '*java*' }; Write-Host ''; Write-Host 'Attempting java -version (this may fail):' -ForegroundColor Yellow; java -version 2>&1\""</summary>
```bash
ssh -t -p 22146 i9-13ks "powershell -Command \"Write-Host 'Checking Java installation...' -ForegroundColor Cyan; Write-Host ''; Write-Host 'Java executable path:' -ForegroundColor Yellow; Get-Command java -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source; Write-Host ''; Write-Host 'JAVA_HOME environment variable:' -ForegroundColor Yellow; Write-Host \$env:JAVA_HOME; Write-Host ''; Write-Host 'JAVA_TOOL_OPTIONS:' -ForegroundColor Yellow; Write-Host \$env:JAVA_TOOL_OPTIONS; Write-Host ''; Write-Host 'PATH entries containing java:' -ForegroundColor Yellow; \$env:PATH -split ';' | Where-Object { \$_ -like '*java*' }; Write-Host ''; Write-Host 'Attempting java -version (this may fail):' -ForegroundColor Yellow; java -version 2>&1\""
```
```
[?9001h[?1004h[?25l]0;Administrator: C:\WINDOWS\system32\conhost.exe[?25h\ : The term &#39;\&#39; is not recognized as the name of a cmdlet, function, script file, or operable program. Check the [?9001l[?1004l
spelling of the name, or if a path was included, verify that the path is correct and try again.
At line:1 char:1
+ \ Write-Host Checking Java installation... -ForegroundColor Cyan
+ ~
+ CategoryInfo : ObjectNotFound: (\:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
Java executable path:
C:\Program Files\Eclipse Adoptium\jdk-21.0.9.10-hotspot\bin\java.exe
JAVA_HOME environment variable:
\
JAVA_TOOL_OPTIONS:
\
PATH entries containing java:
Connection to i9-13ks closed.
Attempting java -version (this may fail):
\$env:PATH: C:\Users\Nathan\AppData\Local\Temp\ps-script-2b04d60b-8094-4ccf-b84e-435f31732af2.ps1:118
Line |
118 | . entries containing java:&#39; -ForegroundColor Yellow; \$env:PATH -split .
| ~~~~~~~~~~
| The term &#39;\$env:PATH&#39; is not recognized as a name of a cmdlet, function, script file, or executable program.
| Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
openjdk version &quot;21.0.9&quot; 2025-10-21 LTS
OpenJDK Runtime Environment Temurin-21.0.9+10 (build 21.0.9+10-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.9+10 (build 21.0.9+10-LTS, mixed mode, sharing)
```
</details>
</tool-use>
---
---
Java is installed (Java 21), but `JAVA_HOME` and `JAVA_TOOL_OPTIONS` are set to `\`, which is invalid and likely causing the boot class path error. Checking and fixing these environment variables:
---
Cancelled
---
_**User (2025-11-19 17:03Z)**_
Connecting to i9-13ks...
SheepIt client already present at C:\Users\nathan\AppData\Roaming\sheepit\sheepit-client.jar. Skipping download.
Starting SheepIt client...
Warning: Could not determine Java version
I had to choco uninstall and reinstall Temurin21. This is the 2nd time I've had to do this on this system.
---
_**Agent (model default, mode Agent)**_
---
Invalid `JAVA_HOME` and `JAVA_TOOL_OPTIONS` (set to `\`) were causing the issue. Updating the script to detect and handle invalid Java environment variables to prevent this from recurring:
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: unified_sheepit_launcher.ps1</summary>
</details>
</tool-use>
---
---
Updating the script to detect and handle invalid Java environment variables:
---
<tool-use data-tool-type="write" data-tool-name="search_replace">
<details><summary>Tool use: **code_edit** • Edit file: unified_sheepit_launcher.ps1</summary>
**Chunk 1**
Lines added: 27, lines removed: 1
```diff
@@ -88,8 +88,34 @@
Write-Host &quot;Starting SheepIt client...&quot; -ForegroundColor Cyan
+
+ # Check and fix problematic environment variables that can cause boot class path errors
+ &#96;$envVarsFixed = &#96;$false
+
+ # Check JAVA_HOME - invalid values like &#39;\&#39; or empty can cause issues
+ if (&#96;$env:JAVA_HOME) {
+ if (&#96;$env:JAVA_HOME -eq &#39;\&#39; -or &#96;$env:JAVA_HOME.Trim() -eq &#39;&#39; -or -not (Test-Path &#96;$env:JAVA_HOME)) {
+ Write-Host &quot;Warning: Invalid JAVA_HOME detected (&#39;&#96;$env:JAVA_HOME&#39;). Temporarily unsetting...&quot; -ForegroundColor Yellow
+ Remove-Item Env:\JAVA_HOME -ErrorAction SilentlyContinue
+ &#96;$envVarsFixed = &#96;$true
+ }
+ }
+
+ # Check JAVA_TOOL_OPTIONS - invalid values can cause boot class path errors
+ if (&#96;$env:JAVA_TOOL_OPTIONS) {
+ if (&#96;$env:JAVA_TOOL_OPTIONS -eq &#39;\&#39; -or &#96;$env:JAVA_TOOL_OPTIONS.Trim() -eq &#39;&#39;) {
+ Write-Host &quot;Warning: Invalid JAVA_TOOL_OPTIONS detected (&#39;&#96;$env:JAVA_TOOL_OPTIONS&#39;). Temporarily unsetting...&quot; -ForegroundColor Yellow
+ Remove-Item Env:\JAVA_TOOL_OPTIONS -ErrorAction SilentlyContinue
+ &#96;$envVarsFixed = &#96;$true
+ }
+ }
+
+ if (&#96;$envVarsFixed) {
+ Write-Host &quot;Environment variables fixed. Proceeding with Java launch...&quot; -ForegroundColor Green
+ }
# Check Java version
try {
- &#96;$javaVersion = java -version 2&gt;&amp;1 | Select-Object -First 1
+ &#96;$javaOutput = java -version 2&gt;&amp;1
+ &#96;$javaVersion = &#96;$javaOutput | Select-Object -First 1
Write-Host &quot;Java version: &#96;$javaVersion&quot; -ForegroundColor Gray
}
```
**Chunk 2**
Lines added: undefined, lines removed: 5
```diff
@@ -99,9 +125,4 @@
Set-Location &#96;$sheepDir
-
- # Check for problematic environment variables
- if (&#96;$env:JAVA_TOOL_OPTIONS) {
- Write-Host &quot;Warning: JAVA_TOOL_OPTIONS is set: &#96;$env:JAVA_TOOL_OPTIONS&quot; -ForegroundColor Yellow
- }
# Use -XX:+IgnoreUnrecognizedVMOptions to handle any incompatible JVM args
```
**Chunk 3**
Lines added: 1, lines removed: 1
```diff
@@ -116,5 +137,5 @@
catch {
Write-Host (&#39;Java execution error: {0}&#39; -f &#96;$_.Exception.Message) -ForegroundColor Red
- Write-Host &quot;If the error persists, try updating Java to a compatible version (Java 8 or Java 11+ recommended).&quot; -ForegroundColor Yellow
+ Write-Host &quot;If the error persists, try reinstalling Java (Temurin 21 recommended).&quot; -ForegroundColor Yellow
throw
}
```
</details>
</tool-use>
---
---
<tool-use data-tool-type="generic" data-tool-name="read_lints">
<details>
<summary>Tool use: **read_lints** • Read lints for 1 file</summary>
Lint paths:
- `unified_sheepit_launcher.ps1`
Lint results
**No lint errors found**
</details>
</tool-use>
---
---
---
Updated the script to detect and handle invalid Java environment variables. It will:
1. **Check `JAVA_HOME`** — if it's set to `\`, empty, or an invalid path, it temporarily unsets it
2. **Check `JAVA_TOOL_OPTIONS`** — if it's set to `\` or empty, it temporarily unsets it
3. **Show warnings** when these are fixed so you know what happened
4. **Proceed with Java launch** after cleaning up the environment
Since this is the second time you've had to reinstall Java on this system, something may be corrupting these environment variables. The script will now automatically fix them when detected, so you shouldn't need to reinstall Java again.
The script temporarily unsets these variables only for the SheepIt client process, so it won't affect other applications or require system-wide changes. This should prevent the "Failed setting boot class path" error from recurring.
---

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
<!-- Generated by SpecStory, Markdown v2.1.0 -->
<!-- cursor Session 21c1dff0-7d20-4158-84d9-19180276948d (2025-11-21 01:14Z) -->
# Workers failing and SSH connection issues (2025-11-21 01:14Z)
_**User (2025-11-21 01:14Z)**_
all workers are failing?
Connecting to i9kf...
exec request failed on channel 0
SSH session ended.
Press Enter to close:
---
_**Agent (model gpt-5.1-codex, mode Agent)**_
<think><details><summary>Thought Process</summary>
**Checking workspace directory**</details></think>
---
<tool-use data-tool-type="generic" data-tool-name="list_dir">
<details>
<summary>Tool use: **list_directory** • Listed directory undefined • **0** results</summary>
No results found
</details>
</tool-use>
---
---

File diff suppressed because one or more lines are too long

29
persistent_workers.md Normal file
View File

@@ -0,0 +1,29 @@
## Persistent Remote Worker Architecture
This repository now includes a small controller/attach framework that keeps both Flamenco and SheepIt workers running on each remote Windows host even after the SSH session closes.
### Components
| File | Purpose |
| --- | --- |
| `remote_worker_controller.ps1` | Runs on each remote host. Launches the real worker process (Flamenco or SheepIt), redirects its stdout/stderr to a log file, listens for commands, and restarts the worker if it crashes. |
| `remote_worker_attach.ps1` | Also runs remotely. Streams the controllers log file back to the local SSH session and forwards commands (`pause`, `resume`, `quit`, etc.) to the workers stdin. It can also be invoked in a `-CommandOnly` mode to send a single command without attaching. |
| `unified_flamenco_launcher.ps1` / `unified_sheepit_launcher.ps1` | Local launchers that copy the helper scripts to each host, start controllers in the background, and open attach sessions as requested. |
All metadata, logs and helper scripts live under `C:\ProgramData\UnifiedWorkers\<worker-type>\<worker-name>\`.
### Typical Flow
1. **Start/attach**: The launcher ensures the controller + payload are present on the target host, starts the controller through `Start-Process`, then launches `remote_worker_attach.ps1` via SSH so you can see live output and type commands. Closing the SSH window only ends the attach session—the worker keeps running.
2. **Reattach**: Run the launcher again and choose the same worker. If a controller reports that a worker is already running, the launcher simply opens another attach session.
3. **Pause/Resume/Quit all (SheepIt)**: The SheepIt launcher now exposes menu options that iterate over every enabled worker and invoke the attach helper in `-CommandOnly` mode to send the requested command.
### Manual Verification Checklist
1. **Controller deployment**: From the launcher, start a worker once and confirm `%ProgramData%\UnifiedWorkers` appears on the remote host with `controller.ps1`, `attach-helper.ps1`, `logs\worker.log`, and `state\worker-info.json`.
2. **Persistence**: Attach to a worker, close the SSH window, then reattach. The log stream should resume and the worker PID reported in metadata should remain unchanged.
3. **Command channel**: While attached, type `pause`, `resume`, and `quit`. The controller should log each command and the worker should react accordingly. Repeat the same commands using the SheepIt “Pause/Resume/Quit All” menu entries to validate the automation path.
4. **Failure recovery**: Manually terminate the worker process on a remote host. The controller should log the exit, update metadata, and restart the worker up to the configured retry limit.
These steps ensure both Flamenco and SheepIt workflows operate as intended with the new persistent worker model. Adjust the retry/backoff values inside `remote_worker_controller.ps1` if you need more aggressive or conservative restart behavior.

116
remote_worker_attach.ps1 Normal file
View File

@@ -0,0 +1,116 @@
param(
[Parameter(Mandatory = $true)]
[string]$WorkerName,
[Parameter(Mandatory = $true)]
[string]$WorkerType,
[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'),
[switch]$CommandOnly,
[string]$Command
)
$ErrorActionPreference = 'Stop'
function Get-WorkerPaths {
param([string]$Root, [string]$Type, [string]$Name)
$instanceRoot = Join-Path -Path (Join-Path -Path $Root -ChildPath $Type) -ChildPath $Name
return [pscustomobject]@{
Metadata = Join-Path -Path $instanceRoot -ChildPath 'state\worker-info.json'
Command = Join-Path -Path $instanceRoot -ChildPath 'state\commands.txt'
Log = Join-Path -Path $instanceRoot -ChildPath 'logs\worker.log'
}
}
$paths = Get-WorkerPaths -Root $DataRoot -Type $WorkerType -Name $WorkerName
if (-not (Test-Path $paths.Metadata)) {
Write-Host "No worker metadata found for $WorkerName ($WorkerType)." -ForegroundColor Red
exit 1
}
try {
$metadata = Get-Content -Path $paths.Metadata -Raw | ConvertFrom-Json
}
catch {
Write-Host "Unable to read worker metadata: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
if (Test-Path $paths.Log) {
# ensure log file exists but do not truncate
$null = (Get-Item $paths.Log)
} else {
New-Item -Path $paths.Log -ItemType File -Force | Out-Null
}
if (-not (Test-Path $paths.Command)) {
New-Item -Path $paths.Command -ItemType File -Force | Out-Null
}
function Send-WorkerCommand {
param([string]$Value)
$clean = $Value.Trim()
if (-not $clean) {
return
}
Add-Content -Path $paths.Command -Value $clean -Encoding UTF8
Write-Host "Sent command '$clean' to $WorkerName." -ForegroundColor DarkGray
}
if ($CommandOnly) {
if (-not $Command) {
Write-Host "CommandOnly flag set but no command provided." -ForegroundColor Red
exit 1
}
Send-WorkerCommand -Value $Command
exit 0
}
Write-Host "Attaching to $WorkerName ($WorkerType) logs." -ForegroundColor Cyan
Write-Host "Type commands and press Enter. Type 'detach' to exit session." -ForegroundColor Yellow
$logJob = Start-Job -ScriptBlock {
param($LogPath)
Get-Content -Path $LogPath -Tail 50 -Wait
} -ArgumentList $paths.Log
try {
while ($true) {
$input = Read-Host "> "
if ($null -eq $input) {
continue
}
$normalized = $input.Trim()
if ($normalized.StartsWith(':')) {
$normalized = $normalized.TrimStart(':').Trim()
}
if ($normalized.Length -eq 0) {
continue
}
if ($normalized.ToLowerInvariant() -eq 'detach') {
break
}
Send-WorkerCommand -Value $input
}
}
finally {
if ($logJob) {
Stop-Job -Job $logJob -ErrorAction SilentlyContinue
Receive-Job -Job $logJob -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $logJob -ErrorAction SilentlyContinue
}
Write-Host "Detached from worker $WorkerName." -ForegroundColor Cyan
}

View File

@@ -0,0 +1,330 @@
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
# Create C# event handler class that doesn't require PowerShell runspace
if (-not ("UnifiedWorkers.ProcessLogHandler" -as [type])) {
$csharpCode = @'
using System;
using System.Diagnostics;
using System.IO;
namespace UnifiedWorkers
{
public sealed class ProcessLogHandler
{
private readonly TextWriter _writer;
private readonly string _prefix;
private readonly object _lock = new object();
public ProcessLogHandler(TextWriter writer, string prefix)
{
_writer = writer ?? throw new ArgumentNullException("writer");
_prefix = prefix ?? throw new ArgumentNullException("prefix");
}
public void OnDataReceived(object sender, DataReceivedEventArgs e)
{
if (e == null || string.IsNullOrEmpty(e.Data))
{
return;
}
lock (_lock)
{
try
{
var timestamp = DateTime.UtcNow.ToString("u");
_writer.WriteLine(string.Format("[{0} {1}] {2}", _prefix, timestamp, e.Data));
_writer.Flush();
}
catch
{
// Ignore write errors to prevent cascading failures
}
}
}
}
}
'@
Add-Type -TypeDefinition $csharpCode -ErrorAction Stop
}
function Write-LogLine {
param(
[string]$Prefix,
[string]$Message
)
if (-not $logWriter) { return }
$timestamp = (Get-Date).ToString('u')
$logWriter.WriteLine("[$Prefix $timestamp] $Message")
}
function Write-ControllerLog {
param([string]$Message)
Write-LogLine -Prefix 'CTRL' -Message $Message
}
function Write-FatalLog {
param([string]$Message)
try {
Write-ControllerLog $Message
}
catch {
$timestamp = (Get-Date).ToString('u')
$fallback = "[CTRL $timestamp] $Message"
try {
[System.IO.File]::AppendAllText($logPath, $fallback + [Environment]::NewLine, [System.Text.Encoding]::UTF8)
}
catch {
# last resort: write to host
Write-Error $fallback
}
}
}
# endregion
# region Helpers
function Resolve-PayloadBase64 {
if ($PayloadBase64) {
return $PayloadBase64.Trim()
}
if ($PayloadBase64Path) {
if (-not (Test-Path $PayloadBase64Path)) {
throw "Payload file '$PayloadBase64Path' not found."
}
$content = Get-Content -Path $PayloadBase64Path -Raw
if ([string]::IsNullOrWhiteSpace($content)) {
throw "Payload file '$PayloadBase64Path' is empty."
}
return $content.Trim()
}
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
try {
# 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
# The payload is base64-encoded UTF-16 (Unicode), so decode it properly
Write-ControllerLog "Decoding payload base64 (length: $($resolvedPayloadBase64.Length))"
$payloadBytes = [Convert]::FromBase64String($resolvedPayloadBase64)
Write-ControllerLog "Decoded payload to $($payloadBytes.Length) bytes"
# Convert UTF-16 bytes back to string, then write as UTF-8 (PowerShell's preferred encoding)
$payloadText = [Text.Encoding]::Unicode.GetString($payloadBytes)
[IO.File]::WriteAllText($payloadPath, $payloadText, [Text.Encoding]::UTF8)
Write-ControllerLog "Payload written to $payloadPath ($($payloadText.Length) characters)"
}
catch {
Write-FatalLog "Unable to write payload: $($_.Exception.Message)"
if ($_.Exception.InnerException) {
Write-FatalLog "Inner exception: $($_.Exception.InnerException.Message)"
}
throw
}
$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
$stdoutHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'OUT')
$stderrHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'ERR')
$outputHandler = [System.Diagnostics.DataReceivedEventHandler]$stdoutHandler.OnDataReceived
$errorHandler = [System.Diagnostics.DataReceivedEventHandler]$stderrHandler.OnDataReceived
$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."
}
catch {
Write-FatalLog "Fatal controller error: $($_.Exception.Message)"
if ($_.ScriptStackTrace) {
Write-FatalLog "Stack: $($_.ScriptStackTrace)"
}
throw
}
finally {
if ($logWriter) {
$logWriter.Dispose()
}
if ($logStream) {
$logStream.Dispose()
}
}

View File

@@ -50,6 +50,372 @@ $workers = @(
} }
) )
$script:ControllerScriptBase64 = $null
$script:AttachHelperScriptBase64 = $null
$script:WorkerBasePathCache = @{}
function Build-SshArgsFromParts {
param(
[pscustomobject]$Parts,
[switch]$Interactive
)
$args = @('-o','ServerAliveInterval=60','-o','ServerAliveCountMax=30')
if ($Interactive -and $Parts.RequestPty) {
$args += '-t'
}
elseif (-not $Interactive) {
$args += '-T'
}
$args += $Parts.Options
if ($Parts.Port) {
$args += '-p'
$args += $Parts.Port
}
$args += $Parts.Host
return $args
}
function Build-ScpArgsFromParts {
param(
[pscustomobject]$Parts
)
$args = @('-o','ServerAliveInterval=60','-o','ServerAliveCountMax=30')
$args += $Parts.Options
if ($Parts.Port) {
$args += '-P'
$args += $Parts.Port
}
return $args
}
function Get-SshArgs {
param(
[object]$Worker,
[switch]$Interactive
)
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
return Build-SshArgsFromParts -Parts $parts -Interactive:$Interactive
}
function Get-WorkerBasePath {
param(
[object]$Worker,
[pscustomobject]$ConnectionParts = $null
)
if ($script:WorkerBasePathCache.ContainsKey($Worker.Name)) {
return $script:WorkerBasePathCache[$Worker.Name]
}
if (-not $ConnectionParts) {
$ConnectionParts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
}
$sshArgs = Build-SshArgsFromParts -Parts $ConnectionParts -Interactive:$false
$scriptBlock = "`$ProgressPreference='SilentlyContinue'; [Environment]::GetFolderPath('LocalApplicationData')"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($scriptBlock))
$remoteCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded"
$output = & ssh @sshArgs $remoteCmd
if ($LASTEXITCODE -ne 0) {
throw "Unable to determine LocalAppData on $($Worker.Name)."
}
$base = ($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim()
if (-not $base) {
throw "Unable to read LocalAppData path on $($Worker.Name)."
}
$final = Join-Path $base 'UnifiedWorkers'
$script:WorkerBasePathCache[$Worker.Name] = $final
return $final
}
function Get-FileBase64 {
param([string]$Path)
[Convert]::ToBase64String([IO.File]::ReadAllBytes($Path))
}
function Get-ControllerScriptBase64 {
if (-not $script:ControllerScriptBase64) {
$controllerPath = Join-Path $PSScriptRoot 'remote_worker_controller.ps1'
$script:ControllerScriptBase64 = Get-FileBase64 -Path $controllerPath
}
return $script:ControllerScriptBase64
}
function Get-AttachHelperScriptBase64 {
if (-not $script:AttachHelperScriptBase64) {
$helperPath = Join-Path $PSScriptRoot 'remote_worker_attach.ps1'
$script:AttachHelperScriptBase64 = Get-FileBase64 -Path $helperPath
}
return $script:AttachHelperScriptBase64
}
function ConvertTo-Base64Unicode {
param([string]$Content)
[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Content))
}
function Get-WorkerSshArgs {
param([string]$RawArgs)
if ([string]::IsNullOrWhiteSpace($RawArgs)) {
return @()
}
return $RawArgs -split '\s+' | Where-Object { $_.Trim().Length -gt 0 }
}
function Get-WorkerConnectionParts {
param(
[string]$RawArgs,
[string]$DefaultHost
)
$tokens = Get-WorkerSshArgs -RawArgs $RawArgs
$options = New-Object System.Collections.Generic.List[string]
$targetHost = $null
$port = $null
$requestPty = $false
$optionsWithArgs = @('-i','-o','-c','-D','-E','-F','-I','-J','-L','-l','-m','-O','-Q','-R','-S','-W','-w')
for ($i = 0; $i -lt $tokens.Count; $i++) {
$token = $tokens[$i]
if ($token -eq '-t' -or $token -eq '-tt') {
$requestPty = $true
continue
}
if ($token -eq '-p' -and ($i + 1) -lt $tokens.Count) {
$port = $tokens[$i + 1]
$i++
continue
}
if ($token.StartsWith('-')) {
$options.Add($token)
if ($optionsWithArgs -contains $token -and ($i + 1) -lt $tokens.Count) {
$options.Add($tokens[$i + 1])
$i++
}
continue
}
if (-not $targetHost) {
$targetHost = $token
continue
}
$options.Add($token)
}
if (-not $targetHost) {
$targetHost = $DefaultHost
}
return [pscustomobject]@{
Host = $targetHost
Options = $options.ToArray()
Port = $port
RequestPty = $requestPty
}
}
function Invoke-RemotePowerShell {
param(
[Parameter(Mandatory = $true)][object]$Worker,
[Parameter(Mandatory = $true)][string]$Script,
[switch]$Interactive
)
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
if (-not $parts.Host) {
throw "Unable to determine SSH host for $($Worker.Name)"
}
$sshBaseArgs = Build-SshArgsFromParts -Parts $parts -Interactive:$Interactive
$remoteBasePath = Get-WorkerBasePath -Worker $Worker -ConnectionParts $parts
$localTemp = [System.IO.Path]::GetTempFileName() + '.ps1'
Set-Content -Path $localTemp -Value $Script -Encoding UTF8
$remoteTmpDir = Join-Path $remoteBasePath 'tmp'
$remoteScriptWin = Join-Path $remoteTmpDir ("script-{0}.ps1" -f ([guid]::NewGuid().ToString()))
$remoteScriptScp = $remoteScriptWin -replace '\\','/'
$remoteTarget = "{0}:{1}" -f $parts.Host, ('"'+$remoteScriptScp+'"')
$ensureScript = "New-Item -ItemType Directory -Path '$remoteTmpDir' -Force | Out-Null"
$ensureCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ensureScript))
& ssh @sshBaseArgs $ensureCmd
if ($LASTEXITCODE -ne 0) {
Remove-Item $localTemp -ErrorAction SilentlyContinue
return $LASTEXITCODE
}
$scpArgs = Build-ScpArgsFromParts -Parts $parts
$scpArgs += $localTemp
$scpArgs += $remoteTarget
& scp @scpArgs
$scpExit = $LASTEXITCODE
Remove-Item $localTemp -ErrorAction SilentlyContinue
if ($scpExit -ne 0) {
return $scpExit
}
$execCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$remoteScriptWin`""
& ssh @sshBaseArgs $execCmd
$execExit = $LASTEXITCODE
$cleanupScript = "Remove-Item -LiteralPath '$remoteScriptWin' -ErrorAction SilentlyContinue"
$cleanupCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cleanupScript))
& ssh @sshBaseArgs $cleanupCmd | Out-Null
return $execExit
}
function Ensure-ControllerDeployed {
param([object]$Worker)
$controllerBase64 = Get-ControllerScriptBase64
$script = @"
`$ProgressPreference = 'SilentlyContinue'
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
New-Item -ItemType Directory -Path `$dataRoot -Force | Out-Null
`$controllerPath = Join-Path `$dataRoot 'controller.ps1'
[IO.File]::WriteAllBytes(`$controllerPath, [Convert]::FromBase64String('$controllerBase64'))
"@
Invoke-RemotePowerShell -Worker $Worker -Script $script | Out-Null
}
function Ensure-AttachHelperDeployed {
param([object]$Worker)
$helperBase64 = Get-AttachHelperScriptBase64
$script = @"
`$ProgressPreference = 'SilentlyContinue'
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
New-Item -ItemType Directory -Path `$dataRoot -Force | Out-Null
`$attachPath = Join-Path `$dataRoot 'attach-helper.ps1'
[IO.File]::WriteAllBytes(`$attachPath, [Convert]::FromBase64String('$helperBase64'))
"@
Invoke-RemotePowerShell -Worker $Worker -Script $script | Out-Null
}
function Get-EnsureWorkerScript {
param(
[string]$WorkerName,
[string]$WorkerType,
[string]$PayloadBase64
)
$payload = @{
WorkerName = $WorkerName
WorkerType = $WorkerType
PayloadBase64 = $PayloadBase64
} | ConvertTo-Json -Compress
$payloadBase64 = ConvertTo-Base64Unicode -Content $payload
return @"
`$ProgressPreference = 'SilentlyContinue'
`$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$payloadBase64')))
`$workerName = `$params.WorkerName
`$workerType = `$params.WorkerType
`$payloadBase64 = `$params.PayloadBase64
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
`$instanceRoot = Join-Path (Join-Path `$dataRoot `$workerType) `$workerName
`$metaPath = Join-Path `$instanceRoot 'state\worker-info.json'
`$controllerPath = Join-Path `$dataRoot 'controller.ps1'
if (-not (Test-Path `$controllerPath)) {
throw "Controller missing at `$controllerPath"
}
`$shouldStart = `$true
if (Test-Path `$metaPath) {
try {
`$meta = Get-Content `$metaPath -Raw | ConvertFrom-Json
if (`$meta.Status -eq 'running' -and `$meta.WorkerPid) {
if (Get-Process -Id `$meta.WorkerPid -ErrorAction SilentlyContinue) {
Write-Host "Worker `$workerName already running (PID `$($meta.WorkerPid))."
`$shouldStart = `$false
}
}
} catch {
Write-Host "Unable to parse metadata for `$workerName. Starting new controller." -ForegroundColor Yellow
}
}
if (`$shouldStart) {
`$pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
if (`$pwsh) {
`$psExe = `$pwsh.Source
}
else {
`$psExe = (Get-Command powershell -ErrorAction Stop).Source
}
`$controllerArgs = @(
'-NoLogo','-NoProfile','-ExecutionPolicy','Bypass',
'-File',"`$controllerPath",
'-WorkerName',"`$workerName",
'-WorkerType',"`$workerType",
'-PayloadBase64',"`$payloadBase64"
)
Start-Process -FilePath `$psExe -ArgumentList `$controllerArgs -WindowStyle Hidden | Out-Null
Write-Host "Worker `$workerName started under controller." -ForegroundColor Green
}
"@
}
function Ensure-PersistentWorker {
param(
[object]$Worker,
[string]$WorkerType,
[string]$PayloadScript
)
Write-Host "[$($Worker.Name)] Ensuring worker is running under controller..." -ForegroundColor Cyan
Ensure-ControllerDeployed -Worker $Worker
$payloadBase64 = ConvertTo-Base64Unicode -Content $PayloadScript
$ensureScript = Get-EnsureWorkerScript -WorkerName $Worker.Name -WorkerType $WorkerType -PayloadBase64 $payloadBase64
$result = Invoke-RemotePowerShell -Worker $Worker -Script $ensureScript
if ($result -ne 0) {
Write-Host "[$($Worker.Name)] Remote ensure command exited with code $result." -ForegroundColor Yellow
}
}
function Invoke-WorkerAttach {
param(
[object]$Worker,
[string]$WorkerType,
[switch]$CommandOnly,
[string]$Command
)
Ensure-AttachHelperDeployed -Worker $Worker
$paramsBlock = @("-WorkerName","$($Worker.Name)","-WorkerType","$WorkerType")
if ($CommandOnly) {
$paramsBlock += "-CommandOnly"
}
if ($Command) {
$paramsBlock += "-Command"
$paramsBlock += $Command
}
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
$sshArgs = Build-SshArgsFromParts -Parts $parts -Interactive:(!$CommandOnly)
$remoteBasePath = Get-WorkerBasePath -Worker $Worker -ConnectionParts $parts
$remoteHelper = Join-Path $remoteBasePath 'attach-helper.ps1'
$quotedArgs = ($paramsBlock | ForEach-Object { '"' + ($_ -replace '"','""') + '"' }) -join ' '
$remoteCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$remoteHelper`" $quotedArgs"
& ssh @sshArgs $remoteCmd
}
# FUNCTIONS # FUNCTIONS
# This function generates the standard PowerShell remote command # This function generates the standard PowerShell remote command
@@ -258,57 +624,10 @@ function Start-StandardWorker {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[object]$Worker [object]$Worker
) )
$retryCount = 0 $payloadScript = Get-RemoteStandardWorkerCommand
$retryDelay = 15 # seconds between retries Ensure-PersistentWorker -Worker $Worker -WorkerType 'flamenco' -PayloadScript $payloadScript
$workerRestarted = $false Invoke-WorkerAttach -Worker $Worker -WorkerType 'flamenco'
while ($true) { # Changed to infinite loop
if ($retryCount -gt 0) {
Write-Host "`nRestarting worker process (Attempt $($retryCount + 1))..." -ForegroundColor Yellow
Start-Sleep -Seconds $retryDelay
$workerRestarted = $true
}
Write-Host "Connecting to $($Worker.Name)..." -ForegroundColor Cyan
if ($workerRestarted) {
Write-Host "Worker was restarted due to disconnection or crash." -ForegroundColor Yellow
}
try {
$remoteCommand = Get-RemoteStandardWorkerCommand
# Encode the command to handle special characters
$bytes = [System.Text.Encoding]::Unicode.GetBytes($remoteCommand)
$encodedCommand = [Convert]::ToBase64String($bytes)
# Execute the encoded command on the remote machine
Write-Host "Connecting to $($Worker.Name) and executing worker script..." -ForegroundColor Yellow
# Add SSH keepalive settings to reduce chance of random disconnections
$sshCommand = "ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=30 $($Worker.SSHArgs) ""powershell -EncodedCommand $encodedCommand"""
# Execute the SSH command and capture the exit code
Invoke-Expression $sshCommand
$sshExitCode = $LASTEXITCODE
# Check if SSH command completed successfully
if ($sshExitCode -eq 0) {
Write-Host "`nWorker completed successfully. Restarting automatically..." -ForegroundColor Green
Start-Sleep -Seconds 2 # Brief pause before restarting
$retryCount = 0 # Reset counter for successful completion
continue # Continue the loop instead of breaking
} else {
throw "Worker process exited with code: $sshExitCode"
}
}
catch {
$retryCount++
Write-Host "`nAn error occurred while running worker on $($Worker.Name):" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host "`nAttempting to restart worker in $retryDelay seconds..." -ForegroundColor Yellow
}
}
} }
# This function launches the CMD worker # This function launches the CMD worker
@@ -317,57 +636,10 @@ function Start-CmdWorker {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[object]$Worker [object]$Worker
) )
$retryCount = 0 $payloadScript = Get-RemoteCmdWorkerCommand
$retryDelay = 5 # seconds between retries Ensure-PersistentWorker -Worker $Worker -WorkerType 'flamenco' -PayloadScript $payloadScript
$workerRestarted = $false Invoke-WorkerAttach -Worker $Worker -WorkerType 'flamenco'
while ($true) { # Changed to infinite loop
if ($retryCount -gt 0) {
Write-Host "`nRestarting worker process (Attempt $($retryCount + 1))..." -ForegroundColor Yellow
Start-Sleep -Seconds $retryDelay
$workerRestarted = $true
}
Write-Host "Connecting to $($Worker.Name) (CMD mode)..." -ForegroundColor Cyan
if ($workerRestarted) {
Write-Host "Worker was restarted due to disconnection or crash." -ForegroundColor Yellow
}
try {
$remoteCommand = Get-RemoteCmdWorkerCommand
# Encode the command to handle special characters
$bytes = [System.Text.Encoding]::Unicode.GetBytes($remoteCommand)
$encodedCommand = [Convert]::ToBase64String($bytes)
# Execute the encoded command on the remote machine
Write-Host "Connecting to $($Worker.Name) and executing CMD worker script..." -ForegroundColor Yellow
# Add SSH keepalive settings to reduce chance of random disconnections
$sshCommand = "ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=30 $($Worker.SSHArgs) ""powershell -EncodedCommand $encodedCommand"""
# Execute the SSH command and capture the exit code
Invoke-Expression $sshCommand
$sshExitCode = $LASTEXITCODE
# Check if SSH command completed successfully
if ($sshExitCode -eq 0) {
Write-Host "`nWorker completed successfully. Restarting automatically..." -ForegroundColor Green
Start-Sleep -Seconds 2 # Brief pause before restarting
$retryCount = 0 # Reset counter for successful completion
continue # Continue the loop instead of breaking
} else {
throw "Worker process exited with code: $sshExitCode"
}
}
catch {
$retryCount++
Write-Host "`nAn error occurred while running worker on $($Worker.Name):" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host "`nAttempting to restart worker in $retryDelay seconds..." -ForegroundColor Yellow
}
}
} }
# This function launches ALL workers in Windows Terminal tabs # This function launches ALL workers in Windows Terminal tabs
@@ -376,174 +648,22 @@ function Start-AllWorkers {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$WorkerType [string]$WorkerType
) )
Write-Host "Launching ALL $WorkerType workers in Windows Terminal tabs..." -ForegroundColor Cyan
try {
# First, check if Windows Terminal is available
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
Write-Host "Windows Terminal (wt.exe) not found. Falling back to separate windows." -ForegroundColor Yellow
$useTerminal = $false
} else {
$useTerminal = $true
}
foreach ($worker in $workers) { Write-Host "Ensuring ALL $WorkerType workers are running under controllers..." -ForegroundColor Cyan
# Create a new PowerShell script with a unique name for this worker
$tempScriptPath = [System.IO.Path]::GetTempFileName() + ".ps1"
# Create different script content based on worker type
if ($WorkerType -eq "CMD") {
# CMD workers get retry logic at the local level
$scriptContent = @"
# Wrap everything in a try-catch to prevent script termination
try {
Write-Host 'Launching $WorkerType worker for $($worker.Name)' -ForegroundColor Cyan
`$retryCount = 0 $payloadScript = if ($WorkerType -eq 'CMD') {
`$retryDelay = 5 # seconds between retries Get-RemoteCmdWorkerCommand
`$workerRestarted = `$false } else {
Get-RemoteStandardWorkerCommand
while (`$true) { # Changed to infinite loop
try {
if (`$retryCount -gt 0) {
Write-Host "`nRestarting worker process (Attempt `$(`$retryCount + 1))..." -ForegroundColor Yellow
Start-Sleep -Seconds `$retryDelay
`$workerRestarted = `$true
}
Write-Host "Connecting to $($worker.Name) ($WorkerType mode)..." -ForegroundColor Cyan
if (`$workerRestarted) {
Write-Host "Worker was restarted due to disconnection or crash." -ForegroundColor Yellow
}
# Get remote command
`$remoteCommand = @'
$(Get-RemoteSimplifiedCmdWorkerCommand)
'@
# Encode the command
`$bytes = [System.Text.Encoding]::Unicode.GetBytes(`$remoteCommand)
`$encodedCommand = [Convert]::ToBase64String(`$bytes)
# Execute SSH command with keepalive settings and capture exit code
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=30 $($worker.SSHArgs) "powershell -EncodedCommand `$encodedCommand"
`$sshExitCode = `$LASTEXITCODE
# Check if SSH command completed successfully
if (`$sshExitCode -eq 0) {
Write-Host "`nWorker completed successfully. Restarting automatically..." -ForegroundColor Green
Start-Sleep -Seconds 2 # Brief pause before restarting
`$retryCount = 0 # Reset counter for successful completion
continue # Continue the loop instead of breaking
} else {
throw "SSH command failed with exit code: `$sshExitCode"
}
}
catch {
`$retryCount++
Write-Host "An error occurred while connecting to $($worker.Name):" -ForegroundColor Red
Write-Host `$_.Exception.Message -ForegroundColor Red
Write-Host "Attempting to reconnect in `$retryDelay seconds..." -ForegroundColor Yellow
# Don't rethrow - we want to continue the retry loop
}
} }
}
catch {
# This outer catch block is for any unexpected errors that might terminate the script
Write-Host "`nCRITICAL ERROR: Script encountered an unexpected error:" -ForegroundColor Red
Write-Host `$_.Exception.Message -ForegroundColor Red
Write-Host "`nRestarting the entire worker process in 5 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
# Restart the script by calling itself
& `$MyInvocation.MyCommand.Path
}
"@
} else {
# Standard workers keep the original retry logic
$scriptContent = @"
# Wrap everything in a try-catch to prevent script termination
try {
Write-Host 'Launching $WorkerType worker for $($worker.Name)' -ForegroundColor Cyan
`$retryCount = 0 foreach ($worker in $workers) {
`$retryDelay = 5 # seconds between retries Ensure-PersistentWorker -Worker $worker -WorkerType 'flamenco' -PayloadScript $payloadScript
while (`$true) { # Changed to infinite loop
try {
if (`$retryCount -gt 0) {
Write-Host "Retry attempt `$retryCount..." -ForegroundColor Yellow
Start-Sleep -Seconds `$retryDelay
}
# Get remote command
`$remoteCommand = @'
$(Get-RemoteStandardWorkerCommand)
'@
# Encode the command
`$bytes = [System.Text.Encoding]::Unicode.GetBytes(`$remoteCommand)
`$encodedCommand = [Convert]::ToBase64String(`$bytes)
# Execute SSH command with keepalive settings
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=30 $($worker.SSHArgs) "powershell -EncodedCommand `$encodedCommand"
`$sshExitCode = `$LASTEXITCODE
# Check SSH exit code and handle accordingly
if (`$sshExitCode -eq 0) {
Write-Host "`nWorker completed successfully. Restarting automatically..." -ForegroundColor Green
Start-Sleep -Seconds 2 # Brief pause before restarting
`$retryCount = 0 # Reset counter for successful completion
continue # Continue the loop instead of breaking
} else {
throw "SSH command failed with exit code: `$sshExitCode"
}
}
catch {
`$retryCount++
Write-Host "An error occurred while connecting to $($worker.Name):" -ForegroundColor Red
Write-Host `$_.Exception.Message -ForegroundColor Red
Write-Host "Attempting to reconnect in `$retryDelay seconds..." -ForegroundColor Yellow
# Don't rethrow - we want to continue the retry loop
}
} }
}
catch { Write-Host "All workers processed. Attach to any worker from the menu to monitor output." -ForegroundColor Green
# This outer catch block is for any unexpected errors that might terminate the script
Write-Host "`nCRITICAL ERROR: Script encountered an unexpected error:" -ForegroundColor Red
Write-Host `$_.Exception.Message -ForegroundColor Red
Write-Host "`nRestarting the entire worker process in 5 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
# Restart the script by calling itself
& `$MyInvocation.MyCommand.Path
}
"@
}
# Write the script to file
Set-Content -Path $tempScriptPath -Value $scriptContent
if ($useTerminal) {
# Launch in a new Windows Terminal tab
$tabTitle = "$($worker.Name) - $WorkerType Worker"
Start-Process wt.exe -ArgumentList "-w 0 new-tab --title `"$tabTitle`" powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$tempScriptPath`""
} else {
# Fallback to separate window if Windows Terminal is not available
Start-Process powershell -ArgumentList "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$tempScriptPath`""
}
Write-Host "Started $($worker.Name) ($WorkerType) worker in a new tab." -ForegroundColor Green
Start-Sleep -Milliseconds 300 # Small delay between launches
}
Write-Host "`nAll $WorkerType worker scripts have been launched in Windows Terminal tabs." -ForegroundColor Cyan
}
catch {
Write-Host "Error launching workers: $($_.Exception.Message)" -ForegroundColor Red
}
Write-Host "Press Enter to return to the menu..." -ForegroundColor Green Write-Host "Press Enter to return to the menu..." -ForegroundColor Green
Read-Host Read-Host | Out-Null
} }
# Main menu loop # Main menu loop

View File

@@ -32,6 +32,531 @@ $workers = @(
@{ ID = 6; Name = "i9-13ks"; SSHArgs = "-t -p 22146 i9-13ks"; Enabled = $true } @{ ID = 6; Name = "i9-13ks"; SSHArgs = "-t -p 22146 i9-13ks"; Enabled = $true }
) )
$script:ControllerScriptBase64 = $null
$script:AttachHelperScriptBase64 = $null
$script:WorkerBasePathCache = @{}
function Remove-ClixmlNoise {
param([object[]]$Lines)
$noisePatterns = @(
'^#<\s*CLIXML',
'^\s*<Objs\b', '^\s*</Objs>',
'^\s*<Obj\b', '^\s*</Obj>',
'^\s*<TN\b', '^\s*</TN>',
'^\s*<MS\b', '^\s*</MS>',
'^\s*<PR\b', '^\s*</PR>',
'^\s*<I64\b', '^\s*</I64>',
'^\s*<AI\b', '^\s*</AI>',
'^\s*<Nil\b', '^\s*</Nil>',
'^\s*<PI\b', '^\s*</PI>',
'^\s*<PC\b', '^\s*</PC>',
'^\s*<SR\b', '^\s*</SR>',
'^\s*<SD\b', '^\s*</SD>',
'^\s*<S\b', '^\s*</S>'
)
$filtered = @()
foreach ($entry in $Lines) {
if ($null -eq $entry) { continue }
$text = $entry.ToString()
$isNoise = $false
foreach ($pattern in $noisePatterns) {
if ($text -match $pattern) {
$isNoise = $true
break
}
}
if (-not $isNoise) {
$filtered += $text
}
}
return $filtered
}
function Write-FilteredSshOutput {
param([object[]]$Lines)
$clean = Remove-ClixmlNoise -Lines $Lines
foreach ($line in $clean) {
Write-Host $line
}
}
function Build-SshArgsFromParts {
param(
[pscustomobject]$Parts,
[switch]$Interactive
)
$args = @('-o','ServerAliveInterval=60','-o','ServerAliveCountMax=30')
if ($Interactive -and $Parts.RequestPty) {
$args += '-t'
}
elseif (-not $Interactive) {
$args += '-T'
}
$args += $Parts.Options
if ($Parts.Port) {
$args += '-p'
$args += $Parts.Port
}
$args += $Parts.Host
return $args
}
function Build-ScpArgsFromParts {
param(
[pscustomobject]$Parts
)
$args = @('-o','ServerAliveInterval=60','-o','ServerAliveCountMax=30')
$args += $Parts.Options
if ($Parts.Port) {
$args += '-P'
$args += $Parts.Port
}
return $args
}
function Get-SshArgs {
param(
[object]$Worker,
[switch]$Interactive
)
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
return Build-SshArgsFromParts -Parts $parts -Interactive:$Interactive
}
function Get-WorkerBasePath {
param(
[object]$Worker,
[pscustomobject]$ConnectionParts = $null
)
if ($script:WorkerBasePathCache.ContainsKey($Worker.Name)) {
return $script:WorkerBasePathCache[$Worker.Name]
}
if (-not $ConnectionParts) {
$ConnectionParts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
}
$sshArgs = Build-SshArgsFromParts -Parts $ConnectionParts -Interactive:$false
$scriptBlock = "`$ProgressPreference='SilentlyContinue'; [Environment]::GetFolderPath('LocalApplicationData')"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($scriptBlock))
$remoteCmd = "powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand $encoded"
$rawOutput = & ssh @sshArgs $remoteCmd 2>&1
$output = Remove-ClixmlNoise -Lines $rawOutput
if ($LASTEXITCODE -ne 0) {
throw "Unable to determine LocalAppData on $($Worker.Name)."
}
$base = ($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim()
if (-not $base) {
throw "Unable to read LocalAppData path on $($Worker.Name)."
}
$final = Join-Path $base 'UnifiedWorkers'
$script:WorkerBasePathCache[$Worker.Name] = $final
return $final
}
function Get-FileBase64 {
param([string]$Path)
[Convert]::ToBase64String([IO.File]::ReadAllBytes($Path))
}
function Get-ControllerScriptBase64 {
if (-not $script:ControllerScriptBase64) {
$controllerPath = Join-Path $PSScriptRoot 'remote_worker_controller.ps1'
$script:ControllerScriptBase64 = Get-FileBase64 -Path $controllerPath
}
return $script:ControllerScriptBase64
}
function Get-AttachHelperScriptBase64 {
if (-not $script:AttachHelperScriptBase64) {
$helperPath = Join-Path $PSScriptRoot 'remote_worker_attach.ps1'
$script:AttachHelperScriptBase64 = Get-FileBase64 -Path $helperPath
}
return $script:AttachHelperScriptBase64
}
function ConvertTo-Base64Unicode {
param([string]$Content)
[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Content))
}
function Get-WorkerSshArgs {
param([string]$RawArgs)
if ([string]::IsNullOrWhiteSpace($RawArgs)) {
return @()
}
return $RawArgs -split '\s+' | Where-Object { $_.Trim().Length -gt 0 }
}
function Get-WorkerConnectionParts {
param(
[string]$RawArgs,
[string]$DefaultHost
)
$tokens = Get-WorkerSshArgs -RawArgs $RawArgs
$options = New-Object System.Collections.Generic.List[string]
$targetHost = $null
$port = $null
$requestPty = $false
$optionsWithArgs = @('-i','-o','-c','-D','-E','-F','-I','-J','-L','-l','-m','-O','-Q','-R','-S','-W','-w')
for ($i = 0; $i -lt $tokens.Count; $i++) {
$token = $tokens[$i]
if ($token -eq '-t' -or $token -eq '-tt') {
$requestPty = $true
continue
}
if ($token -eq '-p' -and ($i + 1) -lt $tokens.Count) {
$port = $tokens[$i + 1]
$i++
continue
}
if ($token.StartsWith('-')) {
$options.Add($token)
if ($optionsWithArgs -contains $token -and ($i + 1) -lt $tokens.Count) {
$options.Add($tokens[$i + 1])
$i++
}
continue
}
if (-not $targetHost) {
$targetHost = $token
continue
}
$options.Add($token)
}
if (-not $targetHost) {
$targetHost = $DefaultHost
}
return [pscustomobject]@{
Host = $targetHost
Options = $options.ToArray()
Port = $port
RequestPty = $requestPty
}
}
function Invoke-RemotePowerShell {
param(
[object]$Worker,
[string]$Script,
[switch]$Interactive
)
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
if (-not $parts.Host) {
throw "Unable to determine SSH host for $($Worker.Name)"
}
$sshBaseArgs = Build-SshArgsFromParts -Parts $parts -Interactive:$Interactive
$remoteBasePath = Get-WorkerBasePath -Worker $Worker -ConnectionParts $parts
$localTemp = [System.IO.Path]::GetTempFileName() + '.ps1'
Set-Content -Path $localTemp -Value $Script -Encoding UTF8
$remoteTmpDir = Join-Path $remoteBasePath 'tmp'
$remoteScriptWin = Join-Path $remoteTmpDir ("script-{0}.ps1" -f ([guid]::NewGuid().ToString()))
$remoteScriptScp = $remoteScriptWin -replace '\\','/'
$remoteTarget = "{0}:{1}" -f $parts.Host, ('"'+$remoteScriptScp+'"')
$ensureScript = "New-Item -ItemType Directory -Path '$remoteTmpDir' -Force | Out-Null"
$ensureCmd = "powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ensureScript))
$ensureOutput = & ssh @sshBaseArgs $ensureCmd 2>&1
$ensureExit = $LASTEXITCODE
Write-FilteredSshOutput -Lines $ensureOutput
if ($ensureExit -ne 0) {
Remove-Item $localTemp -ErrorAction SilentlyContinue
return $ensureExit
}
$scpArgs = Build-ScpArgsFromParts -Parts $parts
$scpArgs += $localTemp
$scpArgs += $remoteTarget
& scp @scpArgs
$scpExit = $LASTEXITCODE
Remove-Item $localTemp -ErrorAction SilentlyContinue
if ($scpExit -ne 0) {
return $scpExit
}
$execCmd = "powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -File `"$remoteScriptWin`""
$execOutput = & ssh @sshBaseArgs $execCmd 2>&1
$execExit = $LASTEXITCODE
Write-FilteredSshOutput -Lines $execOutput
$cleanupScript = "Remove-Item -LiteralPath '$remoteScriptWin' -ErrorAction SilentlyContinue"
$cleanupCmd = "powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cleanupScript))
$cleanupOutput = & ssh @sshBaseArgs $cleanupCmd 2>&1
Write-FilteredSshOutput -Lines $cleanupOutput
return [int]$execExit
}
function Resolve-ExitCode {
param($Value)
if ($Value -is [System.Array]) {
for ($i = $Value.Count - 1; $i -ge 0; $i--) {
$candidate = Resolve-ExitCode -Value $Value[$i]
if ($candidate -ne $null) {
return $candidate
}
}
return 0
}
if ($Value -is [int]) {
return $Value
}
$text = $Value
if ($null -eq $text) {
return 0
}
$parsed = 0
if ([int]::TryParse($text.ToString(), [ref]$parsed)) {
return $parsed
}
return 0
}
function Ensure-ControllerDeployed {
param([object]$Worker)
$controllerBase64 = Get-ControllerScriptBase64
$script = @"
`$ProgressPreference = 'SilentlyContinue'
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
New-Item -ItemType Directory -Path `$dataRoot -Force | Out-Null
`$controllerPath = Join-Path `$dataRoot 'controller.ps1'
[IO.File]::WriteAllBytes(`$controllerPath, [Convert]::FromBase64String('$controllerBase64'))
"@
$exit = Resolve-ExitCode (Invoke-RemotePowerShell -Worker $Worker -Script $script)
if ($exit -ne 0) {
throw "Controller deployment failed on $($Worker.Name) (exit $exit)."
}
}
function Ensure-AttachHelperDeployed {
param([object]$Worker)
$helperBase64 = Get-AttachHelperScriptBase64
$script = @"
`$ProgressPreference = 'SilentlyContinue'
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
New-Item -ItemType Directory -Path `$dataRoot -Force | Out-Null
`$attachPath = Join-Path `$dataRoot 'attach-helper.ps1'
[IO.File]::WriteAllBytes(`$attachPath, [Convert]::FromBase64String('$helperBase64'))
"@
$exit = Resolve-ExitCode (Invoke-RemotePowerShell -Worker $Worker -Script $script)
if ($exit -ne 0) {
throw "Attach helper deployment failed on $($Worker.Name) (exit $exit)."
}
}
function Get-EnsureWorkerScript {
param(
[string]$WorkerName,
[string]$WorkerType,
[string]$PayloadBase64
)
$payload = @{
WorkerName = $WorkerName
WorkerType = $WorkerType
PayloadBase64 = $PayloadBase64
} | ConvertTo-Json -Compress
$payloadBase64 = ConvertTo-Base64Unicode -Content $payload
return @"
`$ProgressPreference = 'SilentlyContinue'
`$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$payloadBase64')))
`$workerName = `$params.WorkerName
`$workerType = `$params.WorkerType
`$payloadBase64 = `$params.PayloadBase64
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
`$instanceRoot = Join-Path (Join-Path `$dataRoot `$workerType) `$workerName
`$logsRoot = Join-Path `$instanceRoot 'logs'
`$stateRoot = Join-Path `$instanceRoot 'state'
New-Item -ItemType Directory -Path `$logsRoot -Force | Out-Null
New-Item -ItemType Directory -Path `$stateRoot -Force | Out-Null
`$logPath = Join-Path `$logsRoot 'worker.log'
`$commandPath = Join-Path `$stateRoot 'commands.txt'
`$payloadPath = Join-Path `$stateRoot 'payload.ps1'
`$payloadBase64Path = Join-Path `$stateRoot 'payload.b64'
if (-not (Test-Path `$logPath)) { New-Item -Path `$logPath -ItemType File -Force | Out-Null }
if (-not (Test-Path `$commandPath)) { New-Item -Path `$commandPath -ItemType File -Force | Out-Null }
[IO.File]::WriteAllText(`$payloadBase64Path, `$payloadBase64, [System.Text.Encoding]::UTF8)
`$metaPath = Join-Path `$instanceRoot 'state\worker-info.json'
`$controllerPath = Join-Path `$dataRoot 'controller.ps1'
if (-not (Test-Path `$controllerPath)) {
throw "Controller missing at `$controllerPath"
}
`$shouldStart = `$true
if (Test-Path `$metaPath) {
try {
`$meta = Get-Content `$metaPath -Raw | ConvertFrom-Json
if (`$meta.Status -eq 'running' -and `$meta.WorkerPid) {
if (Get-Process -Id `$meta.WorkerPid -ErrorAction SilentlyContinue) {
Write-Host "Worker `$workerName already running (PID `$($meta.WorkerPid))."
`$shouldStart = `$false
}
}
} catch {
Write-Host "Failed to read metadata. Controller will restart worker." -ForegroundColor Yellow
}
}
if (`$shouldStart) {
`$initialMeta = [pscustomobject]@{
WorkerName = `$workerName
WorkerType = `$workerType
Status = 'launching'
ControllerPid = `$null
WorkerPid = `$null
Restarts = 0
LastExitCode = `$null
LogPath = `$logPath
CommandPath = `$commandPath
PayloadPath = `$payloadPath
UpdatedAtUtc = (Get-Date).ToUniversalTime()
} | ConvertTo-Json -Depth 5
`$initialMeta | Set-Content -Path `$metaPath -Encoding UTF8
`$pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
if (`$pwsh) {
`$psExe = `$pwsh.Source
}
else {
`$psExe = (Get-Command powershell -ErrorAction Stop).Source
}
`$controllerArgs = @(
'-NoLogo','-NoProfile','-ExecutionPolicy','Bypass',
'-File',"`$controllerPath",
'-WorkerName',"`$workerName",
'-WorkerType',"`$workerType",
'-PayloadBase64Path',"`$payloadBase64Path"
)
Start-Process -FilePath `$psExe -ArgumentList `$controllerArgs -WindowStyle Hidden | Out-Null
Write-Host "Worker `$workerName started under controller." -ForegroundColor Green
}
"@
}
function Ensure-PersistentWorker {
param(
[object]$Worker,
[string]$WorkerType,
[string]$PayloadScript
)
Ensure-ControllerDeployed -Worker $Worker
$payloadBase64 = ConvertTo-Base64Unicode -Content $PayloadScript
$ensureScript = Get-EnsureWorkerScript -WorkerName $Worker.Name -WorkerType $WorkerType -PayloadBase64 $payloadBase64
$exit = Resolve-ExitCode (Invoke-RemotePowerShell -Worker $Worker -Script $ensureScript)
if ($exit -ne 0) {
throw "Worker ensure script failed on $($Worker.Name) (exit $exit)."
}
}
function Test-WorkerMetadataExists {
param(
[object]$Worker,
[string]$WorkerType
)
$payload = @{
WorkerName = $Worker.Name
WorkerType = $WorkerType
} | ConvertTo-Json -Compress
$payloadBase64 = ConvertTo-Base64Unicode -Content $payload
$script = @"
`$ProgressPreference = 'SilentlyContinue'
`$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$payloadBase64')))
`$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
`$instanceRoot = Join-Path (Join-Path `$dataRoot `$params.WorkerType) `$params.WorkerName
`$metaPath = Join-Path `$instanceRoot 'state\worker-info.json'
if (Test-Path `$metaPath) {
exit 0
}
exit 1
"@
$result = Invoke-RemotePowerShell -Worker $Worker -Script $script
return ($result -eq 0)
}
function Wait-WorkerMetadata {
param(
[object]$Worker,
[string]$WorkerType,
[int]$TimeoutSeconds = 30
)
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if (Test-WorkerMetadataExists -Worker $Worker -WorkerType $WorkerType) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
function Invoke-WorkerAttach {
param(
[object]$Worker,
[string]$WorkerType,
[switch]$CommandOnly,
[string]$Command
)
Ensure-AttachHelperDeployed -Worker $Worker
$paramsBlock = @("-WorkerName","$($Worker.Name)","-WorkerType","$WorkerType")
if ($CommandOnly) {
$paramsBlock += "-CommandOnly"
}
if ($Command) {
$paramsBlock += "-Command"
$paramsBlock += $Command
}
$parts = Get-WorkerConnectionParts -RawArgs $Worker.SSHArgs -DefaultHost $Worker.Name
$sshArgs = Build-SshArgsFromParts -Parts $parts -Interactive:(!$CommandOnly)
$remoteBase = Get-WorkerBasePath -Worker $Worker -ConnectionParts $parts
$remoteHelper = Join-Path $remoteBase 'attach-helper.ps1'
$quotedArgs = ($paramsBlock | ForEach-Object { '"' + ($_ -replace '"','""') + '"' }) -join ' '
$remoteCmd = "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$remoteHelper`" $quotedArgs"
& ssh @sshArgs $remoteCmd
}
function Get-RemoteSheepItCommand { function Get-RemoteSheepItCommand {
param( param(
[string]$RenderKey, [string]$RenderKey,
@@ -40,15 +565,11 @@ function Get-RemoteSheepItCommand {
$safeKey = $RenderKey -replace "'", "''" $safeKey = $RenderKey -replace "'", "''"
$safeUser = $Username -replace "'", "''" $safeUser = $Username -replace "'", "''"
$userLine = if ([string]::IsNullOrWhiteSpace($Username)) {
''
} else {
" Write-Host 'Authenticating as ${safeUser} via render key.' -ForegroundColor Yellow`n"
}
$urlLiteral = '@(' + (($SheepItJarUrls | ForEach-Object { "'$_'" }) -join ', ') + ')' $urlLiteral = '@(' + (($SheepItJarUrls | ForEach-Object { "'$_'" }) -join ', ') + ')'
@" @"
`$ProgressPreference = 'SilentlyContinue'
`$ErrorActionPreference = 'Stop' `$ErrorActionPreference = 'Stop'
try { try {
@@ -140,6 +661,7 @@ try {
throw throw
} }
} }
catch { catch {
Write-Host ('Error: {0}' -f `$_.Exception.Message) -ForegroundColor Red Write-Host ('Error: {0}' -f `$_.Exception.Message) -ForegroundColor Red
Write-Host ('Stack trace: {0}' -f `$_.ScriptStackTrace) -ForegroundColor DarkRed Write-Host ('Stack trace: {0}' -f `$_.ScriptStackTrace) -ForegroundColor DarkRed
@@ -147,71 +669,56 @@ catch {
"@ "@
} }
function New-SheepItSessionScript { function Ensure-SheepItWorkerController {
param( param([object]$Worker)
[object]$Worker, Initialize-SheepItCredentials
[string]$EncodedCommand $payloadScript = Get-RemoteSheepItCommand -RenderKey $script:SheepItRenderKey -Username $script:SheepItUsername
) Ensure-PersistentWorker -Worker $Worker -WorkerType 'sheepit' -PayloadScript $payloadScript
@"
Write-Host 'Connecting to $($Worker.Name)...' -ForegroundColor Cyan
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=30 $($Worker.SSHArgs) `"powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand $EncodedCommand`"
Write-Host "`nSSH session ended." -ForegroundColor Yellow
Read-Host "Press Enter to close"
"@
}
function Start-SheepItTab {
param(
[string]$Title,
[string]$Content
)
$tempScript = [System.IO.Path]::GetTempFileName() + '.ps1'
Set-Content -Path $tempScript -Value $Content -Encoding UTF8
if (Get-Command wt.exe -ErrorAction SilentlyContinue) {
Start-Process wt.exe -ArgumentList "-w 0 new-tab --title `"$Title`" powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$tempScript`""
} else {
Start-Process powershell -ArgumentList "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$tempScript`""
}
} }
function Start-SheepItWorker { function Start-SheepItWorker {
param([object]$Worker) param([object]$Worker)
try {
if (-not $Worker.Enabled) { Write-Host "Ensuring SheepIt controller on $($Worker.Name)..." -ForegroundColor Cyan
Write-Host "$($Worker.Name) is not enabled for SheepIt." -ForegroundColor Yellow Ensure-SheepItWorkerController -Worker $Worker
}
catch {
Write-Host "Failed to ensure controller on $($Worker.Name): $($_.Exception.Message)" -ForegroundColor Red
return return
} }
Initialize-SheepItCredentials if (-not (Wait-WorkerMetadata -Worker $Worker -WorkerType 'sheepit' -TimeoutSeconds 30)) {
Write-Host "Worker metadata did not appear on $($Worker.Name). Check controller logs under %LocalAppData%\UnifiedWorkers." -ForegroundColor Red
return
}
$remoteCommand = Get-RemoteSheepItCommand -RenderKey $script:SheepItRenderKey -Username $script:SheepItUsername Write-Host "Controller ready. Attaching to SheepIt worker on $($Worker.Name)..." -ForegroundColor Cyan
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($remoteCommand)) Invoke-WorkerAttach -Worker $Worker -WorkerType 'sheepit'
$sessionScript = New-SheepItSessionScript -Worker $Worker -EncodedCommand $encoded
$title = "$($Worker.Name) - SheepIt"
Start-SheepItTab -Title $title -Content $sessionScript
Write-Host "Opened SheepIt session for $($Worker.Name) in a new terminal tab." -ForegroundColor Green
} }
function Start-AllSheepIt { function Start-AllSheepIt {
Initialize-SheepItCredentials Initialize-SheepItCredentials
foreach ($worker in $workers | Where-Object { $_.Enabled }) {
$targets = $workers | Where-Object { $_.Enabled } Ensure-SheepItWorkerController -Worker $worker
if (-not $targets) {
Write-Host "No systems are ready for SheepIt." -ForegroundColor Yellow
return
}
foreach ($worker in $targets) {
Start-SheepItWorker -Worker $worker
Start-Sleep -Milliseconds 200
} }
Write-Host "All enabled SheepIt workers ensured running under controllers." -ForegroundColor Green
Write-Host "Use the attach option to monitor any worker." -ForegroundColor Cyan
Read-Host "Press Enter to continue" | Out-Null
} }
function Send-SheepItCommandAll {
param([string]$CommandText)
foreach ($worker in $workers | Where-Object { $_.Enabled }) {
Write-Host "[$($worker.Name)] Sending '$CommandText'..." -ForegroundColor Gray
Invoke-WorkerAttach -Worker $worker -WorkerType 'sheepit' -CommandOnly -Command $CommandText
}
Write-Host "Command '$CommandText' dispatched to all enabled workers." -ForegroundColor Green
Read-Host "Press Enter to continue" | Out-Null
}
function Select-SheepItWorker { function Select-SheepItWorker {
while ($true) { while ($true) {
Show-Header Show-Header
@@ -256,20 +763,25 @@ Initialize-SheepItCredentials
while ($true) { while ($true) {
Show-Header Show-Header
Write-Host "Main Menu:" -ForegroundColor Magenta Write-Host "Main Menu:" -ForegroundColor Magenta
Write-Host "1. Launch SheepIt on a single system" -ForegroundColor Yellow Write-Host "1. Launch/Attach SheepIt on a single system" -ForegroundColor Yellow
Write-Host "2. Launch SheepIt on all ready systems" -ForegroundColor Yellow Write-Host "2. Ensure all ready systems are running" -ForegroundColor Yellow
Write-Host "3. Exit" -ForegroundColor Yellow Write-Host "3. Pause all workers" -ForegroundColor Yellow
Write-Host "4. Resume all workers" -ForegroundColor Yellow
Write-Host "5. Quit all workers" -ForegroundColor Yellow
Write-Host "6. Exit" -ForegroundColor Yellow
$choice = Read-Host "Select option (1-3)" $choice = Read-Host "Select option (1-6)"
if ($choice -eq '6') {
break
}
switch ($choice) { switch ($choice) {
'1' { Select-SheepItWorker } '1' { Select-SheepItWorker }
'2' { '2' { Start-AllSheepIt }
Start-AllSheepIt '3' { Send-SheepItCommandAll -CommandText 'pause' }
Write-Host '4' { Send-SheepItCommandAll -CommandText 'resume' }
Read-Host "Press Enter to return to the main menu" | Out-Null '5' { Send-SheepItCommandAll -CommandText 'quit' }
}
'3' { break }
default { default {
Write-Host "Invalid selection." -ForegroundColor Red Write-Host "Invalid selection." -ForegroundColor Red
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1