Files
awoooi/agent99-control-plane.ps1

2901 lines
120 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[ValidateSet("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck", "ProviderFreshness", "SecurityTriage", "ControlTick")]
[string]$Mode = "Status",
[switch]$ControlledApply,
[switch]$SelfTestTelegramCard,
[string]$ConfigPath = "C:\Wooo\Agent99\config\agent99.config.json",
[string]$EvidenceDir = "C:\Wooo\Agent99\evidence"
)
$ErrorActionPreference = "Continue"
New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$logPath = Join-Path $EvidenceDir "agent99-$Mode-$stamp.log"
$jsonPath = Join-Path $EvidenceDir "agent99-$Mode-$stamp.json"
function Write-AgentLog {
param([string]$Message)
$line = "$(Get-Date -Format o) $Message"
$line | Tee-Object -FilePath $logPath -Append | Out-Null
}
function Load-Config {
if (-not (Test-Path $ConfigPath)) {
throw "Config not found: $ConfigPath"
}
Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
$Config = Load-Config
$SshUser = if ($Config.sshUser) { $Config.sshUser } else { "wooo" }
$script:Events = @()
$script:TelegramAttempts = @()
function Get-HostSshUser {
param([string]$TargetHost)
if ($Config.sshUsers -and $Config.sshUsers.PSObject.Properties[$TargetHost]) {
return [string]$Config.sshUsers.PSObject.Properties[$TargetHost].Value
}
$SshUser
}
function Send-AgentTelegramRelay {
param(
[string]$Message,
[object]$Relay,
[string]$ChatIdOverride = ""
)
if (-not ($Relay -and $Relay.enabled)) {
return [pscustomobject]@{ ok = $false; error = "relay_disabled" }
}
$relayHost = if ($Relay.host) { [string]$Relay.host } else { "192.168.0.110" }
$container = if ($Relay.container) { [string]$Relay.container } else { "stockplatform-v2-api-1" }
if ($container -notmatch "^[A-Za-z0-9_.-]+$") {
return [pscustomobject]@{ ok = $false; error = "invalid_relay_container" }
}
$messageBytes = [Text.Encoding]::UTF8.GetBytes($Message)
$messageB64 = [Convert]::ToBase64String($messageBytes)
$chatOverrideB64 = if ($ChatIdOverride) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ChatIdOverride)) } else { "" }
$python = 'import base64, os, sys, urllib.parse, urllib.request
text = base64.b64decode(sys.argv[2]).decode()
chat_override = base64.b64decode(sys.argv[3]).decode() if len(sys.argv) > 3 and sys.argv[3] else ""
token = os.environ.get("TELEGRAM_BOT_TOKEN")
chat = chat_override or os.environ.get("TELEGRAM_CHAT_ID")
assert token and chat
data = urllib.parse.urlencode({"chat_id": chat, "text": text, "disable_web_page_preview": "true"}).encode()
urllib.request.urlopen("https://api.telegram.org/bot" + token + "/sendMessage", data=data, timeout=15).read()
print("sent_ok")
'
$pythonB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($python))
$bootstrap = 'import base64,sys;exec(base64.b64decode(sys.argv[1]))'
$remoteCommand = "docker exec $container python3 -c '$bootstrap' $pythonB64 $messageB64 $chatOverrideB64"
$run = Invoke-SshText $relayHost $remoteCommand 45 1
[pscustomobject]@{
ok = [bool]($run.ok -and $run.output -match "sent_ok")
host = $relayHost
container = $container
exitCode = $run.exitCode
chatOverride = if ($ChatIdOverride) { "telegram-origin" } else { $null }
output = $run.output
error = if ($run.ok) { $null } else { $run.output }
}
}
function Get-AgentObjectValue {
param(
[object]$Data,
[string]$Name,
[object]$Default = $null
)
if ($Data -and $Data.PSObject.Properties[$Name]) {
$value = $Data.PSObject.Properties[$Name].Value
if ($null -ne $value -and [string]$value -ne "") {
return $value
}
}
$Default
}
function Limit-AgentTextLine {
param(
[string]$Text,
[int]$MaxLength = 96
)
if (-not $Text) { return "" }
$clean = (($Text -replace "\s+", " ").Trim())
if ($clean.Length -le $MaxLength) { return $clean }
$clean.Substring(0, $MaxLength - 14) + "...(truncated)"
}
function Format-AgentMetricForCard {
param(
[object]$Value,
[string]$Suffix = ""
)
if ($null -eq $Value -or [string]$Value -eq "") {
return "readback_failed"
}
"$Value$Suffix"
}
function Format-AgentTelegramText {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$severityLabel = switch ($Severity) {
"critical" { "CRITICAL" }
"warning" { "WARNING" }
"info" { "INFO" }
default { $Severity }
}
$lines = @()
$lines += "Agent99 事件卡 / Incident Card"
$lines += "嚴重度 Severity: $severityLabel"
if ($EventType -match "^performance_") {
$hostName = Get-AgentObjectValue $Data "host" "unknown"
$reasons = if ($Data -and $Data.PSObject.Properties["reasons"]) { @($Data.reasons) } else { @() }
$disk = Get-AgentObjectValue $Data "diskUsedPercent" $null
$load = Get-AgentObjectValue $Data "loadPerCore" $null
$mem = Get-AgentObjectValue $Data "memAvailablePercent" $null
$lines += "事件 Event: 主機效能 / host performance"
$lines += "主機 Host: $hostName"
if ($reasons -contains "perf_readback_failed" -or ($null -eq $disk -and $null -eq $load -and $null -eq $mem)) {
$lines += "原因 Reason: 效能指標 readback 失敗。"
$lines += "影響 Impact: CPU load、memory、disk 數值尚未可信,不能直接當成真故障。"
$lines += "Agent99 動作 Action: 維持噪音 sensor 暫停;修復前必須先補 verifier readback。"
} elseif ($reasons -contains "disk_used_warning") {
$lines += "原因 Reason: 磁碟使用率 $disk percent已達 warning thresholdcritical threshold 是 95 percent。"
$lines += "Agent99 動作 Action: 執行 allowlisted disk remediation並在 30 分鐘內 suppress duplicates。"
} elseif ($reasons -contains "disk_used_high") {
$lines += "原因 Reason: 磁碟使用率 $disk percent已達 critical threshold。"
$lines += "Agent99 動作 Action: 先執行 allowlisted cleanupreboot 不是第一反應。"
} elseif ($reasons -contains "load_per_core_warning" -or $reasons -contains "load_per_core_critical") {
$lines += "原因 Reason: CPU load/core=$load"
$lines += "Agent99 動作 Action: 僅使用 allowlisted load reduction不得任意停止服務。"
} elseif ($reasons -contains "memory_available_low") {
$lines += "原因 Reason: 可用記憶體為 $mem percent。"
$lines += "Agent99 動作 Action: 先檢查 memory pressure再進行 allowlisted remediation。"
} else {
$lines += "原因 Reason: $($reasons -join ', ')"
$lines += "Agent99 動作 Action: 檢查 Agent99 evidence完成 full readback 後再處理。"
}
$metricParts = @()
if ($null -ne $load -and [string]$load -ne "") { $metricParts += "load/core=$load" }
if ($null -ne $mem -and [string]$mem -ne "") { $metricParts += "memAvail=$mem percent" }
if ($null -ne $disk -and [string]$disk -ne "") { $metricParts += "disk=$disk percent" }
if ($metricParts.Count -gt 0) {
$lines += "目前狀態 Current: $($metricParts -join ', ')."
} else {
$lines += "目前狀態 Current: metrics unavailable空白指標不視為可信告警。"
}
} elseif ($EventType -match "^backup_") {
$targetName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$reason = if ($Data -and $Data.PSObject.Properties["reason"]) { $Data.reason } else { $EventType }
$age = if ($Data -and $Data.PSObject.Properties["ageMinutes"]) { $Data.ageMinutes } else { $null }
$maxAge = if ($Data -and $Data.PSObject.Properties["maxAgeMinutes"]) { $Data.maxAgeMinutes } else { $null }
$lines += "事件 Event: 備份健康 / backup health"
$lines += "目標 Target: $targetName"
$lines += "原因 Reason: $reason"
if ($age -ne $null -or $maxAge -ne $null) {
$lines += "新鮮度 Freshness: age=$age minutes / max=$maxAge minutes"
}
$lines += "Agent99 動作 Action: 只檢查 /backup/status、/backup/logs、snapshot metadata不得讀取備份內容或 secrets。"
} elseif ($EventType -eq "provider_freshness_triage") {
$candidateId = if ($Data -and $Data.PSObject.Properties["candidateId"]) { $Data.candidateId } else { "unknown" }
$targetName = if ($Data -and $Data.PSObject.Properties["target"]) { $Data.target } else { "provider_freshness_signal" }
$hitCount = if ($Data -and $Data.PSObject.Properties["hitCount"]) { $Data.hitCount } else { 0 }
$missing = if ($Data -and $Data.PSObject.Properties["missingFields"]) { @($Data.missingFields) } else { @() }
$candidatePath = if ($Data -and $Data.PSObject.Properties["candidatePath"]) { $Data.candidatePath } else { $null }
$readback = if ($Data -and $Data.PSObject.Properties["verifierReadback"]) { $Data.verifierReadback } else { $null }
$status = if ($Data -and $Data.PSObject.Properties["status"]) { $Data.status } else { "unknown" }
$lines += "事件 Event: Provider freshness 自動判讀 / provider freshness auto triage"
$lines += "目標 Target: $targetName"
$lines += "狀態 Status: $status"
$lines += "影響 Impact: source freshness 未證明前dashboard green 不可視為健康。"
$lines += "證據命中 Evidence hits: $hitCount"
if ($readback) {
$lines += "驗證 Verifier: directReadback ok=$($readback.ok), sourceEvents=$($readback.sourceEventTotal), latest=$($readback.latestReceivedAt)"
}
if (@($missing).Count -gt 0) {
$lines += "仍缺欄位 Still missing: $($missing -join ', ')"
} else {
$lines += "缺漏狀態 Missing: direct readback fields completed"
}
$lines += "Agent99 動作 Action: 已寫入 candidate 與 KM/RAG相同狀態 duplicate alerts 會被 suppress。"
$lines += "限制 Guard: 不切 provider、不呼叫 paid model、不改 env、不 reload。"
if ($candidatePath) {
$lines += "證據 Evidence: $candidatePath"
}
} elseif ($EventType -match "^load_shed_") {
$actionName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$hostName = if ($Data -and $Data.PSObject.Properties["host"]) { $Data.host } else { "unknown" }
$ok = if ($Data -and $Data.PSObject.Properties["ok"]) { $Data.ok } else { $null }
$executionOk = if ($Data -and $Data.PSObject.Properties["executionOk"]) { $Data.executionOk } else { $null }
$postVerifyOk = if ($Data -and $Data.PSObject.Properties["postVerifyOk"]) { $Data.postVerifyOk } else { $null }
$exitCode = if ($Data -and $Data.PSObject.Properties["exitCode"]) { $Data.exitCode } else { $null }
$rollback = if ($Data -and $Data.PSObject.Properties["rollback"]) { $Data.rollback } else { $null }
$lines += "事件 Event: 受控修復 / controlled remediation"
$lines += "主機 Host: $hostName"
$lines += "Agent99 動作 Action: $actionName"
if ($ok -ne $null) {
$lines += "結果 Result: ok=$ok exitCode=$exitCode"
if ($executionOk -ne $null -or $postVerifyOk -ne $null) {
$lines += "驗證 Verifier: executionOk=$executionOk postVerifyOk=$postVerifyOk"
}
} else {
$lines += "結果 Result: 請查看 Agent99 evidence。"
}
if ($rollback) {
$lines += "回滾 Rollback: $rollback"
}
} elseif ($EventType -match "^operator_command_") {
$commandId = if ($Data -and $Data.PSObject.Properties["id"]) { $Data.id } else { "unknown" }
$modeName = if ($Data -and $Data.PSObject.Properties["mode"]) { $Data.mode } else { "unknown" }
$source = if ($Data -and $Data.PSObject.Properties["source"]) { $Data.source } else { "unknown" }
$instruction = if ($Data -and $Data.PSObject.Properties["instruction"]) { $Data.instruction } else { $null }
$exitCode = if ($Data -and $Data.PSObject.Properties["exitCode"]) { $Data.exitCode } else { $null }
$evidencePath = if ($Data -and $Data.PSObject.Properties["evidence"]) { $Data.evidence } else { $null }
$lines += "事件 Event: Agent99 自動化結果 / automation result"
$lines += "模式 Mode: $modeName"
$lines += "來源 Source: $source"
$lines += "結果 Result: exitCode=$exitCode"
if ($Data -and $Data.PSObject.Properties["problem"]) {
$problem = $Data.problem
if ($problem -and $problem.PSObject.Properties["occurrences"]) {
$lines += "問題計數 Problem count: occurrences=$($problem.occurrences), resolved=$($problem.resolved), failed=$($problem.failed)"
}
}
if ($instruction -and $exitCode -ne 0) {
$lines += "失敗指令 Failure instruction: $instruction"
}
if ($evidencePath) {
$lines += "證據 Evidence: $evidencePath"
}
} elseif ($EventType -eq "agent_control_tick") {
$dashboardPath = if ($Data -and $Data.PSObject.Properties["dashboardPath"]) { $Data.dashboardPath } else { $null }
$processed = if ($Data -and $Data.PSObject.Properties["processedCount"]) { $Data.processedCount } else { 0 }
$pending = if ($Data -and $Data.PSObject.Properties["pendingCount"]) { $Data.pendingCount } else { 0 }
$lines += "事件 Event: Agent99 控制迴圈 / control loop"
$lines += "儀表板 Dashboard: $dashboardPath"
$lines += "佇列 Queue: processed=$processed pending=$pending"
} elseif ($EventType -match "^ai_service_") {
$serviceName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$lines += "事件 Event: AI 服務健康 / AI service health"
$lines += "服務 Service: $serviceName"
$lines += "Agent99 動作 Action: 先檢查 health endpoint 與 service/container state再做 controlled repair。"
} elseif ($EventType -eq "security_triage") {
$status = if ($Data -and $Data.PSObject.Properties["status"]) { $Data.status } else { "unknown" }
$hitCount = if ($Data -and $Data.PSObject.Properties["hitCount"]) { $Data.hitCount } else { 0 }
$evidencePath = if ($Data -and $Data.PSObject.Properties["evidencePath"]) { $Data.evidencePath } else { $null }
$lines += "事件 Event: 資安判讀 / security triage"
$lines += "狀態 Status: $status"
$lines += "證據命中 Evidence hits: $hitCount"
$lines += "Agent99 動作 Action: 分類 blast radius、affected host/service、source log reference、repeated count、allowlisted next action。"
$lines += "限制 Guard: 不執行 arbitrary shell、不刪資料、不停用 security controls、不暴露 secrets。"
if ($evidencePath) {
$lines += "證據 Evidence: $evidencePath"
}
} elseif ($EventType -eq "agent_selfcheck") {
$lines += "事件 Event: Agent99 自我健康 / self health"
$lines += "摘要 Summary: $Message"
$lines += "Agent99 動作 Action: 檢查 task state、evidence freshness、Telegram deliveryreboot 不是第一反應。"
} else {
$lines += "事件 Event: $EventType"
$lines += "摘要 Summary: $Message"
}
$lines += "時間 Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
$text = ($lines -join "`n")
if ($text.Length -gt 3500) {
$text = $text.Substring(0, 3500) + "`n...(truncated)"
}
$text
}
function Get-AgentTelegramVisualPath {
param([object]$Data)
if (-not $Data) { return $null }
foreach ($propName in @("visualPath", "cardImagePath", "imagePath", "screenshotPath")) {
if ($Data.PSObject.Properties[$propName]) {
$path = [string]$Data.PSObject.Properties[$propName].Value
if ($path -and (Test-Path -LiteralPath $path)) {
return $path
}
}
}
$null
}
function New-AgentTelegramCardImage {
param(
[string]$Severity,
[string]$EventType,
[string]$Text
)
$visualCardsEnabled = $true
if ($Config.telegram -and $Config.telegram.PSObject.Properties["visualCardsEnabled"]) {
$visualCardsEnabled = [bool]$Config.telegram.visualCardsEnabled
}
if (-not $visualCardsEnabled) { return $null }
if ($Severity -notin @("warning", "critical")) { return $null }
$cardDir = Join-Path $EvidenceDir "telegram-cards"
New-Item -ItemType Directory -Force -Path $cardDir | Out-Null
$safeEvent = ($EventType -replace "[^A-Za-z0-9_.-]", "-")
$imagePath = Join-Path $cardDir "agent99-card-$safeEvent-$stamp.png"
try {
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
$width = 1040
$height = 620
$bitmap = New-Object System.Drawing.Bitmap -ArgumentList $width, $height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
$bgBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(246, 248, 250))
$panelBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(255, 255, 255))
$textBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(28, 35, 43))
$mutedBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(91, 103, 112))
$accentColor = if ($Severity -eq "critical") {
[System.Drawing.Color]::FromArgb(190, 18, 60)
} elseif ($Severity -eq "warning") {
[System.Drawing.Color]::FromArgb(217, 119, 6)
} else {
[System.Drawing.Color]::FromArgb(37, 99, 235)
}
$accentBrush = New-Object System.Drawing.SolidBrush -ArgumentList $accentColor
$fontFamily = "Microsoft JhengHei UI"
$titleFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 25, ([System.Drawing.FontStyle]::Bold)
$labelFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 13, ([System.Drawing.FontStyle]::Bold)
$bodyFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 16, ([System.Drawing.FontStyle]::Regular)
$smallFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 11, ([System.Drawing.FontStyle]::Regular)
$graphics.FillRectangle($bgBrush, 0, 0, $width, $height)
$graphics.FillRectangle($panelBrush, 36, 34, $width - 72, $height - 68)
$graphics.FillRectangle($accentBrush, 36, 34, 14, $height - 68)
$graphics.DrawString("Agent99 事件卡 / Incident Card", $titleFont, $textBrush, 72, 58)
$graphics.DrawString(("嚴重度 Severity: " + $Severity.ToUpperInvariant()), $labelFont, $accentBrush, 72, 106)
$graphics.DrawString(("事件 Event: " + (Limit-AgentTextLine $EventType 76)), $labelFont, $mutedBrush, 72, 134)
$bodyLines = @($Text -split "`n" | Where-Object { $_ -and $_ -notmatch "^Agent99 事件卡 / Incident Card$" } | Select-Object -First 12)
$y = 184
foreach ($line in $bodyLines) {
$graphics.DrawString((Limit-AgentTextLine $line 96), $bodyFont, $textBrush, 72, $y)
$y += 32
if ($y -gt 545) { break }
}
$graphics.DrawString(("產生時間 Generated: " + (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz")), $smallFont, $mutedBrush, 72, 570)
$bitmap.Save($imagePath, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
$bgBrush.Dispose()
$panelBrush.Dispose()
$textBrush.Dispose()
$mutedBrush.Dispose()
$accentBrush.Dispose()
$titleFont.Dispose()
$labelFont.Dispose()
$bodyFont.Dispose()
$smallFont.Dispose()
return $imagePath
} catch {
Write-AgentLog "telegram_card_image_failed event=$EventType error=$($_.Exception.Message)"
return $null
}
}
function Send-AgentTelegramPhotoDirect {
param(
[string]$Token,
[string]$ChatId,
[string]$Caption,
[string]$PhotoPath
)
Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
$client = New-Object System.Net.Http.HttpClient
$content = New-Object System.Net.Http.MultipartFormDataContent
$fileContent = $null
try {
$content.Add([System.Net.Http.StringContent]::new($ChatId), "chat_id")
$captionToSend = $Caption
if ($captionToSend.Length -gt 900) {
$captionToSend = $captionToSend.Substring(0, 900) + "`n...(truncated; see evidence)"
}
$content.Add([System.Net.Http.StringContent]::new($captionToSend), "caption")
$fileBytes = [IO.File]::ReadAllBytes($PhotoPath)
$fileContent = [System.Net.Http.ByteArrayContent]::new($fileBytes)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("image/png")
$content.Add($fileContent, "photo", [IO.Path]::GetFileName($PhotoPath))
$response = $client.PostAsync("https://api.telegram.org/bot$Token/sendPhoto", $content).GetAwaiter().GetResult()
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) {
throw "telegram_sendPhoto_failed status=$([int]$response.StatusCode) body=$body"
}
} finally {
if ($fileContent) { $fileContent.Dispose() }
if ($content) { $content.Dispose() }
if ($client) { $client.Dispose() }
}
}
function Invoke-AgentTelegramCardSelfTest {
$sample = [pscustomobject]@{
host = "192.168.0.110"
reasons = @("perf_readback_failed")
evidencePath = $jsonPath
}
$text = Format-AgentTelegramText "warning" "performance_warning" "selftest visual card" $sample
$imagePath = New-AgentTelegramCardImage "warning" "performance_warning" $text
$ok = [bool]($imagePath -and (Test-Path -LiteralPath $imagePath))
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = $ok
textPreview = @($text -split "`n" | Select-Object -First 12)
visualPath = $imagePath
visualExists = $ok
sentTelegram = $false
messageFormat = "incident_card_zh_tw_v3"
}
$selfTestPath = Join-Path $EvidenceDir "agent99-TelegramCardSelfTest-$stamp.json"
$result | ConvertTo-Json -Depth 6 | Set-Content -Path $selfTestPath -Encoding UTF8
Write-AgentLog "telegram_card_selftest ok=$ok path=$imagePath evidence=$selfTestPath"
$result | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 $jsonPath
$result | ConvertTo-Json -Depth 6
if (-not $ok) { exit 1 }
exit 0
}
function Send-AgentTelegram {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$telegram = $Config.telegram
$enabled = ($telegram -and $telegram.enabled)
$botTokenEnv = if ($telegram.botTokenEnv) { $telegram.botTokenEnv } else { "AGENT99_TELEGRAM_BOT_TOKEN" }
$chatIdEnv = if ($telegram.chatIdEnv) { $telegram.chatIdEnv } else { "AGENT99_TELEGRAM_CHAT_ID" }
$tokenEnvAliases = @($botTokenEnv)
if ($telegram.botTokenEnvAliases) { $tokenEnvAliases += @($telegram.botTokenEnvAliases) }
$chatIdEnvAliases = @($chatIdEnv)
if ($telegram.chatIdEnvAliases) { $chatIdEnvAliases += @($telegram.chatIdEnvAliases) }
function Get-AgentEnvSecret {
param([string[]]$Names)
foreach ($name in ($Names | Where-Object { $_ } | Select-Object -Unique)) {
foreach ($scope in @("Machine", "User", "Process")) {
$value = [Environment]::GetEnvironmentVariable($name, $scope)
if ($value) {
return [pscustomobject]@{ value = $value; name = $name; scope = $scope }
}
}
}
return $null
}
$tokenSecret = Get-AgentEnvSecret $tokenEnvAliases
$chatSecret = Get-AgentEnvSecret $chatIdEnvAliases
$token = if ($tokenSecret) { $tokenSecret.value } else { $null }
$chatId = if ($chatSecret) { $chatSecret.value } else { $null }
$chatIdOverride = if ($Data -and $Data.PSObject.Properties["replyChatId"]) { [string]$Data.replyChatId } else { "" }
$attempt = [pscustomobject]@{
timestamp = (Get-Date -Format o)
enabled = [bool]$enabled
eventType = $EventType
severity = $Severity
messageFormat = "incident_card_zh_tw_v3"
configured = [bool]($token -and $chatId)
tokenEnv = if ($tokenSecret) { $tokenSecret.name } else { $null }
chatIdEnv = if ($chatSecret) { $chatSecret.name } else { $null }
chatOverride = if ($chatIdOverride) { "telegram-origin" } else { $null }
visualPath = $null
visualSent = $false
visualError = $null
relay = $null
sent = $false
error = $null
}
if (-not $enabled) {
$attempt.error = "telegram_disabled"
$script:TelegramAttempts += $attempt
return
}
$text = Format-AgentTelegramText $Severity $EventType $Message $Data
$visualPath = Get-AgentTelegramVisualPath $Data
if (-not $visualPath) {
$visualPath = New-AgentTelegramCardImage $Severity $EventType $text
}
$attempt.visualPath = $visualPath
if (-not ($token -and ($chatId -or $chatIdOverride))) {
$relayResult = Send-AgentTelegramRelay $text $telegram.relay $chatIdOverride
$attempt.relay = $relayResult
if ($relayResult.ok) {
$attempt.sent = $true
$attempt.error = $null
if ($visualPath) {
$attempt.visualError = "visual_card_generated_but_relay_sendPhoto_not_available"
}
} else {
$attempt.error = "telegram_not_configured"
}
$script:TelegramAttempts += $attempt
return
}
try {
$targetChat = if ($chatIdOverride) { $chatIdOverride } else { $chatId }
if ($visualPath -and (Test-Path -LiteralPath $visualPath)) {
try {
Send-AgentTelegramPhotoDirect $token $targetChat $text $visualPath
$attempt.sent = $true
$attempt.visualSent = $true
} catch {
$attempt.visualError = $_.Exception.Message
Write-AgentLog "telegram_sendPhoto_failed event=$EventType fallback=sendMessage error=$($_.Exception.Message)"
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/sendMessage" -Body @{
chat_id = $targetChat
text = $text
disable_web_page_preview = "true"
} | Out-Null
$attempt.sent = $true
}
} else {
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/sendMessage" -Body @{
chat_id = $targetChat
text = $text
disable_web_page_preview = "true"
} | Out-Null
$attempt.sent = $true
}
} catch {
$attempt.error = $_.Exception.Message
}
$script:TelegramAttempts += $attempt
}
function Record-AgentEvent {
param(
[string]$EventType,
[string]$Severity,
[string]$Message,
[object]$Data = $null,
[switch]$Alert,
[switch]$NoAlert
)
Write-AgentLog "event type=$EventType severity=$Severity message=$Message"
$eventData = $Data
if ($Data -and $Data.PSObject.Properties["replyChatId"]) {
$eventData = $Data | Select-Object * -ExcludeProperty replyChatId
$eventData | Add-Member -MemberType NoteProperty -Name replyChat -Value "telegram-origin" -Force
}
$event = [pscustomobject]@{
timestamp = (Get-Date -Format o)
type = $EventType
severity = $Severity
message = $Message
data = $eventData
}
$script:Events += $event
if ($NoAlert) {
return
}
$alertAll = ($Config.telegram -and $Config.telegram.alertAllOperations)
$alertInfo = ($Config.telegram -and $Config.telegram.alertInfoEvents)
$alertOperatorCommands = $true
if ($Config.telegram -and $Config.telegram.PSObject.Properties["alertOperatorCommands"]) {
$alertOperatorCommands = [bool]$Config.telegram.alertOperatorCommands
}
$operatorCommandAlert = [bool]($alertOperatorCommands -and $EventType -match "^operator_command_")
if ($operatorCommandAlert -and $Data) {
$operatorOk = $false
$operatorMode = ""
$operatorId = ""
$operatorOkValue = $null
$operatorExitCode = $null
if ($Data -is [System.Collections.IDictionary]) {
if ($Data.Contains("mode")) { $operatorMode = [string]$Data["mode"] }
if ($Data.Contains("id")) { $operatorId = [string]$Data["id"] }
if ($Data.Contains("ok")) { $operatorOkValue = $Data["ok"] }
if ($Data.Contains("exitCode")) { $operatorExitCode = $Data["exitCode"] }
} else {
$modeProp = $Data.PSObject.Properties.Match("mode") | Select-Object -First 1
$idProp = $Data.PSObject.Properties.Match("id") | Select-Object -First 1
$okProp = $Data.PSObject.Properties.Match("ok") | Select-Object -First 1
$exitCodeProp = $Data.PSObject.Properties.Match("exitCode") | Select-Object -First 1
if ($modeProp) { $operatorMode = [string]$modeProp.Value }
if ($idProp) { $operatorId = [string]$idProp.Value }
if ($okProp) { $operatorOkValue = $okProp.Value }
if ($exitCodeProp) { $operatorExitCode = $exitCodeProp.Value }
}
if ($null -ne $operatorOkValue -and [string]$operatorOkValue -match "^(?i:true|1)$") { $operatorOk = $true }
$parsedExitCode = -999
if ($null -ne $operatorExitCode -and [int]::TryParse([string]$operatorExitCode, [ref]$parsedExitCode) -and $parsedExitCode -eq 0) { $operatorOk = $true }
$alertSuccessfulOperatorCommands = $false
if ($Config.telegram -and $Config.telegram.PSObject.Properties["alertSuccessfulOperatorCommands"]) {
$alertSuccessfulOperatorCommands = [bool]$Config.telegram.alertSuccessfulOperatorCommands
}
if ($operatorOk -and -not $alertSuccessfulOperatorCommands) {
Write-AgentLog "telegram_operator_success_suppressed type=$EventType mode=$operatorMode id=$operatorId"
$operatorCommandAlert = $false
}
}
$shouldAlert = ($Severity -in @("warning", "critical")) -or $operatorCommandAlert -or ($Alert -and $Severity -ne "info") -or ($alertAll -and ($Severity -ne "info" -or $alertInfo))
if ($shouldAlert) {
$dedupeMinutes = 30
if ($Config.telegram -and $Config.telegram.PSObject.Properties["dedupeMinutes"]) {
$dedupeMinutes = [int]$Config.telegram.dedupeMinutes
}
$dedupeParts = @($EventType, $Severity)
if ($Data) {
foreach ($propName in @("host", "name", "path", "reason", "target", "status", "mode", "alertKind", "source")) {
if ($Data.PSObject.Properties[$propName]) {
$dedupeParts += "$propName=$($Data.PSObject.Properties[$propName].Value)"
}
}
if ($Data.PSObject.Properties["reasons"]) {
$dedupeParts += "reasons=$(@($Data.reasons) -join ',')"
}
if ($Data.PSObject.Properties["missingFields"]) {
$dedupeParts += "missing=$(@($Data.missingFields) -join ',')"
}
if ($Data.PSObject.Properties["problem"] -and $Data.problem -and $Data.problem.PSObject.Properties["key"]) {
$dedupeParts += "problem=$($Data.problem.key)"
}
}
if ($dedupeParts.Count -le 2) {
$normalizedMessage = $Message
foreach ($pattern in @("candidate=[^ ]+", "evidence=[^ ]+", "loadPerCore=[^ ]+", "memAvailPct=[^ ]+", "diskUsedPct=[^ ]+", "ageMinutes=[^ ]+")) {
$normalizedMessage = $normalizedMessage -replace $pattern, ($pattern.Split("=")[0] + "=<value>")
}
$dedupeParts += $normalizedMessage
}
$dedupeSource = ($dedupeParts -join "|")
$dedupeHashBytes = [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($dedupeSource))
$dedupeHash = [BitConverter]::ToString($dedupeHashBytes).Replace("-", "").ToLowerInvariant()
$dedupePath = Join-Path $EvidenceDir "telegram-dedupe-$dedupeHash.marker"
if ($dedupeMinutes -gt 0 -and (Test-Path $dedupePath)) {
$ageMinutes = ((Get-Date) - (Get-Item $dedupePath).LastWriteTime).TotalMinutes
if ($ageMinutes -lt $dedupeMinutes) {
Write-AgentLog "telegram_dedupe_skip type=$EventType severity=$Severity ageMinutes=$([math]::Round($ageMinutes, 2)) cooldown=$dedupeMinutes"
return
}
}
Set-Content -Path $dedupePath -Value (Get-Date -Format o) -Encoding UTF8
Send-AgentTelegram $Severity $EventType $Message $Data
}
}
function Invoke-SshText {
param(
[string]$TargetHost,
[string]$Command,
[int]$TimeoutSeconds = 12,
[int]$Retries = 2,
[string]$User = $null
)
$last = $null
$userToUse = if ($User) { $User } else { Get-HostSshUser $TargetHost }
for ($attempt = 1; $attempt -le $Retries; $attempt++) {
$args = @(
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "NumberOfPasswordPrompts=0",
"-o", "ConnectTimeout=$TimeoutSeconds",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=3",
"-l", $userToUse,
$TargetHost,
$Command
)
try {
$id = [guid]::NewGuid().ToString("N")
$stdoutPath = Join-Path $env:TEMP "agent99-ssh-$id.out"
$stderrPath = Join-Path $env:TEMP "agent99-ssh-$id.err"
$process = Start-Process -FilePath "ssh.exe" -ArgumentList $args -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
$process.Kill()
$process.WaitForExit(2000) | Out-Null
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue | Out-Null
$last = [pscustomobject]@{
ok = $false
exitCode = -2
output = "timeout_after_${TimeoutSeconds}s"
}
} else {
$process.WaitForExit() | Out-Null
$process.Refresh()
$stdout = if (Test-Path $stdoutPath) { Get-Content $stdoutPath -Raw } else { "" }
$stderr = if (Test-Path $stderrPath) { Get-Content $stderrPath -Raw } else { "" }
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue | Out-Null
$text = (@($stdout, $stderr) | Where-Object { $_ }) -join "`n"
$exitCode = $process.ExitCode
if ($null -eq $exitCode) {
$exitCode = if ($stderr) { -3 } else { 0 }
}
$last = [pscustomobject]@{
ok = ($exitCode -eq 0)
exitCode = $exitCode
output = $text
}
}
} catch {
$last = [pscustomobject]@{
ok = $false
exitCode = -1
output = $_.Exception.Message
}
}
if ($last.ok) {
if ($attempt -gt 1) {
Write-AgentLog "ssh_retry_recovered host=$TargetHost attempt=$attempt"
}
return $last
}
if ($attempt -lt $Retries) {
Write-AgentLog "ssh_retry host=$TargetHost attempt=$attempt exit=$($last.exitCode)"
Start-Sleep -Seconds 2
}
}
$last
}
function Quote-ShSingle {
param([string]$Text)
"'" + $Text.Replace("'", "'`"`"'") + "'"
}
function Invoke-HostSshText {
param(
[string]$TargetHost,
[string]$Command,
[int]$TimeoutSeconds = 30,
[int]$Retries = 1
)
$direct = Invoke-SshText $TargetHost $Command $TimeoutSeconds $Retries
if ($direct.ok -or -not $Config.k3s.jumpHost -or $TargetHost -eq $Config.k3s.jumpHost) {
$direct | Add-Member -NotePropertyName route -NotePropertyValue "direct" -Force
return $direct
}
$quotedCommand = Quote-ShSingle $Command
$targetUser = Get-HostSshUser $TargetHost
$jumpCommand = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 -l $targetUser $TargetHost $quotedCommand"
$viaJump = Invoke-SshText $Config.k3s.jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$($Config.k3s.jumpHost)" -Force
return $viaJump
}
function Convert-AgentDouble {
param([string]$Value)
if (-not $Value) { return $null }
try {
return [double]::Parse($Value.Replace(",", "."), [Globalization.CultureInfo]::InvariantCulture)
} catch {
return $null
}
}
function Get-PerfThresholds {
$perf = $Config.performance
[pscustomobject]@{
loadPerCoreWarning = if ($perf.loadPerCoreWarning) { [double]$perf.loadPerCoreWarning } else { 0.9 }
loadPerCoreCritical = if ($perf.loadPerCoreCritical) { [double]$perf.loadPerCoreCritical } else { 1.5 }
memoryAvailablePercentCritical = if ($perf.memoryAvailablePercentCritical) { [double]$perf.memoryAvailablePercentCritical } else { 10 }
diskUsedPercentWarning = if ($perf.diskUsedPercentWarning) { [double]$perf.diskUsedPercentWarning } else { 90 }
diskUsedPercentCritical = if ($perf.diskUsedPercentCritical) { [double]$perf.diskUsedPercentCritical } else { 95 }
}
}
function Get-HostPerformance {
param([switch]$SuppressAlerts)
$thresholds = Get-PerfThresholds
$results = @()
$command = @'
echo __AGENT99_PERF__
echo HOST=$(hostname)
echo CORES=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
uptime
echo __MEM__
free -m 2>/dev/null
echo __DISK__
df -P / 2>/dev/null
'@
foreach ($hostIp in $Config.hosts) {
$readback = Invoke-HostSshText $hostIp $command 25 1
$text = $readback.output
$cores = 1
$coresMatch = [regex]::Match($text, "CORES=(\d+)")
if ($coresMatch.Success) { $cores = [int]$coresMatch.Groups[1].Value }
$load1 = $null
$loadMatch = [regex]::Match($text, "load averages?:\s*([0-9]+(?:[\.,][0-9]+)?)")
if (-not $loadMatch.Success) {
$loadMatch = [regex]::Match($text, "load average:\s*([0-9]+(?:[\.,][0-9]+)?)")
}
if ($loadMatch.Success) {
$load1 = Convert-AgentDouble $loadMatch.Groups[1].Value
}
$memAvailablePercent = $null
$memLine = (($text -split "`n") | Where-Object { $_ -match "^\s*Mem:" } | Select-Object -First 1)
if ($memLine) {
$parts = @($memLine -split "\s+" | Where-Object { $_ })
if ($parts.Count -ge 7) {
$memTotal = Convert-AgentDouble $parts[1]
$memAvailable = Convert-AgentDouble $parts[6]
if ($memTotal -and $memTotal -gt 0 -and $null -ne $memAvailable) {
$memAvailablePercent = [math]::Round(($memAvailable / $memTotal) * 100, 2)
}
}
}
$diskUsedPercent = $null
$diskLine = (($text -split "`n") | Where-Object { $_ -match "\s+\d+%\s+/$" } | Select-Object -First 1)
if ($diskLine) {
$diskMatch = [regex]::Match($diskLine, "\s(\d+)%\s+/$")
if ($diskMatch.Success) { $diskUsedPercent = [double]$diskMatch.Groups[1].Value }
}
$topCpu = @()
$loadPerCore = if ($null -ne $load1 -and $cores -gt 0) { [math]::Round($load1 / $cores, 2) } else { $null }
$severity = "ok"
$reasons = @()
if (-not $readback.ok) {
$severity = "warning"
$reasons += "perf_readback_failed"
}
if ($null -ne $loadPerCore -and $loadPerCore -ge $thresholds.loadPerCoreCritical) {
$severity = "critical"
$reasons += "load_per_core_critical"
} elseif ($null -ne $loadPerCore -and $loadPerCore -ge $thresholds.loadPerCoreWarning -and $severity -ne "critical") {
$severity = "warning"
$reasons += "load_per_core_warning"
}
if ($null -ne $memAvailablePercent -and $memAvailablePercent -le $thresholds.memoryAvailablePercentCritical) {
$severity = "critical"
$reasons += "memory_available_low"
}
if ($null -ne $diskUsedPercent -and $diskUsedPercent -ge $thresholds.diskUsedPercentCritical) {
$severity = "critical"
$reasons += "disk_used_high"
} elseif ($null -ne $diskUsedPercent -and $diskUsedPercent -ge $thresholds.diskUsedPercentWarning -and $severity -ne "critical") {
$severity = "warning"
$reasons += "disk_used_warning"
}
$captureTopCpuOnWarning = $true
if ($Config.performance -and $Config.performance.PSObject.Properties["captureTopCpuOnWarning"]) {
$captureTopCpuOnWarning = [bool]$Config.performance.captureTopCpuOnWarning
}
$topCpuTimeoutSeconds = 10
if ($Config.performance -and $Config.performance.PSObject.Properties["topCpuTimeoutSeconds"]) {
$topCpuTimeoutSeconds = [int]$Config.performance.topCpuTimeoutSeconds
}
$shouldCaptureTopCpu = ($severity -eq "critical") -or ($severity -eq "warning" -and $captureTopCpuOnWarning)
if ($shouldCaptureTopCpu -and $readback.ok) {
$topReadback = Invoke-HostSshText $hostIp "ps -eo pid,ppid,comm,pcpu,pmem --sort=-pcpu" $topCpuTimeoutSeconds 1
if ($topReadback.ok) {
$topCpu = @($topReadback.output -split "`n" | Select-Object -First 8)
} else {
$topCpu = @("top_cpu_readback_failed: $($topReadback.output)")
}
}
$result = [pscustomobject]@{
host = $hostIp
ok = [bool]$readback.ok
route = $readback.route
severity = $severity
reasons = $reasons
cores = $cores
load1 = $load1
loadPerCore = $loadPerCore
memAvailablePercent = $memAvailablePercent
diskUsedPercent = $diskUsedPercent
topCpu = $topCpu
output = $text
}
Write-AgentLog "perf host=$hostIp severity=$severity loadPerCore=$loadPerCore memAvailPct=$memAvailablePercent diskUsedPct=$diskUsedPercent route=$($readback.route)"
if ($severity -in @("warning", "critical") -and -not $SuppressAlerts) {
Record-AgentEvent "performance_$severity" $severity "host=$hostIp loadPerCore=$loadPerCore memAvailPct=$memAvailablePercent diskUsedPct=$diskUsedPercent reasons=$($reasons -join ',')" $result -Alert
}
$results += $result
}
$results
}
function Record-PerformanceIssuesWithoutRemediation {
param(
[object[]]$Performance,
[object[]]$LoadShedding
)
foreach ($perf in @($Performance | Where-Object { $_ -and $_.severity -in @("warning", "critical") })) {
$attempts = @($LoadShedding | Where-Object { $_ -and $_.host -eq $perf.host })
$executed = @($attempts | Where-Object { $_.PSObject.Properties["skipped"] -and $_.skipped -eq $false })
if ($executed.Count -gt 0) {
continue
}
$message = "host=$($perf.host) loadPerCore=$($perf.loadPerCore) memAvailPct=$($perf.memAvailablePercent) diskUsedPct=$($perf.diskUsedPercent) reasons=$(@($perf.reasons) -join ',')"
Record-AgentEvent "performance_$($perf.severity)" $perf.severity $message $perf -Alert
}
}
function Invoke-LoadShedding {
param([object[]]$Performance)
$results = @()
$loadShedding = $Config.performance.loadShedding
if (-not ($loadShedding -and $loadShedding.enabled)) {
Write-AgentLog "load_shedding skipped enabled=false"
return $results
}
if (-not $ControlledApply) {
Write-AgentLog "load_shedding skipped controlled_apply=false"
return $results
}
foreach ($action in @($loadShedding.actions)) {
if ($action.enabled -eq $false) { continue }
$hostPerf = $Performance | Where-Object { $_.host -eq $action.host } | Select-Object -First 1
if (-not $hostPerf) { continue }
$severityRank = @{ ok = 0; warning = 1; critical = 2 }
$minSeverity = if ($action.minSeverity) { [string]$action.minSeverity } else { "critical" }
if (-not $severityRank.ContainsKey($minSeverity)) { $minSeverity = "critical" }
$hostSeverity = if ($hostPerf.severity -and $severityRank.ContainsKey([string]$hostPerf.severity)) { [string]$hostPerf.severity } else { "ok" }
if ($severityRank[$hostSeverity] -lt $severityRank[$minSeverity]) {
$results += [pscustomobject]@{ name = $action.name; host = $action.host; skipped = $true; reason = "host_below_min_severity"; hostSeverity = $hostSeverity; minSeverity = $minSeverity }
continue
}
$reasonMatches = @($action.reasonMatches)
if ($reasonMatches.Count -eq 0) { $reasonMatches = @("load_per_core_critical") }
$matchedReasons = @($hostPerf.reasons | Where-Object { $_ -in $reasonMatches })
if ($matchedReasons.Count -eq 0) {
$results += [pscustomobject]@{
name = $action.name
host = $action.host
skipped = $true
reason = "critical_reason_not_allowlisted"
hostReasons = $hostPerf.reasons
allowedReasons = $reasonMatches
}
continue
}
$cooldownMinutes = if ($action.cooldownMinutes) { [int]$action.cooldownMinutes } else { 30 }
$safeName = ($action.name -replace "[^A-Za-z0-9_.-]", "_")
$markerPath = Join-Path $EvidenceDir "loadshedding-$safeName.marker"
if (Test-Path $markerPath) {
$ageMinutes = ((Get-Date) - (Get-Item $markerPath).LastWriteTime).TotalMinutes
if ($ageMinutes -lt $cooldownMinutes) {
$results += [pscustomobject]@{ name = $action.name; host = $action.host; skipped = $true; reason = "cooldown"; ageMinutes = [math]::Round($ageMinutes, 1) }
continue
}
}
Record-AgentEvent "load_shed_attempt" $hostSeverity "host=$($action.host) action=$($action.name) command=$($action.command)" $action -NoAlert
$timeoutSeconds = if ($action.PSObject.Properties["timeoutSeconds"]) { [int]$action.timeoutSeconds } else { 60 }
$run = Invoke-HostSshText $action.host $action.command $timeoutSeconds 1
$postVerify = $null
$postVerifyOk = $false
if ($action.PSObject.Properties["postVerifyCommand"] -and $action.postVerifyCommand) {
$verifyRun = Invoke-HostSshText $action.host ([string]$action.postVerifyCommand) 30 1
$postVerify = [pscustomobject]@{
ok = [bool]$verifyRun.ok
exitCode = $verifyRun.exitCode
output = $verifyRun.output
route = $verifyRun.route
}
if ($verifyRun.ok) {
$postVerifyOk = $true
if ($action.PSObject.Properties["postVerifyMaxDiskUsedPercent"]) {
$match = [regex]::Match([string]$verifyRun.output, "__DISK_USED__=(\d+)")
if ($match.Success) {
$postVerifyOk = ([int]$match.Groups[1].Value -le [int]$action.postVerifyMaxDiskUsedPercent)
} else {
$postVerifyOk = $false
}
}
if ($action.PSObject.Properties["postVerifyOkRegex"] -and $action.postVerifyOkRegex) {
$postVerifyOk = [bool]([string]$verifyRun.output -match [string]$action.postVerifyOkRegex)
}
}
}
$effectiveOk = [bool]($run.ok -or $postVerifyOk)
Set-Content -Path $markerPath -Value (Get-Date -Format o) -Encoding UTF8
$result = [pscustomobject]@{
name = $action.name
host = $action.host
skipped = $false
ok = $effectiveOk
executionOk = [bool]$run.ok
postVerifyOk = [bool]$postVerifyOk
postVerify = $postVerify
route = $run.route
exitCode = $run.exitCode
output = $run.output
rollback = $action.rollback
}
Record-AgentEvent "load_shed_result" $(if ($effectiveOk) { "warning" } else { "critical" }) "host=$($action.host) action=$($action.name) ok=$effectiveOk executionOk=$($run.ok) postVerifyOk=$postVerifyOk" $result -Alert
$results += $result
}
$results
}
function Get-AgentBooleanSetting {
param(
[object]$Object,
[string]$Name,
[bool]$Default
)
if ($Object -and $Object.PSObject.Properties[$Name]) {
return [bool]$Object.PSObject.Properties[$Name].Value
}
return $Default
}
function Get-AgentSelfHealthConfig {
$selfHealth = $Config.selfHealth
$defaultTasks = @(
"Wooo-Agent99-Startup-Recovery",
"Wooo-Agent99-Heartbeat",
"Wooo-Agent99-Performance-Guard",
"Wooo-Agent99-Control-Loop",
"Wooo-Agent99-Telegram-Inbox",
"Wooo-Agent99-SRE-Alert-Inbox",
"Wooo-Agent99-Self-Health",
"Wooo-Agent99-Backup-Health"
)
$defaultEvidence = @(
[pscustomobject]@{ name = "performance_guard"; pattern = "agent99-Perf-*.json"; maxAgeMinutes = 5; required = $true },
[pscustomobject]@{ name = "heartbeat_status"; pattern = "agent99-Status-*.json"; maxAgeMinutes = 15; required = $true },
[pscustomobject]@{ name = "self_health"; pattern = "agent99-SelfCheck-*.json"; maxAgeMinutes = 15; required = $false },
[pscustomobject]@{ name = "backup_health"; pattern = "agent99-BackupCheck-*.json"; maxAgeMinutes = 30; required = $false },
[pscustomobject]@{ name = "startup_recovery"; pattern = "agent99-Recover-*.json"; maxAgeMinutes = 1440; required = $false },
[pscustomobject]@{ name = "telegram_inbox"; pattern = "agent99-TelegramInbox-*.json"; maxAgeMinutes = 15; required = $false },
[pscustomobject]@{ name = "sre_alert_inbox"; pattern = "agent99-SreAlertInbox-*.json"; maxAgeMinutes = 15; required = $false }
)
[pscustomobject]@{
scheduledTasks = if ($selfHealth -and $selfHealth.scheduledTasks) { @($selfHealth.scheduledTasks) } else { $defaultTasks }
evidenceFreshness = if ($selfHealth -and $selfHealth.evidenceFreshness) { @($selfHealth.evidenceFreshness) } else { $defaultEvidence }
requiredHosts = if ($selfHealth -and $selfHealth.requiredHosts) { @($selfHealth.requiredHosts) } else { @($Config.hosts) }
requireRecentTelegramDelivery = Get-AgentBooleanSetting $selfHealth "requireRecentTelegramDelivery" $true
}
}
function Get-AgentLatestEvidence {
param([string]$Pattern)
$file = Get-ChildItem -Path $EvidenceDir -Filter $Pattern -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $file) {
return [pscustomobject]@{
exists = $false
path = $null
fileName = $null
lastWriteTime = $null
ageMinutes = $null
data = $null
parseError = $null
}
}
$data = $null
$parseError = $null
try {
$data = Get-Content $file.FullName -Raw | ConvertFrom-Json
} catch {
$parseError = $_.Exception.Message
}
[pscustomobject]@{
exists = $true
path = $file.FullName
fileName = $file.Name
lastWriteTime = $file.LastWriteTime.ToString("o")
ageMinutes = [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 2)
data = $data
parseError = $parseError
}
}
function Test-AgentScheduledTaskHealth {
param([string[]]$TaskNames)
$results = @()
foreach ($taskName in $TaskNames) {
try {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction Stop
$state = [string]$task.State
$lastTaskResult = $info.LastTaskResult
$pendingFirstRun = ($lastTaskResult -eq 267011)
$ok = ($state -eq "Running") -or (($state -eq "Ready") -and ($lastTaskResult -in @(0, 267011)))
$results += [pscustomobject]@{
name = $taskName
exists = $true
ok = [bool]$ok
state = $state
lastTaskResult = $lastTaskResult
pendingFirstRun = [bool]$pendingFirstRun
lastRunTime = if ($info.LastRunTime) { $info.LastRunTime.ToString("o") } else { $null }
nextRunTime = if ($info.NextRunTime) { $info.NextRunTime.ToString("o") } else { $null }
}
Write-AgentLog "selfcheck task name=$taskName ok=$ok state=$state lastTaskResult=$lastTaskResult pendingFirstRun=$pendingFirstRun"
} catch {
$results += [pscustomobject]@{
name = $taskName
exists = $false
ok = $false
state = $null
lastTaskResult = $null
lastRunTime = $null
nextRunTime = $null
error = $_.Exception.Message
}
Write-AgentLog "selfcheck task_missing name=$taskName error=$($_.Exception.Message)"
}
}
$results
}
function Test-AgentEvidenceFreshness {
param([object[]]$Contracts)
$results = @()
foreach ($contract in $Contracts) {
$name = if ($contract.name) { [string]$contract.name } else { [string]$contract.pattern }
$pattern = [string]$contract.pattern
$maxAgeMinutes = if ($contract.maxAgeMinutes) { [double]$contract.maxAgeMinutes } else { 15 }
$required = Get-AgentBooleanSetting $contract "required" $true
$latest = Get-AgentLatestEvidence $pattern
$fresh = [bool]($latest.exists -and $null -eq $latest.parseError -and $latest.ageMinutes -le $maxAgeMinutes)
$ok = [bool](((-not $required) -and (-not $latest.exists)) -or $fresh)
$severity = if ($ok) { "ok" } elseif ($required) { "critical" } else { "warning" }
$results += [pscustomobject]@{
name = $name
pattern = $pattern
required = [bool]$required
ok = $ok
severity = $severity
latestPath = $latest.path
latestFile = $latest.fileName
lastWriteTime = $latest.lastWriteTime
ageMinutes = $latest.ageMinutes
maxAgeMinutes = $maxAgeMinutes
parseError = $latest.parseError
}
Write-AgentLog "selfcheck evidence name=$name ok=$ok severity=$severity ageMinutes=$($latest.ageMinutes) maxAgeMinutes=$maxAgeMinutes file=$($latest.fileName)"
}
$results
}
function Test-AgentLatestPerformanceEvidence {
param([string[]]$RequiredHosts)
$latest = Get-AgentLatestEvidence "agent99-Perf-*.json"
$hostChecks = @()
if (-not $latest.exists) {
return [pscustomobject]@{
ok = $false
severity = "critical"
reason = "missing_perf_evidence"
latestPath = $null
latestFile = $null
ageMinutes = $null
hosts = $hostChecks
}
}
if ($latest.parseError) {
return [pscustomobject]@{
ok = $false
severity = "critical"
reason = "perf_evidence_parse_error"
latestPath = $latest.path
latestFile = $latest.fileName
ageMinutes = $latest.ageMinutes
parseError = $latest.parseError
hosts = $hostChecks
}
}
$perf = @($latest.data.performance)
foreach ($hostIp in $RequiredHosts) {
$entry = $perf | Where-Object { $_.host -eq $hostIp } | Select-Object -First 1
$hostSeverity = if (-not $entry) {
"missing"
} elseif ($entry.severity) {
[string]$entry.severity
} elseif ($entry.ok -eq $false) {
"warning"
} else {
"ok"
}
$ok = [bool]($hostSeverity -eq "ok")
$hostChecks += [pscustomobject]@{
host = $hostIp
present = [bool]$entry
ok = $ok
severity = $hostSeverity
loadPerCore = if ($entry) { $entry.loadPerCore } else { $null }
memAvailablePercent = if ($entry) { $entry.memAvailablePercent } else { $null }
diskUsedPercent = if ($entry) { $entry.diskUsedPercent } else { $null }
reasons = if ($entry) { @($entry.reasons) } else { @("missing_perf_host") }
}
}
$missingOrCritical = @($hostChecks | Where-Object { $_.severity -in @("missing", "critical") }).Count
$warning = @($hostChecks | Where-Object { $_.severity -eq "warning" }).Count
$severity = if ($missingOrCritical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" }
[pscustomobject]@{
ok = [bool]($severity -eq "ok")
severity = $severity
reason = if ($severity -eq "ok") { "all_required_hosts_ok" } else { "required_host_perf_not_ok" }
latestPath = $latest.path
latestFile = $latest.fileName
ageMinutes = $latest.ageMinutes
hosts = $hostChecks
}
}
function Test-AgentRecentTelegramDelivery {
param([object[]]$Contracts)
$checks = @()
$lookbackMinutes = 60
if ($Config.selfHealth -and $Config.selfHealth.PSObject.Properties["telegramDeliveryLookbackMinutes"]) {
$lookbackMinutes = [int]$Config.selfHealth.telegramDeliveryLookbackMinutes
}
$cutoff = (Get-Date).AddMinutes(-1 * $lookbackMinutes)
foreach ($contract in $Contracts) {
$pattern = [string]$contract.pattern
$files = Get-ChildItem -Path $EvidenceDir -Filter $pattern -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $cutoff } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 20
foreach ($file in @($files)) {
$data = $null
try {
$data = Get-Content $file.FullName -Raw | ConvertFrom-Json
} catch {
continue
}
if (-not $data) { continue }
$attempts = @($data.telegram)
if ($attempts.Count -eq 0) { continue }
$sent = @($attempts | Where-Object { $_.sent -eq $true -or ($_.relay -and $_.relay.ok -eq $true) })
$checks += [pscustomobject]@{
name = if ($contract.name) { [string]$contract.name } else { [string]$contract.pattern }
latestPath = $file.FullName
latestFile = $file.Name
ageMinutes = [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 2)
attempts = $attempts.Count
sent = $sent.Count
ok = [bool]($sent.Count -gt 0)
}
}
}
$attemptCount = 0
$sentCount = 0
foreach ($check in @($checks)) {
$attemptCount += [int]$check.attempts
$sentCount += [int]$check.sent
}
if ($attemptCount -eq 0) {
return [pscustomobject]@{
ok = $true
severity = "ok"
reason = "no_recent_alert_attempts"
checks = $checks
}
}
[pscustomobject]@{
ok = [bool]($sentCount -gt 0)
severity = if ($sentCount -gt 0) { "ok" } else { "critical" }
reason = if ($sentCount -gt 0) { "recent_alert_delivery_ok" } else { "recent_alert_delivery_failed" }
checks = $checks
}
}
function Test-AgentSelfHealth {
$selfConfig = Get-AgentSelfHealthConfig
$tasks = Test-AgentScheduledTaskHealth $selfConfig.scheduledTasks
$evidence = Test-AgentEvidenceFreshness $selfConfig.evidenceFreshness
$latestPerf = Test-AgentLatestPerformanceEvidence $selfConfig.requiredHosts
$telegram = if ($selfConfig.requireRecentTelegramDelivery) {
Test-AgentRecentTelegramDelivery $selfConfig.evidenceFreshness
} else {
[pscustomobject]@{ ok = $true; severity = "ok"; checks = @(); skipped = $true }
}
$taskFailures = @($tasks | Where-Object { -not $_.ok }).Count
$evidenceCritical = @($evidence | Where-Object { $_.severity -eq "critical" }).Count
$evidenceWarning = @($evidence | Where-Object { $_.severity -eq "warning" }).Count
$critical = $taskFailures + $evidenceCritical
if ($latestPerf.severity -eq "critical") { $critical += 1 }
if ($telegram.severity -eq "critical") { $critical += 1 }
$warning = $evidenceWarning
if ($latestPerf.severity -eq "warning") { $warning += 1 }
$severity = if ($critical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" }
$message = "selfHealth severity=$severity taskFailures=$taskFailures evidenceCritical=$evidenceCritical evidenceWarning=$evidenceWarning perf=$($latestPerf.severity) telegram=$($telegram.severity)"
Record-AgentEvent "agent_selfcheck" $(if ($severity -eq "ok") { "info" } else { $severity }) $message $null -Alert
[pscustomobject]@{
ok = [bool]($severity -eq "ok")
severity = $severity
tasks = $tasks
evidenceFreshness = $evidence
latestPerformance = $latestPerf
telegramDelivery = $telegram
summary = [pscustomobject]@{
taskFailures = $taskFailures
evidenceCritical = $evidenceCritical
evidenceWarning = $evidenceWarning
performanceSeverity = $latestPerf.severity
telegramSeverity = $telegram.severity
}
}
}
function Get-AgentUnixAgeMinutes {
param([double]$UnixSeconds)
$now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
[math]::Round(($now - $UnixSeconds) / 60, 2)
}
function Convert-AgentUnixToIso {
param([double]$UnixSeconds)
try {
[DateTimeOffset]::FromUnixTimeSeconds([int64][math]::Floor($UnixSeconds)).LocalDateTime.ToString("o")
} catch {
$null
}
}
function Get-AgentBackupHealthConfig {
$backupHealth = $Config.backupHealth
$backupHost = if ($backupHealth -and $backupHealth.host) { [string]$backupHealth.host } else { "192.168.0.110" }
$targets = @(
[pscustomobject]@{ name = "awoooi"; path = "/backup/awoooi/snapshots"; maxAgeMinutes = 430; required = $true },
[pscustomobject]@{ name = "gitea"; path = "/backup/gitea/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "harbor"; path = "/backup/harbor/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "momo"; path = "/backup/momo/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "langfuse"; path = "/backup/langfuse/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "monitoring"; path = "/backup/monitoring/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "signoz"; path = "/backup/signoz/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "clawbot"; path = "/backup/clawbot/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "open-webui"; path = "/backup/open-webui/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "sentry"; path = "/backup/sentry/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "ai-artifacts"; path = "/backup/ai-artifacts/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "configs"; path = "/backup/configs/snapshots"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "public-routes"; path = "/backup/public-routes/snapshots"; maxAgeMinutes = 10080; required = $false }
)
$fileChecks = @(
[pscustomobject]@{ name = "backup_log"; path = "/backup/logs/backup.log"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "backup_status_log"; path = "/backup/logs/backup-status.log"; maxAgeMinutes = 1800; required = $true },
[pscustomobject]@{ name = "status_log"; path = "/backup/logs/status.log"; maxAgeMinutes = 10080; required = $false },
[pscustomobject]@{ name = "cron_log"; path = "/backup/logs/cron.log"; maxAgeMinutes = 10080; required = $false },
[pscustomobject]@{ name = "integrity_check_status"; path = "/backup/integrity/check.status"; maxAgeMinutes = 10080; required = $true },
[pscustomobject]@{ name = "restore_drill_status"; path = "/backup/integrity/restore-drill.status"; maxAgeMinutes = 45000; required = $false },
[pscustomobject]@{ name = "configs_last_status"; path = "/backup/status/backup-configs-last-status.json"; maxAgeMinutes = 1800; required = $false }
)
$cronPatterns = @(
"backup-all.sh",
"backup-awoooi-frequent.sh",
"backup-health-textfile-exporter.py",
"check-backup-integrity.sh",
"sync-offsite-backups.sh --mode status",
"backup-status.sh"
)
[pscustomobject]@{
host = $backupHost
targets = if ($backupHealth -and $backupHealth.targets) { @($backupHealth.targets) } else { $targets }
fileChecks = if ($backupHealth -and $backupHealth.fileChecks) { @($backupHealth.fileChecks) } else { $fileChecks }
cronPatterns = if ($backupHealth -and $backupHealth.cronPatterns) { @($backupHealth.cronPatterns) } else { $cronPatterns }
}
}
function Test-AgentBackupSnapshotTarget {
param(
[object]$Target,
[string]$DefaultHost
)
$name = [string]$Target.name
$hostIp = if ($Target.host) { [string]$Target.host } else { $DefaultHost }
$path = [string]$Target.path
$required = Get-AgentBooleanSetting $Target "required" $true
$maxAgeMinutes = if ($Target.maxAgeMinutes) { [double]$Target.maxAgeMinutes } else { 1800 }
if ($path -notmatch "^/backup/[A-Za-z0-9_.\/-]+$") {
return [pscustomobject]@{ name = $name; host = $hostIp; path = $path; ok = $false; severity = "critical"; reason = "invalid_backup_path" }
}
$quotedPath = Quote-ShSingle $path
$command = "if [ -d $quotedPath ]; then echo __COUNT__=`$(find $quotedPath -maxdepth 1 -type f 2>/dev/null | wc -l); find $quotedPath -maxdepth 1 -type f -printf '__LATEST__=%T@|%TY-%Tm-%TdT%TH:%TM:%TS|%s|%p\n' 2>/dev/null | sort -n | tail -1; else echo __MISSING_DIR__=$quotedPath; fi"
$timeoutSeconds = 60
if ($Config.backupHealth -and $Config.backupHealth.PSObject.Properties["snapshotReadTimeoutSeconds"]) {
$timeoutSeconds = [int]$Config.backupHealth.snapshotReadTimeoutSeconds
}
if ($Target.PSObject.Properties["timeoutSeconds"]) {
$timeoutSeconds = [int]$Target.timeoutSeconds
}
$readback = Invoke-HostSshText $hostIp $command $timeoutSeconds 1
$count = 0
$latestUnix = $null
$latestSize = $null
$latestPath = $null
if ($readback.ok) {
$countMatch = [regex]::Match($readback.output, "__COUNT__=(\d+)")
if ($countMatch.Success) { $count = [int]$countMatch.Groups[1].Value }
$latestMatch = [regex]::Match($readback.output, "__LATEST__=([0-9.]+)\|([^|]+)\|(\d+)\|(.+)")
if ($latestMatch.Success) {
$latestUnix = [double]::Parse($latestMatch.Groups[1].Value, [Globalization.CultureInfo]::InvariantCulture)
$latestSize = [int64]$latestMatch.Groups[3].Value
$latestPath = $latestMatch.Groups[4].Value.Trim()
}
}
$ageMinutes = if ($null -ne $latestUnix) { Get-AgentUnixAgeMinutes $latestUnix } else { $null }
$severity = "ok"
$reason = "fresh"
if (-not $readback.ok) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_readback_failed"
} elseif ($readback.output -match "__MISSING_DIR__" -or $count -eq 0 -or $null -eq $latestUnix) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_snapshot_missing"
} elseif ($ageMinutes -gt $maxAgeMinutes) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_snapshot_stale"
}
$result = [pscustomobject]@{
name = $name
host = $hostIp
path = $path
required = [bool]$required
ok = [bool]($severity -eq "ok")
severity = $severity
reason = $reason
count = $count
latestPath = $latestPath
latestSizeBytes = $latestSize
latestTime = if ($null -ne $latestUnix) { Convert-AgentUnixToIso $latestUnix } else { $null }
ageMinutes = $ageMinutes
maxAgeMinutes = $maxAgeMinutes
route = $readback.route
exitCode = $readback.exitCode
}
Write-AgentLog "backup target=$name severity=$severity reason=$reason ageMinutes=$ageMinutes count=$count"
if ($severity -in @("warning", "critical")) {
Record-AgentEvent "backup_$reason" $severity "target=$name host=$hostIp ageMinutes=$ageMinutes maxAgeMinutes=$maxAgeMinutes path=$path" $result -Alert
}
$result
}
function Test-AgentBackupFileCheck {
param(
[object]$FileCheck,
[string]$DefaultHost
)
$name = [string]$FileCheck.name
$hostIp = if ($FileCheck.host) { [string]$FileCheck.host } else { $DefaultHost }
$path = [string]$FileCheck.path
$required = Get-AgentBooleanSetting $FileCheck "required" $true
$maxAgeMinutes = if ($FileCheck.maxAgeMinutes) { [double]$FileCheck.maxAgeMinutes } else { 1800 }
if ($path -notmatch "^/backup/[A-Za-z0-9_.\/-]+$") {
return [pscustomobject]@{ name = $name; host = $hostIp; path = $path; ok = $false; severity = "critical"; reason = "invalid_backup_file_path" }
}
$quotedPath = Quote-ShSingle $path
$command = "if [ -f $quotedPath ]; then stat -c '__STAT__=%Y|%y|%s|%n' $quotedPath; else echo __MISSING_FILE__=$quotedPath; fi"
$readback = Invoke-HostSshText $hostIp $command 15 1
$unix = $null
$size = $null
if ($readback.ok) {
$statMatch = [regex]::Match($readback.output, "__STAT__=([0-9]+)\|([^|]+)\|(\d+)\|(.+)")
if ($statMatch.Success) {
$unix = [double]$statMatch.Groups[1].Value
$size = [int64]$statMatch.Groups[3].Value
}
}
$ageMinutes = if ($null -ne $unix) { Get-AgentUnixAgeMinutes $unix } else { $null }
$severity = "ok"
$reason = "fresh"
if (-not $readback.ok) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_file_readback_failed"
} elseif ($readback.output -match "__MISSING_FILE__" -or $null -eq $unix) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_file_missing"
} elseif ($ageMinutes -gt $maxAgeMinutes) {
$severity = if ($required) { "critical" } else { "warning" }
$reason = "backup_file_stale"
}
$result = [pscustomobject]@{
name = $name
host = $hostIp
path = $path
required = [bool]$required
ok = [bool]($severity -eq "ok")
severity = $severity
reason = $reason
sizeBytes = $size
lastWriteTime = if ($null -ne $unix) { Convert-AgentUnixToIso $unix } else { $null }
ageMinutes = $ageMinutes
maxAgeMinutes = $maxAgeMinutes
route = $readback.route
exitCode = $readback.exitCode
}
Write-AgentLog "backup file=$name severity=$severity reason=$reason ageMinutes=$ageMinutes"
if ($severity -in @("warning", "critical")) {
Record-AgentEvent "backup_$reason" $severity "file=$name host=$hostIp ageMinutes=$ageMinutes maxAgeMinutes=$maxAgeMinutes path=$path" $result -Alert
}
$result
}
function Test-AgentBackupCronPattern {
param(
[string]$Pattern,
[string]$DefaultHost
)
$quotedPattern = Quote-ShSingle $Pattern
$command = "crontab -l 2>/dev/null | grep -F -- $quotedPattern >/dev/null && echo __PRESENT__ || echo __MISSING__"
$readback = Invoke-HostSshText $DefaultHost $command 8 2
$present = [bool]($readback.ok -and $readback.output -match "__PRESENT__")
$result = [pscustomobject]@{
host = $DefaultHost
pattern = $Pattern
ok = $present
severity = if ($present) { "ok" } else { "critical" }
reason = if ($present) { "cron_pattern_present" } else { "cron_pattern_missing" }
route = $readback.route
exitCode = $readback.exitCode
}
Write-AgentLog "backup cron pattern=$Pattern ok=$present"
if (-not $present) {
Record-AgentEvent "backup_cron_pattern_missing" "critical" "host=$DefaultHost pattern=$Pattern" $result -Alert
}
$result
}
function Test-AgentBackupCronPatterns {
param(
[string[]]$Patterns,
[string]$DefaultHost
)
$readback = Invoke-HostSshText $DefaultHost "crontab -l 2>/dev/null" 12 2
$results = @()
if (-not $readback.ok) {
foreach ($pattern in $Patterns) {
$result = [pscustomobject]@{
host = $DefaultHost
pattern = $pattern
ok = $false
severity = "warning"
reason = "cron_readback_failed"
route = $readback.route
exitCode = $readback.exitCode
}
Record-AgentEvent "backup_cron_readback_failed" "warning" "host=$DefaultHost pattern=$pattern" $result -Alert
$results += $result
}
return $results
}
$crontab = [string]$readback.output
foreach ($pattern in $Patterns) {
$present = [bool]($crontab -match [regex]::Escape($pattern))
$result = [pscustomobject]@{
host = $DefaultHost
pattern = $pattern
ok = $present
severity = if ($present) { "ok" } else { "critical" }
reason = if ($present) { "cron_pattern_present" } else { "cron_pattern_missing" }
route = $readback.route
exitCode = $readback.exitCode
}
Write-AgentLog "backup cron pattern=$pattern ok=$present"
if (-not $present) {
Record-AgentEvent "backup_cron_pattern_missing" "critical" "host=$DefaultHost pattern=$pattern" $result -Alert
}
$results += $result
}
$results
}
function Test-AgentBackupHealth {
$backupConfig = Get-AgentBackupHealthConfig
$targets = @()
foreach ($target in @($backupConfig.targets)) {
$targets += Test-AgentBackupSnapshotTarget $target $backupConfig.host
}
$files = @()
foreach ($fileCheck in @($backupConfig.fileChecks)) {
$files += Test-AgentBackupFileCheck $fileCheck $backupConfig.host
}
$cron = Test-AgentBackupCronPatterns @($backupConfig.cronPatterns) $backupConfig.host
$targetCritical = @($targets | Where-Object { $_.severity -eq "critical" }).Count
$targetWarning = @($targets | Where-Object { $_.severity -eq "warning" }).Count
$fileCritical = @($files | Where-Object { $_.severity -eq "critical" }).Count
$fileWarning = @($files | Where-Object { $_.severity -eq "warning" }).Count
$cronCritical = @($cron | Where-Object { $_.severity -eq "critical" }).Count
$cronWarning = @($cron | Where-Object { $_.severity -eq "warning" }).Count
$critical = $targetCritical + $fileCritical + $cronCritical
$warning = $targetWarning + $fileWarning + $cronWarning
$severity = if ($critical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" }
$summary = [pscustomobject]@{
targetCritical = $targetCritical
targetWarning = $targetWarning
fileCritical = $fileCritical
fileWarning = $fileWarning
cronCritical = $cronCritical
cronWarning = $cronWarning
targetCount = @($targets).Count
fileCheckCount = @($files).Count
cronPatternCount = @($cron).Count
}
if ($severity -in @("warning", "critical")) {
Record-AgentEvent "backup_health_degraded" $severity "severity=$severity targetCritical=$targetCritical fileCritical=$fileCritical cronCritical=$cronCritical" $summary -Alert
} else {
Write-AgentLog "backup_health ok targets=$($summary.targetCount) files=$($summary.fileCheckCount) cron=$($summary.cronPatternCount)"
}
[pscustomobject]@{
ok = [bool]($severity -eq "ok")
severity = $severity
host = $backupConfig.host
targets = $targets
files = $files
cron = $cron
summary = $summary
}
}
function Convert-AgentHtml {
param([object]$Value)
[System.Net.WebUtility]::HtmlEncode([string]$Value)
}
function Get-AgentLatestEvidenceSummary {
param(
[string]$Name,
[string]$Pattern
)
$latest = Get-AgentLatestEvidence $Pattern
[pscustomobject]@{
name = $Name
exists = $latest.exists
file = $latest.fileName
path = $latest.path
ageMinutes = $latest.ageMinutes
parseError = $latest.parseError
}
}
function Convert-AgentSafeKey {
param([string]$Value)
$safe = [regex]::Replace(([string]$Value), "[^A-Za-z0-9_.:-]", "_")
if (-not $safe) { return "unknown" }
if ($safe.Length -gt 140) { return $safe.Substring(0, 140) }
return $safe
}
function Get-AgentProblemStats {
$stateDir = Join-Path $Config.agentRoot "state"
$statsPath = Join-Path $stateDir "problem-counts.json"
if (-not (Test-Path $statsPath)) {
return [pscustomobject]@{
path = $statsPath
totalEvents = 0
totalResolved = 0
totalFailed = 0
problems = @()
}
}
try {
$data = Get-Content $statsPath -Raw | ConvertFrom-Json
return [pscustomobject]@{
path = $statsPath
totalEvents = if ($data.totalEvents) { [int]$data.totalEvents } else { 0 }
totalResolved = if ($data.totalResolved) { [int]$data.totalResolved } else { 0 }
totalFailed = if ($data.totalFailed) { [int]$data.totalFailed } else { 0 }
problems = @($data.problems)
}
} catch {
Write-AgentLog "problem_stats_parse_failed path=$statsPath error=$($_.Exception.Message)"
return [pscustomobject]@{
path = $statsPath
totalEvents = 0
totalResolved = 0
totalFailed = 0
problems = @()
parseError = $_.Exception.Message
}
}
}
function Update-AgentProblemKnowledge {
param([object]$Result)
$stateDir = Join-Path $Config.agentRoot "state"
$playbookDir = Join-Path $Config.agentRoot "playbooks"
$ragDir = Join-Path $playbookDir "rag-import"
New-Item -ItemType Directory -Force -Path $stateDir, $playbookDir, $ragDir | Out-Null
$statsPath = Join-Path $stateDir "problem-counts.json"
$eventsPath = Join-Path $playbookDir "agent99-incident-records.jsonl"
$kmPath = Join-Path $playbookDir "agent99-incident-km.md"
$ragPath = Join-Path $ragDir "agent99-incidents.jsonl"
$source = if ($Result.source) { [string]$Result.source } else { "agent99" }
$kind = if ($Result.alertKind) { [string]$Result.alertKind } elseif ($Result.reason) { [string]$Result.reason } else { [string]$Result.mode }
$service = if ($Result.alertService) { [string]$Result.alertService } else { "agent99" }
$hostName = if ($Result.alertHost) { [string]$Result.alertHost } else { "" }
$severity = if ($Result.alertSeverity) { [string]$Result.alertSeverity } elseif ($Result.ok) { "info" } else { "critical" }
$key = Convert-AgentSafeKey (($source, $service, $hostName, $kind | Where-Object { $_ }) -join "|")
$now = Get-Date -Format o
$statusText = if ($Result.ok) { "resolved" } else { "failed" }
$event = [pscustomobject]@{
timestamp = $now
problemKey = $key
status = $statusText
commandId = $Result.id
source = $source
reason = $Result.reason
alertId = $Result.alertId
alertKind = $kind
alertSeverity = $severity
service = $service
host = $hostName
mode = $Result.mode
instruction = $Result.instruction
ok = [bool]$Result.ok
exitCode = $Result.exitCode
evidence = $Result.evidence
}
($event | ConvertTo-Json -Depth 8 -Compress) | Add-Content -Path $eventsPath -Encoding UTF8
($event | ConvertTo-Json -Depth 8 -Compress) | Add-Content -Path $ragPath -Encoding UTF8
$stats = Get-AgentProblemStats
$problems = @($stats.problems)
$existing = $problems | Where-Object { $_.key -eq $key } | Select-Object -First 1
if (-not $existing) {
$existing = [pscustomobject]@{
key = $key
source = $source
kind = $kind
service = $service
host = $hostName
severity = $severity
occurrences = 0
resolved = 0
failed = 0
lastStatus = $null
firstSeenAt = $now
lastSeenAt = $null
lastResolvedAt = $null
lastEvidence = $null
lastMode = $null
lastInstruction = $null
}
$problems += $existing
}
$existing.occurrences = [int]$existing.occurrences + 1
if ($Result.ok) {
$existing.resolved = [int]$existing.resolved + 1
$existing.lastResolvedAt = $now
} else {
$existing.failed = [int]$existing.failed + 1
}
$existing.lastStatus = $statusText
$existing.lastSeenAt = $now
$existing.lastEvidence = $Result.evidence
$existing.lastMode = $Result.mode
$existing.lastInstruction = $Result.instruction
$existing.severity = $severity
$newTotalEvents = [int]$stats.totalEvents + 1
$newTotalResolved = [int]$stats.totalResolved + $(if ($Result.ok) { 1 } else { 0 })
$newTotalFailed = [int]$stats.totalFailed + $(if ($Result.ok) { 0 } else { 1 })
$updated = [pscustomobject]@{
generatedAt = $now
totalEvents = $newTotalEvents
totalResolved = $newTotalResolved
totalFailed = $newTotalFailed
problems = @($problems | Sort-Object @{ Expression = { [int]$_.occurrences }; Descending = $true }, @{ Expression = { $_.lastSeenAt }; Descending = $true })
}
$updated | ConvertTo-Json -Depth 10 | Set-Content -Path $statsPath -Encoding UTF8
$rows = @($updated.problems | Select-Object -First 50 | ForEach-Object {
"- key=$($_.key); occurrences=$($_.occurrences); resolved=$($_.resolved); failed=$($_.failed); lastStatus=$($_.lastStatus); lastMode=$($_.lastMode); evidence=$($_.lastEvidence)"
})
$km = @(
"# Agent99 Incident KM",
"",
"Last updated: $now",
"",
"## Totals",
"",
"- totalEvents: $($updated.totalEvents)",
"- totalResolved: $($updated.totalResolved)",
"- totalFailed: $($updated.totalFailed)",
"",
"## Recurring Problems",
"",
($rows -join "`n")
) -join "`n"
Set-Content -Path $kmPath -Value $km -Encoding UTF8
[pscustomobject]@{
key = $key
occurrences = $existing.occurrences
resolved = $existing.resolved
failed = $existing.failed
lastStatus = $existing.lastStatus
statsPath = $statsPath
eventsPath = $eventsPath
kmPath = $kmPath
ragPath = $ragPath
}
}
function Get-AgentDashboardSummary {
$status = Get-AgentLatestEvidence "agent99-Status-*.json"
$perf = Get-AgentLatestEvidence "agent99-Perf-*.json"
$self = Get-AgentLatestEvidence "agent99-SelfCheck-*.json"
$backup = Get-AgentLatestEvidence "agent99-BackupCheck-*.json"
$recover = Get-AgentLatestEvidence "agent99-Recover-*.json"
$taskNames = @(
"Wooo-Agent99-Startup-Recovery",
"Wooo-Agent99-Heartbeat",
"Wooo-Agent99-Performance-Guard",
"Wooo-Agent99-Control-Loop",
"Wooo-Agent99-Telegram-Inbox",
"Wooo-Agent99-SRE-Alert-Inbox",
"Wooo-Agent99-Self-Health",
"Wooo-Agent99-Backup-Health"
)
$tasks = @()
foreach ($taskName in $taskNames) {
try {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction Stop
$tasks += [pscustomobject]@{
name = $taskName
exists = $true
state = [string]$task.State
lastTaskResult = $info.LastTaskResult
lastRunTime = if ($info.LastRunTime) { $info.LastRunTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null }
nextRunTime = if ($info.NextRunTime) { $info.NextRunTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null }
}
} catch {
$tasks += [pscustomobject]@{
name = $taskName
exists = $false
state = "missing"
lastTaskResult = $null
lastRunTime = $null
nextRunTime = $null
}
}
}
$queueDir = Join-Path $Config.agentRoot "queue"
if (-not (Test-Path $queueDir)) { New-Item -ItemType Directory -Force -Path $queueDir | Out-Null }
$pending = @(Get-ChildItem -Path $queueDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch "^(processed|failed|running)-" })
$processedDir = Join-Path $queueDir "processed"
$failedDir = Join-Path $queueDir "failed"
$processedCount = if (Test-Path $processedDir) { @(Get-ChildItem -Path $processedDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 }
$failedCount = if (Test-Path $failedDir) { @(Get-ChildItem -Path $failedDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 }
$alertIncomingDir = Join-Path $Config.agentRoot "alerts\incoming"
$alertProcessedDir = Join-Path $Config.agentRoot "alerts\processed"
$alertFailedDir = Join-Path $Config.agentRoot "alerts\failed"
$alertPendingCount = if (Test-Path $alertIncomingDir) { @(Get-ChildItem -Path $alertIncomingDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 }
$alertProcessedCount = if (Test-Path $alertProcessedDir) { @(Get-ChildItem -Path $alertProcessedDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 }
$alertFailedCount = if (Test-Path $alertFailedDir) { @(Get-ChildItem -Path $alertFailedDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 }
$problemStats = Get-AgentProblemStats
[pscustomobject]@{
timestamp = (Get-Date -Format o)
computer = $env:COMPUTERNAME
user = $env:USERNAME
agentRoot = $Config.agentRoot
evidenceDir = $EvidenceDir
queue = [pscustomobject]@{
path = $queueDir
pending = $pending.Count
processed = $processedCount
failed = $failedCount
}
alerts = [pscustomobject]@{
incomingPath = $alertIncomingDir
pending = $alertPendingCount
processed = $alertProcessedCount
failed = $alertFailedCount
}
problemCounts = [pscustomobject]@{
path = $problemStats.path
totalEvents = $problemStats.totalEvents
totalResolved = $problemStats.totalResolved
totalFailed = $problemStats.totalFailed
top = @($problemStats.problems | Select-Object -First 10)
}
tasks = $tasks
evidence = @(
Get-AgentLatestEvidenceSummary "status" "agent99-Status-*.json"
Get-AgentLatestEvidenceSummary "performance" "agent99-Perf-*.json"
Get-AgentLatestEvidenceSummary "self_health" "agent99-SelfCheck-*.json"
Get-AgentLatestEvidenceSummary "backup" "agent99-BackupCheck-*.json"
Get-AgentLatestEvidenceSummary "startup_recovery" "agent99-Recover-*.json"
)
public = if ($status.exists -and -not $status.parseError -and $status.data) { @($status.data.public | Select-Object url,status,non502,ok) } else { @() }
hosts = if ($status.exists -and -not $status.parseError -and $status.data) { @($status.data.hosts | Select-Object host,ping) } else { @() }
performance = if ($perf.exists -and -not $perf.parseError -and $perf.data) { @($perf.data.performance | Select-Object host,severity,loadPerCore,memAvailablePercent,diskUsedPercent,route) } else { @() }
selfHealth = if ($self.exists -and -not $self.parseError -and $self.data) { $self.data.selfHealth.summary } else { $null }
backupHealth = if ($backup.exists -and -not $backup.parseError -and $backup.data) { $backup.data.backupHealth.summary } else { $null }
latestSelfHealthSeverity = if ($self.exists -and -not $self.parseError -and $self.data) { $self.data.selfHealth.severity } else { "unknown" }
latestBackupSeverity = if ($backup.exists -and -not $backup.parseError -and $backup.data) { $backup.data.backupHealth.severity } else { "unknown" }
}
}
function Write-AgentDashboard {
param([object]$Summary)
$dashboardDir = Join-Path $Config.agentRoot "dashboard"
New-Item -ItemType Directory -Force -Path $dashboardDir | Out-Null
$jsonPath = Join-Path $dashboardDir "agent99-control-center.latest.json"
$htmlPath = Join-Path $dashboardDir "index.html"
$Summary | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding UTF8
$taskRows = foreach ($task in @($Summary.tasks)) {
"<tr><td>$(Convert-AgentHtml $task.name)</td><td>$(Convert-AgentHtml $task.state)</td><td>$(Convert-AgentHtml $task.lastTaskResult)</td><td>$(Convert-AgentHtml $task.lastRunTime)</td><td>$(Convert-AgentHtml $task.nextRunTime)</td></tr>"
}
$hostRows = foreach ($hostItem in @($Summary.hosts)) {
"<tr><td>$(Convert-AgentHtml $hostItem.host)</td><td>$(Convert-AgentHtml $hostItem.ping)</td></tr>"
}
$publicRows = foreach ($route in @($Summary.public)) {
"<tr><td>$(Convert-AgentHtml $route.url)</td><td>$(Convert-AgentHtml $route.status)</td><td>$(Convert-AgentHtml $route.non502)</td><td>$(Convert-AgentHtml $route.ok)</td></tr>"
}
$perfRows = foreach ($perfItem in @($Summary.performance)) {
"<tr><td>$(Convert-AgentHtml $perfItem.host)</td><td>$(Convert-AgentHtml $perfItem.severity)</td><td>$(Convert-AgentHtml $perfItem.loadPerCore)</td><td>$(Convert-AgentHtml $perfItem.memAvailablePercent)</td><td>$(Convert-AgentHtml $perfItem.diskUsedPercent)</td><td>$(Convert-AgentHtml $perfItem.route)</td></tr>"
}
$evidenceRows = foreach ($ev in @($Summary.evidence)) {
"<tr><td>$(Convert-AgentHtml $ev.name)</td><td>$(Convert-AgentHtml $ev.file)</td><td>$(Convert-AgentHtml $ev.ageMinutes)</td><td>$(Convert-AgentHtml $ev.parseError)</td></tr>"
}
$problemRows = foreach ($problem in @($Summary.problemCounts.top)) {
"<tr><td>$(Convert-AgentHtml $problem.key)</td><td>$(Convert-AgentHtml $problem.occurrences)</td><td>$(Convert-AgentHtml $problem.resolved)</td><td>$(Convert-AgentHtml $problem.failed)</td><td>$(Convert-AgentHtml $problem.lastStatus)</td><td>$(Convert-AgentHtml $problem.lastMode)</td></tr>"
}
$html = @"
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="30">
<title>Agent99 Control Center</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 24px; background: #f5f7fb; color: #16202a; }
h1 { margin: 0 0 6px; font-size: 28px; }
h2 { margin-top: 26px; font-size: 18px; }
.meta { color: #526170; margin-bottom: 18px; }
.grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 12px; }
.card { background: #fff; border: 1px solid #d8e0ea; border-radius: 8px; padding: 14px; }
.label { color: #637282; font-size: 12px; text-transform: uppercase; }
.value { font-size: 22px; margin-top: 4px; font-weight: 650; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d8e0ea; }
th, td { padding: 8px 10px; border-bottom: 1px solid #e7edf5; text-align: left; font-size: 13px; }
th { background: #eef3f8; }
.ok { color: #087443; }
.path { font-family: Consolas, monospace; font-size: 12px; color: #465565; }
</style>
</head>
<body>
<h1>Agent99 Control Center</h1>
<div class="meta">Last update: $(Convert-AgentHtml $Summary.timestamp) on $(Convert-AgentHtml $Summary.computer) as $(Convert-AgentHtml $Summary.user). Auto-refreshes every 30 seconds.</div>
<div class="grid">
<div class="card"><div class="label">Self Health</div><div class="value">$(Convert-AgentHtml $Summary.latestSelfHealthSeverity)</div></div>
<div class="card"><div class="label">Backup Health</div><div class="value">$(Convert-AgentHtml $Summary.latestBackupSeverity)</div></div>
<div class="card"><div class="label">Queue Pending</div><div class="value">$(Convert-AgentHtml $Summary.queue.pending)</div></div>
<div class="card"><div class="label">Alerts Pending</div><div class="value">$(Convert-AgentHtml $Summary.alerts.pending)</div></div>
<div class="card"><div class="label">Problem Events</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalEvents)</div></div>
<div class="card"><div class="label">Resolved</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalResolved)</div></div>
<div class="card"><div class="label">Failed</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalFailed)</div></div>
</div>
<p class="path">Agent root: $(Convert-AgentHtml $Summary.agentRoot)<br>Queue: $(Convert-AgentHtml $Summary.queue.path)<br>Alerts: $(Convert-AgentHtml $Summary.alerts.incomingPath)<br>Problem counts: $(Convert-AgentHtml $Summary.problemCounts.path)<br>Evidence: $(Convert-AgentHtml $Summary.evidenceDir)</p>
<h2>Problem Counts</h2>
<table><tr><th>Problem</th><th>Occurrences</th><th>Resolved</th><th>Failed</th><th>Last Status</th><th>Last Mode</th></tr>$($problemRows -join "`n")</table>
<h2>Scheduled Tasks</h2>
<table><tr><th>Task</th><th>State</th><th>Last Result</th><th>Last Run</th><th>Next Run</th></tr>$($taskRows -join "`n")</table>
<h2>Hosts</h2>
<table><tr><th>Host</th><th>Ping</th></tr>$($hostRows -join "`n")</table>
<h2>Public Routes</h2>
<table><tr><th>URL</th><th>Status</th><th>Non-502</th><th>OK</th></tr>$($publicRows -join "`n")</table>
<h2>Performance</h2>
<table><tr><th>Host</th><th>Severity</th><th>Load/Core</th><th>Mem Avail %</th><th>Disk Used %</th><th>Route</th></tr>$($perfRows -join "`n")</table>
<h2>Evidence</h2>
<table><tr><th>Name</th><th>File</th><th>Age Minutes</th><th>Parse Error</th></tr>$($evidenceRows -join "`n")</table>
</body>
</html>
"@
Set-Content -Path $htmlPath -Value $html -Encoding UTF8
[pscustomobject]@{
dashboardDir = $dashboardDir
htmlPath = $htmlPath
jsonPath = $jsonPath
}
}
function Ensure-AgentDesktopShortcuts {
param([string]$DashboardPath)
$created = @()
$targets = @(
[pscustomobject]@{ name = "Agent99 Control Center.lnk"; target = $DashboardPath },
[pscustomobject]@{ name = "Agent99 Submit Request.lnk"; target = (Join-Path $Config.agentRoot "agent99-submit-request.cmd") },
[pscustomobject]@{ name = "Agent99 Queue.lnk"; target = (Join-Path $Config.agentRoot "queue") },
[pscustomobject]@{ name = "Agent99 Evidence.lnk"; target = $EvidenceDir }
)
$desktopDirs = @(
[Environment]::GetFolderPath("CommonDesktopDirectory"),
[Environment]::GetFolderPath("Desktop")
) | Where-Object { $_ } | Select-Object -Unique
foreach ($desktop in $desktopDirs) {
if (-not (Test-Path $desktop)) { continue }
foreach ($item in $targets) {
$shortcutPath = Join-Path $desktop $item.name
try {
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $item.target
$shortcut.WorkingDirectory = Split-Path -Parent $item.target
$shortcut.Description = "Agent99 local control and evidence shortcut"
$shortcut.Save()
$created += $shortcutPath
} catch {
Write-AgentLog "desktop_shortcut_failed path=$shortcutPath error=$($_.Exception.Message)"
}
}
}
$created
}
function Invoke-AgentQueuedCommands {
$queueDir = Join-Path $Config.agentRoot "queue"
$processedDir = Join-Path $queueDir "processed"
$failedDir = Join-Path $queueDir "failed"
New-Item -ItemType Directory -Force -Path $queueDir, $processedDir, $failedDir | Out-Null
$allowedModes = @("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck", "ProviderFreshness", "SecurityTriage")
$commands = @(Get-ChildItem -Path $queueDir -Filter "*.json" -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notmatch "^(processed|failed|running)-" } |
Sort-Object LastWriteTime |
Select-Object -First 3)
$results = @()
foreach ($file in $commands) {
$command = $null
try {
$command = Get-Content $file.FullName -Raw | ConvertFrom-Json
} catch {
$target = Join-Path $failedDir ("failed-" + $file.Name)
Move-Item -Force $file.FullName $target
$results += [pscustomobject]@{ id = $file.BaseName; ok = $false; reason = "invalid_json"; file = $target }
continue
}
$id = if ($command.id) { [string]$command.id } else { $file.BaseName }
$modeName = if ($command.mode) { [string]$command.mode } else { "" }
$controlled = if ($command.PSObject.Properties["controlledApply"]) { [bool]$command.controlledApply } else { $false }
$commandSource = if ($command.PSObject.Properties["source"]) { [string]$command.source } else { "queue" }
$commandReason = if ($command.PSObject.Properties["reason"]) { [string]$command.reason } else { "operator_instruction" }
$commandInstruction = if ($command.PSObject.Properties["instruction"]) { [string]$command.instruction } else { "" }
$alertId = if ($command.PSObject.Properties["alertId"]) { [string]$command.alertId } else { $null }
$alertSource = if ($command.PSObject.Properties["alertSource"]) { [string]$command.alertSource } else { $null }
$alertKind = if ($command.PSObject.Properties["alertKind"]) { [string]$command.alertKind } else { $null }
$alertSeverity = if ($command.PSObject.Properties["alertSeverity"]) { [string]$command.alertSeverity } else { $null }
$alertService = if ($command.PSObject.Properties["alertService"]) { [string]$command.alertService } else { $null }
$alertHost = if ($command.PSObject.Properties["alertHost"]) { [string]$command.alertHost } else { $null }
$replyChatId = if ($command.PSObject.Properties["replyChatId"]) { [string]$command.replyChatId } else { "" }
if ($modeName -notin $allowedModes) {
$target = Join-Path $failedDir ("failed-" + $file.Name)
Move-Item -Force $file.FullName $target
$result = [pscustomobject]@{ id = $id; mode = $modeName; source = $commandSource; instruction = $commandInstruction; ok = $false; reason = "mode_not_allowlisted"; file = $target }
Record-AgentEvent "operator_command_rejected" "warning" "id=$id mode=$modeName reason=mode_not_allowlisted" $result -Alert
$results += $result
continue
}
$runningPath = Join-Path $queueDir ("running-" + $file.Name)
Move-Item -Force $file.FullName $runningPath
$startTime = Get-Date
$launcher = Join-Path $Config.agentRoot "agent99-run.ps1"
$stdoutPath = Join-Path $EvidenceDir "agent99-command-$id.out"
$stderrPath = Join-Path $EvidenceDir "agent99-command-$id.err"
$args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $launcher, "-Mode", $modeName)
if ($controlled) { $args += "-ControlledApply" }
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $args -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru
$completed = $process.WaitForExit(600000)
if (-not $completed) {
$process.Kill()
$process.WaitForExit(2000) | Out-Null
}
$process.WaitForExit() | Out-Null
$process.Refresh()
$latest = Get-AgentLatestEvidence "agent99-$modeName-*.json"
$exitCode = if (-not $completed) {
-2
} elseif ($null -ne $process.ExitCode) {
$process.ExitCode
} elseif ($latest.exists -and -not $latest.parseError) {
0
} else {
-3
}
$result = [pscustomobject]@{
id = $id
mode = $modeName
controlledApply = $controlled
source = $commandSource
reason = $commandReason
instruction = $commandInstruction
alertId = $alertId
alertSource = $alertSource
alertKind = $alertKind
alertSeverity = $alertSeverity
alertService = $alertService
alertHost = $alertHost
ok = [bool]($exitCode -eq 0)
exitCode = $exitCode
durationSeconds = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 1)
evidence = $latest.path
stdout = $stdoutPath
stderr = $stderrPath
}
$problem = Update-AgentProblemKnowledge $result
$result | Add-Member -MemberType NoteProperty -Name problem -Value $problem -Force
$donePath = Join-Path $processedDir ("processed-$id.json")
$result | ConvertTo-Json -Depth 8 | Set-Content -Path $donePath -Encoding UTF8
Remove-Item $runningPath -Force -ErrorAction SilentlyContinue | Out-Null
$recordData = $result | Select-Object *
if ($replyChatId) {
$recordData | Add-Member -MemberType NoteProperty -Name replyChatId -Value $replyChatId -Force
}
Record-AgentEvent "operator_command_result" $(if ($exitCode -eq 0) { "info" } else { "critical" }) "id=$id mode=$modeName exitCode=$exitCode evidence=$($latest.path)" $recordData -Alert
$results += $result
}
$results
}
function Invoke-AgentControlTick {
$processed = Invoke-AgentQueuedCommands
$summary = Get-AgentDashboardSummary
$dashboard = Write-AgentDashboard $summary
$shortcuts = Ensure-AgentDesktopShortcuts $dashboard.htmlPath
$pendingCount = if ($summary.queue) { $summary.queue.pending } else { 0 }
$result = [pscustomobject]@{
ok = $true
dashboardPath = $dashboard.htmlPath
dashboardJson = $dashboard.jsonPath
shortcutCount = @($shortcuts).Count
shortcuts = $shortcuts
processedCount = @($processed).Count
processed = $processed
pendingCount = $pendingCount
}
Record-AgentEvent "agent_control_tick" "info" "dashboard=$($dashboard.htmlPath) processed=$(@($processed).Count) pending=$pendingCount" $result -NoAlert
$result
}
function Invoke-AgentSecurityTriage {
$lookbackMinutes = 120
$since = (Get-Date).AddMinutes(-1 * $lookbackMinutes)
$triageId = "security-triage-" + (Get-Date -Format "yyyyMMdd-HHmmss")
$triageDir = Join-Path $Config.agentRoot "state\security-triage"
New-Item -ItemType Directory -Force -Path $triageDir | Out-Null
$triagePath = Join-Path $triageDir "$triageId.json"
$patterns = @(
"wazuh",
"siem",
"security",
"auth_failed",
"bruteforce",
"brute force",
"intrusion",
"malware",
"privilege",
"vulnerability",
"cve",
"ioc",
"suspicious",
"unauthorized"
)
$scanDirs = @(
(Join-Path $Config.agentRoot "alerts\incoming"),
(Join-Path $Config.agentRoot "alerts\processed"),
(Join-Path $Config.agentRoot "queue"),
(Join-Path $Config.agentRoot "queue\processed"),
(Join-Path $Config.agentRoot "requests")
) | Where-Object { Test-Path $_ } | Select-Object -Unique
$hits = @()
foreach ($dir in $scanDirs) {
$files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $since -and $_.Length -lt 2097152 })
foreach ($file in $files) {
$matchedPatterns = @()
foreach ($pattern in $patterns) {
if (Select-String -LiteralPath $file.FullName -Pattern $pattern -SimpleMatch -Quiet -ErrorAction SilentlyContinue) {
$matchedPatterns += $pattern
}
}
if (@($matchedPatterns).Count -gt 0) {
$excerpt = ""
$firstHit = Select-String -LiteralPath $file.FullName -Pattern $matchedPatterns[0] -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
if ($firstHit) {
$excerpt = [string]$firstHit.Line
$excerpt = $excerpt -replace '("?(telegram|token|chat|authorization|password|secret|cookie)[^,}\r\n]*"?\s*:\s*)"[^"]+"', '$1"[redacted]"'
if ($excerpt.Length -gt 500) { $excerpt = $excerpt.Substring(0, 500) + "...(truncated)" }
}
$hits += [pscustomobject]@{
path = $file.FullName
lastWrite = $file.LastWriteTime.ToString("o")
patterns = @($matchedPatterns)
excerpt = $excerpt
}
}
}
}
$status = if (@($hits).Count -gt 0) { "security_triage_candidate_created" } else { "security_triage_no_recent_alert_body_seen" }
$triage = [pscustomobject]@{
timestamp = (Get-Date -Format o)
triageId = $triageId
status = $status
lookbackMinutes = $lookbackMinutes
since = $since.ToString("o")
hitCount = @($hits).Count
hits = @($hits | Sort-Object lastWrite -Descending | Select-Object -First 20)
requiredReadback = @("source_log_reference", "affected_host_or_service", "blast_radius", "repeated_count", "allowlisted_next_action")
allowedActions = @("classify", "read_existing_evidence", "write_km", "request_source_log_reference", "recommend_allowlisted_next_action")
prohibitedActions = @("arbitrary_shell", "data_delete", "disable_security_control", "secret_exposure")
evidencePath = $triagePath
}
$triage | ConvertTo-Json -Depth 10 | Set-Content -Path $triagePath -Encoding UTF8
Write-AgentLog "security_triage triage=$triageId hits=$(@($hits).Count) status=$status path=$triagePath"
$eventSeverity = if (@($hits).Count -gt 0) { "warning" } else { "info" }
Record-AgentEvent "security_triage" $eventSeverity "status=$status hits=$(@($hits).Count)" $triage -Alert
$triage
}
function Invoke-AgentProviderFreshnessDirectReadback {
param(
[int]$LookbackMinutes
)
$baseUrl = "https://awoooi.wooo.work/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=20"
$startedAt = Get-Date
try {
$response = Invoke-RestMethod -Method Get -Uri $baseUrl -TimeoutSec 12
$summary = if ($response -and $response.PSObject.Properties["summary"]) { $response.summary } else { $null }
$latest = if ($summary -and $summary.PSObject.Properties["latest_received_at"]) { $summary.latest_received_at } else { $null }
$sourceTotal = if ($summary -and $summary.PSObject.Properties["source_event_total"]) { [int]$summary.source_event_total } else { 0 }
$itemsCount = if ($response -and $response.PSObject.Properties["items"]) { @($response.items).Count } else { 0 }
return [pscustomobject]@{
ok = $true
verifier = "awoooi-platform-events-recurrence"
directReference = $baseUrl
providerWindowMinutes = $LookbackMinutes
latestReceivedAt = $latest
sourceEventTotal = $sourceTotal
recurrenceGroupTotal = if ($summary -and $summary.PSObject.Properties["recurrence_group_total"]) { [int]$summary.recurrence_group_total } else { 0 }
itemsCount = $itemsCount
httpStatus = 200
durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2)
checkedAt = (Get-Date -Format o)
}
} catch {
return [pscustomobject]@{
ok = $false
verifier = "awoooi-platform-events-recurrence"
directReference = $baseUrl
providerWindowMinutes = $LookbackMinutes
latestReceivedAt = $null
sourceEventTotal = 0
recurrenceGroupTotal = 0
itemsCount = 0
httpStatus = $null
durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2)
checkedAt = (Get-Date -Format o)
error = ($_ | Out-String).Trim()
}
}
}
function Invoke-AgentProviderFreshnessTriage {
$providerConfig = if ($Config.PSObject.Properties["providerFreshness"]) { $Config.providerFreshness } else { $null }
$lookbackMinutes = if ($providerConfig -and $providerConfig.PSObject.Properties["lookbackMinutes"]) { [int]$providerConfig.lookbackMinutes } else { 120 }
$since = (Get-Date).AddMinutes(-1 * $lookbackMinutes)
$candidateId = "provider-freshness-" + (Get-Date -Format "yyyyMMdd-HHmmss")
$candidateDir = Join-Path $Config.agentRoot "state\provider-freshness-candidates"
New-Item -ItemType Directory -Force -Path $candidateDir | Out-Null
$candidatePath = Join-Path $candidateDir "$candidateId.json"
$patterns = @(
"provider_freshness_signal",
"source_provider_freshness_triage",
"raw_signal_normalized",
"controlled_runtime_candidate",
"freshness",
"last_seen",
"last seen",
"ingestion"
)
$scanDirs = @(
(Join-Path $Config.agentRoot "alerts\incoming"),
(Join-Path $Config.agentRoot "alerts\processed"),
(Join-Path $Config.agentRoot "queue"),
(Join-Path $Config.agentRoot "queue\processed"),
(Join-Path $Config.agentRoot "requests")
) | Where-Object { Test-Path $_ } | Select-Object -Unique
$hits = @()
foreach ($dir in $scanDirs) {
$files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $since -and $_.Length -lt 2097152 })
foreach ($file in $files) {
$matchedPatterns = @()
foreach ($pattern in $patterns) {
if (Select-String -LiteralPath $file.FullName -Pattern $pattern -SimpleMatch -Quiet -ErrorAction SilentlyContinue) {
$matchedPatterns += $pattern
}
}
if (@($matchedPatterns).Count -gt 0) {
$excerpt = ""
$firstHit = Select-String -LiteralPath $file.FullName -Pattern $matchedPatterns[0] -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
if ($firstHit) {
$excerpt = [string]$firstHit.Line
$excerpt = $excerpt -replace '("?(telegram|token|chat|authorization|password)[^,}\r\n]*"?\s*:\s*)"[^"]+"', '$1"[redacted]"'
if ($excerpt.Length -gt 500) { $excerpt = $excerpt.Substring(0, 500) + "...(truncated)" }
}
$hits += [pscustomobject]@{
path = $file.FullName
lastWrite = $file.LastWriteTime.ToString("o")
patterns = @($matchedPatterns)
excerpt = $excerpt
}
}
}
}
$targets = @()
if ($providerConfig -and $providerConfig.targets) {
foreach ($target in @($providerConfig.targets)) {
$targets += [pscustomobject]@{
name = if ($target.name) { [string]$target.name } else { "provider_freshness_signal" }
providerWindowMinutes = if ($target.PSObject.Properties["providerWindowMinutes"]) { $target.providerWindowMinutes } else { $null }
directReference = if ($target.directReference) { [string]$target.directReference } else { $null }
verifier = if ($target.verifier) { [string]$target.verifier } else { "agent99-provider-freshness-triage" }
}
}
}
if (@($targets).Count -eq 0) {
$targets += [pscustomobject]@{
name = "provider_freshness_signal"
providerWindowMinutes = $null
directReference = $null
verifier = "agent99-provider-freshness-triage"
}
}
$verifierReadback = Invoke-AgentProviderFreshnessDirectReadback -LookbackMinutes $lookbackMinutes
$directReference = if ($verifierReadback -and $verifierReadback.PSObject.Properties["directReference"]) { $verifierReadback.directReference } else { $null }
$latestSeen = if ($verifierReadback -and $verifierReadback.PSObject.Properties["latestReceivedAt"]) { $verifierReadback.latestReceivedAt } else { $null }
$missingFields = @()
if (-not $lookbackMinutes) { $missingFields += "providerWindow" }
if (-not $latestSeen) { $missingFields += "lastSeen" }
if (-not $directReference) { $missingFields += "directReference" }
if (-not ($verifierReadback -and $verifierReadback.ok -eq $true)) { $missingFields += "verifierReadback" }
$status = if ($verifierReadback -and $verifierReadback.ok -eq $true -and $latestSeen) {
"direct_readback_ok"
} elseif ($verifierReadback -and $verifierReadback.ok -eq $true) {
"direct_readback_ok_no_source_events"
} elseif (@($hits).Count -gt 0) {
"candidate_created_waiting_direct_readback"
} else {
"candidate_created_no_alert_body_seen"
}
$candidate = [pscustomobject]@{
timestamp = (Get-Date -Format o)
candidateId = $candidateId
target = "provider_freshness_signal"
status = $status
lookbackMinutes = $lookbackMinutes
since = $since.ToString("o")
hitCount = @($hits).Count
hits = @($hits | Sort-Object lastWrite -Descending | Select-Object -First 20)
targets = $targets
missingFields = $missingFields
providerWindowMinutes = $lookbackMinutes
lastSeen = $latestSeen
directReference = $directReference
verifierReadback = $verifierReadback
requiredVerifierReadback = @("provider_window", "last_seen", "direct_reference", "source_trace_or_sentry_signoz_ref")
noFalseGreenRisk = $true
allowedActions = @("create_candidate", "read_existing_evidence", "provider_freshness_direct_readback", "request_direct_reference", "write_km")
prohibitedActions = @("provider_switch", "paid_model_call", "env_change", "reload")
candidatePath = $candidatePath
}
$candidate | ConvertTo-Json -Depth 10 | Set-Content -Path $candidatePath -Encoding UTF8
Write-AgentLog "provider_freshness candidate=$candidateId hits=$(@($hits).Count) status=$status missing=$($missingFields -join ',') path=$candidatePath"
$eventSeverity = if (@($missingFields).Count -gt 0) { "warning" } else { "info" }
Record-AgentEvent "provider_freshness_triage" $eventSeverity "target=provider_freshness_signal status=$status missing=$($missingFields -join ',')" $candidate -Alert
$candidate
}
function Test-HostReachability {
$results = @()
foreach ($hostIp in $Config.hosts) {
$ping = Test-Connection -ComputerName $hostIp -Count 1 -Quiet -ErrorAction SilentlyContinue
Write-AgentLog "host host=$hostIp ping=$ping"
$results += [pscustomobject]@{
host = $hostIp
ping = [bool]$ping
}
}
$results
}
function Test-PublicRoutes {
$results = @()
foreach ($url in $Config.publicUrls) {
try {
$output = & curl.exe --location --head --max-time 12 $url 2>&1
$text = $output -join "`n"
$matches = [regex]::Matches($text, "HTTP/\S+\s+(\d+)")
$status = if ($matches.Count -gt 0) { [int]$matches[$matches.Count - 1].Groups[1].Value } else { $null }
$non502 = ($null -ne $status -and $status -ne 502)
$ok = ($null -ne $status -and $status -ge 200 -and $status -lt 500)
Write-AgentLog "public url=$url status=$status non502=$non502 ok=$ok"
$results += [pscustomobject]@{
url = $url
status = $status
non502 = $non502
ok = $ok
}
} catch {
Write-AgentLog "public url=$url error=$($_.Exception.Message)"
$results += [pscustomobject]@{
url = $url
status = $null
non502 = $false
ok = $false
}
}
}
$results
}
function Test-AiServices {
$results = @()
foreach ($svc in @($Config.aiServices)) {
if (-not $svc.url) { continue }
$name = if ($svc.name) { [string]$svc.name } else { [string]$svc.url }
try {
$output = & curl.exe --max-time 12 -sS $svc.url 2>&1
$text = ($output -join "`n").Trim()
$statusValue = $null
try {
$json = $text | ConvertFrom-Json
if ($json.status) { $statusValue = [string]$json.status }
} catch {
$statusValue = $null
}
$expectedStatus = if ($svc.expectedStatus) { [string]$svc.expectedStatus } else { "healthy" }
$ok = ($statusValue -eq $expectedStatus)
$severity = if ($ok) { "ok" } elseif ($svc.critical) { "critical" } else { "warning" }
$result = [pscustomobject]@{
name = $name
url = $svc.url
host = $svc.host
ok = $ok
severity = $severity
status = $statusValue
expectedStatus = $expectedStatus
output = $text
}
Write-AgentLog "ai_service name=$name ok=$ok severity=$severity status=$statusValue url=$($svc.url)"
if (-not $ok) {
Record-AgentEvent "ai_service_unhealthy" $severity "name=$name status=$statusValue expected=$expectedStatus url=$($svc.url)" $result -Alert
}
$results += $result
} catch {
$severity = if ($svc.critical) { "critical" } else { "warning" }
$result = [pscustomobject]@{
name = $name
url = $svc.url
host = $svc.host
ok = $false
severity = $severity
status = $null
expectedStatus = if ($svc.expectedStatus) { [string]$svc.expectedStatus } else { "healthy" }
output = $_.Exception.Message
}
Write-AgentLog "ai_service name=$name error=$($_.Exception.Message)"
Record-AgentEvent "ai_service_unhealthy" $severity "name=$name error=$($_.Exception.Message) url=$($svc.url)" $result -Alert
$results += $result
}
}
$results
}
function Get-HarborState {
$inspectCommand = "docker inspect --format '{{.Name}}|{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' harbor-core redis registry"
$inspect = Invoke-SshText "192.168.0.110" $inspectCommand 30 1
$containerStates = @{}
foreach ($line in @(([string]$inspect.output) -split "`n")) {
$trimmed = $line.Trim()
if (-not $trimmed) { continue }
$parts = @($trimmed -split "\|")
if ($parts.Count -lt 3) { continue }
$name = $parts[0].Trim().TrimStart("/")
$containerStates[$name] = [pscustomobject]@{
name = $name
status = $parts[1].Trim()
health = $parts[2].Trim()
}
}
$core = $containerStates["harbor-core"]
$redis = $containerStates["redis"]
$registry = $containerStates["registry"]
$coreLine = if ($core) { "$($core.name) $($core.status) $($core.health)" } else { "harbor-core missing" }
$redisLine = if ($redis) { "$($redis.name) $($redis.status) $($redis.health)" } else { "redis missing" }
$registryLine = if ($registry) { "$($registry.name) $($registry.status) $($registry.health)" } else { "registry missing" }
$state = [pscustomobject]@{
coreHealthy = ($core -and $core.status -eq "running" -and $core.health -eq "healthy")
redisUp = ($redis -and $redis.status -eq "running")
registryHealthy = ($registry -and $registry.status -eq "running" -and $registry.health -eq "healthy")
coreOutput = $coreLine
redisOutput = $redisLine
registryOutput = $registryLine
inspectExitCode = $inspect.exitCode
healthOutput = $inspect.output
inspectOutput = "[redacted: raw docker inspect omitted]"
}
Write-AgentLog "harbor coreHealthy=$($state.coreHealthy) redisUp=$($state.redisUp) registryHealthy=$($state.registryHealthy)"
$state
}
function Get-AwoooState {
$nestedSsh = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=8 -l $SshUser 192.168.0.120"
$readback = Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl get deploy,pods -n awoooi-prod" 60 1
$state = [pscustomobject]@{
deployReady = (
$readback.output -match "deployment.apps/awoooi-api\s+1/1" -and
$readback.output -match "deployment.apps/awoooi-web\s+2/2" -and
$readback.output -match "deployment.apps/awoooi-worker\s+1/1"
)
imagePullFailure = ($readback.output -match "ImagePullBackOff|ErrImagePull")
crashFailure = ($readback.output -match "CrashLoopBackOff|Pending")
deployOutput = $readback.output
podOutput = $readback.output
readbackExitCode = $readback.exitCode
}
Write-AgentLog "awoooi deployReady=$($state.deployReady) imagePullFailure=$($state.imagePullFailure) crashFailure=$($state.crashFailure)"
$state
}
function Find-Vmrun {
if ($Config.vmrunPath -and (Test-Path $Config.vmrunPath)) {
return $Config.vmrunPath
}
foreach ($candidate in @(
"C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe",
"C:\Program Files\VMware\VMware Workstation\vmrun.exe"
)) {
if (Test-Path $candidate) { return $candidate }
}
return $null
}
function Start-ConfiguredVMs {
$vmrun = Find-Vmrun
$results = @()
if (-not $vmrun) {
Write-AgentLog "vmrun missing"
return @([pscustomobject]@{ ok = $false; reason = "vmrun_missing" })
}
foreach ($vm in $Config.vms) {
if (-not $vm.vmx) {
$results += [pscustomobject]@{ name = $vm.name; ok = $false; skipped = $true; reason = "missing_vmx_path" }
continue
}
$hostPing = $false
if ($vm.host) {
$hostPing = Test-Connection -ComputerName $vm.host -Count 1 -Quiet -ErrorAction SilentlyContinue
}
if ($hostPing) {
$results += [pscustomobject]@{ name = $vm.name; ok = $true; skipped = $true; reason = "host_already_reachable" }
continue
}
if (-not $ControlledApply) {
$results += [pscustomobject]@{ name = $vm.name; ok = $false; skipped = $true; reason = "controlled_apply_required" }
continue
}
Write-AgentLog "CONTROLLED_APPLY vm_start name=$($vm.name) vmx=$($vm.vmx)"
$output = & $vmrun start $vm.vmx nogui 2>&1
$results += [pscustomobject]@{
name = $vm.name
ok = ($LASTEXITCODE -eq 0)
skipped = $false
output = ($output -join "`n")
}
}
$results
}
function Repair-Harbor {
param([object]$Harbor)
if (-not $ControlledApply) {
Write-AgentLog "harbor repair skipped controlled_apply=false"
return $false
}
$changed = $false
if (-not $Harbor.redisUp) {
Write-AgentLog "CONTROLLED_APPLY harbor restart redis"
Invoke-SshText "192.168.0.110" "docker restart redis" | Out-Null
$changed = $true
}
if (-not $Harbor.coreHealthy) {
Write-AgentLog "CONTROLLED_APPLY harbor restart harbor-core"
Invoke-SshText "192.168.0.110" "docker restart harbor-core" | Out-Null
$changed = $true
}
return $changed
}
function Repair-Awooo {
param([object]$Awooo, [object]$Harbor)
if (-not $ControlledApply) {
Write-AgentLog "awoooi repair skipped controlled_apply=false"
return $false
}
if (-not $Harbor.coreHealthy) {
Write-AgentLog "awoooi repair skipped harbor_core_not_healthy"
return $false
}
if (-not $Awooo.imagePullFailure) {
Write-AgentLog "awoooi repair skipped no_image_pull_failure"
return $false
}
Write-AgentLog "CONTROLLED_APPLY awoooi delete failed pods"
$nestedSsh = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=8 -l $SshUser 192.168.0.120"
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-api" 25 | Out-Null
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-web" 25 | Out-Null
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-worker" 25 | Out-Null
return $true
}
if ($SelfTestTelegramCard) {
Invoke-AgentTelegramCardSelfTest
}
Write-AgentLog "agent99_start mode=$Mode controlledApply=$ControlledApply config=$ConfigPath"
Record-AgentEvent "agent_start" "info" "mode=$Mode controlledApply=$ControlledApply host=$env:COMPUTERNAME" $null -Alert
$vmResults = @()
$hostResults = @()
$harbor = $null
$awooo = $null
$public = @()
$aiServices = @()
$performance = @()
$loadShedding = @()
$selfHealth = $null
$backupHealth = $null
$providerFreshness = $null
$securityTriage = $null
$controlTick = $null
if ($Mode -in @("Recover", "StartVMs")) {
$vmResults = Start-ConfiguredVMs
}
if ($Mode -in @("Status", "Recover")) {
$hostResults = Test-HostReachability
}
if ($Mode -in @("Status", "Recover", "HarborRepair")) {
$harbor = Get-HarborState
}
if ($Mode -in @("Recover", "HarborRepair") -and $harbor) {
$harborChanged = Repair-Harbor $harbor
if ($harborChanged) {
Start-Sleep -Seconds 3
$harbor = Get-HarborState
}
}
if ($Mode -in @("Status", "Recover", "AwoooRepair")) {
$awooo = Get-AwoooState
}
if ($Mode -in @("Recover", "AwoooRepair") -and $awooo) {
if (-not $harbor) { $harbor = Get-HarborState }
$awoooChanged = Repair-Awooo $awooo $harbor
if ($awoooChanged) {
Start-Sleep -Seconds 3
$awooo = Get-AwoooState
}
}
if ($Mode -in @("Status", "Recover", "PublicSmoke")) {
$public = Test-PublicRoutes
}
if ($Mode -in @("Status", "Recover", "Perf")) {
$aiServices = Test-AiServices
}
if ($Mode -in @("Status", "Recover", "Perf", "LoadShed")) {
$suppressPerfAlerts = [bool]($ControlledApply -and $Mode -in @("Perf", "LoadShed"))
$performance = Get-HostPerformance -SuppressAlerts:$suppressPerfAlerts
}
if ($Mode -in @("Perf", "LoadShed")) {
$loadShedding = Invoke-LoadShedding $performance
if ($ControlledApply) {
Record-PerformanceIssuesWithoutRemediation $performance $loadShedding
}
}
if ($Mode -in @("SelfCheck")) {
$selfHealth = Test-AgentSelfHealth
}
if ($Mode -in @("BackupCheck")) {
$backupHealth = Test-AgentBackupHealth
}
if ($Mode -in @("ProviderFreshness")) {
$providerFreshness = Invoke-AgentProviderFreshnessTriage
}
if ($Mode -in @("SecurityTriage")) {
$securityTriage = Invoke-AgentSecurityTriage
}
if ($Mode -in @("ControlTick")) {
$controlTick = Invoke-AgentControlTick
}
$publicFailures = @($public | Where-Object { $_ -and -not $_.ok }).Count
$aiFailures = @($aiServices | Where-Object { $_ -and -not $_.ok }).Count
$perfCritical = @($performance | Where-Object { $_ -and $_.severity -eq "critical" }).Count
$perfWarning = @($performance | Where-Object { $_ -and $_.severity -eq "warning" }).Count
$selfHealthCritical = if ($selfHealth -and $selfHealth.severity -eq "critical") { 1 } else { 0 }
$selfHealthWarning = if ($selfHealth -and $selfHealth.severity -eq "warning") { 1 } else { 0 }
$backupCritical = if ($backupHealth -and $backupHealth.severity -eq "critical") { 1 } else { 0 }
$backupWarning = if ($backupHealth -and $backupHealth.severity -eq "warning") { 1 } else { 0 }
$providerFreshnessWarning = if ($providerFreshness) { 1 } else { 0 }
$securityTriageWarning = if ($securityTriage -and $securityTriage.hitCount -gt 0) { 1 } else { 0 }
$summarySeverity = if ($publicFailures -gt 0 -or $aiFailures -gt 0 -or $perfCritical -gt 0 -or $selfHealthCritical -gt 0 -or $backupCritical -gt 0) { "critical" } elseif ($perfWarning -gt 0 -or $selfHealthWarning -gt 0 -or $backupWarning -gt 0 -or $providerFreshnessWarning -gt 0 -or $securityTriageWarning -gt 0) { "warning" } else { "info" }
Record-AgentEvent "agent_complete" $summarySeverity "mode=$Mode publicFailures=$publicFailures aiFailures=$aiFailures perfCritical=$perfCritical perfWarning=$perfWarning selfHealth=$($selfHealth.severity) backupHealth=$($backupHealth.severity) providerFreshness=$($providerFreshness.status) securityTriage=$($securityTriage.status) evidence=$jsonPath" $null -NoAlert
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
mode = $Mode
controlledApply = [bool]$ControlledApply
vmResults = $vmResults
hosts = $hostResults
harbor = $harbor
awoooi = $awooo
public = $public
aiServices = $aiServices
performance = $performance
loadShedding = $loadShedding
selfHealth = $selfHealth
backupHealth = $backupHealth
providerFreshness = $providerFreshness
securityTriage = $securityTriage
controlTick = $controlTick
events = $script:Events
telegram = $script:TelegramAttempts
evidenceLog = $logPath
}
$result | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $jsonPath
Write-AgentLog "agent99_complete json=$jsonPath"
exit 0