Files
awoooi/agent99-control-plane.ps1

2557 lines
105 KiB
PowerShell

param(
[ValidateSet("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck", "ProviderFreshness", "ControlTick")]
[string]$Mode = "Status",
[switch]$ControlledApply,
[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 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 Alert: $severityLabel"
if ($EventType -match "^performance_") {
$hostName = if ($Data -and $Data.PSObject.Properties["host"]) { $Data.host } else { "unknown" }
$reasons = if ($Data -and $Data.PSObject.Properties["reasons"]) { @($Data.reasons) } else { @() }
$disk = if ($Data -and $Data.PSObject.Properties["diskUsedPercent"]) { $Data.diskUsedPercent } else { $null }
$load = if ($Data -and $Data.PSObject.Properties["loadPerCore"]) { $Data.loadPerCore } else { $null }
$mem = if ($Data -and $Data.PSObject.Properties["memAvailablePercent"]) { $Data.memAvailablePercent } else { $null }
$lines += "Event: host performance"
$lines += "Host: $hostName"
if ($reasons -contains "disk_used_warning") {
$lines += "Reason: disk usage is $disk percent. Warning threshold reached; critical threshold is 95 percent."
$lines += "Action: Agent99 will run allowlisted disk remediation and suppress duplicates for 30 minutes."
} elseif ($reasons -contains "disk_used_high") {
$lines += "Reason: disk usage is $disk percent. Critical threshold reached."
$lines += "Action: Agent99 will run allowlisted cleanup first; reboot is not the first response."
} elseif ($reasons -contains "load_per_core_warning" -or $reasons -contains "load_per_core_critical") {
$lines += "Reason: CPU load/core=$load."
$lines += "Action: Agent99 will use allowlisted load reduction only; no arbitrary service stop."
} elseif ($reasons -contains "memory_available_low") {
$lines += "Reason: available memory is $mem percent."
$lines += "Action: Agent99 will inspect memory pressure before allowlisted remediation."
} else {
$lines += "Reason: $($reasons -join ', ')"
$lines += "Action: check Agent99 evidence for full readback."
}
$lines += "Current: load/core=$load, memAvail=$mem percent, disk=$disk percent."
} 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 += "Action: inspect /backup/status, /backup/logs, and snapshot metadata only; do not read backup contents or 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 auto triage"
$lines += "Target: $targetName"
$lines += "Status: $status"
$lines += "Evidence hits: $hitCount"
if ($readback) {
$lines += "Direct readback: 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 += "Action: candidate and KM/RAG were written; duplicate same-state alerts are suppressed."
$lines += "Guard: no provider switch, paid model call, env change, or 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 += "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: check 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 service health"
$lines += "Service: $serviceName"
$lines += "Action: inspect health endpoint and service/container state before controlled repair."
} elseif ($EventType -eq "agent_selfcheck") {
$lines += "Event: Agent99 self health"
$lines += "Summary: $Message"
$lines += "Action: inspect task state, evidence freshness, and Telegram delivery; reboot is not the first response."
} 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 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
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 }
relay = $null
sent = $false
error = $null
}
if (-not $enabled) {
$attempt.error = "telegram_disabled"
$script:TelegramAttempts += $attempt
return
}
$text = Format-AgentTelegramText $Severity $EventType $Message $Data
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
} else {
$attempt.error = "telegram_not_configured"
}
$script:TelegramAttempts += $attempt
return
}
try {
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/sendMessage" -Body @{
chat_id = if ($chatIdOverride) { $chatIdOverride } else { $chatId }
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")
$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-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
}
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
$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 @("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 }
$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) { "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) 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
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