Files
awoooi/agent99-control-plane.ps1

5656 lines
262 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[ValidateSet("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck", "ProviderFreshness", "SecurityTriage", "ControlTick")]
[string]$Mode = "Status",
[switch]$ControlledApply,
[switch]$SelfTestTelegramCard,
[switch]$SelfTestTelegramDelivery,
[switch]$SelfTestSensorGate,
[switch]$SelfTestOutcomeContract,
[switch]$SuppressAlerts,
[string]$SuppressReason = "",
[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" }
$SshIdentityFile = if ($Config.PSObject.Properties["sshIdentityFile"] -and $Config.sshIdentityFile) {
[Environment]::ExpandEnvironmentVariables([string]$Config.sshIdentityFile)
} else {
""
}
$script:Events = @()
$script:TelegramAttempts = @()
$script:SuppressAgentAlerts = [bool]$SuppressAlerts
$script:SuppressAgentAlertReason = if ($SuppressReason) { $SuppressReason } elseif ($SuppressAlerts) { "suppress_alerts" } else { "" }
function Convert-AgentBool {
param([object]$Value)
if ($null -eq $Value) { return $false }
if ($Value -is [bool]) { return [bool]$Value }
$text = ([string]$Value).Trim()
if (-not $text) { return $false }
return [bool]($text -match "^(?i:true|1|yes|y|on)$")
}
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 Get-AgentSshIdentityArguments {
if (-not $SshIdentityFile) { return @() }
@("-i", $SshIdentityFile, "-o", "IdentitiesOnly=yes")
}
function Get-AgentSshTransportConfig {
$configured = if ($Config.PSObject.Properties["sshTransport"]) { $Config.sshTransport } else { $null }
[pscustomobject]@{
serialized = [bool](-not $configured -or -not $configured.PSObject.Properties["serialized"] -or (Convert-AgentBool $configured.serialized))
lockTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["lockTimeoutSeconds"]) { [math]::Max(10, [int]$configured.lockTimeoutSeconds) } else { 90 }
lockPollMilliseconds = if ($configured -and $configured.PSObject.Properties["lockPollMilliseconds"]) { [math]::Min(2000, [math]::Max(50, [int]$configured.lockPollMilliseconds)) } else { 200 }
lockPath = Join-Path (Join-Path $Config.agentRoot "state") "ssh-transport.lock"
}
}
function Enter-AgentSshTransportLock {
$transport = Get-AgentSshTransportConfig
if (-not $transport.serialized) {
return [pscustomobject]@{ acquired = $true; disabled = $true; reason = "serialization_disabled"; waitMs = 0; stream = $null }
}
$lockDir = Split-Path -Parent $transport.lockPath
New-Item -ItemType Directory -Force -Path $lockDir | Out-Null
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
while ($stopwatch.Elapsed.TotalSeconds -lt $transport.lockTimeoutSeconds) {
try {
$stream = [IO.File]::Open(
$transport.lockPath,
[IO.FileMode]::OpenOrCreate,
[IO.FileAccess]::ReadWrite,
[IO.FileShare]::None
)
$stopwatch.Stop()
return [pscustomobject]@{
acquired = $true
disabled = $false
reason = "acquired"
waitMs = $stopwatch.ElapsedMilliseconds
stream = $stream
}
} catch [System.IO.IOException] {
Start-Sleep -Milliseconds $transport.lockPollMilliseconds
} catch [System.UnauthorizedAccessException] {
$stopwatch.Stop()
return [pscustomobject]@{
acquired = $false
disabled = $false
reason = "access_denied"
waitMs = $stopwatch.ElapsedMilliseconds
stream = $null
}
}
}
$stopwatch.Stop()
[pscustomobject]@{
acquired = $false
disabled = $false
reason = "timeout"
waitMs = $stopwatch.ElapsedMilliseconds
stream = $null
}
}
function Exit-AgentSshTransportLock {
param([object]$TransportLock)
if ($TransportLock -and $TransportLock.stream) {
try { $TransportLock.stream.Dispose() } catch {}
}
}
function Add-AgentSshTransportMetadata {
param([object]$Result, [object]$TransportLock)
if ($Result) {
$Result | Add-Member -NotePropertyName transportSerialized -NotePropertyValue ([bool](-not $TransportLock.disabled)) -Force
$Result | Add-Member -NotePropertyName transportLockWaitMs -NotePropertyValue ([long]$TransportLock.waitMs) -Force
}
$Result
}
function Get-AgentSshProcessGuardConfig {
$configured = if ($Config.PSObject.Properties["sshProcessGuard"]) { $Config.sshProcessGuard } else { $null }
[pscustomobject]@{
enabled = [bool](-not $configured -or -not $configured.PSObject.Properties["enabled"] -or (Convert-AgentBool $configured.enabled))
staleMinutes = if ($configured -and $configured.PSObject.Properties["staleMinutes"]) { [math]::Max(5, [int]$configured.staleMinutes) } else { 15 }
hardStaleMinutes = if ($configured -and $configured.PSObject.Properties["hardStaleMinutes"]) { [math]::Max(15, [int]$configured.hardStaleMinutes) } else { 60 }
}
}
function Invoke-AgentStaleSshProcessGuard {
$guard = Get-AgentSshProcessGuardConfig
if (-not $guard.enabled -or -not $ControlledApply) {
return [pscustomobject]@{ ok = $true; skipped = $true; reason = "guard_disabled_or_observe_mode"; stoppedCount = 0; remainingCount = $null; processes = @() }
}
$targets = @($Config.hosts)
if ($Config.k3s -and $Config.k3s.PSObject.Properties["jumpHost"] -and $Config.k3s.jumpHost) {
$targets += [string]$Config.k3s.jumpHost
}
$targets = @($targets | Select-Object -Unique)
$rows = @()
foreach ($processInfo in @(Get-CimInstance Win32_Process -Filter "Name='ssh.exe'" -ErrorAction SilentlyContinue)) {
try {
$process = Get-Process -Id $processInfo.ProcessId -ErrorAction Stop
$ageMinutes = [math]::Round(((Get-Date) - $process.StartTime).TotalMinutes, 1)
$target = $targets | Where-Object { $processInfo.CommandLine -match [regex]::Escape($_) } | Select-Object -First 1
$batchMode = [bool]($processInfo.CommandLine -match "BatchMode=yes")
$portForward = [bool]($processInfo.CommandLine -cmatch "(^|\s)-[NLRD](\s|$)")
$parentAlive = [bool](Get-Process -Id $processInfo.ParentProcessId -ErrorAction SilentlyContinue)
$eligible = [bool](
$ageMinutes -ge $guard.staleMinutes -and
$target -and
$batchMode -and
-not $portForward -and
(-not $parentAlive -or $ageMinutes -ge $guard.hardStaleMinutes)
)
$stopped = $false
if ($eligible) {
Stop-Process -Id $processInfo.ProcessId -Force -ErrorAction Stop
$stopped = $true
}
$rows += [pscustomobject]@{
pid = $processInfo.ProcessId
ageMinutes = $ageMinutes
target = $target
batchMode = $batchMode
portForward = $portForward
parentAlive = $parentAlive
eligible = $eligible
stopped = $stopped
}
} catch {
$rows += [pscustomobject]@{ pid = $processInfo.ProcessId; eligible = $false; stopped = $false; error = "process_disappeared_or_stop_failed" }
}
}
$stoppedCount = @($rows | Where-Object { $_.stopped }).Count
$failures = @($rows | Where-Object { $_.eligible -and -not $_.stopped }).Count
$remainingCount = @(Get-Process ssh -ErrorAction SilentlyContinue).Count
$result = [pscustomobject]@{
ok = [bool]($failures -eq 0)
skipped = $false
stoppedCount = $stoppedCount
failureCount = $failures
remainingCount = $remainingCount
staleMinutes = $guard.staleMinutes
hardStaleMinutes = $guard.hardStaleMinutes
processes = $rows
}
if ($stoppedCount -gt 0 -or $failures -gt 0) {
Record-AgentEvent "ssh_stale_process_guard" $(if ($failures -gt 0) { "warning" } else { "info" }) "stopped=$stoppedCount failures=$failures remaining=$remainingCount" $result -NoAlert
}
$result
}
function Send-AgentTelegramRelay {
param(
[string]$Message,
[object]$Relay,
[string]$ChatIdOverride = "",
[string]$PhotoPath = "",
[string]$ReplyToMessageId = ""
)
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 { "" }
$photoSent = $false
$photoError = $null
$containerPhotoPath = ""
if ($PhotoPath -and (Test-Path -LiteralPath $PhotoPath)) {
$safePhotoName = "agent99-card-" + ([guid]::NewGuid().ToString("N")) + ".png"
$remotePhotoPath = "/tmp/" + $safePhotoName
$containerPhotoPath = "/tmp/" + $safePhotoName
$relayUser = Get-HostSshUser $relayHost
$scpArgs = @(Get-AgentSshIdentityArguments) + @(
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "NumberOfPasswordPrompts=0",
"-o", "ConnectTimeout=10",
$PhotoPath,
"${relayUser}@${relayHost}:$remotePhotoPath"
)
try {
$scp = Start-Process -FilePath "scp.exe" -ArgumentList $scpArgs -NoNewWindow -Wait -PassThru
$scp.Refresh()
if ($null -ne $scp.ExitCode -and $scp.ExitCode -eq 0) {
$copyRun = Invoke-SshText $relayHost "docker cp $remotePhotoPath $container`:$containerPhotoPath && rm -f $remotePhotoPath" 45 1
if (-not $copyRun.ok) {
$photoError = "relay_docker_cp_failed: $($copyRun.output)"
$containerPhotoPath = ""
}
} else {
$photoError = "relay_scp_failed exitCode=$($scp.ExitCode)"
$containerPhotoPath = ""
}
} catch {
$photoError = "relay_scp_exception: $($_.Exception.Message)"
$containerPhotoPath = ""
}
}
$containerPhotoB64 = if ($containerPhotoPath) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($containerPhotoPath)) } else { "" }
$python = 'import base64, json, mimetypes, os, sys, urllib.error, 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] and sys.argv[3] != "-" else ""
photo_path = base64.b64decode(sys.argv[4]).decode() if len(sys.argv) > 4 and sys.argv[4] and sys.argv[4] != "-" else ""
reply_to = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5].isdigit() else None
token = os.environ.get("TELEGRAM_BOT_TOKEN")
chat = chat_override or os.environ.get("TELEGRAM_CHAT_ID")
assert token and chat
reply_parameters = json.dumps({"message_id": reply_to}) if reply_to else None
def emit_receipt(payload):
result = payload.get("result") or {}
safe = {"message_id": result.get("message_id"), "chat_type": (result.get("chat") or {}).get("type")}
print("telegram_receipt_b64=" + base64.b64encode(json.dumps(safe).encode()).decode())
if photo_path and os.path.exists(photo_path):
caption = text
if len(caption) > 900:
caption = caption[:900] + "\n...(truncated; see evidence)"
filename = os.path.basename(photo_path)
ctype = mimetypes.guess_type(filename)[0] or "image/png"
try:
import requests
request_data = {"chat_id": chat, "caption": caption}
if reply_parameters:
request_data["reply_parameters"] = reply_parameters
with open(photo_path, "rb") as fh:
response = requests.post(
"https://api.telegram.org/bot" + token + "/sendPhoto",
data=request_data,
files={"photo": (filename, fh, ctype)},
timeout=30,
)
if not response.ok:
print("photo_send_failed status=%s body=%s" % (response.status_code, response.text[:500]))
sys.exit(2)
emit_receipt(response.json())
print("photo_sent_ok")
except ImportError:
boundary = "----agent99boundary"
fields = [("chat_id", chat), ("caption", caption)]
if reply_parameters:
fields.append(("reply_parameters", reply_parameters))
body = bytearray()
for name, value in fields:
body.extend(("--" + boundary + "\r\n").encode())
body.extend(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").encode())
body.extend(str(value).encode())
body.extend(b"\r\n")
body.extend(("--" + boundary + "\r\n").encode())
body.extend(("Content-Disposition: form-data; name=\"photo\"; filename=\"" + filename + "\"\r\n").encode())
body.extend(("Content-Type: " + ctype + "\r\n\r\n").encode())
with open(photo_path, "rb") as fh:
body.extend(fh.read())
body.extend(b"\r\n")
body.extend(("--" + boundary + "--\r\n").encode())
req = urllib.request.Request("https://api.telegram.org/bot" + token + "/sendPhoto", data=bytes(body))
req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary)
try:
response_payload = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
emit_receipt(response_payload)
print("photo_sent_ok")
except urllib.error.HTTPError as exc:
print("photo_send_failed status=%s body=%s" % (exc.code, exc.read().decode(errors="replace")[:500]))
sys.exit(2)
else:
fields = {"chat_id": chat, "text": text, "disable_web_page_preview": "true"}
if reply_parameters:
fields["reply_parameters"] = reply_parameters
data = urllib.parse.urlencode(fields).encode()
response_payload = json.loads(urllib.request.urlopen("https://api.telegram.org/bot" + token + "/sendMessage", data=data, timeout=15).read().decode())
emit_receipt(response_payload)
print("sent_ok")
'
$pythonB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($python))
$bootstrap = 'import base64,sys;exec(base64.b64decode(sys.argv[1]))'
$chatOverrideArg = if ($chatOverrideB64) { $chatOverrideB64 } else { "-" }
$containerPhotoArg = if ($containerPhotoB64) { $containerPhotoB64 } else { "-" }
$replyArg = if ($ReplyToMessageId -match "^\d+$") { $ReplyToMessageId } else { "-" }
$remoteCommand = "docker exec $container python3 -c '$bootstrap' $pythonB64 $messageB64 $chatOverrideArg $containerPhotoArg $replyArg"
$run = Invoke-SshText $relayHost $remoteCommand 45 1
$photoSent = [bool]($run.ok -and $run.output -match "photo_sent_ok")
$messageId = $null
$receiptMatch = [regex]::Match([string]$run.output, "telegram_receipt_b64=([A-Za-z0-9+/=]+)")
if ($receiptMatch.Success) {
try {
$receiptJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($receiptMatch.Groups[1].Value)) | ConvertFrom-Json
$messageId = $receiptJson.message_id
} catch {
$messageId = $null
}
}
if ($containerPhotoPath) {
Invoke-SshText $relayHost "docker exec $container rm -f $containerPhotoPath" 20 1 | Out-Null
}
[pscustomobject]@{
ok = [bool]($run.ok -and ($run.output -match "sent_ok|photo_sent_ok"))
host = $relayHost
container = $container
exitCode = $run.exitCode
chatOverride = if ($ChatIdOverride) { "telegram-origin" } else { $null }
replyToMessageId = if ($ReplyToMessageId) { $ReplyToMessageId } else { $null }
messageId = $messageId
photoSent = $photoSent
photoError = $photoError
output = $run.output
error = if ($run.ok) { $photoError } else { $run.output }
}
}
function Get-AgentObjectValue {
param(
[object]$Data,
[string]$Name,
[object]$Default = $null
)
if ($Data -and $Data.PSObject.Properties[$Name]) {
$value = $Data.PSObject.Properties[$Name].Value
if ($null -ne $value -and [string]$value -ne "") {
return $value
}
}
$Default
}
function Limit-AgentTextLine {
param(
[string]$Text,
[int]$MaxLength = 96
)
if (-not $Text) { return "" }
$clean = (($Text -replace "\s+", " ").Trim())
if ($clean.Length -le $MaxLength) { return $clean }
$clean.Substring(0, $MaxLength - 14) + "...(truncated)"
}
function Format-AgentMetricForCard {
param(
[object]$Value,
[string]$Suffix = ""
)
if ($null -eq $Value -or [string]$Value -eq "") {
return "readback_failed"
}
"$Value$Suffix"
}
function Format-AgentTelegramLegacyText {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$severityLabel = switch ($Severity) {
"critical" { "CRITICAL" }
"warning" { "WARNING" }
"info" { "INFO" }
default { $Severity }
}
$lines = @()
$lines += "Agent99 事件卡 / Incident Card"
$lines += "嚴重度 Severity: $severityLabel"
if ($EventType -eq "recovery_slo_result") {
$runId = [string](Get-AgentObjectValue $Data "runId" "unknown")
$scope = [string](Get-AgentObjectValue $Data "scope" "service_recovery")
$elapsedSeconds = [double](Get-AgentObjectValue $Data "elapsedSeconds" 0)
$targetMinutes = [int](Get-AgentObjectValue $Data "targetMinutes" 10)
$completed = Convert-AgentBool (Get-AgentObjectValue $Data "completed" $false)
$withinSlo = Convert-AgentBool (Get-AgentObjectValue $Data "withinSlo" $false)
$coordinatorVerified = Convert-AgentBool (Get-AgentObjectValue $Data "coordinatorVerified" $false)
$rebootSloClaimed = Convert-AgentBool (Get-AgentObjectValue $Data "rebootSloClaimed" $false)
$phases = if ($Data -and $Data.PSObject.Properties["phases"]) { @($Data.phases) } else { @() }
$failedPhases = @($phases | Where-Object { [string]$_.status -ne "ok" })
$statusLabel = if ($completed -and $withinSlo) {
"已恢復且符合目標 / recovered within target"
} elseif ($completed) {
"已恢復但超過目標 / recovered late"
} else {
"恢復未完成 / recovery incomplete"
}
$scopeLabel = if ($scope -eq "full_host_reboot") { "全主機重啟 / full-host reboot" } else { "服務恢復 / service recovery" }
$phaseSummary = @($phases | ForEach-Object {
"$([string]$_.name)=$([string]$_.status)@$([math]::Round([double]$_.elapsedSeconds, 1))s"
}) -join ", "
$lines += "事件 Event: 主機與服務恢復 / recovery lifecycle"
$lines += "結果 Result: $statusLabel"
$lines += "範圍 Scope: $scopeLabel"
$lines += "耗時 Duration: $elapsedSeconds 秒;目標 Target: $targetMinutes 分鐘"
$lines += "驗證 Verifier: coordinator=$coordinatorVerified, rebootSloClaimed=$rebootSloClaimed"
$lines += "階段 Phases: $(Limit-AgentTextLine $phaseSummary 500)"
$lines += "Run ID: $runId"
if ($failedPhases.Count -gt 0) {
$lines += "Agent99 動作 Action: 保留維護狀態並持續處理失敗階段:$(@($failedPhases | ForEach-Object { $_.name }) -join ', ')"
} elseif ($scope -eq "service_recovery" -and -not $rebootSloClaimed) {
$lines += "Agent99 動作 Action: 服務 verifier 已通過;本輪不是 fresh reboot drill不宣稱全主機重啟 SLA。"
} else {
$lines += "Agent99 動作 Action: verifier 已通過,完成事件閉環並保留 recurrence/KM receipt。"
}
} elseif ($EventType -match "^performance_") {
$hostName = Get-AgentObjectValue $Data "host" "unknown"
$reasons = if ($Data -and $Data.PSObject.Properties["reasons"]) { @($Data.reasons) } else { @() }
$disk = Get-AgentObjectValue $Data "diskUsedPercent" $null
$load = Get-AgentObjectValue $Data "loadPerCore" $null
$mem = Get-AgentObjectValue $Data "memAvailablePercent" $null
$lines += "事件 Event: 主機效能 / host performance"
$lines += "主機 Host: $hostName"
if ($reasons -contains "perf_readback_failed" -or ($null -eq $disk -and $null -eq $load -and $null -eq $mem)) {
$lines += "原因 Reason: 效能指標 readback 失敗。"
$lines += "影響 Impact: CPU load、memory、disk 數值尚未可信,不能直接當成真故障。"
$lines += "Agent99 動作 Action: 維持噪音 sensor 暫停;修復前必須先補 verifier readback。"
} elseif ($reasons -contains "disk_used_warning") {
$lines += "原因 Reason: 磁碟使用率 $disk percent已達 warning thresholdcritical threshold 是 95 percent。"
$lines += "Agent99 動作 Action: 執行 allowlisted disk remediation並在 30 分鐘內 suppress duplicates。"
} elseif ($reasons -contains "disk_used_high") {
$lines += "原因 Reason: 磁碟使用率 $disk percent已達 critical threshold。"
$lines += "Agent99 動作 Action: 先執行 allowlisted cleanupreboot 不是第一反應。"
} elseif ($reasons -contains "load_per_core_warning" -or $reasons -contains "load_per_core_critical") {
$lines += "原因 Reason: CPU load/core=$load"
$lines += "Agent99 動作 Action: 僅使用 allowlisted load reduction不得任意停止服務。"
} elseif ($reasons -contains "memory_available_low") {
$lines += "原因 Reason: 可用記憶體為 $mem percent。"
$lines += "Agent99 動作 Action: 先檢查 memory pressure再進行 allowlisted remediation。"
} else {
$lines += "原因 Reason: $($reasons -join ', ')"
$lines += "Agent99 動作 Action: 檢查 Agent99 evidence完成 full readback 後再處理。"
}
$metricParts = @()
if ($null -ne $load -and [string]$load -ne "") { $metricParts += "load/core=$load" }
if ($null -ne $mem -and [string]$mem -ne "") { $metricParts += "memAvail=$mem percent" }
if ($null -ne $disk -and [string]$disk -ne "") { $metricParts += "disk=$disk percent" }
if ($metricParts.Count -gt 0) {
$lines += "目前狀態 Current: $($metricParts -join ', ')."
} else {
$lines += "目前狀態 Current: metrics unavailable空白指標不視為可信告警。"
}
} elseif ($EventType -match "^backup_") {
$targetName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$reason = if ($Data -and $Data.PSObject.Properties["reason"]) { $Data.reason } else { $EventType }
$age = if ($Data -and $Data.PSObject.Properties["ageMinutes"]) { $Data.ageMinutes } else { $null }
$maxAge = if ($Data -and $Data.PSObject.Properties["maxAgeMinutes"]) { $Data.maxAgeMinutes } else { $null }
$lines += "事件 Event: 備份健康 / backup health"
$lines += "目標 Target: $targetName"
$lines += "原因 Reason: $reason"
if ($age -ne $null -or $maxAge -ne $null) {
$lines += "新鮮度 Freshness: age=$age minutes / max=$maxAge minutes"
}
$lines += "Agent99 動作 Action: 只檢查 /backup/status、/backup/logs、snapshot metadata不得讀取備份內容或 secrets。"
} elseif ($EventType -eq "provider_freshness_triage") {
$candidateId = if ($Data -and $Data.PSObject.Properties["candidateId"]) { $Data.candidateId } else { "unknown" }
$targetName = if ($Data -and $Data.PSObject.Properties["target"]) { $Data.target } else { "provider_freshness_signal" }
$hitCount = if ($Data -and $Data.PSObject.Properties["hitCount"]) { $Data.hitCount } else { 0 }
$missing = if ($Data -and $Data.PSObject.Properties["missingFields"]) { @($Data.missingFields) } else { @() }
$candidatePath = if ($Data -and $Data.PSObject.Properties["candidatePath"]) { $Data.candidatePath } else { $null }
$readback = if ($Data -and $Data.PSObject.Properties["verifierReadback"]) { $Data.verifierReadback } else { $null }
$status = if ($Data -and $Data.PSObject.Properties["status"]) { $Data.status } else { "unknown" }
$lines += "事件 Event: Provider freshness 自動判讀 / provider freshness auto triage"
$lines += "目標 Target: $targetName"
$lines += "狀態 Status: $status"
$lines += "影響 Impact: source freshness 未證明前dashboard green 不可視為健康。"
$lines += "證據命中 Evidence hits: $hitCount"
if ($readback) {
$lines += "驗證 Verifier: directReadback ok=$($readback.ok), sourceEvents=$($readback.sourceEventTotal), latest=$($readback.latestReceivedAt)"
}
if (@($missing).Count -gt 0) {
$lines += "仍缺欄位 Still missing: $($missing -join ', ')"
} else {
$lines += "缺漏狀態 Missing: direct readback fields completed"
}
$lines += "Agent99 動作 Action: 已寫入 candidate 與 KM/RAG相同狀態 duplicate alerts 會被 suppress。"
$lines += "限制 Guard: 不切 provider、不呼叫 paid model、不改 env、不 reload。"
if ($candidatePath) {
$lines += "證據 Evidence: $candidatePath"
}
} elseif ($EventType -match "^load_shed_") {
$actionName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$hostName = if ($Data -and $Data.PSObject.Properties["host"]) { $Data.host } else { "unknown" }
$ok = if ($Data -and $Data.PSObject.Properties["ok"]) { $Data.ok } else { $null }
$executionOk = if ($Data -and $Data.PSObject.Properties["executionOk"]) { $Data.executionOk } else { $null }
$postVerifyOk = if ($Data -and $Data.PSObject.Properties["postVerifyOk"]) { $Data.postVerifyOk } else { $null }
$exitCode = if ($Data -and $Data.PSObject.Properties["exitCode"]) { $Data.exitCode } else { $null }
$rollback = if ($Data -and $Data.PSObject.Properties["rollback"]) { $Data.rollback } else { $null }
$lines += "事件 Event: 受控修復 / controlled remediation"
$lines += "主機 Host: $hostName"
$lines += "Agent99 動作 Action: $actionName"
if ($ok -ne $null) {
$lines += "結果 Result: ok=$ok exitCode=$exitCode"
if ($executionOk -ne $null -or $postVerifyOk -ne $null) {
$lines += "驗證 Verifier: executionOk=$executionOk postVerifyOk=$postVerifyOk"
}
} else {
$lines += "結果 Result: 請查看 Agent99 evidence。"
}
if ($rollback) {
$lines += "回滾 Rollback: $rollback"
}
} elseif ($EventType -match "^operator_command_") {
$commandId = if ($Data -and $Data.PSObject.Properties["id"]) { $Data.id } else { "unknown" }
$modeName = if ($Data -and $Data.PSObject.Properties["mode"]) { $Data.mode } else { "unknown" }
$source = if ($Data -and $Data.PSObject.Properties["source"]) { $Data.source } else { "unknown" }
$instruction = if ($Data -and $Data.PSObject.Properties["instruction"]) { $Data.instruction } else { $null }
$exitCode = if ($Data -and $Data.PSObject.Properties["exitCode"]) { $Data.exitCode } else { $null }
$evidencePath = if ($Data -and $Data.PSObject.Properties["evidence"]) { $Data.evidence } else { $null }
$outcome = if ($Data -and $Data.PSObject.Properties["outcome"]) { $Data.outcome } else { $null }
$outcomeState = if ($outcome -and $outcome.PSObject.Properties["state"]) { [string]$outcome.state } else { "failed" }
$resultLabel = switch ($outcomeState) {
"resolved" { "已驗證解決 / verified resolved" }
"candidate_ready" { "修復候選已建立 / candidate ready" }
"verifying" { "等待來源事件驗證 / verifying" }
"degraded" { "仍有異常 / degraded" }
"blocked" { "受阻擋 / blocked" }
default { "執行失敗 / failed" }
}
$actionText = switch ($modeName) {
"ProviderFreshness" { "建立 provider freshness candidate要求 provider window、last seen、direct reference、verifier readback不切 provider。" }
"BackupCheck" { "讀回備份 freshness、status/log、snapshot metadata、cron contract不讀備份內容或 secrets。" }
"Perf" { "讀回主機 CPU/記憶體/磁碟;必要時只做 allowlisted 降載。" }
"LoadShed" { "執行 allowlisted 降載並做 post-apply verifier。" }
"Recover" { "依序檢查 VM、SSH、Harbor/K3s/public route必要時執行受控恢復。" }
"StartVMs" { "檢查並啟動 allowlisted VMware VM。" }
"HarborRepair" { "檢查 Harbor core/redis/registry必要時執行受控修復。" }
"AwoooRepair" { "檢查 AWOOOI/K3s pod、deployment、image pull 狀態,必要時受控修復。" }
"SecurityTriage" { "建立資安 triage candidate分類 blast radius、affected host/service、source log reference不任意 shell、不暴露 secrets。" }
default { "執行 allowlisted Agent99 mode 並寫入 evidence/KM。" }
}
$lines += "事件 Event: Agent99 自動化結果 / automation result"
$lines += "模式 Mode: $modeName"
$lines += "來源 Source: $source"
$lines += "結果 Result: $resultLabel, transportExitCode=$exitCode"
if ($outcome) {
$lines += "驗證 Verifier: name=$($outcome.verifierName), passed=$($outcome.verifierPassed), sourceEventResolved=$($outcome.sourceEventResolved)"
}
$lines += "Agent99 動作 Action: $actionText"
if ($Data -and $Data.PSObject.Properties["problem"]) {
$problem = $Data.problem
if ($problem -and $problem.PSObject.Properties["occurrences"]) {
$lines += "問題計數 Problem count: occurrences=$($problem.occurrences), verifiedResolved=$($problem.verifiedResolved), failed=$($problem.failed), degraded=$($problem.degraded), blocked=$($problem.blocked), verifying=$($problem.verifying), candidateReady=$($problem.candidateReady)"
if ($problem.PSObject.Properties["kmPath"]) {
$lines += "KM: $($problem.kmPath)"
}
}
}
if ($instruction -and $exitCode -ne 0) {
$lines += "失敗指令 Failure instruction: $instruction"
} elseif ($instruction) {
$lines += "處理摘要 Summary: $(Limit-AgentTextLine $instruction 130)"
}
if ($evidencePath) {
$lines += "證據 Evidence: $evidencePath"
}
if ($exitCode -eq 0) {
$lines += "下一步 Next: 若相同問題再發生,依 problem count 追蹤重複率並回寫 PlayBook/KM。"
} else {
$lines += "下一步 Next: 保留 failed queue/evidence升級到對應 playbook不做破壞性動作。"
}
} 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 -eq "telegram_delivery_test") {
$testId = if ($Data -and $Data.PSObject.Properties["testId"]) { $Data.testId } else { "unknown" }
$lines += "事件 Event: Telegram 低噪音送達測試 / delivery test"
$lines += "測試 Test: TEST_DO_NOT_PAGE"
$lines += "測試 ID Test ID: $testId"
$lines += "影響 Impact: 無使用者影響;此訊息只驗證 Agent99 繁中事件卡與 TG delivery。"
$lines += "Agent99 動作 Action: 驗證送達後寫入 evidence不觸發修復、不恢復 sensor、不建立事故。"
} elseif ($EventType -match "^ai_service_") {
$serviceName = if ($Data -and $Data.PSObject.Properties["name"]) { $Data.name } else { "unknown" }
$lines += "事件 Event: AI 服務健康 / AI service health"
$lines += "服務 Service: $serviceName"
$lines += "Agent99 動作 Action: 先檢查 health endpoint 與 service/container state再做 controlled repair。"
} elseif ($EventType -eq "security_triage") {
$status = if ($Data -and $Data.PSObject.Properties["status"]) { $Data.status } else { "unknown" }
$hitCount = if ($Data -and $Data.PSObject.Properties["hitCount"]) { $Data.hitCount } else { 0 }
$evidencePath = if ($Data -and $Data.PSObject.Properties["evidencePath"]) { $Data.evidencePath } else { $null }
$lines += "事件 Event: 資安判讀 / security triage"
$lines += "狀態 Status: $status"
$lines += "證據命中 Evidence hits: $hitCount"
$lines += "Agent99 動作 Action: 分類 blast radius、affected host/service、source log reference、repeated count、allowlisted next action。"
$lines += "限制 Guard: 不執行 arbitrary shell、不刪資料、不停用 security controls、不暴露 secrets。"
if ($evidencePath) {
$lines += "證據 Evidence: $evidencePath"
}
} elseif ($EventType -eq "agent_selfcheck") {
$lines += "事件 Event: Agent99 自我健康 / self health"
$lines += "摘要 Summary: $Message"
$lines += "Agent99 動作 Action: 檢查 task state、evidence freshness、Telegram deliveryreboot 不是第一反應。"
} else {
$lines += "事件 Event: $EventType"
$lines += "摘要 Summary: $Message"
}
$lines += "時間 Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
$text = ($lines -join "`n")
if ($text.Length -gt 3500) {
$text = $text.Substring(0, 3500) + "`n...(truncated)"
}
$text
}
function Get-AgentSha256Hex {
param([string]$Text)
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$Text))
[BitConverter]::ToString($bytes).Replace("-", "").ToLowerInvariant()
} finally {
$sha.Dispose()
}
}
function Get-AgentIncidentCardModel {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$hostName = [string](Get-AgentObjectValue $Data "host" (Get-AgentObjectValue $Data "alertHost" ""))
$modeName = [string](Get-AgentObjectValue $Data "mode" "")
$targetName = [string](Get-AgentObjectValue $Data "target" (Get-AgentObjectValue $Data "alertService" ""))
$alertId = [string](Get-AgentObjectValue $Data "alertId" "")
$correlationKey = [string](Get-AgentObjectValue $Data "correlationKey" "")
$problem = if ($Data -and $Data.PSObject.Properties["problem"]) { $Data.problem } else { $null }
$outcome = if ($Data -and $Data.PSObject.Properties["outcome"]) { $Data.outcome } else { $null }
$problemKey = [string](Get-AgentObjectValue $problem "key" "")
$outcomeState = [string](Get-AgentObjectValue $outcome "state" (Get-AgentObjectValue $Data "outcomeState" ""))
$verifierPassed = Convert-AgentBool (Get-AgentObjectValue $outcome "verifierPassed" (Get-AgentObjectValue $Data "verifierPassed" $false))
$sourceResolved = Convert-AgentBool (Get-AgentObjectValue $outcome "sourceEventResolved" (Get-AgentObjectValue $Data "sourceEventResolved" $false))
$executionOk = Convert-AgentBool (Get-AgentObjectValue $Data "executionOk" (Get-AgentObjectValue $Data "ok" $false))
$postVerifyOk = Convert-AgentBool (Get-AgentObjectValue $Data "postVerifyOk" $false)
$family = switch -Regex ($EventType) {
"^performance_|^load_shed_" { "host_performance"; break }
"^backup_" { "backup"; break }
"provider_freshness" { "provider_freshness"; break }
"^recovery_|^host_reboot_|^host112_guest_" { "recovery"; break }
"^operator_command_" { "automation_result"; break }
"^security_" { "security"; break }
"^ai_service_" { "ai_service"; break }
"^agent_selfcheck" { "agent_health"; break }
default { "platform" }
}
$identityTarget = if ($problemKey) {
$problemKey
} elseif ($correlationKey) {
$correlationKey
} elseif ($alertId) {
$alertId
} elseif ($hostName) {
"$family`:$hostName"
} elseif ($targetName) {
"$family`:$targetName"
} else {
"$family`:$EventType"
}
$fingerprint = Get-AgentSha256Hex $identityTarget
$incidentId = "INC-" + $fingerprint.Substring(0, 8).ToUpperInvariant()
$completed = Convert-AgentBool (Get-AgentObjectValue $Data "completed" $false)
$lifecycle = "detected"
if ($EventType -eq "recovery_slo_result") {
$lifecycle = if ($completed) { "recovered" } else { "remediating" }
} elseif ($EventType -match "recovered|resolved") {
$lifecycle = "recovered"
} elseif ($EventType -eq "load_shed_result") {
$lifecycle = if ($executionOk -and $postVerifyOk) { "recovered" } else { "blocked" }
} elseif ($outcomeState) {
$lifecycle = switch ($outcomeState) {
"resolved" { "recovered" }
"candidate_ready" { "investigating" }
"verifying" { "verifying" }
"degraded" { "remediating" }
"blocked" { "blocked" }
"failed" { "blocked" }
default { "investigating" }
}
} elseif ($EventType -match "triage|selfcheck") {
$lifecycle = "investigating"
} elseif ($EventType -match "result|attempt") {
$lifecycle = if ($executionOk -and ($postVerifyOk -or $verifierPassed)) { "recovered" } else { "verifying" }
}
$lifecycleLabel = switch ($lifecycle) {
"detected" { "已偵測 / DETECTED" }
"investigating" { "調查中 / INVESTIGATING" }
"remediating" { "修復中 / REMEDIATING" }
"verifying" { "驗證中 / VERIFYING" }
"recovered" { "已恢復 / RECOVERED" }
"blocked" { "需介入 / ACTION REQUIRED" }
default { "狀態未知 / UNKNOWN" }
}
$severityLabel = switch ($Severity) {
"critical" { "重大 / CRITICAL" }
"warning" { "警告 / WARNING" }
"info" { "資訊 / INFO" }
default { ([string]$Severity).ToUpperInvariant() }
}
$title = "Agent99 平台事件"
$target = if ($targetName) { $targetName } elseif ($hostName) { $hostName } else { "AWOOOI platform" }
$impact = "Agent99 已接手判讀;目前影響仍在確認。"
$action = "收集受控 evidence匹配 allowlisted PlayBook再執行獨立 verifier。"
$verification = "尚未形成可關閉事件的 verifier 結果。"
$visualKind = "lifecycle"
$metrics = @()
$gates = @()
$phases = @()
if ($EventType -eq "recovery_slo_result") {
$scope = [string](Get-AgentObjectValue $Data "scope" "service_recovery")
$target = if ($scope -eq "full_host_reboot") { "全主機重啟" } else { "主機與服務恢復" }
$title = if ($completed) { "主機與服務已通過恢復驗證" } else { "主機與服務恢復尚未完成" }
$impact = if ($completed) { "目前服務 verifier 已通過;使用者流量已恢復。" } else { "仍有恢復階段未通過,維護狀態不可撤除。" }
$coordinatorVerified = Convert-AgentBool (Get-AgentObjectValue $Data "coordinatorVerified" $false)
$rebootSloClaimed = Convert-AgentBool (Get-AgentObjectValue $Data "rebootSloClaimed" $false)
$verification = if ($coordinatorVerified) {
if ($rebootSloClaimed) { "協調器與 fresh reboot SLO verifier 均已通過。" } else { "服務協調器已通過;本輪不是 fresh reboot drill不宣稱全主機 SLA。" }
} else {
"協調器尚未通過,事件保持開啟。"
}
$action = if ($completed) { "完成獨立驗證並寫回 incident/KM receipt。" } else { "持續處理失敗 phase維持 maintenance fallback。" }
$phases = if ($Data -and $Data.PSObject.Properties["phases"]) { @($Data.phases) } else { @() }
$visualKind = "recovery"
} elseif ($EventType -match "^performance_") {
$reasons = if ($Data -and $Data.PSObject.Properties["reasons"]) { @($Data.reasons) } else { @() }
$load = Get-AgentObjectValue $Data "loadPerCore" $null
$memory = Get-AgentObjectValue $Data "memAvailablePercent" $null
$disk = Get-AgentObjectValue $Data "diskUsedPercent" $null
$title = if ($reasons -contains "perf_readback_failed") { "$hostName 效能讀回失敗" } else { "$hostName 主機效能異常" }
$target = if ($hostName) { $hostName } else { "managed host" }
if ($reasons -contains "perf_readback_failed" -or ($null -eq $load -and $null -eq $memory -and $null -eq $disk)) {
$impact = "CPU、記憶體與磁碟數值目前不可信不能把空白讀回當成主機故障。"
$action = "先修復監控 transport/readback再決定是否需要降載。"
$verification = "等待完整三項主機指標重新讀回。"
} else {
$impact = "資源壓力可能降低服務回應速度或可用性。"
$action = "只執行 allowlisted 降載或清理,禁止任意停服務或重啟主機。"
$verification = "修復後必須重新讀回 CPU、記憶體與磁碟才可關閉。"
}
$metrics = @(
[pscustomobject]@{ name = "CPU load/core"; value = $load; scale = 2.0; suffix = "" },
[pscustomobject]@{ name = "可用記憶體"; value = $memory; scale = 100.0; suffix = "%" },
[pscustomobject]@{ name = "磁碟使用"; value = $disk; scale = 100.0; suffix = "%" }
)
$visualKind = "performance"
} elseif ($EventType -match "^backup_") {
$age = Get-AgentObjectValue $Data "ageMinutes" $null
$maxAge = Get-AgentObjectValue $Data "maxAgeMinutes" $null
$target = [string](Get-AgentObjectValue $Data "name" (Get-AgentObjectValue $Data "target" "備份系統"))
$title = "$target 備份健康異常"
$impact = "備份新鮮度或可還原性未被證明,災難復原保障可能下降。"
$action = "檢查 status、log metadata、snapshot metadata 與 restore drill receipt不讀備份內容或 secrets。"
$verification = if ($null -ne $age -and $null -ne $maxAge) { "目前 age=$age 分鐘SLA 上限=$maxAge 分鐘;仍需 integrity/restore verifier。" } else { "備份讀回不完整,事件保持開啟。" }
$metrics = @([pscustomobject]@{ name = "備份 age / SLA"; value = $age; scale = $maxAge; suffix = " 分鐘" })
$visualKind = "backup"
} elseif ($EventType -match "provider_freshness") {
$missing = if ($Data -and $Data.PSObject.Properties["missingFields"]) { @($Data.missingFields) } else { @() }
$target = [string](Get-AgentObjectValue $Data "target" "provider freshness")
$title = "Provider freshness 證據不完整"
$impact = "觀測來源可能過期或 ingestion 中斷route 200 或 dashboard up 不能視為健康。"
$action = "建立 freshness candidate補 provider window、last seen、direct reference 與 verifier readback。"
$verification = if ($missing.Count -eq 0) { "四項 freshness evidence 已齊,等待來源事件解除。" } else { "仍缺 $($missing.Count) 項 freshness evidence事件保持開啟。" }
foreach ($gate in @("providerWindow", "lastSeen", "directReference", "verifierReadback")) {
$gates += [pscustomobject]@{ name = $gate; passed = [bool]($gate -notin $missing) }
}
$visualKind = "freshness"
} elseif ($EventType -match "^operator_command_") {
$target = if ($targetName) { $targetName } elseif ($hostName) { $hostName } elseif ($modeName) { $modeName } else { "Agent99 task" }
$title = switch ($modeName) {
"Recover" { "主機與服務自動恢復結果" }
"StartVMs" { "VMware 虛擬主機恢復結果" }
"ProviderFreshness" { "Provider freshness 自動判讀結果" }
"BackupCheck" { "備份健康自動稽核結果" }
"Perf" { "主機效能自動判讀結果" }
"LoadShed" { "主機受控降載結果" }
"HarborRepair" { "Harbor 受控修復結果" }
"AwoooRepair" { "AWOOOI 受控修復結果" }
"SecurityTriage" { "資安事件自動判讀結果" }
default { "Agent99 自動化處置結果" }
}
$impact = switch ($modeName) {
"ProviderFreshness" { "資料來源 freshness 未完整證明前,產品健康狀態不可判綠。" }
"BackupCheck" { "備份 freshness 或 restore readiness 可能不符合 SLA。" }
"Perf" { "主機資源壓力可能影響服務延遲與穩定性。" }
"LoadShed" { "正在降低主機資源壓力,避免擴大使用者影響。" }
"Recover" { "一或多個主機、服務或公開路由未通過恢復 gate。" }
"StartVMs" { "必要 VM 未就緒會阻斷後續服務與網站恢復。" }
"SecurityTriage" { "資安訊號需確認 blast radius 與受影響資產。" }
default { "Agent99 已接手來源告警並執行受控處置。" }
}
$action = switch ($modeName) {
"ProviderFreshness" { "補齊四項 freshness evidence不切 provider、不呼叫付費模型、不改 env。" }
"BackupCheck" { "讀回 freshness、cron、integrity 與 restore drill receipt不讀備份內容。" }
"Perf" { "讀回 CPU、記憶體、磁碟必要時只做 allowlisted 降載。" }
"LoadShed" { "執行 allowlisted 降載並立即做 post-apply verifier。" }
"Recover" { "依序驗證 VM、SSH、112 readiness、Harbor/K3s、網站與 full-SOP scorecard。" }
"StartVMs" { "檢查 VMX/lock/guest identity 後,只啟動 allowlisted VM。" }
"HarborRepair" { "檢查 core、Redis、Registry 後執行可回滾修復。" }
"AwoooRepair" { "檢查 K3s workload/image pull 後執行受控修復。" }
"SecurityTriage" { "分類 blast radius 與 source evidence不執行任意 shell。" }
default { "執行 allowlisted Agent99 mode 並保留 execution/verifier receipt。" }
}
if ($outcomeState -eq "resolved" -and $verifierPassed -and $sourceResolved) {
$verification = "獨立 verifier 已通過,來源告警已解除。"
} elseif ($verifierPassed -and -not $sourceResolved) {
$verification = "修復 verifier 已通過,但來源告警尚未解除;事件保持開啟。"
} elseif ($outcomeState -eq "candidate_ready") {
$verification = "已建立受控修復候選,尚未執行。"
} elseif ($outcomeState -eq "verifying") {
$verification = "執行已完成,正在等待獨立 verifier 與來源事件回讀。"
} else {
$verification = "尚未通過獨立 verifier不可標示已解決。"
}
$gates = @(
[pscustomobject]@{ name = "受控執行"; passed = [bool]((Get-AgentObjectValue $Data "exitCode" -1) -eq 0) },
[pscustomobject]@{ name = "獨立驗證"; passed = $verifierPassed },
[pscustomobject]@{ name = "來源解除"; passed = $sourceResolved }
)
$visualKind = "automation"
} elseif ($EventType -match "^load_shed_") {
$target = if ($hostName) { $hostName } else { "managed host" }
$title = if ($lifecycle -eq "recovered") { "$target 降載完成" } else { "$target 降載未通過驗證" }
$impact = "主機資源壓力可能影響產品延遲或可用性。"
$action = "執行 allowlisted 降載,保留 rollback 與 cooldown receipt。"
$verification = if ($executionOk -and $postVerifyOk) { "執行與 post-verifier 均通過。" } else { "post-verifier 未通過,事件保持開啟且不建立成功 cooldown。" }
$visualKind = "automation"
} elseif ($EventType -match "^host112_guest_") {
$target = "192.168.0.112 Kali"
$title = if ($lifecycle -eq "recovered") { "Kali 112 已完整恢復" } else { "Kali 112 尚未完整開機" }
$impact = "Console、VMware Tools 或 Wazuh 未就緒會阻斷 AISOC 與重啟 SLA。"
$action = "只執行 graphical/LightDM/Tools/Indexer 的 bounded recovery不改 VM power。"
$verification = if ($lifecycle -eq "recovered") { "Console、Wazuh、timer 與 guest-ready verifier 已通過。" } else { "至少一項 guest readiness gate 仍未通過。" }
$visualKind = "recovery"
} elseif ($EventType -match "^security_") {
$title = "資安訊號調查中"
$impact = "可能涉及受影響主機或服務,需先確認 blast radius。"
$action = "建立 no-secret triage、來源證據與 allowlisted next action。"
$verification = "需由獨立 security verifier 確認來源事件解除。"
} elseif ($EventType -eq "agent_selfcheck") {
$title = "Agent99 自我健康異常"
$target = "Windows 99 Agent99"
$impact = "Agent99 任務、evidence 或 Telegram transport 可能降級。"
$action = "檢查 task、manifest、evidence freshness 與 delivery不把 reboot 當第一反應。"
$verification = "所有必要 task、runtime parity 與新鮮 evidence 通過後才關閉。"
}
$occurrences = 1
if ($problem -and $problem.PSObject.Properties["occurrences"]) {
$occurrences = [math]::Max(1, [int]$problem.occurrences)
} elseif ($Data -and $Data.PSObject.Properties["occurrenceCount"]) {
$occurrences = [math]::Max(1, [int]$Data.occurrenceCount)
}
$durationSeconds = Get-AgentObjectValue $Data "durationSeconds" (Get-AgentObjectValue $Data "elapsedSeconds" $null)
$durationText = if ($null -ne $durationSeconds -and [string]$durationSeconds -ne "") {
"$([math]::Round([double]$durationSeconds, 1))"
} else {
"計時中"
}
$nextUpdate = switch ($lifecycle) {
"recovered" { "事件已完成 verifier再次發生會累計 recurrence 並重開生命週期。" }
"blocked" { "待補受控執行條件或人工核准;狀態改變時更新。" }
default { "狀態改變時更新;相同 fingerprint、相同狀態不重複推播。" }
}
[pscustomobject]@{
schemaVersion = "agent99_incident_card_v5"
incidentId = $incidentId
fingerprint = $fingerprint
identityKey = $identityTarget
eventType = $EventType
family = $family
severity = $Severity
severityLabel = $severityLabel
lifecycle = $lifecycle
lifecycleLabel = $lifecycleLabel
stateKey = "$lifecycle|$Severity"
title = Limit-AgentTextLine $title 96
target = Limit-AgentTextLine $target 96
impact = Limit-AgentTextLine $impact 240
action = Limit-AgentTextLine $action 280
verification = Limit-AgentTextLine $verification 240
durationText = $durationText
occurrences = $occurrences
nextUpdate = $nextUpdate
visualKind = $visualKind
metrics = $metrics
gates = $gates
phases = $phases
}
}
function Format-AgentTelegramText {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
try {
$card = Get-AgentIncidentCardModel $Severity $EventType $Message $Data
@(
"$($card.severityLabel) | $($card.lifecycleLabel)",
"$($card.title)",
"資產:$($card.target)",
"影響:$($card.impact)",
"Agent99$($card.action)",
"驗證:$($card.verification)",
"耗時:$($card.durationText) | 發生:$($card.occurrences)",
"事件:$($card.incidentId)",
"下一次更新:$($card.nextUpdate)",
"時間:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
) -join "`n"
} catch {
Write-AgentLog "telegram_card_model_failed event=$EventType error=$($_.Exception.Message)"
Format-AgentTelegramLegacyText $Severity $EventType $Message $Data
}
}
function Format-AgentTelegramCaption {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$card = Get-AgentIncidentCardModel $Severity $EventType $Message $Data
@(
"$($card.severityLabel) | $($card.lifecycleLabel) | $($card.incidentId)",
"$($card.title)",
"影響:$($card.impact)",
"目前:$($card.verification)",
"下一次更新:$($card.nextUpdate)"
) -join "`n"
}
function Get-AgentTelegramIncidentState {
param([object]$Card)
$stateDir = Join-Path (Join-Path $Config.agentRoot "state") "telegram-incidents"
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
$path = Join-Path $stateDir ("incident-" + $Card.fingerprint + ".json")
$value = $null
if (Test-Path -LiteralPath $path) {
try {
$value = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
} catch {
Write-AgentLog "telegram_incident_state_parse_failed incident=$($Card.incidentId)"
}
}
[pscustomobject]@{
path = $path
exists = [bool]($null -ne $value)
value = $value
}
}
function Save-AgentTelegramIncidentState {
param(
[object]$Card,
[object]$PreviousState,
[object]$MessageId,
[object]$ReplyToMessageId
)
$state = Get-AgentTelegramIncidentState $Card
$previous = if ($PreviousState -and $PreviousState.value) { $PreviousState.value } else { $state.value }
$rootMessageId = if ($previous -and $previous.PSObject.Properties["rootMessageId"] -and $previous.rootMessageId) {
$previous.rootMessageId
} elseif ($ReplyToMessageId) {
$ReplyToMessageId
} else {
$MessageId
}
$payload = [pscustomobject]@{
schemaVersion = "agent99_telegram_incident_state_v1"
incidentId = $Card.incidentId
fingerprint = $Card.fingerprint
lastStateKey = $Card.stateKey
lastLifecycle = $Card.lifecycle
lastSeverity = $Card.severity
rootMessageId = $rootMessageId
lastMessageId = $MessageId
occurrences = $Card.occurrences
updatedAt = (Get-Date -Format o)
}
$tempPath = $state.path + ".tmp-" + ([guid]::NewGuid().ToString("N"))
try {
$payload | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $tempPath -Encoding UTF8
Move-Item -LiteralPath $tempPath -Destination $state.path -Force
return $state.path
} finally {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue | Out-Null
}
}
function Get-AgentTelegramVisualPath {
param([object]$Data)
if (-not $Data) { return $null }
$visualEvidenceVerified = [bool](
$Data.PSObject.Properties["visualEvidenceVerified"] -and
(Convert-AgentBool $Data.visualEvidenceVerified)
)
foreach ($propName in @("visualPath", "cardImagePath", "imagePath", "screenshotPath")) {
if ($Data.PSObject.Properties[$propName]) {
$path = [string]$Data.PSObject.Properties[$propName].Value
$agentGenerated = [bool]($propName -eq "cardImagePath")
if ($path -and ($agentGenerated -or $visualEvidenceVerified) -and (Test-Path -LiteralPath $path)) {
return $path
}
}
}
$null
}
function Test-AgentSreOperatorResultAlertEnabled {
param([object]$Data)
if (-not $Data) { return $false }
$source = [string](Get-AgentObjectValue $Data "source" "")
$alertId = [string](Get-AgentObjectValue $Data "alertId" "")
$enabled = $true
if ($Config.telegram -and $Config.telegram.PSObject.Properties["alertSreCommandResults"]) {
$enabled = Convert-AgentBool $Config.telegram.alertSreCommandResults
}
[bool]($enabled -and $source -eq "agent99-sre-alert-inbox" -and $alertId)
}
function New-AgentTelegramCardImage {
param(
[string]$Severity,
[string]$EventType,
[string]$Text,
[object]$Data = $null
)
$visualCardsEnabled = $true
if ($Config.telegram -and $Config.telegram.PSObject.Properties["visualCardsEnabled"]) {
$visualCardsEnabled = [bool]$Config.telegram.visualCardsEnabled
}
if (-not $visualCardsEnabled) { return $null }
$forceVisual = [bool](
$EventType -eq "recovery_slo_result" -or
($EventType -match "^operator_command_" -and (Test-AgentSreOperatorResultAlertEnabled $Data))
)
if (($Severity -notin @("warning", "critical")) -and -not $forceVisual) { return $null }
$card = Get-AgentIncidentCardModel $Severity $EventType $Text $Data
$cardDir = Join-Path $EvidenceDir "telegram-cards"
New-Item -ItemType Directory -Force -Path $cardDir | Out-Null
$safeEvent = ($EventType -replace "[^A-Za-z0-9_.-]", "-")
$imagePath = Join-Path $cardDir "agent99-card-$safeEvent-$stamp.png"
try {
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
$width = 1200
$height = 760
$bitmap = [System.Drawing.Bitmap]::new($width, $height, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
$graphics.Clear([System.Drawing.Color]::FromArgb(246, 248, 250))
$bgBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(246, 248, 250))
$panelBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(255, 255, 255))
$textBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(28, 35, 43))
$mutedBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(91, 103, 112))
$accentColor = if ($card.lifecycle -eq "recovered") {
[System.Drawing.Color]::FromArgb(22, 101, 52)
} elseif ($Severity -eq "critical") {
[System.Drawing.Color]::FromArgb(190, 18, 60)
} elseif ($Severity -eq "warning") {
[System.Drawing.Color]::FromArgb(217, 119, 6)
} else {
[System.Drawing.Color]::FromArgb(37, 99, 235)
}
$accentBrush = New-Object System.Drawing.SolidBrush -ArgumentList $accentColor
$softBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(244, 246, 248))
$goodBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(22, 101, 52))
$badBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(190, 18, 60))
$neutralBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(148, 163, 184))
$barBackBrush = New-Object System.Drawing.SolidBrush -ArgumentList ([System.Drawing.Color]::FromArgb(226, 232, 240))
$borderPen = New-Object System.Drawing.Pen -ArgumentList ([System.Drawing.Color]::FromArgb(218, 223, 230)), 1
$fontFamily = "Microsoft JhengHei UI"
$titleFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 24, ([System.Drawing.FontStyle]::Bold)
$statusFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 21, ([System.Drawing.FontStyle]::Bold)
$labelFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 13, ([System.Drawing.FontStyle]::Bold)
$bodyFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 15, ([System.Drawing.FontStyle]::Regular)
$smallFont = New-Object System.Drawing.Font -ArgumentList $fontFamily, 11, ([System.Drawing.FontStyle]::Regular)
$graphics.FillRectangle($bgBrush, 0, 0, $width, $height)
$graphics.FillRectangle($panelBrush, 36, 34, $width - 72, $height - 68)
$graphics.FillRectangle($accentBrush, 36, 34, 14, $height - 68)
$graphics.DrawString("Agent99 事故狀態 / INCIDENT STATUS", $titleFont, $textBrush, 72, 54)
$graphics.DrawString($card.lifecycleLabel, $statusFont, $accentBrush, 72, 96)
$graphics.DrawString(("$($card.severityLabel) | $($card.incidentId) | 發生 $($card.occurrences)"), $labelFont, $mutedBrush, 72, 137)
$graphics.DrawString($card.title, $titleFont, $textBrush, [System.Drawing.RectangleF]::new(72, 176, 1048, 46))
$graphics.DrawString(("受影響資產:" + $card.target), $labelFont, $mutedBrush, 72, 224)
$graphics.FillRectangle($softBrush, 72, 258, 1056, 82)
$graphics.DrawString("使用者影響", $labelFont, $accentBrush, 92, 274)
$graphics.DrawString($card.impact, $bodyFont, $textBrush, [System.Drawing.RectangleF]::new(92, 301, 1016, 34))
$graphics.DrawString("Agent99 實際動作", $labelFont, $mutedBrush, 72, 366)
$graphics.DrawString($card.action, $bodyFont, $textBrush, [System.Drawing.RectangleF]::new(72, 394, 1056, 58))
$graphics.DrawLine($borderPen, 72, 462, 1128, 462)
$graphics.DrawString("獨立驗證", $labelFont, $mutedBrush, 72, 480)
$graphics.DrawString($card.verification, $bodyFont, $textBrush, [System.Drawing.RectangleF]::new(72, 508, 1056, 54))
$graphics.DrawString(("耗時 " + $card.durationText), $labelFont, $textBrush, 930, 100)
$graphics.DrawString("證據視圖", $labelFont, $mutedBrush, 72, 578)
if ($card.visualKind -eq "performance") {
$metricIndex = 0
foreach ($metric in @($card.metrics | Select-Object -First 3)) {
$x = 72 + ($metricIndex * 350)
$metricIndex += 1
$rawValue = 0.0
$valid = [double]::TryParse([string]$metric.value, [ref]$rawValue)
$scale = if ($metric.scale -and [double]$metric.scale -gt 0) { [double]$metric.scale } else { 100.0 }
$ratio = if ($valid) { [math]::Min(1.0, [math]::Max(0.0, $rawValue / $scale)) } else { 0.0 }
$graphics.DrawString([string]$metric.name, $smallFont, $mutedBrush, $x, 608)
$graphics.FillRectangle($barBackBrush, $x, 638, 300, 18)
if ($valid) { $graphics.FillRectangle($accentBrush, $x, 638, [int](300 * $ratio), 18) }
$valueText = if ($valid) { "$rawValue$([string]$metric.suffix)" } else { "N/A" }
$graphics.DrawString($valueText, $labelFont, $(if ($valid) { $textBrush } else { $badBrush }), $x, 664)
}
} elseif ($card.visualKind -in @("freshness", "automation")) {
$gateRows = @($card.gates | Select-Object -First 4)
$gateIndex = 0
foreach ($gate in $gateRows) {
$x = 72 + ($gateIndex * 260)
$gateIndex += 1
$passed = Convert-AgentBool $gate.passed
$gateBrush = if ($passed) { $goodBrush } else { $badBrush }
$gateLabel = switch ([string]$gate.name) {
"providerWindow" { "Provider window" }
"lastSeen" { "Last seen" }
"directReference" { "Direct reference" }
"verifierReadback" { "Verifier readback" }
default { [string]$gate.name }
}
$graphics.FillRectangle($softBrush, $x, 612, 230, 62)
$graphics.FillRectangle($gateBrush, $x, 612, 8, 62)
$graphics.DrawString((Limit-AgentTextLine $gateLabel 22), $smallFont, $textBrush, ($x + 20), 622)
$graphics.DrawString($(if ($passed) { "已通過 / PASS" } else { "未通過 / OPEN" }), $labelFont, $gateBrush, ($x + 20), 646)
}
} elseif ($card.visualKind -eq "backup" -and @($card.metrics).Count -gt 0) {
$metric = @($card.metrics)[0]
$rawValue = 0.0
$scaleValue = 0.0
$valid = [double]::TryParse([string]$metric.value, [ref]$rawValue) -and [double]::TryParse([string]$metric.scale, [ref]$scaleValue) -and $scaleValue -gt 0
$ratio = if ($valid) { [math]::Min(1.0, [math]::Max(0.0, $rawValue / $scaleValue)) } else { 0.0 }
$graphics.FillRectangle($barBackBrush, 72, 630, 850, 22)
if ($valid) { $graphics.FillRectangle($accentBrush, 72, 630, [int](850 * $ratio), 22) }
$graphics.DrawString($(if ($valid) { "$rawValue / $scaleValue 分鐘" } else { "freshness readback unavailable" }), $labelFont, $textBrush, 72, 662)
} elseif ($card.visualKind -eq "recovery" -and @($card.phases).Count -gt 0) {
$phaseRows = @($card.phases | Select-Object -First 7)
$phaseWidth = [math]::Floor(1030 / [math]::Max(1, $phaseRows.Count))
$phaseIndex = 0
foreach ($phase in $phaseRows) {
$x = 72 + ($phaseIndex * $phaseWidth)
$phaseIndex += 1
$passed = [bool]([string]$phase.status -eq "ok")
$phaseBrush = if ($passed) { $goodBrush } else { $badBrush }
$graphics.FillRectangle($phaseBrush, $x, 620, ($phaseWidth - 10), 12)
$graphics.DrawString((Limit-AgentTextLine ([string]$phase.name) 18), $smallFont, $textBrush, $x, 643)
$graphics.DrawString($(if ($passed) { "PASS" } else { "OPEN" }), $smallFont, $phaseBrush, $x, 668)
}
} else {
$states = @("detected", "investigating", "remediating", "verifying", "recovered")
$stateIndex = 0
foreach ($state in $states) {
$x = 72 + ($stateIndex * 205)
$stateIndex += 1
$active = [bool]($state -eq $card.lifecycle)
$stateBrush = if ($active) { $accentBrush } else { $neutralBrush }
$graphics.FillRectangle($stateBrush, $x, 630, 170, 10)
$graphics.DrawString($state.ToUpperInvariant(), $smallFont, $stateBrush, $x, 651)
}
}
$graphics.DrawString(("下一次更新:" + $card.nextUpdate), $smallFont, $mutedBrush, [System.Drawing.RectangleF]::new(72, 698, 850, 30))
$graphics.DrawString((Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $smallFont, $mutedBrush, 930, 704)
$bitmap.Save($imagePath, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
$bgBrush.Dispose()
$panelBrush.Dispose()
$textBrush.Dispose()
$mutedBrush.Dispose()
$accentBrush.Dispose()
$softBrush.Dispose()
$goodBrush.Dispose()
$badBrush.Dispose()
$neutralBrush.Dispose()
$barBackBrush.Dispose()
$borderPen.Dispose()
$titleFont.Dispose()
$statusFont.Dispose()
$labelFont.Dispose()
$bodyFont.Dispose()
$smallFont.Dispose()
return $imagePath
} catch {
Write-AgentLog "telegram_card_image_failed event=$EventType error=$($_.Exception.Message)"
return $null
}
}
function Send-AgentTelegramPhotoDirect {
param(
[string]$Token,
[string]$ChatId,
[string]$Caption,
[string]$PhotoPath,
[string]$ReplyToMessageId = ""
)
Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
$client = New-Object System.Net.Http.HttpClient
$content = New-Object System.Net.Http.MultipartFormDataContent
$fileContent = $null
try {
$content.Add([System.Net.Http.StringContent]::new($ChatId), "chat_id")
$captionToSend = $Caption
if ($captionToSend.Length -gt 900) {
$captionToSend = $captionToSend.Substring(0, 900) + "`n...(truncated; see evidence)"
}
$content.Add([System.Net.Http.StringContent]::new($captionToSend), "caption")
if ($ReplyToMessageId -match "^\d+$") {
$replyJson = @{ message_id = [long]$ReplyToMessageId } | ConvertTo-Json -Compress
$content.Add([System.Net.Http.StringContent]::new($replyJson), "reply_parameters")
}
$fileBytes = [IO.File]::ReadAllBytes($PhotoPath)
$fileContent = [System.Net.Http.ByteArrayContent]::new($fileBytes)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("image/png")
$content.Add($fileContent, "photo", [IO.Path]::GetFileName($PhotoPath))
$response = $client.PostAsync("https://api.telegram.org/bot$Token/sendPhoto", $content).GetAwaiter().GetResult()
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) {
throw "telegram_sendPhoto_failed status=$([int]$response.StatusCode) body=$body"
}
$payload = $body | ConvertFrom-Json
return [pscustomobject]@{
ok = [bool]$payload.ok
messageId = if ($payload.result) { $payload.result.message_id } else { $null }
replyToMessageId = if ($ReplyToMessageId) { $ReplyToMessageId } else { $null }
}
} finally {
if ($fileContent) { $fileContent.Dispose() }
if ($content) { $content.Dispose() }
if ($client) { $client.Dispose() }
}
}
function Invoke-AgentTelegramCardSelfTest {
$sample = [pscustomobject]@{
host = "192.168.0.110"
reasons = @("perf_readback_failed")
evidencePath = $jsonPath
}
$text = Format-AgentTelegramText "warning" "performance_warning" "selftest visual card" $sample
$imagePath = New-AgentTelegramCardImage "warning" "performance_warning" $text $sample
$ok = [bool]($imagePath -and (Test-Path -LiteralPath $imagePath))
$operatorSample = [pscustomobject]@{
id = "selftest-sre-result"
mode = "ProviderFreshness"
source = "agent99-sre-alert-inbox"
reason = "sre_alert"
instruction = "Provider freshness candidate required; alertId=selftest-provider; require providerWindow,lastSeen,directReference,verifierReadback; do not switch provider."
alertId = "selftest-provider"
alertKind = "provider_freshness_signal"
alertSeverity = "warning"
alertService = "provider_freshness_signal"
ok = $true
exitCode = 0
evidence = $jsonPath
outcome = [pscustomobject]@{
state = "resolved"
verifierName = "selftest-verifier"
verifierPassed = $true
sourceEventResolved = $true
}
problem = [pscustomobject]@{
key = "selftest-provider-freshness"
occurrences = 2
resolved = 2
verifiedResolved = 2
failed = 0
degraded = 0
blocked = 0
verifying = 0
candidateReady = 0
kmPath = (Join-Path $Config.agentRoot "playbooks\agent99-incident-km.md")
}
}
$operatorText = Format-AgentTelegramText "info" "operator_command_result" "selftest SRE completion card" $operatorSample
$operatorCaption = Format-AgentTelegramCaption "info" "operator_command_result" "selftest SRE completion card" $operatorSample
$operatorModel = Get-AgentIncidentCardModel "info" "operator_command_result" "selftest SRE completion card" $operatorSample
$operatorImagePath = New-AgentTelegramCardImage "info" "operator_command_result" $operatorText $operatorSample
$operatorDecisionOk = Test-AgentSreOperatorResultAlertEnabled $operatorSample
$operatorTextOk = [bool](
$operatorText -match "已恢復 / RECOVERED" -and
$operatorText -match "Agent99" -and
$operatorText -match "驗證:" -and
$operatorText -match "事件INC-" -and
$operatorText -notmatch "C:\\" -and
$operatorText -notmatch "KM:" -and
$operatorText -notmatch "verifierName=" -and
$operatorCaption.Length -lt 900 -and
$operatorModel.lifecycle -eq "recovered" -and
$operatorModel.occurrences -eq 2
)
$operatorVisualOk = [bool]($operatorImagePath -and (Test-Path -LiteralPath $operatorImagePath))
$stateTestOk = $false
$stateSuppressionReason = ""
$stateRootMessageId = $null
$originalAgentRoot = [string]$Config.agentRoot
$originalSuppressAlerts = $script:SuppressAgentAlerts
$originalTelegramEnabled = if ($Config.telegram -and $Config.telegram.PSObject.Properties["enabled"]) { [bool]$Config.telegram.enabled } else { $false }
$stateTestRoot = Join-Path $EvidenceDir "telegram-lifecycle-selftest"
try {
$Config.agentRoot = $stateTestRoot
$script:SuppressAgentAlerts = $false
if ($Config.telegram -and $Config.telegram.PSObject.Properties["enabled"]) { $Config.telegram.enabled = $false }
$stateBefore = Get-AgentTelegramIncidentState $operatorModel
Save-AgentTelegramIncidentState $operatorModel $stateBefore 888 777 | Out-Null
$stateAfter = Get-AgentTelegramIncidentState $operatorModel
$attemptCountBefore = @($script:TelegramAttempts).Count
Record-AgentEvent "operator_command_result" "info" "selftest lifecycle suppression" $operatorSample -Alert
$newAttempts = @($script:TelegramAttempts | Select-Object -Skip $attemptCountBefore)
$stateSuppressionReason = if ($newAttempts.Count -gt 0) { [string]$newAttempts[-1].reason } else { "" }
$stateRootMessageId = if ($stateAfter.value) { $stateAfter.value.rootMessageId } else { $null }
$stateTestOk = [bool](
$stateAfter.exists -and
[string]$stateAfter.value.lastStateKey -eq $operatorModel.stateKey -and
[string]$stateRootMessageId -eq "777" -and
$stateSuppressionReason -eq "same_lifecycle_state" -and
@($newAttempts | Where-Object { $_.sent }).Count -eq 0
)
} finally {
$Config.agentRoot = $originalAgentRoot
$script:SuppressAgentAlerts = $originalSuppressAlerts
if ($Config.telegram -and $Config.telegram.PSObject.Properties["enabled"]) { $Config.telegram.enabled = $originalTelegramEnabled }
Remove-Item -LiteralPath $stateTestRoot -Recurse -Force -ErrorAction SilentlyContinue | Out-Null
}
$ok = [bool]($ok -and $operatorTextOk -and $operatorVisualOk -and $operatorDecisionOk -and $stateTestOk)
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = $ok
textPreview = @($text -split "`n" | Select-Object -First 12)
visualPath = $imagePath
visualExists = $ok
operatorTextPreview = @($operatorText -split "`n" | Select-Object -First 14)
operatorCaptionPreview = @($operatorCaption -split "`n" | Select-Object -First 8)
operatorIncidentId = $operatorModel.incidentId
operatorLifecycle = $operatorModel.lifecycle
operatorVisualPath = $operatorImagePath
operatorVisualExists = $operatorVisualOk
operatorTextOk = $operatorTextOk
operatorDecisionOk = $operatorDecisionOk
lifecycleStateTestOk = $stateTestOk
lifecycleSuppressionReason = $stateSuppressionReason
lifecycleRootMessageId = $stateRootMessageId
sentTelegram = $false
messageFormat = "incident_card_zh_tw_v5"
}
$selfTestPath = Join-Path $EvidenceDir "agent99-TelegramCardSelfTest-$stamp.json"
$result | ConvertTo-Json -Depth 6 | Set-Content -Path $selfTestPath -Encoding UTF8
Write-AgentLog "telegram_card_selftest ok=$ok path=$imagePath evidence=$selfTestPath"
$result | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 $jsonPath
$result | ConvertTo-Json -Depth 6
if (-not $ok) { exit 1 }
exit 0
}
function Invoke-AgentTelegramDeliverySelfTest {
$testId = "telegram-delivery-test-" + (Get-Date -Format "yyyyMMdd-HHmmss")
$data = [pscustomobject]@{
testId = $testId
visualPath = $null
}
$deliveryAttempt = Send-AgentTelegram "warning" "telegram_delivery_test" "TEST_DO_NOT_PAGE bilingual Telegram incident card delivery self-test" $data
$sent = [bool](@($script:TelegramAttempts | Where-Object { $_.sent }).Count -gt 0)
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = $sent
testId = $testId
sentTelegram = $sent
deliveryAttempt = $deliveryAttempt
messageFormat = "incident_card_zh_tw_v5"
attempts = $script:TelegramAttempts
}
$selfTestPath = Join-Path $EvidenceDir "agent99-TelegramDeliverySelfTest-$stamp.json"
$result | ConvertTo-Json -Depth 10 | Set-Content -Path $selfTestPath -Encoding UTF8
Write-AgentLog "telegram_delivery_selftest ok=$sent evidence=$selfTestPath"
$result | ConvertTo-Json -Depth 10 | Set-Content -Encoding UTF8 $jsonPath
$result | ConvertTo-Json -Depth 10
if (-not $sent) { exit 1 }
exit 0
}
function Invoke-AgentSensorGateSelfTest {
$previousSuppress = $script:SuppressAgentAlerts
$script:SuppressAgentAlerts = $true
$gateError = $null
$performance = @()
$backupHealth = $null
$selfHealth = $null
try {
Write-AgentLog "sensor_gate start no_telegram=true controlled_apply=false remediation=false"
$performance = @(Get-HostPerformance)
$backupHealth = Test-AgentBackupHealth
$selfHealth = Test-AgentSelfHealth
} catch {
$gateError = $_.Exception.Message
Write-AgentLog "sensor_gate error=$gateError"
} finally {
$script:SuppressAgentAlerts = $previousSuppress
}
$perfReadbackFailed = @($performance | Where-Object { $_ -and ((-not $_.ok) -or (@($_.reasons) -contains "perf_readback_failed")) })
$perfMissingMetrics = @($performance | Where-Object {
$_ -and $_.ok -and (($null -eq $_.loadPerCore) -or ($null -eq $_.memAvailablePercent) -or ($null -eq $_.diskUsedPercent))
})
$perfCritical = @($performance | Where-Object { $_ -and $_.severity -eq "critical" })
$perfWarning = @($performance | Where-Object { $_ -and $_.severity -eq "warning" })
$sentTelegram = [bool](@($script:TelegramAttempts | Where-Object { $_.sent }).Count -gt 0)
$suppressedTelegram = @($script:TelegramAttempts | Where-Object { $_.PSObject.Properties["suppressed"] -and $_.suppressed })
$perfReadbackOk = [bool](@($performance).Count -gt 0 -and @($perfReadbackFailed).Count -eq 0 -and @($perfMissingMetrics).Count -eq 0)
$backupOk = [bool]($backupHealth -and $backupHealth.severity -eq "ok")
$selfOk = [bool]($selfHealth -and $selfHealth.severity -eq "ok")
$performanceDecision = if (-not $perfReadbackOk) {
"hold_readback_or_metric_gap"
} elseif (@($perfCritical).Count -gt 0) {
"hold_critical_needs_remediation"
} elseif (@($perfWarning).Count -gt 0) {
"hold_warning_needs_summary_or_remediation"
} else {
"eligible"
}
$backupDecision = if ($backupOk) { "eligible" } elseif ($backupHealth) { "hold_$($backupHealth.severity)" } else { "hold_readback_failed" }
$selfDecision = if ($selfOk) { "eligible" } elseif ($selfHealth) { "hold_$($selfHealth.severity)" } else { "hold_readback_failed" }
$readyToEnableAll = [bool]($performanceDecision -eq "eligible" -and $backupDecision -eq "eligible" -and $selfDecision -eq "eligible")
$gateSafe = [bool]((-not $sentTelegram) -and (-not $ControlledApply) -and ($null -eq $gateError))
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = $gateSafe
readyToEnableAll = $readyToEnableAll
sentTelegram = $sentTelegram
suppressedTelegramCount = @($suppressedTelegram).Count
controlledApply = [bool]$ControlledApply
ranRemediation = $false
error = $gateError
decisions = [pscustomobject]@{
performanceGuard = $performanceDecision
backupHealth = $backupDecision
selfHealth = $selfDecision
}
performance = [pscustomobject]@{
ok = $perfReadbackOk
hostCount = @($performance).Count
readbackFailedHosts = @($perfReadbackFailed | ForEach-Object { $_.host })
missingMetricHosts = @($perfMissingMetrics | ForEach-Object { $_.host })
warningHosts = @($perfWarning | ForEach-Object { $_.host })
criticalHosts = @($perfCritical | ForEach-Object { $_.host })
rows = $performance
}
backupHealth = $backupHealth
selfHealth = $selfHealth
telegramAttempts = $script:TelegramAttempts
events = $script:Events
evidenceLog = $logPath
}
$selfTestPath = Join-Path $EvidenceDir "agent99-SensorGateSelfTest-$stamp.json"
$result | ConvertTo-Json -Depth 12 | Set-Content -Path $selfTestPath -Encoding UTF8
$result | ConvertTo-Json -Depth 12 | Set-Content -Encoding UTF8 $jsonPath
Write-AgentLog "sensor_gate complete ok=$($result.ok) readyToEnableAll=$readyToEnableAll evidence=$selfTestPath"
$result | ConvertTo-Json -Depth 12
if (-not $gateSafe) { exit 1 }
exit 0
}
function Send-AgentTelegram {
param(
[string]$Severity,
[string]$EventType,
[string]$Message,
[object]$Data = $null
)
$telegram = $Config.telegram
$enabled = ($telegram -and $telegram.enabled)
$botTokenEnv = if ($telegram.botTokenEnv) { $telegram.botTokenEnv } else { "AGENT99_TELEGRAM_BOT_TOKEN" }
$chatIdEnv = if ($telegram.chatIdEnv) { $telegram.chatIdEnv } else { "AGENT99_TELEGRAM_CHAT_ID" }
$tokenEnvAliases = @($botTokenEnv)
if ($telegram.botTokenEnvAliases) { $tokenEnvAliases += @($telegram.botTokenEnvAliases) }
$chatIdEnvAliases = @($chatIdEnv)
if ($telegram.chatIdEnvAliases) { $chatIdEnvAliases += @($telegram.chatIdEnvAliases) }
function Get-AgentEnvSecret {
param([string[]]$Names)
foreach ($name in ($Names | Where-Object { $_ } | Select-Object -Unique)) {
foreach ($scope in @("Machine", "User", "Process")) {
$value = [Environment]::GetEnvironmentVariable($name, $scope)
if ($value) {
return [pscustomobject]@{ value = $value; name = $name; scope = $scope }
}
}
}
return $null
}
$tokenSecret = Get-AgentEnvSecret $tokenEnvAliases
$chatSecret = Get-AgentEnvSecret $chatIdEnvAliases
$token = if ($tokenSecret) { $tokenSecret.value } else { $null }
$chatId = if ($chatSecret) { $chatSecret.value } else { $null }
$chatIdOverride = if ($Data -and $Data.PSObject.Properties["replyChatId"]) { [string]$Data.replyChatId } else { "" }
$card = Get-AgentIncidentCardModel $Severity $EventType $Message $Data
$incidentState = Get-AgentTelegramIncidentState $card
$explicitReplyMessageId = if ($Data -and $Data.PSObject.Properties["replyMessageId"]) { [string]$Data.replyMessageId } else { "" }
$storedRootMessageId = if ($incidentState.value -and $incidentState.value.PSObject.Properties["rootMessageId"]) { [string]$incidentState.value.rootMessageId } else { "" }
$replyToMessageId = if ($explicitReplyMessageId -match "^\d+$") { $explicitReplyMessageId } elseif ($storedRootMessageId -match "^\d+$") { $storedRootMessageId } else { "" }
$attempt = [pscustomobject]@{
timestamp = (Get-Date -Format o)
enabled = [bool]$enabled
eventType = $EventType
severity = $Severity
messageFormat = "incident_card_zh_tw_v5"
incidentId = $card.incidentId
fingerprint = $card.fingerprint
lifecycle = $card.lifecycle
stateKey = $card.stateKey
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 }
replyToMessageId = if ($replyToMessageId) { $replyToMessageId } else { $null }
messageId = $null
incidentStatePath = $incidentState.path
incidentStateWritten = $false
visualPath = $null
visualSent = $false
visualError = $null
relay = $null
sent = $false
error = $null
}
if (-not $enabled) {
$attempt.error = "telegram_disabled"
$script:TelegramAttempts += $attempt
return $attempt
}
$text = Format-AgentTelegramText $Severity $EventType $Message $Data
$caption = Format-AgentTelegramCaption $Severity $EventType $Message $Data
$visualPath = Get-AgentTelegramVisualPath $Data
if (-not $visualPath) {
$visualPath = New-AgentTelegramCardImage $Severity $EventType $text $Data
}
$attempt.visualPath = $visualPath
if (-not ($token -and ($chatId -or $chatIdOverride))) {
$relayText = if ($visualPath) { $caption } else { $text }
$relayResult = Send-AgentTelegramRelay $relayText $telegram.relay $chatIdOverride $visualPath $replyToMessageId
$attempt.relay = $relayResult
if ($relayResult.ok) {
$attempt.sent = $true
$attempt.messageId = $relayResult.messageId
$attempt.error = $null
$attempt.visualSent = [bool]$relayResult.photoSent
if ($visualPath -and -not $relayResult.photoSent) {
$attempt.visualError = if ($relayResult.photoError) { $relayResult.photoError } else { "relay_sendPhoto_not_available" }
}
} else {
$attempt.error = "telegram_not_configured"
}
if ($attempt.sent) {
$attempt.incidentStatePath = Save-AgentTelegramIncidentState $card $incidentState $attempt.messageId $replyToMessageId
$attempt.incidentStateWritten = $true
}
$script:TelegramAttempts += $attempt
return $attempt
}
try {
$targetChat = if ($chatIdOverride) { $chatIdOverride } else { $chatId }
if ($visualPath -and (Test-Path -LiteralPath $visualPath)) {
try {
$photoResult = Send-AgentTelegramPhotoDirect $token $targetChat $caption $visualPath $replyToMessageId
$attempt.sent = $true
$attempt.visualSent = $true
$attempt.messageId = $photoResult.messageId
} catch {
$attempt.visualError = $_.Exception.Message
Write-AgentLog "telegram_sendPhoto_failed event=$EventType fallback=sendMessage error=$($_.Exception.Message)"
$fallbackBody = @{
chat_id = $targetChat
text = $text
disable_web_page_preview = "true"
}
if ($replyToMessageId) { $fallbackBody.reply_parameters = (@{ message_id = [long]$replyToMessageId } | ConvertTo-Json -Compress) }
$fallbackResult = Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/sendMessage" -Body $fallbackBody
$attempt.sent = $true
$attempt.messageId = if ($fallbackResult.result) { $fallbackResult.result.message_id } else { $null }
}
} else {
$textBody = @{
chat_id = $targetChat
text = $text
disable_web_page_preview = "true"
}
if ($replyToMessageId) { $textBody.reply_parameters = (@{ message_id = [long]$replyToMessageId } | ConvertTo-Json -Compress) }
$textResult = Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/sendMessage" -Body $textBody
$attempt.sent = $true
$attempt.messageId = if ($textResult.result) { $textResult.result.message_id } else { $null }
}
} catch {
$attempt.error = $_.Exception.Message
}
if ($attempt.sent) {
$attempt.incidentStatePath = Save-AgentTelegramIncidentState $card $incidentState $attempt.messageId $replyToMessageId
$attempt.incidentStateWritten = $true
}
$script:TelegramAttempts += $attempt
return $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
}
$eventSuppressTelegram = $false
if ($Data -and $Data.PSObject.Properties["suppressTelegram"]) {
$eventSuppressTelegram = Convert-AgentBool $Data.suppressTelegram
}
if ($script:SuppressAgentAlerts -or $eventSuppressTelegram) {
$suppressReason = if ($eventSuppressTelegram) {
"do_not_page"
} elseif ($script:SuppressAgentAlertReason) {
$script:SuppressAgentAlertReason
} else {
"sensor_gate_no_telegram"
}
Write-AgentLog "telegram_suppressed reason=$suppressReason type=$EventType severity=$Severity"
$script:TelegramAttempts += [pscustomobject]@{
timestamp = (Get-Date -Format o)
eventType = $EventType
severity = $Severity
sent = $false
suppressed = $true
reason = $suppressReason
}
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 = ""
$operatorSource = ""
$operatorAlertId = ""
$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("source")) { $operatorSource = [string]$Data["source"] }
if ($Data.Contains("alertId")) { $operatorAlertId = [string]$Data["alertId"] }
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
$sourceProp = $Data.PSObject.Properties.Match("source") | Select-Object -First 1
$alertIdProp = $Data.PSObject.Properties.Match("alertId") | 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 ($sourceProp) { $operatorSource = [string]$sourceProp.Value }
if ($alertIdProp) { $operatorAlertId = [string]$alertIdProp.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
}
$operatorIsSreAlertResult = Test-AgentSreOperatorResultAlertEnabled $Data
if ($operatorOk -and -not $alertSuccessfulOperatorCommands -and -not $operatorIsSreAlertResult) {
Write-AgentLog "telegram_operator_success_suppressed type=$EventType mode=$operatorMode id=$operatorId"
$operatorCommandAlert = $false
} elseif ($operatorOk -and $operatorIsSreAlertResult) {
Write-AgentLog "telegram_sre_operator_success_enabled type=$EventType mode=$operatorMode id=$operatorId alertId=$operatorAlertId"
}
}
$lifecycleInfoAlert = [bool]($Alert -and $Severity -eq "info" -and $EventType -in @("recovery_slo_result"))
$shouldAlert = ($Severity -in @("warning", "critical")) -or $operatorCommandAlert -or $lifecycleInfoAlert -or ($Alert -and $Severity -ne "info") -or ($alertAll -and ($Severity -ne "info" -or $alertInfo))
if ($shouldAlert) {
$incidentCard = Get-AgentIncidentCardModel $Severity $EventType $Message $Data
$incidentState = Get-AgentTelegramIncidentState $incidentCard
$forceLifecycleNotification = [bool]($Data -and $Data.PSObject.Properties["forceLifecycleNotification"] -and (Convert-AgentBool $Data.forceLifecycleNotification))
if (-not $forceLifecycleNotification -and $incidentState.value -and [string]$incidentState.value.lastStateKey -eq $incidentCard.stateKey) {
Write-AgentLog "telegram_lifecycle_suppressed incident=$($incidentCard.incidentId) state=$($incidentCard.stateKey)"
$script:TelegramAttempts += [pscustomobject]@{
timestamp = (Get-Date -Format o)
eventType = $EventType
severity = $Severity
messageFormat = "incident_card_zh_tw_v5"
incidentId = $incidentCard.incidentId
fingerprint = $incidentCard.fingerprint
lifecycle = $incidentCard.lifecycle
stateKey = $incidentCard.stateKey
sent = $false
suppressed = $true
reason = "same_lifecycle_state"
}
return
}
$dedupeMinutes = 30
if ($Config.telegram -and $Config.telegram.PSObject.Properties["dedupeMinutes"]) {
$dedupeMinutes = [int]$Config.telegram.dedupeMinutes
}
$dedupeParts = @($EventType, $Severity)
$dedupeParts += "lifecycle=$($incidentCard.stateKey)"
if ($Data) {
foreach ($propName in @("host", "name", "path", "reason", "target", "status", "mode", "alertKind", "source", "scope", "completed", "withinSlo", "rebootSloClaimed")) {
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"
$script:TelegramAttempts += [pscustomobject]@{
timestamp = (Get-Date -Format o)
eventType = $EventType
severity = $Severity
messageFormat = "incident_card_zh_tw_v5"
incidentId = $incidentCard.incidentId
fingerprint = $incidentCard.fingerprint
lifecycle = $incidentCard.lifecycle
sent = $false
suppressed = $true
reason = "dedupe_window"
dedupeAgeMinutes = [math]::Round($ageMinutes, 2)
dedupeMinutes = $dedupeMinutes
}
return
}
}
$telegramAttempt = Send-AgentTelegram $Severity $EventType $Message $Data
if ($telegramAttempt -and $telegramAttempt.sent) {
Set-Content -Path $dedupePath -Value (Get-Date -Format o) -Encoding UTF8
}
}
}
function Get-AgentBootState {
$stateDir = Join-Path $Config.agentRoot "state"
$statePath = Join-Path $stateDir "boot-state.json"
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$bootTime = ([datetime]$os.LastBootUpTime).ToString("o")
} catch {
Write-AgentLog "boot_state_read_failed error=$($_.Exception.Message)"
return [pscustomobject]@{ ok = $false; detected = $false; reason = "boot_time_unavailable"; statePath = $statePath }
}
$observedAt = (Get-Date).ToString("o")
$previous = $null
if (Test-Path $statePath) {
try { $previous = Get-Content $statePath -Raw | ConvertFrom-Json } catch { $previous = $null }
}
$previousBootTime = if ($previous -and $previous.PSObject.Properties["bootTime"]) { [string]$previous.bootTime } else { $null }
$detected = [bool]($previousBootTime -and $previousBootTime -ne $bootTime)
$sameBoot = [bool]($previousBootTime -and $previousBootTime -eq $bootTime)
$previousPending = [bool](
$sameBoot -and
$previous.PSObject.Properties["pendingRecovery"] -and
(Convert-AgentBool $previous.pendingRecovery)
)
$pendingRecovery = [bool]($detected -or $previousPending)
$eventId = if ($detected) {
"windows99-boot-" + ($bootTime -replace '[^0-9]', '')
} elseif ($sameBoot -and $previous.PSObject.Properties["eventId"]) {
[string]$previous.eventId
} else { "" }
$result = [pscustomobject]@{
ok = $true
detected = $detected
pendingRecovery = $pendingRecovery
eventId = $eventId
detectedAt = if ($detected) { $observedAt } elseif ($sameBoot -and $previous.PSObject.Properties["detectedAt"]) { [string]$previous.detectedAt } else { "" }
bootTime = $bootTime
previousBootTime = $previousBootTime
terminalState = if ($sameBoot -and $previous.PSObject.Properties["terminalState"]) { [string]$previous.terminalState } elseif ($pendingRecovery) { "pending" } else { "observed" }
terminalAt = if ($sameBoot -and $previous.PSObject.Properties["terminalAt"]) { [string]$previous.terminalAt } else { "" }
lastRecoveryRunId = if ($sameBoot -and $previous.PSObject.Properties["lastRecoveryRunId"]) { [string]$previous.lastRecoveryRunId } else { "" }
lastRecoveryCompletedAt = if ($sameBoot -and $previous.PSObject.Properties["lastRecoveryCompletedAt"]) { [string]$previous.lastRecoveryCompletedAt } else { "" }
lastRecoveryCompleted = [bool]($sameBoot -and $previous.PSObject.Properties["lastRecoveryCompleted"] -and (Convert-AgentBool $previous.lastRecoveryCompleted))
lastRecoveryWithinSlo = [bool]($sameBoot -and $previous.PSObject.Properties["lastRecoveryWithinSlo"] -and (Convert-AgentBool $previous.lastRecoveryWithinSlo))
lastObservedAt = $observedAt
statePath = $statePath
}
$result | ConvertTo-Json -Depth 5 | Set-Content -Path $statePath -Encoding UTF8
if ($detected) {
Record-AgentEvent "host_reboot_detected" "warning" "偵測到 99 主機重新啟動Agent99 已進入受控恢復主線。" $result -Alert
}
$result
}
function Update-AgentBootRecoveryState {
param([object]$RecoverySlo)
$statePath = Join-Path (Join-Path $Config.agentRoot "state") "boot-state.json"
if (-not $RecoverySlo -or -not (Test-Path $statePath)) { return $null }
try {
$state = Get-Content $statePath -Raw | ConvertFrom-Json
if (-not $state.PSObject.Properties["pendingRecovery"] -or -not (Convert-AgentBool $state.pendingRecovery)) {
return $state
}
$state | Add-Member -NotePropertyName lastRecoveryRunId -NotePropertyValue ([string]$RecoverySlo.runId) -Force
$state | Add-Member -NotePropertyName lastRecoveryCompletedAt -NotePropertyValue ([string]$RecoverySlo.completedAt) -Force
$state | Add-Member -NotePropertyName lastRecoveryCompleted -NotePropertyValue ([bool]$RecoverySlo.completed) -Force
$state | Add-Member -NotePropertyName lastRecoveryWithinSlo -NotePropertyValue ([bool]$RecoverySlo.withinSlo) -Force
if ($RecoverySlo.completed) {
$state | Add-Member -NotePropertyName pendingRecovery -NotePropertyValue $false -Force
$terminalState = if ($RecoverySlo.withinSlo) { "recovered_within_slo" } else { "recovered_late" }
$state | Add-Member -NotePropertyName terminalState -NotePropertyValue $terminalState -Force
$state | Add-Member -NotePropertyName terminalAt -NotePropertyValue ((Get-Date).ToString("o")) -Force
} else {
$bootAgeSeconds = try { [math]::Max(0, [int64]((Get-Date) - ([datetime]$state.bootTime)).TotalSeconds) } catch { -1 }
if ($bootAgeSeconds -gt ([int64]$RecoverySlo.targetMinutes * 60)) {
$state | Add-Member -NotePropertyName terminalState -NotePropertyValue "slo_breached_recovery_pending" -Force
}
}
$state | Add-Member -NotePropertyName lastObservedAt -NotePropertyValue ((Get-Date).ToString("o")) -Force
$state | ConvertTo-Json -Depth 6 | Set-Content -Path $statePath -Encoding UTF8
return $state
} catch {
Write-AgentLog "boot_recovery_state_write_failed error=$($_.Exception.GetType().Name)"
return $null
}
}
function Get-AgentAutoRecoveryConfig {
$configured = if ($Config.PSObject.Properties["autoRecovery"]) { $Config.autoRecovery } else { $null }
[pscustomobject]@{
enabled = [bool](-not $configured -or -not $configured.PSObject.Properties["enabled"] -or (Convert-AgentBool $configured.enabled))
triggerOnHostUnreachable = [bool](-not $configured -or -not $configured.PSObject.Properties["triggerOnHostUnreachable"] -or (Convert-AgentBool $configured.triggerOnHostUnreachable))
minimumUnreachableHosts = if ($configured -and $configured.PSObject.Properties["minimumUnreachableHosts"]) { [math]::Max(1, [int]$configured.minimumUnreachableHosts) } else { 1 }
cooldownMinutes = if ($configured -and $configured.PSObject.Properties["cooldownMinutes"]) { [math]::Max(1, [int]$configured.cooldownMinutes) } else { 10 }
}
}
function Start-AgentRecoveryFromObservation {
param(
[string]$Reason,
[object]$Observation = $null
)
$autoRecovery = Get-AgentAutoRecoveryConfig
if (-not $autoRecovery.enabled) {
return [pscustomobject]@{ ok = $false; queued = $false; reason = "auto_recovery_disabled"; trigger = $Reason }
}
$queueDir = Join-Path $Config.agentRoot "queue"
$stateDir = Join-Path $Config.agentRoot "state"
$statePath = Join-Path $stateDir "recovery-trigger-state.json"
New-Item -ItemType Directory -Force -Path $queueDir, $stateDir | Out-Null
foreach ($file in @(Get-ChildItem -Path $queueDir -Filter "*.json" -File -ErrorAction SilentlyContinue)) {
try {
$pending = Get-Content $file.FullName -Raw | ConvertFrom-Json
if ([string]$pending.mode -eq "Recover") {
return [pscustomobject]@{ ok = $true; queued = $false; suppressed = $true; reason = "recovery_already_queued"; queuePath = $file.FullName; trigger = $Reason }
}
} catch {
Write-AgentLog "recovery_queue_probe_failed path=$($file.FullName) error=$($_.Exception.Message)"
}
}
if (Test-Path $statePath) {
try {
$state = Get-Content $statePath -Raw | ConvertFrom-Json
$lastRequestedAt = [datetime]$state.lastRequestedAt
$ageMinutes = ((Get-Date) - $lastRequestedAt).TotalMinutes
if ($ageMinutes -lt $autoRecovery.cooldownMinutes) {
return [pscustomobject]@{ ok = $true; queued = $false; suppressed = $true; reason = "recovery_trigger_cooldown"; ageMinutes = [math]::Round($ageMinutes, 2); trigger = $Reason }
}
} catch {
Write-AgentLog "recovery_trigger_state_read_failed error=$($_.Exception.Message)"
}
}
$id = "auto-recover-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + ([guid]::NewGuid().ToString("N").Substring(0, 8))
$queuePath = Join-Path $queueDir "$id.json"
$temporaryPath = "$queuePath.tmp"
$createdAt = Get-Date -Format o
$request = [pscustomobject]@{
id = $id
mode = "Recover"
controlledApply = $true
requestedBy = "Agent99"
source = "agent99-recovery-observer"
externalId = $Reason
reason = $Reason
instruction = "Agent99 detected $Reason; run the allowlisted VM, host, Harbor, K3s, public-route, and freshness recovery chain."
observation = $Observation
createdAt = $createdAt
}
$request | ConvertTo-Json -Depth 8 | Set-Content -Path $temporaryPath -Encoding UTF8
Move-Item -Force $temporaryPath $queuePath
[pscustomobject]@{
schemaVersion = "agent99_recovery_trigger_state_v1"
lastRequestedAt = $createdAt
reason = $Reason
queueId = $id
queuePath = $queuePath
} | ConvertTo-Json -Depth 6 | Set-Content -Path $statePath -Encoding UTF8
$result = [pscustomobject]@{ ok = $true; queued = $true; suppressed = $false; reason = $Reason; id = $id; queuePath = $queuePath }
Record-AgentEvent "recovery_auto_triggered" "warning" "偵測到 $Reason;已排入單一受控 Recover 佇列。" $result -Alert
$result
}
function Get-AgentRecoverySloConfig {
$configured = if ($Config.PSObject.Properties["recoverySlo"]) { $Config.recoverySlo } else { $null }
[pscustomobject]@{
targetMinutes = if ($configured -and $configured.PSObject.Properties["targetMinutes"]) { [int]$configured.targetMinutes } else { 10 }
hostTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["hostTimeoutSeconds"]) { [int]$configured.hostTimeoutSeconds } else { 90 }
harborTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["harborTimeoutSeconds"]) { [int]$configured.harborTimeoutSeconds } else { 120 }
workloadTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["workloadTimeoutSeconds"]) { [int]$configured.workloadTimeoutSeconds } else { 180 }
}
}
function New-AgentRecoveryPhase {
param(
[string]$Name,
[string]$Status,
[double]$ElapsedSeconds,
[string]$Evidence = ""
)
[pscustomobject]@{
name = $Name
status = $Status
elapsedSeconds = [math]::Round($ElapsedSeconds, 1)
evidence = if ($Evidence) { $Evidence } else { $null }
}
}
function Invoke-SshText {
param(
[string]$TargetHost,
[string]$Command,
[int]$TimeoutSeconds = 12,
[int]$Retries = 2,
[string]$User = $null
)
$transportLock = Enter-AgentSshTransportLock
if (-not $transportLock.acquired) {
Write-AgentLog "ssh_transport_lock_failed host=$TargetHost reason=$($transportLock.reason) waitMs=$($transportLock.waitMs)"
return [pscustomobject]@{
ok = $false
exitCode = -4
output = "ssh_transport_lock_$($transportLock.reason)"
elapsedMs = $transportLock.waitMs
transportSerialized = $true
transportLockWaitMs = $transportLock.waitMs
}
}
try {
$last = $null
$userToUse = if ($User) { $User } else { Get-HostSshUser $TargetHost }
for ($attempt = 1; $attempt -le $Retries; $attempt++) {
$args = @(Get-AgentSshIdentityArguments) + @(
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "NumberOfPasswordPrompts=0",
"-o", "ConnectTimeout=$TimeoutSeconds",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=3",
"-l", $userToUse,
$TargetHost,
$Command
)
$stopwatch = $null
try {
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
$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)) {
$stopwatch.Stop()
try {
& taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null
} catch {}
if (-not $process.WaitForExit(2000) -and -not $process.HasExited) {
$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"
elapsedMs = $stopwatch.ElapsedMilliseconds
}
} else {
$process.WaitForExit() | Out-Null
$stopwatch.Stop()
$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
elapsedMs = $stopwatch.ElapsedMilliseconds
}
}
} catch {
$last = [pscustomobject]@{
ok = $false
exitCode = -1
output = $_.Exception.Message
elapsedMs = if ($stopwatch) { $stopwatch.ElapsedMilliseconds } else { $null }
}
}
if ($last.ok) {
if ($attempt -gt 1) {
Write-AgentLog "ssh_retry_recovered host=$TargetHost attempt=$attempt"
}
return (Add-AgentSshTransportMetadata $last $transportLock)
}
if ($attempt -lt $Retries) {
Write-AgentLog "ssh_retry host=$TargetHost attempt=$attempt exit=$($last.exitCode)"
Start-Sleep -Seconds 2
}
}
Add-AgentSshTransportMetadata $last $transportLock
} finally {
Exit-AgentSshTransportLock $transportLock
}
}
function Quote-ShSingle {
param([string]$Text)
"'" + $Text.Replace("'", "'`"`"'") + "'"
}
function Invoke-HostSshText {
param(
[string]$TargetHost,
[string]$Command,
[int]$TimeoutSeconds = 30,
[int]$Retries = 1
)
$jumpHost = if ($Config.k3s -and $Config.k3s.PSObject.Properties["jumpHost"]) { [string]$Config.k3s.jumpHost } else { "" }
$preferJumpHost = [bool]($Config.k3s -and $Config.k3s.PSObject.Properties["preferJumpHost"] -and (Convert-AgentBool $Config.k3s.preferJumpHost))
$directHosts = if ($Config.k3s -and $Config.k3s.PSObject.Properties["directHosts"]) { @($Config.k3s.directHosts) } else { @() }
$canUseJump = [bool]($jumpHost -and $TargetHost -ne $jumpHost -and $TargetHost -notin $directHosts)
$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"
if ($preferJumpHost -and $canUseJump) {
$viaJump = Invoke-SshText $jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$jumpHost" -Force
if ($viaJump.ok) { return $viaJump }
Write-AgentLog "ssh_jump_fallback host=$TargetHost jumpHost=$jumpHost exit=$($viaJump.exitCode)"
}
$direct = Invoke-SshText $TargetHost $Command $TimeoutSeconds $Retries
if ($direct.ok -or -not $canUseJump -or $preferJumpHost) {
$direct | Add-Member -NotePropertyName route -NotePropertyValue "direct" -Force
return $direct
}
$viaJump = Invoke-SshText $jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$jumpHost" -Force
return $viaJump
}
function Get-AgentRecoveryCoordinatorConfig {
$configured = if ($Config.PSObject.Properties["recoveryCoordinator"]) { $Config.recoveryCoordinator } else { $null }
[pscustomobject]@{
enabled = [bool](-not $configured -or -not $configured.PSObject.Properties["enabled"] -or (Convert-AgentBool $configured.enabled))
controllerHost = if ($configured -and $configured.PSObject.Properties["controllerHost"]) { [string]$configured.controllerHost } else { "192.168.0.110" }
readbackTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["readbackTimeoutSeconds"]) { [math]::Min(30, [math]::Max(5, [int]$configured.readbackTimeoutSeconds)) } else { 15 }
pollIntervalSeconds = if ($configured -and $configured.PSObject.Properties["pollIntervalSeconds"]) { [math]::Min(30, [math]::Max(5, [int]$configured.pollIntervalSeconds)) } else { 10 }
freshWaitTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["freshWaitTimeoutSeconds"]) { [math]::Min(480, [math]::Max(30, [int]$configured.freshWaitTimeoutSeconds)) } else { 420 }
maxArtifactAgeSeconds = if ($configured -and $configured.PSObject.Properties["maxArtifactAgeSeconds"]) { [math]::Min(1800, [math]::Max(60, [int]$configured.maxArtifactAgeSeconds)) } else { 600 }
artifactClockSkewSeconds = if ($configured -and $configured.PSObject.Properties["artifactClockSkewSeconds"]) { [math]::Min(300, [math]::Max(0, [int]$configured.artifactClockSkewSeconds)) } else { 90 }
requiredHostAliases = if ($configured -and $configured.PSObject.Properties["requiredHostAliases"]) { @($configured.requiredHostAliases | ForEach-Object { [string]$_ }) } else { @("99", "110", "111", "112", "120", "121", "188") }
}
}
function Convert-AgentRecoveryReadback {
param(
[string]$Text,
[datetime]$RecoveryStartedAt,
[bool]$RequireFreshRebootWindow,
[object]$CoordinatorConfig
)
$values = @{}
$hosts = @()
foreach ($line in @($Text -split "`r?`n")) {
if ($line -match '^([A-Z0-9_]+)=(.*)$') {
$values[$Matches[1]] = $Matches[2].Trim()
continue
}
if ($line -match '^HOST_BOOT\s+alias=([^\s]+).*?\sreachable=([01])(?:\s|$).*?\suptime_seconds=(-?[0-9]+)(?:\s|$)') {
$hosts += [pscustomobject]@{
alias = [string]$Matches[1]
reachable = [bool]([int]$Matches[2])
uptimeSeconds = [int64]$Matches[3]
}
}
}
$get = {
param([string]$Name, [string]$Default = "")
if ($values.ContainsKey($Name)) { return [string]$values[$Name] }
return $Default
}
$asBool = {
param([string]$Name)
[bool]((& $get $Name "0") -eq "1")
}
$asInt = {
param([string]$Name, [int64]$Default = -1)
$value = & $get $Name ""
if ($value -match '^-?[0-9]+$') { return [int64]$value }
return $Default
}
$artifactEpoch = & $asInt "RECOVERY_ARTIFACT_EPOCH" -1
$nowEpoch = [DateTimeOffset]::Now.ToUnixTimeSeconds()
$evidenceNotBeforeEpoch = ([DateTimeOffset]$RecoveryStartedAt).ToUnixTimeSeconds()
$artifactAgeSeconds = if ($artifactEpoch -gt 0) { [math]::Max(0, $nowEpoch - $artifactEpoch) } else { -1 }
$artifactRecent = [bool]($artifactAgeSeconds -ge 0 -and $artifactAgeSeconds -le $CoordinatorConfig.maxArtifactAgeSeconds)
$artifactFreshForRecovery = [bool]($artifactEpoch -ge ($evidenceNotBeforeEpoch - $CoordinatorConfig.artifactClockSkewSeconds))
$requiredHostFailures = @()
foreach ($alias in @($CoordinatorConfig.requiredHostAliases)) {
$row = $hosts | Where-Object { $_.alias -eq $alias } | Select-Object -First 1
if (-not $row -or -not $row.reachable) { $requiredHostFailures += $alias }
}
$checks = @(
[pscustomobject]@{ name = "readback_present"; passed = (& $asBool "RECOVERY_READBACK") },
[pscustomobject]@{ name = "artifact_recent"; passed = $artifactRecent },
[pscustomobject]@{ name = "required_hosts_reachable"; passed = [bool]($requiredHostFailures.Count -eq 0) },
[pscustomobject]@{ name = "post_start_unblocked"; passed = [bool]((& $get "POST_START_BLOCKED" "-1") -eq "0") },
[pscustomobject]@{ name = "service_green"; passed = (& $asBool "SERVICE_GREEN") },
[pscustomobject]@{ name = "product_data_green"; passed = (& $asBool "PRODUCT_DATA_GREEN") },
[pscustomobject]@{ name = "stock_freshness_ok"; passed = [bool]((& $get "STOCK_FRESHNESS_STATUS") -eq "ok") },
[pscustomobject]@{ name = "backup_core_green"; passed = (& $asBool "BACKUP_CORE_GREEN") },
[pscustomobject]@{ name = "host_188_service_green"; passed = (& $asBool "HOST_188_SERVICE_GREEN") },
[pscustomobject]@{ name = "host_188_hygiene_green"; passed = [bool]((& $get "HOST_188_HYGIENE_BLOCKED" "1") -eq "0") },
[pscustomobject]@{ name = "edge_fallback_ready"; passed = (& $asBool "EDGE_FALLBACK_READY") },
[pscustomobject]@{ name = "windows99_vmware_ready"; passed = (& $asBool "VMWARE_AUTOSTART_VERIFY_READY") },
[pscustomobject]@{ name = "windows99_update_policy_ready"; passed = (& $asBool "WINDOWS_UPDATE_NO_AUTO_REBOOT_READY") },
[pscustomobject]@{ name = "scorecard_source_controls_ready"; passed = (& $asBool "SCORECARD_SOURCE_CONTROLS_READY") },
[pscustomobject]@{ name = "scorecard_public_fallback_ready"; passed = (& $asBool "SCORECARD_PUBLIC_FALLBACK_READY") }
)
$sloChecks = @()
if ($RequireFreshRebootWindow) {
$checks += @(
[pscustomobject]@{ name = "artifact_fresh_for_recovery"; passed = $artifactFreshForRecovery },
[pscustomobject]@{
name = "scorecard_non_slo_blockers_clear"
passed = [bool](
((& $asInt "SCORECARD_BLOCKER_COUNT" -1) -eq 0) -or
(& $asBool "SCORECARD_BLOCKED_BY_REBOOT_WINDOW_ONLY")
)
}
)
$sloChecks = @(
[pscustomobject]@{ name = "fresh_reboot_window_observed"; passed = (& $asBool "SCORECARD_FRESH_REBOOT_WINDOW") },
[pscustomobject]@{ name = "scorecard_can_claim_slo"; passed = (& $asBool "SCORECARD_CAN_CLAIM") },
[pscustomobject]@{ name = "scorecard_has_no_blockers"; passed = [bool]((& $asInt "SCORECARD_BLOCKER_COUNT" -1) -eq 0) }
)
}
$failedChecks = @($checks | Where-Object { -not $_.passed } | ForEach-Object { $_.name })
$sloFailedChecks = @($sloChecks | Where-Object { -not $_.passed } | ForEach-Object { $_.name })
[pscustomobject]@{
schemaVersion = "agent99_recovery_coordinator_readback_v1"
ok = [bool]($failedChecks.Count -eq 0)
verified = [bool]($failedChecks.Count -eq 0)
requireFreshRebootWindow = $RequireFreshRebootWindow
rebootSloClaimed = [bool]($RequireFreshRebootWindow -and $sloFailedChecks.Count -eq 0)
artifactDir = & $get "RECOVERY_ARTIFACT_DIR"
artifactEpoch = $artifactEpoch
artifactAgeSeconds = $artifactAgeSeconds
artifactFreshForRecovery = $artifactFreshForRecovery
scorecardStatus = & $get "SCORECARD_STATUS"
scorecardBlockerCount = & $asInt "SCORECARD_BLOCKER_COUNT" -1
scorecardPrimaryBlocker = & $get "SCORECARD_PRIMARY_BLOCKER"
readinessPercent = & $asInt "SCORECARD_READINESS_PERCENT" -1
postStartResult = & $get "POST_START_RESULT"
overallDeclaration = & $get "OVERALL_DECLARATION"
requiredHostAliases = @($CoordinatorConfig.requiredHostAliases)
requiredHostFailures = $requiredHostFailures
hosts = $hosts
checks = $checks
failedChecks = $failedChecks
sloChecks = $sloChecks
sloFailedChecks = $sloFailedChecks
}
}
function Invoke-AgentRecoveryCoordinatorReadback {
param(
[datetime]$RecoveryStartedAt,
[bool]$RequireFreshRebootWindow
)
$coordinator = Get-AgentRecoveryCoordinatorConfig
if (-not $coordinator.enabled) {
return [pscustomobject]@{
schemaVersion = "agent99_recovery_coordinator_readback_v1"
enabled = $false
ok = $false
verified = $false
requireFreshRebootWindow = $RequireFreshRebootWindow
rebootSloClaimed = $false
failedChecks = @("recovery_coordinator_disabled")
attempts = @()
}
}
$readbackCommand = @'
latest="$(find /home/wooo/reboot-recovery -mindepth 2 -maxdepth 2 -type f -name 'scorecard.json' -printf '%T@ %h\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
printf 'RECOVERY_READBACK=0\n'
exit 0
fi
printf 'RECOVERY_READBACK=1\n'
printf 'RECOVERY_ARTIFACT_DIR=%s\n' "$latest"
stat -c 'RECOVERY_ARTIFACT_EPOCH=%Y' "$latest" 2>/dev/null || true
grep -E '^(POST_START_RESULT|POST_START_BLOCKED|SERVICE_GREEN|PRODUCT_DATA_GREEN|STOCK_FRESHNESS_STATUS|BACKUP_CORE_GREEN|HOST_188_SERVICE_GREEN|HOST_188_HYGIENE_BLOCKED|OVERALL_DECLARATION)=' "$latest/summary.txt" 2>/dev/null || true
grep -E '^HOST_BOOT ' "$latest/host-probe.txt" 2>/dev/null || true
grep -E '^(EDGE_FALLBACK_READY|PRIVILEGED_EXECUTOR_READY|EDGE_EXECUTOR_PAYLOAD_PARITY)=' "$latest/public-maintenance-edge-fallback.txt" 2>/dev/null || true
grep -E '^(VMWARE_AUTOSTART_CONFIG_READY|VMWARE_AUTOSTART_POWER_READY|WINDOWS_UPDATE_NO_AUTO_REBOOT_READY|VMWARE_AUTOSTART_VERIFY_READY)=' "$latest/windows99-vmware-verify.txt" 2>/dev/null || true
printf %s aW1wb3J0IGpzb24sc3lzCmQ9anNvbi5sb2FkKG9wZW4oc3lzLmFyZ3ZbMV0pKQpyPWQuZ2V0KCJyZXF1aXJlZF9jaGVja3MiLHt9KQpvPWQuZ2V0KCJyb2xsdXBzIix7fSkKYj1sYW1iZGEgdjoiMSIgaWYgdiBlbHNlICIwIgpwcmludCgiU0NPUkVDQVJEX1NUQVRVUz0iK3N0cihkLmdldCgic3RhdHVzIiwiIikpKQpwcmludCgiU0NPUkVDQVJEX0JMT0NLRVJfQ09VTlQ9IitzdHIoby5nZXQoImFjdGl2ZV9ibG9ja2VyX2NvdW50IiwtMSkpKQpwcmludCgiU0NPUkVDQVJEX1JFQURJTkVTU19QRVJDRU5UPSIrc3RyKG8uZ2V0KCJyZWFkaW5lc3NfcGVyY2VudCIsLTEpKSkKcHJpbnQoIlNDT1JFQ0FSRF9QUklNQVJZX0JMT0NLRVI9IitzdHIoZC5nZXQoInByaW1hcnlfYmxvY2tlciIsIiIpKSkKcHJpbnQoIlNDT1JFQ0FSRF9DQU5fQ0xBSU09IitiKGQuZ2V0KCJjYW5fY2xhaW1fYWxsX3NlcnZpY2VzX3JlY292ZXJlZF93aXRoaW5fdGFyZ2V0IikpKQpwcmludCgiU0NPUkVDQVJEX0ZSRVNIX1JFQk9PVF9XSU5ET1c9IitiKHIuZ2V0KCJmcmVzaF9yZWJvb3Rfd2luZG93X29ic2VydmVkIikpKQpwcmludCgiU0NPUkVDQVJEX0JMT0NLRURfQllfUkVCT09UX1dJTkRPV19PTkxZPSIrYihvLmdldCgiYmxvY2tlZF9ieV9mcmVzaF9yZWJvb3Rfd2luZG93X29ubHkiKSkpCnByaW50KCJTQ09SRUNBUkRfU09VUkNFX0NPTlRST0xTX1JFQURZPSIrYihyLmdldCgic291cmNlX2NvbnRyb2xzX3ByZXNlbnQiKSkpCnByaW50KCJTQ09SRUNBUkRfUFVCTElDX0ZBTExCQUNLX1JFQURZPSIrYihyLmdldCgicHVibGljX21haW50ZW5hbmNlX2ZhbGxiYWNrX3J1bnRpbWVfcmVhZHkiKSkpCg== | base64 -d | python3 - "$latest/scorecard.json" 2>/dev/null || true
'@
$deadline = (Get-Date).AddSeconds($(if ($RequireFreshRebootWindow) { $coordinator.freshWaitTimeoutSeconds } else { 1 }))
$attempts = @()
$latestResult = $null
do {
$transport = Invoke-HostSshText $coordinator.controllerHost $readbackCommand $coordinator.readbackTimeoutSeconds 1
if ($transport.ok) {
$latestResult = Convert-AgentRecoveryReadback $transport.output $RecoveryStartedAt $RequireFreshRebootWindow $coordinator
$latestResult | Add-Member -NotePropertyName enabled -NotePropertyValue $true -Force
$latestResult | Add-Member -NotePropertyName controllerHost -NotePropertyValue $coordinator.controllerHost -Force
$latestResult | Add-Member -NotePropertyName transportRoute -NotePropertyValue $transport.route -Force
$latestResult | Add-Member -NotePropertyName transportSerialized -NotePropertyValue $transport.transportSerialized -Force
$latestResult | Add-Member -NotePropertyName transportLockWaitMs -NotePropertyValue $transport.transportLockWaitMs -Force
} else {
$latestResult = [pscustomobject]@{
schemaVersion = "agent99_recovery_coordinator_readback_v1"
enabled = $true
ok = $false
verified = $false
requireFreshRebootWindow = $RequireFreshRebootWindow
rebootSloClaimed = $false
controllerHost = $coordinator.controllerHost
transportRoute = $transport.route
transportSerialized = $transport.transportSerialized
transportLockWaitMs = $transport.transportLockWaitMs
failedChecks = @("controller_transport_failed")
}
}
$attempts += [pscustomobject]@{
observedAt = (Get-Date -Format o)
verified = [bool]$latestResult.verified
artifactDir = if ($latestResult.PSObject.Properties["artifactDir"]) { [string]$latestResult.artifactDir } else { "" }
failedChecks = @($latestResult.failedChecks)
}
if ($latestResult.verified -or -not $RequireFreshRebootWindow) { break }
if ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $coordinator.pollIntervalSeconds }
} while ((Get-Date) -lt $deadline)
$latestResult | Add-Member -NotePropertyName attempts -NotePropertyValue $attempts -Force
$latestResult | Add-Member -NotePropertyName attemptCount -NotePropertyValue $attempts.Count -Force
$latestResult
}
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
$readTimeoutSeconds = 45
if ($Config.performance -and $Config.performance.PSObject.Properties["readTimeoutSeconds"]) {
$readTimeoutSeconds = [int]$Config.performance.readTimeoutSeconds
}
$readRetries = 1
if ($Config.performance -and $Config.performance.PSObject.Properties["readRetries"]) {
$readRetries = [int]$Config.performance.readRetries
}
$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) {
$hostTimeoutSeconds = $readTimeoutSeconds
if ($Config.performance -and $Config.performance.PSObject.Properties["hostReadTimeoutSeconds"] -and $Config.performance.hostReadTimeoutSeconds.PSObject.Properties[$hostIp]) {
$hostTimeoutSeconds = [int]$Config.performance.hostReadTimeoutSeconds.PSObject.Properties[$hostIp].Value
}
$slowRetryUsed = $false
$slowTimeoutSeconds = $null
$readback = Invoke-HostSshText $hostIp $command $hostTimeoutSeconds $readRetries
if (-not $readback.ok -and $readback.exitCode -eq -2) {
$slowTimeoutSeconds = 90
if ($Config.performance -and $Config.performance.PSObject.Properties["slowReadTimeoutSeconds"]) {
$slowTimeoutSeconds = [int]$Config.performance.slowReadTimeoutSeconds
}
if ($Config.performance -and $Config.performance.PSObject.Properties["hostSlowReadTimeoutSeconds"] -and $Config.performance.hostSlowReadTimeoutSeconds.PSObject.Properties[$hostIp]) {
$slowTimeoutSeconds = [int]$Config.performance.hostSlowReadTimeoutSeconds.PSObject.Properties[$hostIp].Value
}
if ($slowTimeoutSeconds -gt $hostTimeoutSeconds) {
Write-AgentLog "perf slow_retry host=$hostIp firstTimeoutSeconds=$hostTimeoutSeconds slowTimeoutSeconds=$slowTimeoutSeconds"
$slowReadback = Invoke-HostSshText $hostIp $command $slowTimeoutSeconds 1
$slowReadback | Add-Member -NotePropertyName firstAttemptExitCode -NotePropertyValue $readback.exitCode -Force
$slowReadback | Add-Member -NotePropertyName firstAttemptElapsedMs -NotePropertyValue $readback.elapsedMs -Force
$readback = $slowReadback
$slowRetryUsed = $true
}
}
$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
readTimeoutSeconds = $hostTimeoutSeconds
slowReadTimeoutSeconds = $slowTimeoutSeconds
readbackSlowRetry = $slowRetryUsed
readbackElapsedMs = $readback.elapsedMs
transportSerialized = if ($readback.PSObject.Properties["transportSerialized"]) { [bool]$readback.transportSerialized } else { $false }
transportLockWaitMs = if ($readback.PSObject.Properties["transportLockWaitMs"]) { [long]$readback.transportLockWaitMs } else { $null }
}
Write-AgentLog "perf host=$hostIp severity=$severity loadPerCore=$loadPerCore memAvailPct=$memAvailablePercent diskUsedPct=$diskUsedPercent route=$($readback.route) elapsedMs=$($readback.elapsedMs) transportSerialized=$($result.transportSerialized) transportLockWaitMs=$($result.transportLockWaitMs) timeoutSeconds=$hostTimeoutSeconds slowRetry=$slowRetryUsed"
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 }
$failureBackoffMinutes = if ($action.PSObject.Properties["failureBackoffMinutes"]) { [int]$action.failureBackoffMinutes } else { 10 }
$safeName = ($action.name -replace "[^A-Za-z0-9_.-]", "_")
$markerPath = Join-Path $EvidenceDir "loadshedding-$safeName.marker"
$failureMarkerPath = Join-Path $EvidenceDir "loadshedding-$safeName.failure.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
}
}
if (Test-Path $failureMarkerPath) {
$failureAgeMinutes = ((Get-Date) - (Get-Item $failureMarkerPath).LastWriteTime).TotalMinutes
if ($failureAgeMinutes -lt $failureBackoffMinutes) {
$results += [pscustomobject]@{ name = $action.name; host = $action.host; skipped = $true; reason = "failure_backoff"; ageMinutes = [math]::Round($failureAgeMinutes, 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)
}
}
}
$postVerifyRequired = [bool]($action.PSObject.Properties["postVerifyCommand"] -and $action.postVerifyCommand)
$effectiveOk = if ($postVerifyRequired) { [bool]$postVerifyOk } else { [bool]$run.ok }
if ($effectiveOk) {
Set-Content -Path $markerPath -Value (Get-Date -Format o) -Encoding UTF8
Remove-Item -Force $failureMarkerPath -ErrorAction SilentlyContinue | Out-Null
} else {
Set-Content -Path $failureMarkerPath -Value (Get-Date -Format o) -Encoding UTF8
}
$result = [pscustomobject]@{
name = $action.name
host = $action.host
skipped = $false
ok = $effectiveOk
executionOk = [bool]$run.ok
postVerifyRequired = $postVerifyRequired
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]@{
name = "sre_alert_relay"
pattern = "agent99-SreAlertRelay-start-*.json"
maxAgeMinutes = 1440
required = $false
runningTaskName = "Wooo-Agent99-SRE-Alert-Relay"
}
)
[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,
[object[]]$TaskHealth = @()
)
$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)
$runningTaskName = if ($contract.PSObject.Properties["runningTaskName"]) {
[string]$contract.runningTaskName
} elseif ($name -eq "sre_alert_relay") {
# Backward-compatible migration for live configs written before runningTaskName existed.
"Wooo-Agent99-SRE-Alert-Relay"
} else {
$null
}
$runningTask = if ($runningTaskName) {
$TaskHealth | Where-Object { $_.name -eq $runningTaskName } | Select-Object -First 1
} else {
$null
}
$runningTaskHealthy = [bool]($runningTask -and $runningTask.ok -and $runningTask.state -eq "Running")
$staleStartEvidence = [bool](
$latest.exists -and
$null -eq $latest.parseError -and
$latest.ageMinutes -gt $maxAgeMinutes
)
$freshnessSupersededByRunningTask = [bool]($runningTaskName -and $runningTaskHealthy -and $staleStartEvidence)
$ok = [bool](((-not $required) -and (-not $latest.exists)) -or $fresh -or $freshnessSupersededByRunningTask)
$severity = if ($ok) { "ok" } elseif ($required) { "critical" } else { "warning" }
$reason = if ($fresh) {
"evidence_fresh"
} elseif ($freshnessSupersededByRunningTask) {
"running_task_supersedes_stale_start_evidence"
} elseif ((-not $required) -and (-not $latest.exists)) {
"optional_evidence_missing"
} elseif ($latest.parseError) {
"evidence_parse_failed"
} else {
"evidence_stale_or_missing"
}
$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
reason = $reason
runningTaskName = $runningTaskName
runningTaskHealthy = $runningTaskHealthy
freshnessSupersededByRunningTask = $freshnessSupersededByRunningTask
}
Write-AgentLog "selfcheck evidence name=$name ok=$ok severity=$severity reason=$reason ageMinutes=$($latest.ageMinutes) maxAgeMinutes=$maxAgeMinutes file=$($latest.fileName) runningTask=$runningTaskName runningTaskHealthy=$runningTaskHealthy"
}
$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-AgentRuntimeManifest {
$manifestPath = Join-Path $Config.agentRoot "state\runtime-manifest.json"
if (-not (Test-Path $manifestPath)) {
return [pscustomobject]@{
ok = $false
severity = "critical"
reason = "runtime_manifest_missing"
manifestPath = $manifestPath
sourceRevision = $null
driftCount = 0
missingCount = 1
files = @()
}
}
try {
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
} catch {
return [pscustomobject]@{
ok = $false
severity = "critical"
reason = "runtime_manifest_parse_failed"
manifestPath = $manifestPath
sourceRevision = $null
driftCount = 0
missingCount = 0
files = @()
error = $_.Exception.Message
}
}
$rows = @()
foreach ($entry in @($manifest.files)) {
$name = [string]$entry.name
$runtimePath = Join-Path $PSScriptRoot $name
$exists = Test-Path $runtimePath
$actualSha256 = $null
if ($exists) {
try {
$actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $runtimePath).Hash.ToLowerInvariant()
} catch {
$actualSha256 = $null
}
}
$expectedSha256 = if ($entry.runtimeSha256) { ([string]$entry.runtimeSha256).ToLowerInvariant() } elseif ($entry.sourceSha256) { ([string]$entry.sourceSha256).ToLowerInvariant() } else { "" }
$matched = [bool]($exists -and $actualSha256 -and $expectedSha256 -and $actualSha256 -eq $expectedSha256)
$rows += [pscustomobject]@{
name = $name
exists = $exists
matched = $matched
expectedSha256 = $expectedSha256
actualSha256 = $actualSha256
}
}
$missingCount = @($rows | Where-Object { -not $_.exists }).Count
$driftCount = @($rows | Where-Object { $_.exists -and -not $_.matched }).Count
$ready = [bool](@($rows).Count -gt 0 -and $missingCount -eq 0 -and $driftCount -eq 0 -and $manifest.runtimeMatched -eq $true)
[pscustomobject]@{
ok = $ready
severity = if ($ready) { "ok" } else { "critical" }
reason = if ($ready) { "runtime_manifest_matched" } else { "runtime_source_drift_or_missing" }
manifestPath = $manifestPath
sourceRevision = $manifest.sourceRevision
generatedAt = $manifest.generatedAt
driftCount = $driftCount
missingCount = $missingCount
files = $rows
}
}
function Test-AgentSelfHealth {
$selfConfig = Get-AgentSelfHealthConfig
$tasks = Test-AgentScheduledTaskHealth $selfConfig.scheduledTasks
$evidence = Test-AgentEvidenceFreshness $selfConfig.evidenceFreshness $tasks
$latestPerf = Test-AgentLatestPerformanceEvidence $selfConfig.requiredHosts
$runtimeManifest = Test-AgentRuntimeManifest
$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 }
if ($runtimeManifest.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) runtimeManifest=$($runtimeManifest.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
runtimeManifest = $runtimeManifest
summary = [pscustomobject]@{
taskFailures = $taskFailures
evidenceCritical = $evidenceCritical
evidenceWarning = $evidenceWarning
performanceSeverity = $latestPerf.severity
telegramSeverity = $telegram.severity
runtimeManifestSeverity = $runtimeManifest.severity
runtimeManifestSourceRevision = $runtimeManifest.sourceRevision
runtimeManifestDriftCount = $runtimeManifest.driftCount
}
}
}
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"
$timeoutSeconds = 45
if ($Config.backupHealth -and $Config.backupHealth.PSObject.Properties["fileReadTimeoutSeconds"]) {
$timeoutSeconds = [int]$Config.backupHealth.fileReadTimeoutSeconds
}
if ($FileCheck.PSObject.Properties["timeoutSeconds"]) {
$timeoutSeconds = [int]$FileCheck.timeoutSeconds
}
$readback = Invoke-HostSshText $hostIp $command $timeoutSeconds 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
readTimeoutSeconds = $timeoutSeconds
readbackElapsedMs = $readback.elapsedMs
}
Write-AgentLog "backup file=$name severity=$severity reason=$reason ageMinutes=$ageMinutes elapsedMs=$($readback.elapsedMs) timeoutSeconds=$timeoutSeconds"
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__"
$timeoutSeconds = 45
if ($Config.backupHealth -and $Config.backupHealth.PSObject.Properties["cronReadTimeoutSeconds"]) {
$timeoutSeconds = [int]$Config.backupHealth.cronReadTimeoutSeconds
}
$readback = Invoke-HostSshText $DefaultHost $command $timeoutSeconds 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
readTimeoutSeconds = $timeoutSeconds
readbackElapsedMs = $readback.elapsedMs
}
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
)
$timeoutSeconds = 45
if ($Config.backupHealth -and $Config.backupHealth.PSObject.Properties["cronReadTimeoutSeconds"]) {
$timeoutSeconds = [int]$Config.backupHealth.cronReadTimeoutSeconds
}
$readback = Invoke-HostSshText $DefaultHost "crontab -l 2>/dev/null" $timeoutSeconds 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
readTimeoutSeconds = $timeoutSeconds
readbackElapsedMs = $readback.elapsedMs
}
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
readTimeoutSeconds = $timeoutSeconds
readbackElapsedMs = $readback.elapsedMs
}
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 New-AgentOutcomeCheck {
param(
[string]$Name,
[bool]$Passed,
[bool]$Required = $true,
[string]$Detail = ""
)
[pscustomobject]@{
name = $Name
passed = $Passed
required = $Required
detail = $Detail
}
}
function Get-AgentOutcomeContract {
param(
[string]$ModeName,
[int]$TransportExitCode,
[object]$LatestEvidence,
[datetime]$StartedAt,
[bool]$SourceEventResolutionRequired,
[object]$SourceEventResolved = $null,
[string]$SourceEventEvidence = "",
[string]$SourceEventResolutionPolicy = "external_source_receipt_required"
)
$checks = @()
$transportOk = [bool]($TransportExitCode -eq 0)
$evidenceExists = [bool]($LatestEvidence -and $LatestEvidence.exists)
$evidenceParsed = [bool]($evidenceExists -and -not $LatestEvidence.parseError -and $LatestEvidence.data)
$evidenceFresh = $false
if ($evidenceExists -and $LatestEvidence.lastWriteTime) {
try {
$evidenceTime = [datetime]::Parse([string]$LatestEvidence.lastWriteTime)
$evidenceFresh = [bool]($evidenceTime -ge $StartedAt.AddSeconds(-5))
} catch {
$evidenceFresh = $false
}
}
$data = if ($evidenceParsed) { $LatestEvidence.data } else { $null }
$evidenceMode = if ($data -and $data.PSObject.Properties["mode"]) { [string]$data.mode } else { "" }
$modeMatches = [bool]($evidenceMode -eq $ModeName)
$checks += New-AgentOutcomeCheck "transport_completed" $transportOk $true "exitCode=$TransportExitCode"
$checks += New-AgentOutcomeCheck "evidence_exists" $evidenceExists $true ([string]$LatestEvidence.path)
$checks += New-AgentOutcomeCheck "evidence_parsed" $evidenceParsed $true ([string]$LatestEvidence.parseError)
$checks += New-AgentOutcomeCheck "evidence_fresh" $evidenceFresh $true ([string]$LatestEvidence.lastWriteTime)
$checks += New-AgentOutcomeCheck "evidence_mode_matches" $modeMatches $true "expected=$ModeName actual=$evidenceMode"
$verifierName = "agent99-$($ModeName.ToLowerInvariant())-post-condition-v1"
$modeVerifierPassed = $false
$successState = "resolved"
$failureState = "degraded"
if ($data) {
switch ($ModeName) {
"Status" {
$expectedHosts = @($Config.hosts).Count
$hostRows = @($data.hosts)
$hostsOk = [bool]($expectedHosts -gt 0 -and $hostRows.Count -ge $expectedHosts -and @($hostRows | Where-Object { -not $_.ping }).Count -eq 0)
$harborOk = [bool]($data.harbor -and $data.harbor.redisUp -and $data.harbor.coreHealthy -and $data.harbor.registryHealthy)
$awoooOk = [bool]($data.awoooi -and $data.awoooi.deployReady -and -not $data.awoooi.imagePullFailure -and -not $data.awoooi.crashFailure)
$publicRows = @($data.public)
$publicOk = [bool]($publicRows.Count -gt 0 -and @($publicRows | Where-Object { -not $_.ok }).Count -eq 0)
$aiOk = [bool](@($data.aiServices | Where-Object { -not $_.ok }).Count -eq 0)
$host112Ok = [bool]($data.host112Recovery -and $data.host112Recovery.verified)
$checks += New-AgentOutcomeCheck "required_hosts_reachable" $hostsOk $true "expected=$expectedHosts actual=$($hostRows.Count)"
$checks += New-AgentOutcomeCheck "harbor_healthy" $harborOk
$checks += New-AgentOutcomeCheck "awoooi_deployments_ready" $awoooOk
$checks += New-AgentOutcomeCheck "public_routes_healthy" $publicOk
$checks += New-AgentOutcomeCheck "ai_services_healthy" $aiOk $false
$checks += New-AgentOutcomeCheck "host112_guest_ready" $host112Ok $true $([string](@($data.host112Recovery.after.failedChecks) -join ','))
$modeVerifierPassed = [bool]($hostsOk -and $host112Ok -and $harborOk -and $awoooOk -and $publicOk)
}
"Recover" {
$expectedVms = @($Config.vms).Count
$vmRows = @($data.vmResults)
$vmsOk = [bool]($expectedVms -gt 0 -and $vmRows.Count -ge $expectedVms -and @($vmRows | Where-Object { -not $_.ok }).Count -eq 0)
$expectedHosts = @($Config.hosts).Count
$hostRows = @($data.hosts)
$hostsOk = [bool]($expectedHosts -gt 0 -and $hostRows.Count -ge $expectedHosts -and @($hostRows | Where-Object { -not $_.ping }).Count -eq 0)
$harborOk = [bool]($data.harbor -and $data.harbor.redisUp -and $data.harbor.coreHealthy -and $data.harbor.registryHealthy)
$awoooOk = [bool]($data.awoooi -and $data.awoooi.deployReady -and -not $data.awoooi.imagePullFailure -and -not $data.awoooi.crashFailure)
$publicRows = @($data.public)
$publicOk = [bool]($publicRows.Count -gt 0 -and @($publicRows | Where-Object { -not $_.ok }).Count -eq 0)
$coordinatorOk = [bool]($data.recoveryCoordinator -and $data.recoveryCoordinator.verified)
$host112Ok = [bool]($data.host112Recovery -and $data.host112Recovery.verified)
$checks += New-AgentOutcomeCheck "configured_vms_ready" $vmsOk $true "expected=$expectedVms actual=$($vmRows.Count)"
$checks += New-AgentOutcomeCheck "required_hosts_reachable" $hostsOk $true "expected=$expectedHosts actual=$($hostRows.Count)"
$checks += New-AgentOutcomeCheck "harbor_healthy" $harborOk
$checks += New-AgentOutcomeCheck "awoooi_deployments_ready" $awoooOk
$checks += New-AgentOutcomeCheck "public_routes_healthy" $publicOk
$checks += New-AgentOutcomeCheck "host112_guest_ready" $host112Ok $true $([string](@($data.host112Recovery.after.failedChecks) -join ','))
$checks += New-AgentOutcomeCheck "full_sop_coordinator_verified" $coordinatorOk $true $([string](@($data.recoveryCoordinator.failedChecks) -join ','))
$modeVerifierPassed = [bool]($vmsOk -and $hostsOk -and $host112Ok -and $harborOk -and $awoooOk -and $publicOk -and $coordinatorOk)
}
"StartVMs" {
$expectedVms = @($Config.vms).Count
$vmRows = @($data.vmResults)
$commandOk = [bool]($expectedVms -gt 0 -and $vmRows.Count -ge $expectedVms -and @($vmRows | Where-Object { -not $_.ok }).Count -eq 0)
$unreachable = @()
foreach ($vm in @($Config.vms)) {
if (-not $vm.host) { $unreachable += [string]$vm.name; continue }
$reachable = Test-Connection -ComputerName ([string]$vm.host) -Count 1 -Quiet -ErrorAction SilentlyContinue
if (-not $reachable) { $unreachable += [string]$vm.name }
}
$guestsReachable = [bool]($expectedVms -gt 0 -and @($unreachable).Count -eq 0)
$checks += New-AgentOutcomeCheck "vm_start_commands_ok" $commandOk $true "expected=$expectedVms actual=$($vmRows.Count)"
$checks += New-AgentOutcomeCheck "vm_guests_reachable" $guestsReachable $true "unreachable=$($unreachable -join ',')"
$modeVerifierPassed = [bool]($commandOk -and $guestsReachable)
}
"PublicSmoke" {
$rows = @($data.public)
$routesOk = [bool]($rows.Count -gt 0 -and @($rows | Where-Object { -not $_.ok -or -not $_.non502 }).Count -eq 0)
$checks += New-AgentOutcomeCheck "public_routes_non502" $routesOk $true "count=$($rows.Count)"
$modeVerifierPassed = $routesOk
}
"HarborRepair" {
$harborOk = [bool]($data.harbor -and $data.harbor.redisUp -and $data.harbor.coreHealthy -and $data.harbor.registryHealthy)
$checks += New-AgentOutcomeCheck "harbor_healthy" $harborOk
$modeVerifierPassed = $harborOk
}
"AwoooRepair" {
$awoooOk = [bool]($data.awoooi -and $data.awoooi.deployReady -and -not $data.awoooi.imagePullFailure -and -not $data.awoooi.crashFailure)
$checks += New-AgentOutcomeCheck "awoooi_deployments_ready" $awoooOk
$modeVerifierPassed = $awoooOk
}
"Perf" {
$rows = @($data.performance)
$readbackOk = [bool]($rows.Count -gt 0 -and @($rows | Where-Object { -not $_.ok -or (@($_.reasons) -contains "perf_readback_failed") }).Count -eq 0)
$pressureClear = [bool]($readbackOk -and @($rows | Where-Object { $_.severity -in @("warning", "critical") }).Count -eq 0)
$checks += New-AgentOutcomeCheck "performance_readback_complete" $readbackOk
$checks += New-AgentOutcomeCheck "performance_pressure_cleared" $pressureClear
$modeVerifierPassed = [bool]($readbackOk -and $pressureClear)
}
"LoadShed" {
$rows = @($data.performance)
$readbackOk = [bool]($rows.Count -gt 0 -and @($rows | Where-Object { -not $_.ok -or (@($_.reasons) -contains "perf_readback_failed") }).Count -eq 0)
$executed = @($data.loadShedding | Where-Object { -not $_.skipped })
$postVerifyOk = [bool]($executed.Count -gt 0 -and @($executed | Where-Object { -not $_.postVerifyOk }).Count -eq 0)
$alreadyClear = [bool]($readbackOk -and @($rows | Where-Object { $_.severity -in @("warning", "critical") }).Count -eq 0)
$checks += New-AgentOutcomeCheck "performance_readback_complete" $readbackOk
$checks += New-AgentOutcomeCheck "load_shed_post_verify" ([bool]($postVerifyOk -or $alreadyClear)) $true "executed=$($executed.Count)"
$modeVerifierPassed = [bool]($readbackOk -and ($postVerifyOk -or $alreadyClear))
}
"SelfCheck" {
$selfOk = [bool]($data.selfHealth -and $data.selfHealth.severity -eq "ok")
$checks += New-AgentOutcomeCheck "agent_self_health_ok" $selfOk
$modeVerifierPassed = $selfOk
}
"BackupCheck" {
$backupOk = [bool]($data.backupHealth -and $data.backupHealth.severity -eq "ok")
$checks += New-AgentOutcomeCheck "backup_health_ok" $backupOk
$modeVerifierPassed = $backupOk
}
"ProviderFreshness" {
$provider = $data.providerFreshness
$directOk = [bool]($provider -and $provider.status -eq "direct_readback_ok" -and @($provider.missingFields).Count -eq 0 -and $provider.lastSeen -and $provider.verifierReadback -and $provider.verifierReadback.ok -eq $true -and [int]$provider.verifierReadback.sourceEventTotal -gt 0)
$checks += New-AgentOutcomeCheck "provider_freshness_direct_readback" $directOk $true ([string]$provider.status)
$modeVerifierPassed = $directOk
}
"SecurityTriage" {
$candidateReady = [bool]($data.securityTriage -and $data.securityTriage.status -eq "security_triage_candidate_created")
$checks += New-AgentOutcomeCheck "security_candidate_created" $candidateReady
$modeVerifierPassed = $candidateReady
$successState = "candidate_ready"
}
"ControlTick" {
$controlOk = [bool]($data.controlTick -and $data.controlTick.ok)
$checks += New-AgentOutcomeCheck "control_tick_completed" $controlOk
$modeVerifierPassed = $controlOk
}
default {
$checks += New-AgentOutcomeCheck "mode_verifier_defined" $false
$failureState = "blocked"
}
}
}
$basePassed = [bool]($transportOk -and $evidenceExists -and $evidenceParsed -and $evidenceFresh -and $modeMatches)
$verifierPassed = [bool]($basePassed -and $modeVerifierPassed)
$sourceEventResolvedBool = if (-not $SourceEventResolutionRequired) {
$true
} elseif ($SourceEventResolved -eq $true) {
$true
} elseif ($SourceEventResolutionPolicy -eq "mode_verifier_can_resolve") {
[bool]$verifierPassed
} else {
$false
}
$effectiveSourceEventEvidence = if ($SourceEventEvidence) {
$SourceEventEvidence
} elseif ($SourceEventResolutionPolicy -eq "mode_verifier_can_resolve" -and $verifierPassed) {
[string]$LatestEvidence.path
} else {
""
}
if ($SourceEventResolutionRequired) {
$checks += New-AgentOutcomeCheck "source_event_resolved" $sourceEventResolvedBool $true "policy=$SourceEventResolutionPolicy evidence=$effectiveSourceEventEvidence"
}
$state = if (-not $transportOk) {
"failed"
} elseif (-not $basePassed) {
"blocked"
} elseif (-not $modeVerifierPassed) {
$failureState
} elseif ($successState -eq "candidate_ready") {
"candidate_ready"
} elseif ($SourceEventResolutionRequired -and -not $sourceEventResolvedBool) {
"verifying"
} else {
"resolved"
}
$failedChecks = @($checks | Where-Object { $_.required -and -not $_.passed } | ForEach-Object { $_.name })
[pscustomobject]@{
schemaVersion = "agent99_outcome_contract_v1"
state = $state
resolved = [bool]($state -eq "resolved")
transportOk = $transportOk
transportExitCode = $TransportExitCode
verifierName = $verifierName
verifierPassed = $verifierPassed
evidenceFresh = $evidenceFresh
evidenceModeMatched = $modeMatches
sourceEventResolutionRequired = $SourceEventResolutionRequired
sourceEventResolutionPolicy = $SourceEventResolutionPolicy
sourceEventResolved = $sourceEventResolvedBool
sourceEventEvidence = if ($effectiveSourceEventEvidence) { $effectiveSourceEventEvidence } else { $null }
failedChecks = $failedChecks
checks = $checks
verifiedAt = (Get-Date -Format o)
}
}
function Invoke-AgentOutcomeContractSelfTest {
$startedAt = (Get-Date).AddSeconds(-1)
$publicOkEvidence = [pscustomobject]@{
exists = $true
path = "selftest-public-ok.json"
lastWriteTime = (Get-Date -Format o)
parseError = $null
data = [pscustomobject]@{
mode = "PublicSmoke"
public = @([pscustomobject]@{ url = "https://example.invalid"; status = 200; non502 = $true; ok = $true })
}
}
$publicFailedEvidence = [pscustomobject]@{
exists = $true
path = "selftest-public-failed.json"
lastWriteTime = (Get-Date -Format o)
parseError = $null
data = [pscustomobject]@{
mode = "PublicSmoke"
public = @([pscustomobject]@{ url = "https://example.invalid"; status = 502; non502 = $false; ok = $false })
}
}
$providerEvidence = [pscustomobject]@{
exists = $true
path = "selftest-provider.json"
lastWriteTime = (Get-Date -Format o)
parseError = $null
data = [pscustomobject]@{
mode = "ProviderFreshness"
providerFreshness = [pscustomobject]@{
status = "direct_readback_ok_no_source_events"
missingFields = @("lastSeen")
lastSeen = $null
verifierReadback = [pscustomobject]@{ ok = $true; sourceEventTotal = 0 }
}
}
}
$securityEvidence = [pscustomobject]@{
exists = $true
path = "selftest-security.json"
lastWriteTime = (Get-Date -Format o)
parseError = $null
data = [pscustomobject]@{
mode = "SecurityTriage"
securityTriage = [pscustomobject]@{ status = "security_triage_candidate_created"; hitCount = 1 }
}
}
$operatorResolved = Get-AgentOutcomeContract "PublicSmoke" 0 $publicOkEvidence $startedAt $false
$alertVerifying = Get-AgentOutcomeContract "PublicSmoke" 0 $publicOkEvidence $startedAt $true $false "selftest-source-event"
$alertResolvedByVerifier = Get-AgentOutcomeContract "PublicSmoke" 0 $publicOkEvidence $startedAt $true $false "" "mode_verifier_can_resolve"
$alertTransportFailedVerifier = Get-AgentOutcomeContract "PublicSmoke" 7 $publicOkEvidence $startedAt $true $false "" "mode_verifier_can_resolve"
$failedPostCondition = Get-AgentOutcomeContract "PublicSmoke" 0 $publicFailedEvidence $startedAt $false
$transportFailed = Get-AgentOutcomeContract "PublicSmoke" 7 $publicOkEvidence $startedAt $false
$providerDegraded = Get-AgentOutcomeContract "ProviderFreshness" 0 $providerEvidence $startedAt $true $false
$securityCandidate = Get-AgentOutcomeContract "SecurityTriage" 0 $securityEvidence $startedAt $true $false
$ok = [bool](
$operatorResolved.state -eq "resolved" -and $operatorResolved.verifierPassed -and
$alertVerifying.state -eq "verifying" -and -not $alertVerifying.resolved -and
$alertResolvedByVerifier.state -eq "resolved" -and $alertResolvedByVerifier.sourceEventResolved -and
$alertTransportFailedVerifier.state -eq "failed" -and -not $alertTransportFailedVerifier.sourceEventResolved -and
$failedPostCondition.state -eq "degraded" -and -not $failedPostCondition.verifierPassed -and
$transportFailed.state -eq "failed" -and
$providerDegraded.state -eq "degraded" -and
$securityCandidate.state -eq "candidate_ready"
)
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = $ok
contract = "agent99_outcome_contract_v1"
cases = [pscustomobject]@{
operatorResolved = $operatorResolved
alertVerifying = $alertVerifying
alertResolvedByVerifier = $alertResolvedByVerifier
alertTransportFailedVerifier = $alertTransportFailedVerifier
failedPostCondition = $failedPostCondition
transportFailed = $transportFailed
providerDegraded = $providerDegraded
securityCandidate = $securityCandidate
}
}
$selfTestPath = Join-Path $EvidenceDir "agent99-OutcomeContractSelfTest-$stamp.json"
$result | ConvertTo-Json -Depth 12 | Set-Content -Path $selfTestPath -Encoding UTF8
$result | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding UTF8
Write-AgentLog "outcome_contract_selftest ok=$ok evidence=$selfTestPath"
$result | ConvertTo-Json -Depth 12
if (-not $ok) { exit 1 }
exit 0
}
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
schemaVersion = "agent99_problem_counts_v2"
outcomeContractActivatedAt = $null
totalEvents = 0
totalResolved = 0
totalVerifiedResolved = 0
totalFailed = 0
totalDegraded = 0
totalBlocked = 0
totalVerifying = 0
totalCandidateReady = 0
problems = @()
}
}
try {
$data = Get-Content $statsPath -Raw | ConvertFrom-Json
return [pscustomobject]@{
path = $statsPath
schemaVersion = if ($data.schemaVersion) { [string]$data.schemaVersion } else { "agent99_problem_counts_legacy_v1" }
outcomeContractActivatedAt = if ($data.outcomeContractActivatedAt) { [string]$data.outcomeContractActivatedAt } else { $null }
totalEvents = if ($data.totalEvents) { [int]$data.totalEvents } else { 0 }
totalResolved = if ($data.totalResolved) { [int]$data.totalResolved } else { 0 }
totalVerifiedResolved = if ($data.totalVerifiedResolved) { [int]$data.totalVerifiedResolved } else { 0 }
totalFailed = if ($data.totalFailed) { [int]$data.totalFailed } else { 0 }
totalDegraded = if ($data.totalDegraded) { [int]$data.totalDegraded } else { 0 }
totalBlocked = if ($data.totalBlocked) { [int]$data.totalBlocked } else { 0 }
totalVerifying = if ($data.totalVerifying) { [int]$data.totalVerifying } else { 0 }
totalCandidateReady = if ($data.totalCandidateReady) { [int]$data.totalCandidateReady } else { 0 }
problems = @($data.problems)
}
} catch {
Write-AgentLog "problem_stats_parse_failed path=$statsPath error=$($_.Exception.Message)"
return [pscustomobject]@{
path = $statsPath
schemaVersion = "agent99_problem_counts_v2"
outcomeContractActivatedAt = $null
totalEvents = 0
totalResolved = 0
totalVerifiedResolved = 0
totalFailed = 0
totalDegraded = 0
totalBlocked = 0
totalVerifying = 0
totalCandidateReady = 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
$outcome = if ($Result.PSObject.Properties["outcome"]) { $Result.outcome } else { $null }
$statusText = if ($outcome -and $outcome.PSObject.Properties["state"]) { [string]$outcome.state } elseif ($Result.ok) { "resolved" } else { "failed" }
$isResolved = [bool]($statusText -eq "resolved")
$isFailed = [bool]($statusText -eq "failed")
if (-not $Result.alertSeverity) {
$severity = if ($isResolved) { "info" } elseif ($isFailed) { "critical" } else { "warning" }
}
$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 = $isResolved
transportOk = if ($Result.PSObject.Properties["transportOk"]) { [bool]$Result.transportOk } else { [bool]($Result.exitCode -eq 0) }
exitCode = $Result.exitCode
outcomeState = $statusText
verifierName = if ($outcome) { $outcome.verifierName } else { $null }
verifierPassed = if ($outcome) { [bool]$outcome.verifierPassed } else { $false }
sourceEventResolved = if ($outcome) { [bool]$outcome.sourceEventResolved } else { $false }
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
verifiedResolved = 0
failed = 0
degraded = 0
blocked = 0
verifying = 0
candidateReady = 0
lastStatus = $null
firstSeenAt = $now
lastSeenAt = $null
lastResolvedAt = $null
lastEvidence = $null
lastMode = $null
lastInstruction = $null
}
$problems += $existing
}
foreach ($counterName in @("verifiedResolved", "degraded", "blocked", "verifying", "candidateReady")) {
if (-not $existing.PSObject.Properties[$counterName]) {
$existing | Add-Member -MemberType NoteProperty -Name $counterName -Value 0
}
}
$existing.occurrences = [int]$existing.occurrences + 1
if ($isResolved) {
$existing.resolved = [int]$existing.resolved + 1
$existing.verifiedResolved = [int]$existing.verifiedResolved + 1
$existing.lastResolvedAt = $now
} elseif ($isFailed) {
$existing.failed = [int]$existing.failed + 1
} elseif ($statusText -eq "degraded") {
$existing.degraded = [int]$existing.degraded + 1
} elseif ($statusText -eq "blocked") {
$existing.blocked = [int]$existing.blocked + 1
} elseif ($statusText -eq "verifying") {
$existing.verifying = [int]$existing.verifying + 1
} elseif ($statusText -eq "candidate_ready") {
$existing.candidateReady = [int]$existing.candidateReady + 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 ($isResolved) { 1 } else { 0 })
$newTotalVerifiedResolved = [int]$stats.totalVerifiedResolved + $(if ($isResolved) { 1 } else { 0 })
$newTotalFailed = [int]$stats.totalFailed + $(if ($isFailed) { 1 } else { 0 })
$newTotalDegraded = [int]$stats.totalDegraded + $(if ($statusText -eq "degraded") { 1 } else { 0 })
$newTotalBlocked = [int]$stats.totalBlocked + $(if ($statusText -eq "blocked") { 1 } else { 0 })
$newTotalVerifying = [int]$stats.totalVerifying + $(if ($statusText -eq "verifying") { 1 } else { 0 })
$newTotalCandidateReady = [int]$stats.totalCandidateReady + $(if ($statusText -eq "candidate_ready") { 1 } else { 0 })
$outcomeContractActivatedAt = if ($stats.outcomeContractActivatedAt) { $stats.outcomeContractActivatedAt } else { $now }
$updated = [pscustomobject]@{
schemaVersion = "agent99_problem_counts_v2"
generatedAt = $now
outcomeContractActivatedAt = $outcomeContractActivatedAt
legacyResolvedCountMayIncludeTransportOnly = [bool]($stats.schemaVersion -ne "agent99_problem_counts_v2" -or ([int]$stats.totalResolved -gt [int]$stats.totalVerifiedResolved))
totalEvents = $newTotalEvents
totalResolved = $newTotalResolved
totalVerifiedResolved = $newTotalVerifiedResolved
totalFailed = $newTotalFailed
totalDegraded = $newTotalDegraded
totalBlocked = $newTotalBlocked
totalVerifying = $newTotalVerifying
totalCandidateReady = $newTotalCandidateReady
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); verifiedResolved=$($_.verifiedResolved); failed=$($_.failed); degraded=$($_.degraded); blocked=$($_.blocked); verifying=$($_.verifying); candidateReady=$($_.candidateReady); lastStatus=$($_.lastStatus); lastMode=$($_.lastMode); evidence=$($_.lastEvidence)"
})
$km = @(
"# Agent99 Incident KM",
"",
"Last updated: $now",
"",
"## Totals",
"",
"- totalEvents: $($updated.totalEvents)",
"- totalResolvedLegacyAndVerified: $($updated.totalResolved)",
"- totalVerifiedResolved: $($updated.totalVerifiedResolved)",
"- totalFailed: $($updated.totalFailed)",
"- totalDegraded: $($updated.totalDegraded)",
"- totalBlocked: $($updated.totalBlocked)",
"- totalVerifying: $($updated.totalVerifying)",
"- totalCandidateReady: $($updated.totalCandidateReady)",
"",
"## Recurring Problems",
"",
($rows -join "`n")
) -join "`n"
Set-Content -Path $kmPath -Value $km -Encoding UTF8
[pscustomobject]@{
key = $key
occurrences = $existing.occurrences
resolved = $existing.resolved
verifiedResolved = $existing.verifiedResolved
failed = $existing.failed
degraded = $existing.degraded
blocked = $existing.blocked
verifying = $existing.verifying
candidateReady = $existing.candidateReady
lastStatus = $existing.lastStatus
statsPath = $statsPath
eventsPath = $eventsPath
kmPath = $kmPath
ragPath = $ragPath
}
}
function Get-AgentCompletionCallbackSettings {
if (-not $Config.PSObject.Properties["completionCallback"] -or -not $Config.completionCallback) {
return [pscustomobject]@{ enabled = $false }
}
$settings = $Config.completionCallback
[pscustomobject]@{
enabled = Convert-AgentBool (Get-AgentObjectValue $settings "enabled" $false)
url = [string](Get-AgentObjectValue $settings "url" "")
tokenEnv = [string](Get-AgentObjectValue $settings "tokenEnv" "AGENT99_SRE_RELAY_TOKEN")
timeoutSeconds = [math]::Max(3, [math]::Min(30, [int](Get-AgentObjectValue $settings "timeoutSeconds" 15)))
maxAttempts = [math]::Max(1, [math]::Min(3, [int](Get-AgentObjectValue $settings "maxAttempts" 2)))
batchLimit = [math]::Max(1, [math]::Min(10, [int](Get-AgentObjectValue $settings "batchLimit" 5)))
retryAfterMinutes = [math]::Max(1, [math]::Min(60, [int](Get-AgentObjectValue $settings "retryAfterMinutes" 5)))
}
}
function Get-AgentEnvironmentValue {
param([string]$Name)
if (-not $Name) { return $null }
foreach ($scope in @("Machine", "User", "Process")) {
$value = [Environment]::GetEnvironmentVariable($Name, $scope)
if ($value) { return [string]$value }
}
return $null
}
function Invoke-AgentCompletionCallback {
param(
[object]$Result = $null,
[object]$TelegramAttempt = $null,
[string]$PayloadPath = ""
)
$settings = Get-AgentCompletionCallbackSettings
if (-not $settings.enabled) {
return [pscustomobject]@{ ok = $false; status = "disabled"; durableReadback = $false }
}
$callbackRoot = Join-Path $Config.agentRoot "state\completion-callbacks"
$pendingDir = Join-Path $callbackRoot "pending"
$processedDir = Join-Path $callbackRoot "processed"
New-Item -ItemType Directory -Force -Path $pendingDir, $processedDir | Out-Null
$payload = $null
$pendingPath = $PayloadPath
if ($PayloadPath) {
try {
$payload = Get-Content -Path $PayloadPath -Raw | ConvertFrom-Json
} catch {
return [pscustomobject]@{
ok = $false
status = "pending_payload_invalid"
durableReadback = $false
errorType = $_.Exception.GetType().Name
}
}
} else {
$outcome = if ($Result -and $Result.PSObject.Properties["outcome"]) { $Result.outcome } else { $null }
$outcomeState = [string](Get-AgentObjectValue $outcome "state" (Get-AgentObjectValue $Result "outcomeState" "failed"))
$resultId = [string](Get-AgentObjectValue $Result "id" "unknown")
$alertId = [string](Get-AgentObjectValue $Result "alertId" "")
$correlationKey = [string](Get-AgentObjectValue $Result "correlationKey" "")
$callbackId = Convert-AgentSafeKey "agent99:$resultId`:$outcomeState"
$evidencePath = [string](Get-AgentObjectValue $Result "evidence" "")
$visualPath = [string](Get-AgentObjectValue $TelegramAttempt "visualPath" "")
$problem = Get-AgentObjectValue $Result "problem" $null
$payload = [ordered]@{
schema_version = "agent99_completion_callback_v1"
callback_id = $callbackId
project_id = "awoooi"
run_id = $resultId
trace_id = if ($correlationKey) { $correlationKey } else { $resultId }
work_item_id = if ($alertId) { "agent99-incident:$alertId" } else { "agent99-command:$resultId" }
alert_id = if ($alertId) { $alertId } else { $null }
correlation_key = if ($correlationKey) { $correlationKey } else { $null }
source = [string](Get-AgentObjectValue $Result "source" "agent99")
mode = [string](Get-AgentObjectValue $Result "mode" "Status")
outcome_state = $outcomeState
controlled_apply = Convert-AgentBool (Get-AgentObjectValue $Result "controlledApply" $false)
transport_ok = Convert-AgentBool (Get-AgentObjectValue $Result "transportOk" $false)
verifier_name = [string](Get-AgentObjectValue $outcome "verifierName" "agent99_mode_post_condition")
verifier_passed = Convert-AgentBool (Get-AgentObjectValue $outcome "verifierPassed" $false)
source_event_resolved = Convert-AgentBool (Get-AgentObjectValue $outcome "sourceEventResolved" $false)
source_event_resolution_policy = [string](Get-AgentObjectValue $Result "sourceEventResolutionPolicy" "external_source_receipt_required")
duration_seconds = [double](Get-AgentObjectValue $Result "durationSeconds" 0)
evidence_ref = if ($evidencePath) { Split-Path -Leaf $evidencePath } else { "evidence-missing.json" }
alert_kind = [string](Get-AgentObjectValue $Result "alertKind" "")
alert_service = [string](Get-AgentObjectValue $Result "alertService" "")
alert_host = [string](Get-AgentObjectValue $Result "alertHost" "")
telegram = [ordered]@{
sent = Convert-AgentBool (Get-AgentObjectValue $TelegramAttempt "sent" $false)
suppressed = Convert-AgentBool (Get-AgentObjectValue $TelegramAttempt "suppressed" $false)
reason = [string](Get-AgentObjectValue $TelegramAttempt "reason" "")
message_format = [string](Get-AgentObjectValue $TelegramAttempt "messageFormat" "")
visual_sent = Convert-AgentBool (Get-AgentObjectValue $TelegramAttempt "visualSent" $false)
visual_file = if ($visualPath) { Split-Path -Leaf $visualPath } else { $null }
}
problem = [ordered]@{
key = [string](Get-AgentObjectValue $problem "key" "")
occurrences = [int](Get-AgentObjectValue $problem "occurrences" 0)
verified_resolved = [int](Get-AgentObjectValue $problem "verifiedResolved" 0)
failed = [int](Get-AgentObjectValue $problem "failed" 0)
degraded = [int](Get-AgentObjectValue $problem "degraded" 0)
blocked = [int](Get-AgentObjectValue $problem "blocked" 0)
verifying = [int](Get-AgentObjectValue $problem "verifying" 0)
candidate_ready = [int](Get-AgentObjectValue $problem "candidateReady" 0)
}
occurred_at = (Get-Date -Format o)
}
$pendingPath = Join-Path $pendingDir "$callbackId.json"
$payload | ConvertTo-Json -Depth 10 | Set-Content -Path $pendingPath -Encoding UTF8
}
$callbackIdValue = [string](Get-AgentObjectValue $payload "callback_id" "unknown")
$receipt = [ordered]@{
schemaVersion = "agent99_completion_callback_delivery_v1"
ok = $false
timestamp = (Get-Date -Format o)
callbackId = $callbackIdValue
runId = [string](Get-AgentObjectValue $payload "run_id" "")
traceId = [string](Get-AgentObjectValue $payload "trace_id" "")
endpointConfigured = [bool]$settings.url
tokenPresent = $false
attempts = 0
accepted = $false
durableReadback = $false
status = "pending"
errorType = $null
httpStatus = $null
responseSchema = $null
awooopRunId = $null
operationReceiptCount = 0
pendingFile = Split-Path -Leaf $pendingPath
processedFile = $null
secretValueLogged = $false
rawResponseStored = $false
evidenceFile = $null
}
$token = Get-AgentEnvironmentValue $settings.tokenEnv
$receipt.tokenPresent = [bool]$token
if (-not $settings.url) {
$receipt.status = "blocked_endpoint_missing"
} elseif (-not $token) {
$receipt.status = "blocked_token_missing"
} else {
for ($attempt = 1; $attempt -le $settings.maxAttempts; $attempt++) {
$receipt.attempts = $attempt
try {
$response = Invoke-RestMethod -Method Post -Uri $settings.url -Headers @{
"X-Agent99-Completion-Token" = $token
"X-Project-ID" = [string](Get-AgentObjectValue $payload "project_id" "awoooi")
} -ContentType "application/json; charset=utf-8" -Body ($payload | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec $settings.timeoutSeconds
$receipt.responseSchema = [string](Get-AgentObjectValue $response "schema_version" "")
$receipt.accepted = Convert-AgentBool (Get-AgentObjectValue $response "accepted" $false)
$receipt.durableReadback = Convert-AgentBool (Get-AgentObjectValue $response "durable_readback" $false)
$responseCallbackId = [string](Get-AgentObjectValue $response "callback_id" "")
$responseRunId = [string](Get-AgentObjectValue $response "run_id" "")
$responseTraceId = [string](Get-AgentObjectValue $response "trace_id" "")
$receipt.awooopRunId = [string](Get-AgentObjectValue $response "awooop_run_id" "")
$receipt.operationReceiptCount = [int](Get-AgentObjectValue $response "operation_receipt_count" 0)
$sameIdentity = [bool](
$responseCallbackId -eq [string](Get-AgentObjectValue $payload "callback_id" "") -and
$responseRunId -eq [string](Get-AgentObjectValue $payload "run_id" "") -and
$responseTraceId -eq [string](Get-AgentObjectValue $payload "trace_id" "")
)
if ($receipt.accepted -and $receipt.durableReadback -and $sameIdentity -and $receipt.operationReceiptCount -gt 0) {
$processedPath = Join-Path $processedDir (Split-Path -Leaf $pendingPath)
Move-Item -Force $pendingPath $processedPath
$receipt.status = "durable_verified"
$receipt.processedFile = Split-Path -Leaf $processedPath
$receipt.pendingFile = $null
break
}
$receipt.status = "response_not_verified"
} catch {
$receipt.errorType = $_.Exception.GetType().Name
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
$receipt.httpStatus = [int]$_.Exception.Response.StatusCode
}
$receipt.status = "delivery_failed"
}
if ($attempt -lt $settings.maxAttempts) { Start-Sleep -Seconds $attempt }
}
}
if ($receipt.status -ne "durable_verified" -and (Test-Path $pendingPath)) {
(Get-Item $pendingPath).LastWriteTime = Get-Date
}
$receipt.ok = [bool]($receipt.status -eq "durable_verified")
$safeCallbackId = Convert-AgentSafeKey $callbackIdValue
$receiptPath = Join-Path $EvidenceDir "agent99-CompletionCallback-$safeCallbackId-$stamp.json"
$receipt.evidenceFile = Split-Path -Leaf $receiptPath
$receipt | ConvertTo-Json -Depth 10 | Set-Content -Path $receiptPath -Encoding UTF8
[pscustomobject]$receipt
}
function Invoke-AgentPendingCompletionCallbacks {
$settings = Get-AgentCompletionCallbackSettings
if (-not $settings.enabled) {
return [pscustomobject]@{ enabled = $false; attemptedCount = 0; verifiedCount = 0; pendingCount = 0; receipts = @() }
}
$pendingDir = Join-Path $Config.agentRoot "state\completion-callbacks\pending"
if (-not (Test-Path $pendingDir)) {
return [pscustomobject]@{ enabled = $true; attemptedCount = 0; verifiedCount = 0; pendingCount = 0; receipts = @() }
}
$eligibleBefore = (Get-Date).AddMinutes(-1 * $settings.retryAfterMinutes)
$files = @(Get-ChildItem -Path $pendingDir -Filter "*.json" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -le $eligibleBefore } |
Sort-Object LastWriteTime |
Select-Object -First $settings.batchLimit)
$receipts = @($files | ForEach-Object { Invoke-AgentCompletionCallback -PayloadPath $_.FullName })
[pscustomobject]@{
enabled = $true
attemptedCount = $receipts.Count
verifiedCount = @($receipts | Where-Object { $_.ok }).Count
pendingCount = @(Get-ChildItem -Path $pendingDir -Filter "*.json" -File -ErrorAction SilentlyContinue).Count
receipts = $receipts
}
}
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
totalVerifiedResolved = $problemStats.totalVerifiedResolved
totalFailed = $problemStats.totalFailed
totalDegraded = $problemStats.totalDegraded
totalBlocked = $problemStats.totalBlocked
totalVerifying = $problemStats.totalVerifying
totalCandidateReady = $problemStats.totalCandidateReady
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.verifiedResolved)</td><td>$(Convert-AgentHtml $problem.failed)</td><td>$(Convert-AgentHtml $problem.degraded)</td><td>$(Convert-AgentHtml $problem.blocked)</td><td>$(Convert-AgentHtml $problem.verifying)</td><td>$(Convert-AgentHtml $problem.candidateReady)</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">Verified Resolved</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalVerifiedResolved)</div></div>
<div class="card"><div class="label">Failed</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalFailed)</div></div>
<div class="card"><div class="label">Degraded</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalDegraded)</div></div>
<div class="card"><div class="label">Verifying</div><div class="value">$(Convert-AgentHtml $Summary.problemCounts.totalVerifying)</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>Verified Resolved</th><th>Failed</th><th>Degraded</th><th>Blocked</th><th>Verifying</th><th>Candidate Ready</th><th>Last Status</th><th>Last Mode</th></tr>$($problemRows -join "`n")</table>
<h2>Scheduled Tasks</h2>
<table><tr><th>Task</th><th>State</th><th>Last Result</th><th>Last Run</th><th>Next Run</th></tr>$($taskRows -join "`n")</table>
<h2>Hosts</h2>
<table><tr><th>Host</th><th>Ping</th></tr>$($hostRows -join "`n")</table>
<h2>Public Routes</h2>
<table><tr><th>URL</th><th>Status</th><th>Non-502</th><th>OK</th></tr>$($publicRows -join "`n")</table>
<h2>Performance</h2>
<table><tr><th>Host</th><th>Severity</th><th>Load/Core</th><th>Mem Avail %</th><th>Disk Used %</th><th>Route</th></tr>$($perfRows -join "`n")</table>
<h2>Evidence</h2>
<table><tr><th>Name</th><th>File</th><th>Age Minutes</th><th>Parse Error</th></tr>$($evidenceRows -join "`n")</table>
</body>
</html>
"@
Set-Content -Path $htmlPath -Value $html -Encoding UTF8
[pscustomobject]@{
dashboardDir = $dashboardDir
htmlPath = $htmlPath
jsonPath = $jsonPath
}
}
function Ensure-AgentDesktopShortcuts {
param([string]$DashboardPath)
$created = @()
$targets = @(
[pscustomobject]@{ name = "Agent99 Control Center.lnk"; target = $DashboardPath },
[pscustomobject]@{ name = "Agent99 Submit Request.lnk"; target = (Join-Path $Config.agentRoot "agent99-submit-request.cmd") },
[pscustomobject]@{ name = "Agent99 Queue.lnk"; target = (Join-Path $Config.agentRoot "queue") },
[pscustomobject]@{ name = "Agent99 Evidence.lnk"; target = $EvidenceDir }
)
$desktopDirs = @(
[Environment]::GetFolderPath("CommonDesktopDirectory"),
[Environment]::GetFolderPath("Desktop")
) | Where-Object { $_ } | Select-Object -Unique
foreach ($desktop in $desktopDirs) {
if (-not (Test-Path $desktop)) { continue }
foreach ($item in $targets) {
$shortcutPath = Join-Path $desktop $item.name
try {
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $item.target
$shortcut.WorkingDirectory = Split-Path -Parent $item.target
$shortcut.Description = "Agent99 local control and evidence shortcut"
$shortcut.Save()
$created += $shortcutPath
} catch {
Write-AgentLog "desktop_shortcut_failed path=$shortcutPath error=$($_.Exception.Message)"
}
}
}
$created
}
function Invoke-AgentQueuedCommands {
$queueDir = Join-Path $Config.agentRoot "queue"
$processedDir = Join-Path $queueDir "processed"
$failedDir = Join-Path $queueDir "failed"
New-Item -ItemType Directory -Force -Path $queueDir, $processedDir, $failedDir | Out-Null
$allowedModes = @("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck", "ProviderFreshness", "SecurityTriage")
$commands = @(Get-ChildItem -Path $queueDir -Filter "*.json" -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notmatch "^(processed|failed|running)-" } |
Sort-Object LastWriteTime |
Select-Object -First 3)
$results = @()
foreach ($file in $commands) {
$command = $null
try {
$command = Get-Content $file.FullName -Raw | ConvertFrom-Json
} catch {
$target = Join-Path $failedDir ("failed-" + $file.Name)
Move-Item -Force $file.FullName $target
$results += [pscustomobject]@{ id = $file.BaseName; ok = $false; reason = "invalid_json"; file = $target }
continue
}
$id = if ($command.id) { [string]$command.id } else { $file.BaseName }
$modeName = if ($command.mode) { [string]$command.mode } else { "" }
$controlled = if ($command.PSObject.Properties["controlledApply"]) { [bool]$command.controlledApply } else { $false }
$commandSource = if ($command.PSObject.Properties["source"]) { [string]$command.source } else { "queue" }
$commandReason = if ($command.PSObject.Properties["reason"]) { [string]$command.reason } else { "operator_instruction" }
$commandInstruction = if ($command.PSObject.Properties["instruction"]) { [string]$command.instruction } else { "" }
$correlationKey = if ($command.PSObject.Properties["correlationKey"]) { [string]$command.correlationKey } 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 }
$sourceEventResolutionRequired = if ($command.PSObject.Properties["sourceEventResolutionRequired"]) { Convert-AgentBool $command.sourceEventResolutionRequired } else { [bool]$alertId }
$sourceEventResolutionPolicy = if ($command.PSObject.Properties["sourceEventResolutionPolicy"]) { [string]$command.sourceEventResolutionPolicy } else { "external_source_receipt_required" }
$sourceEventResolved = if ($command.PSObject.Properties["sourceEventResolved"]) { [object](Convert-AgentBool $command.sourceEventResolved) } else { $null }
$sourceEventEvidence = if ($command.PSObject.Properties["sourceEventEvidence"]) { [string]$command.sourceEventEvidence } else { "" }
$replyChatId = if ($command.PSObject.Properties["replyChatId"]) { [string]$command.replyChatId } else { "" }
$replyMessageId = if ($command.PSObject.Properties["replyMessageId"]) { [string]$command.replyMessageId } else { "" }
$suppressTelegram = if ($command.PSObject.Properties["suppressTelegram"]) { Convert-AgentBool $command.suppressTelegram } else { $false }
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" }
if ($suppressTelegram) { $args += @("-SuppressAlerts", "-SuppressReason", "do_not_page") }
$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
}
$outcome = Get-AgentOutcomeContract -ModeName $modeName -TransportExitCode $exitCode -LatestEvidence $latest -StartedAt $startTime -SourceEventResolutionRequired $sourceEventResolutionRequired -SourceEventResolved $sourceEventResolved -SourceEventEvidence $sourceEventEvidence -SourceEventResolutionPolicy $sourceEventResolutionPolicy
$result = [pscustomobject]@{
id = $id
mode = $modeName
controlledApply = $controlled
source = $commandSource
reason = $commandReason
instruction = $commandInstruction
correlationKey = $correlationKey
alertId = $alertId
alertSource = $alertSource
alertKind = $alertKind
alertSeverity = $alertSeverity
alertService = $alertService
alertHost = $alertHost
suppressTelegram = $suppressTelegram
transportOk = [bool]($exitCode -eq 0)
ok = [bool]$outcome.resolved
outcomeState = $outcome.state
verifierPassed = [bool]$outcome.verifierPassed
sourceEventResolved = [bool]$outcome.sourceEventResolved
sourceEventResolutionPolicy = $sourceEventResolutionPolicy
outcome = $outcome
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
Remove-Item $runningPath -Force -ErrorAction SilentlyContinue | Out-Null
$recordData = $result | Select-Object *
if ($replyChatId) {
$recordData | Add-Member -MemberType NoteProperty -Name replyChatId -Value $replyChatId -Force
}
if ($replyMessageId) {
$recordData | Add-Member -MemberType NoteProperty -Name replyMessageId -Value $replyMessageId -Force
}
$outcomeSeverity = if ($outcome.state -eq "resolved") { "info" } elseif ($outcome.state -eq "failed") { "critical" } else { "warning" }
Record-AgentEvent "operator_command_result" $outcomeSeverity "id=$id mode=$modeName outcome=$($outcome.state) transportExitCode=$exitCode verifierPassed=$($outcome.verifierPassed) sourceEventResolved=$($outcome.sourceEventResolved) evidence=$($latest.path)" $recordData -Alert
$telegramAttempt = @($script:TelegramAttempts | Where-Object { $_.eventType -eq "operator_command_result" } | Select-Object -Last 1)
$completionCallback = Invoke-AgentCompletionCallback -Result $result -TelegramAttempt $(if ($telegramAttempt.Count -gt 0) { $telegramAttempt[0] } else { $null })
$result | Add-Member -MemberType NoteProperty -Name completionCallback -Value $completionCallback -Force
$donePath = Join-Path $processedDir ("processed-$id.json")
$result | ConvertTo-Json -Depth 10 | Set-Content -Path $donePath -Encoding UTF8
$results += $result
}
$results
}
function Invoke-AgentControlTick {
$completionCallbackReplay = Invoke-AgentPendingCompletionCallbacks
$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
completionCallbackReplay = $completionCallbackReplay
}
Record-AgentEvent "agent_control_tick" "info" "dashboard=$($dashboard.htmlPath) processed=$(@($processed).Count) pending=$pendingCount" $result -NoAlert
$result
}
function Invoke-AgentSecurityTriage {
$lookbackMinutes = 120
$since = (Get-Date).AddMinutes(-1 * $lookbackMinutes)
$triageId = "security-triage-" + (Get-Date -Format "yyyyMMdd-HHmmss")
$triageDir = Join-Path $Config.agentRoot "state\security-triage"
New-Item -ItemType Directory -Force -Path $triageDir | Out-Null
$triagePath = Join-Path $triageDir "$triageId.json"
$patterns = @(
"wazuh",
"siem",
"security",
"auth_failed",
"bruteforce",
"brute force",
"intrusion",
"malware",
"privilege",
"vulnerability",
"cve",
"ioc",
"suspicious",
"unauthorized"
)
$scanDirs = @(
(Join-Path $Config.agentRoot "alerts\incoming"),
(Join-Path $Config.agentRoot "alerts\processed"),
(Join-Path $Config.agentRoot "queue"),
(Join-Path $Config.agentRoot "queue\processed"),
(Join-Path $Config.agentRoot "requests")
) | Where-Object { Test-Path $_ } | Select-Object -Unique
$hits = @()
foreach ($dir in $scanDirs) {
$files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $since -and $_.Length -lt 2097152 })
foreach ($file in $files) {
$matchedPatterns = @()
foreach ($pattern in $patterns) {
if (Select-String -LiteralPath $file.FullName -Pattern $pattern -SimpleMatch -Quiet -ErrorAction SilentlyContinue) {
$matchedPatterns += $pattern
}
}
if (@($matchedPatterns).Count -gt 0) {
$excerpt = ""
$firstHit = Select-String -LiteralPath $file.FullName -Pattern $matchedPatterns[0] -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
if ($firstHit) {
$excerpt = [string]$firstHit.Line
$excerpt = $excerpt -replace '("?(telegram|token|chat|authorization|password|secret|cookie)[^,}\r\n]*"?\s*:\s*)"[^"]+"', '$1"[redacted]"'
if ($excerpt.Length -gt 500) { $excerpt = $excerpt.Substring(0, 500) + "...(truncated)" }
}
$hits += [pscustomobject]@{
path = $file.FullName
lastWrite = $file.LastWriteTime.ToString("o")
patterns = @($matchedPatterns)
excerpt = $excerpt
}
}
}
}
$status = if (@($hits).Count -gt 0) { "security_triage_candidate_created" } else { "security_triage_no_recent_alert_body_seen" }
$triage = [pscustomobject]@{
timestamp = (Get-Date -Format o)
triageId = $triageId
status = $status
lookbackMinutes = $lookbackMinutes
since = $since.ToString("o")
hitCount = @($hits).Count
hits = @($hits | Sort-Object lastWrite -Descending | Select-Object -First 20)
requiredReadback = @("source_log_reference", "affected_host_or_service", "blast_radius", "repeated_count", "allowlisted_next_action")
allowedActions = @("classify", "read_existing_evidence", "write_km", "request_source_log_reference", "recommend_allowlisted_next_action")
prohibitedActions = @("arbitrary_shell", "data_delete", "disable_security_control", "secret_exposure")
evidencePath = $triagePath
}
$triage | ConvertTo-Json -Depth 10 | Set-Content -Path $triagePath -Encoding UTF8
Write-AgentLog "security_triage triage=$triageId hits=$(@($hits).Count) status=$status path=$triagePath"
$eventSeverity = if (@($hits).Count -gt 0) { "warning" } else { "info" }
Record-AgentEvent "security_triage" $eventSeverity "status=$status hits=$(@($hits).Count)" $triage -Alert
$triage
}
function Invoke-AgentProviderFreshnessDirectReadback {
param(
[int]$LookbackMinutes
)
$baseUrl = "https://awoooi.wooo.work/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=20"
$startedAt = Get-Date
try {
$response = Invoke-RestMethod -Method Get -Uri $baseUrl -TimeoutSec 12
$summary = if ($response -and $response.PSObject.Properties["summary"]) { $response.summary } else { $null }
$latest = if ($summary -and $summary.PSObject.Properties["latest_received_at"]) { $summary.latest_received_at } else { $null }
$sourceTotal = if ($summary -and $summary.PSObject.Properties["source_event_total"]) { [int]$summary.source_event_total } else { 0 }
$itemsCount = if ($response -and $response.PSObject.Properties["items"]) { @($response.items).Count } else { 0 }
return [pscustomobject]@{
ok = $true
verifier = "awoooi-platform-events-recurrence"
directReference = $baseUrl
providerWindowMinutes = $LookbackMinutes
latestReceivedAt = $latest
sourceEventTotal = $sourceTotal
recurrenceGroupTotal = if ($summary -and $summary.PSObject.Properties["recurrence_group_total"]) { [int]$summary.recurrence_group_total } else { 0 }
itemsCount = $itemsCount
httpStatus = 200
durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2)
checkedAt = (Get-Date -Format o)
}
} catch {
return [pscustomobject]@{
ok = $false
verifier = "awoooi-platform-events-recurrence"
directReference = $baseUrl
providerWindowMinutes = $LookbackMinutes
latestReceivedAt = $null
sourceEventTotal = 0
recurrenceGroupTotal = 0
itemsCount = 0
httpStatus = $null
durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2)
checkedAt = (Get-Date -Format o)
error = ($_ | Out-String).Trim()
}
}
}
function Invoke-AgentProviderFreshnessTriage {
$providerConfig = if ($Config.PSObject.Properties["providerFreshness"]) { $Config.providerFreshness } else { $null }
$lookbackMinutes = if ($providerConfig -and $providerConfig.PSObject.Properties["lookbackMinutes"]) { [int]$providerConfig.lookbackMinutes } else { 120 }
$since = (Get-Date).AddMinutes(-1 * $lookbackMinutes)
$candidateId = "provider-freshness-" + (Get-Date -Format "yyyyMMdd-HHmmss")
$candidateDir = Join-Path $Config.agentRoot "state\provider-freshness-candidates"
New-Item -ItemType Directory -Force -Path $candidateDir | Out-Null
$candidatePath = Join-Path $candidateDir "$candidateId.json"
$patterns = @(
"provider_freshness_signal",
"source_provider_freshness_triage",
"raw_signal_normalized",
"controlled_runtime_candidate",
"freshness",
"last_seen",
"last seen",
"ingestion"
)
$scanDirs = @(
(Join-Path $Config.agentRoot "alerts\incoming"),
(Join-Path $Config.agentRoot "alerts\processed"),
(Join-Path $Config.agentRoot "queue"),
(Join-Path $Config.agentRoot "queue\processed"),
(Join-Path $Config.agentRoot "requests")
) | Where-Object { Test-Path $_ } | Select-Object -Unique
$hits = @()
foreach ($dir in $scanDirs) {
$files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $since -and $_.Length -lt 2097152 })
foreach ($file in $files) {
$matchedPatterns = @()
foreach ($pattern in $patterns) {
if (Select-String -LiteralPath $file.FullName -Pattern $pattern -SimpleMatch -Quiet -ErrorAction SilentlyContinue) {
$matchedPatterns += $pattern
}
}
if (@($matchedPatterns).Count -gt 0) {
$excerpt = ""
$firstHit = Select-String -LiteralPath $file.FullName -Pattern $matchedPatterns[0] -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
if ($firstHit) {
$excerpt = [string]$firstHit.Line
$excerpt = $excerpt -replace '("?(telegram|token|chat|authorization|password)[^,}\r\n]*"?\s*:\s*)"[^"]+"', '$1"[redacted]"'
if ($excerpt.Length -gt 500) { $excerpt = $excerpt.Substring(0, 500) + "...(truncated)" }
}
$hits += [pscustomobject]@{
path = $file.FullName
lastWrite = $file.LastWriteTime.ToString("o")
patterns = @($matchedPatterns)
excerpt = $excerpt
}
}
}
}
$targets = @()
if ($providerConfig -and $providerConfig.targets) {
foreach ($target in @($providerConfig.targets)) {
$targets += [pscustomobject]@{
name = if ($target.name) { [string]$target.name } else { "provider_freshness_signal" }
providerWindowMinutes = if ($target.PSObject.Properties["providerWindowMinutes"]) { $target.providerWindowMinutes } else { $null }
directReference = if ($target.directReference) { [string]$target.directReference } else { $null }
verifier = if ($target.verifier) { [string]$target.verifier } else { "agent99-provider-freshness-triage" }
}
}
}
if (@($targets).Count -eq 0) {
$targets += [pscustomobject]@{
name = "provider_freshness_signal"
providerWindowMinutes = $null
directReference = $null
verifier = "agent99-provider-freshness-triage"
}
}
$verifierReadback = Invoke-AgentProviderFreshnessDirectReadback -LookbackMinutes $lookbackMinutes
$directReference = if ($verifierReadback -and $verifierReadback.PSObject.Properties["directReference"]) { $verifierReadback.directReference } else { $null }
$latestSeen = if ($verifierReadback -and $verifierReadback.PSObject.Properties["latestReceivedAt"]) { $verifierReadback.latestReceivedAt } else { $null }
$missingFields = @()
if (-not $lookbackMinutes) { $missingFields += "providerWindow" }
if (-not $latestSeen) { $missingFields += "lastSeen" }
if (-not $directReference) { $missingFields += "directReference" }
if (-not ($verifierReadback -and $verifierReadback.ok -eq $true)) { $missingFields += "verifierReadback" }
$status = if ($verifierReadback -and $verifierReadback.ok -eq $true -and $latestSeen) {
"direct_readback_ok"
} elseif ($verifierReadback -and $verifierReadback.ok -eq $true) {
"direct_readback_ok_no_source_events"
} elseif (@($hits).Count -gt 0) {
"candidate_created_waiting_direct_readback"
} else {
"candidate_created_no_alert_body_seen"
}
$candidate = [pscustomobject]@{
timestamp = (Get-Date -Format o)
candidateId = $candidateId
target = "provider_freshness_signal"
status = $status
lookbackMinutes = $lookbackMinutes
since = $since.ToString("o")
hitCount = @($hits).Count
hits = @($hits | Sort-Object lastWrite -Descending | Select-Object -First 20)
targets = $targets
missingFields = $missingFields
providerWindowMinutes = $lookbackMinutes
lastSeen = $latestSeen
directReference = $directReference
verifierReadback = $verifierReadback
requiredVerifierReadback = @("provider_window", "last_seen", "direct_reference", "source_trace_or_sentry_signoz_ref")
noFalseGreenRisk = $true
allowedActions = @("create_candidate", "read_existing_evidence", "provider_freshness_direct_readback", "request_direct_reference", "write_km")
prohibitedActions = @("provider_switch", "paid_model_call", "env_change", "reload")
candidatePath = $candidatePath
}
$candidate | ConvertTo-Json -Depth 10 | Set-Content -Path $candidatePath -Encoding UTF8
Write-AgentLog "provider_freshness candidate=$candidateId hits=$(@($hits).Count) status=$status missing=$($missingFields -join ',') path=$candidatePath"
$eventSeverity = if (@($missingFields).Count -gt 0) { "warning" } else { "info" }
Record-AgentEvent "provider_freshness_triage" $eventSeverity "target=provider_freshness_signal status=$status missing=$($missingFields -join ',')" $candidate -Alert
$candidate
}
function Convert-AgentHost112GuestReadback {
param([object]$Transport)
$values = @{}
$text = if ($Transport -and $Transport.PSObject.Properties["output"]) { [string]$Transport.output } else { "" }
foreach ($match in [regex]::Matches($text, '(?:^|\s)([A-Za-z0-9_]+)=([^\s]+)')) {
$values[$match.Groups[1].Value] = $match.Groups[2].Value
}
$get = {
param([string]$Name, [string]$Default = "unknown")
if ($values.ContainsKey($Name)) { return [string]$values[$Name] }
return $Default
}
$asBool = {
param([string]$Name)
[bool]((& $get $Name "0") -eq "1")
}
$checks = @(
[pscustomobject]@{ name = "readback_present"; passed = [bool]($values.ContainsKey("boot_id") -and $values.ContainsKey("guest_ready")) },
[pscustomobject]@{ name = "systemd_running"; passed = [bool]((& $get "systemd_state") -eq "running") },
[pscustomobject]@{ name = "console_ready"; passed = (& $asBool "console_ready") },
[pscustomobject]@{ name = "services_ready"; passed = (& $asBool "services_ready") },
[pscustomobject]@{ name = "recovery_timer_ready"; passed = (& $asBool "recovery_timer_ready") },
[pscustomobject]@{ name = "guest_ready"; passed = (& $asBool "guest_ready") }
)
$failedChecks = @($checks | Where-Object { -not $_.passed } | ForEach-Object { $_.name })
[pscustomobject]@{
schemaVersion = "agent99_host112_guest_readback_v1"
readbackPresent = [bool]($values.ContainsKey("boot_id") -and $values.ContainsKey("guest_ready"))
ready = [bool]($failedChecks.Count -eq 0)
bootId = & $get "boot_id"
uptimeSeconds = & $get "uptime_seconds" "-1"
systemdState = & $get "systemd_state"
defaultTarget = & $get "default_target"
graphicalTarget = & $get "graphical_target"
displayManager = & $get "display_manager"
lightdm = & $get "lightdm"
vmwareTools = & $get "vmware_tools"
xorg = & $get "xorg"
wazuhIndexer = & $get "wazuh_indexer"
wazuhManager = & $get "wazuh_manager"
wazuhDashboard = & $get "wazuh_dashboard"
filebeat = & $get "filebeat"
consoleReady = (& $asBool "console_ready")
servicesReady = (& $asBool "services_ready")
recoveryTimerReady = (& $asBool "recovery_timer_ready")
guestReady = (& $asBool "guest_ready")
actionCount = & $get "action_count" "0"
actions = & $get "actions" "none"
transportOk = [bool]($Transport -and $Transport.ok)
transportExitCode = if ($Transport) { $Transport.exitCode } else { -1 }
transportRoute = if ($Transport -and $Transport.PSObject.Properties["route"]) { [string]$Transport.route } else { "unknown" }
transportSerialized = [bool]($Transport -and $Transport.PSObject.Properties["transportSerialized"] -and $Transport.transportSerialized)
transportLockWaitMs = if ($Transport -and $Transport.PSObject.Properties["transportLockWaitMs"]) { $Transport.transportLockWaitMs } else { 0 }
checks = $checks
failedChecks = $failedChecks
}
}
function Invoke-AgentHost112GuestRecovery {
$targetHost = "192.168.0.112"
$checkCommand = "/usr/local/sbin/awoooi-host112-guest-readiness --check"
$applyCommand = "sudo -n /usr/local/sbin/awoooi-host112-guest-readiness --apply"
$beforeTransport = Invoke-HostSshText $targetHost $checkCommand 20 1
$before = Convert-AgentHost112GuestReadback $beforeTransport
$applyTransport = $null
$after = $before
$applyAttempted = $false
if (-not $before.ready -and $ControlledApply) {
$applyAttempted = $true
Write-AgentLog "CONTROLLED_APPLY host112_guest_recovery failedChecks=$($before.failedChecks -join ',')"
$applyTransport = Invoke-HostSshText $targetHost $applyCommand 300 1
$afterTransport = Invoke-HostSshText $targetHost $checkCommand 20 1
$after = Convert-AgentHost112GuestReadback $afterTransport
}
$verified = [bool]$after.ready
$changed = [bool]($applyAttempted -and $verified -and -not $before.ready)
$result = [pscustomobject]@{
schemaVersion = "agent99_host112_guest_recovery_v1"
targetHost = $targetHost
controlledApply = [bool]$ControlledApply
applyAttempted = $applyAttempted
applyExitCode = if ($applyTransport) { $applyTransport.exitCode } else { $null }
changed = $changed
verified = $verified
before = $before
after = $after
rollback = "systemd package backup under /var/lib/awoooi-host112-recovery/backups"
verifier = "forced_command_host112_guest_readiness_check"
prohibitedActions = @("host_reboot", "vm_power_change", "vm_reset", "secret_read", "firewall_change", "database_write")
}
if ($changed) {
Record-AgentEvent "host112_guest_recovered" "info" "112 圖形登入、VMware Tools、Wazuh 與 recovery timer 已由 Agent99 修復並驗證。" $result -Alert
} elseif ($applyAttempted -and -not $verified) {
Record-AgentEvent "host112_guest_recovery_failed" "critical" "112 受控修復後仍未通過 verifier保留 failed checks 與 rollback evidence。" $result -Alert
}
$result
}
function Test-HostReachability {
$results = @()
foreach ($hostIp in $Config.hosts) {
$ping = Test-Connection -ComputerName $hostIp -Count 1 -Quiet -ErrorAction SilentlyContinue
Write-AgentLog "host host=$hostIp ping=$ping"
$results += [pscustomobject]@{
host = $hostIp
ping = [bool]$ping
}
}
$results
}
function Test-PublicRoutes {
$results = @()
foreach ($url in $Config.publicUrls) {
try {
$output = & curl.exe --location --head --max-time 12 $url 2>&1
$text = $output -join "`n"
$matches = [regex]::Matches($text, "HTTP/\S+\s+(\d+)")
$status = if ($matches.Count -gt 0) { [int]$matches[$matches.Count - 1].Groups[1].Value } else { $null }
$non502 = ($null -ne $status -and $status -ne 502)
$ok = ($null -ne $status -and $status -ge 200 -and $status -lt 500)
Write-AgentLog "public url=$url status=$status non502=$non502 ok=$ok"
$results += [pscustomobject]@{
url = $url
status = $status
non502 = $non502
ok = $ok
}
} catch {
Write-AgentLog "public url=$url error=$($_.Exception.Message)"
$results += [pscustomobject]@{
url = $url
status = $null
non502 = $false
ok = $false
}
}
}
$results
}
function Test-AiServices {
$results = @()
foreach ($svc in @($Config.aiServices)) {
if (-not $svc.url) { continue }
$name = if ($svc.name) { [string]$svc.name } else { [string]$svc.url }
try {
$output = & curl.exe --max-time 12 -sS $svc.url 2>&1
$text = ($output -join "`n").Trim()
$statusValue = $null
try {
$json = $text | ConvertFrom-Json
if ($json.status) { $statusValue = [string]$json.status }
} catch {
$statusValue = $null
}
$expectedStatus = if ($svc.expectedStatus) { [string]$svc.expectedStatus } else { "healthy" }
$ok = ($statusValue -eq $expectedStatus)
$severity = if ($ok) { "ok" } elseif ($svc.critical) { "critical" } else { "warning" }
$result = [pscustomobject]@{
name = $name
url = $svc.url
host = $svc.host
ok = $ok
severity = $severity
status = $statusValue
expectedStatus = $expectedStatus
output = $text
}
Write-AgentLog "ai_service name=$name ok=$ok severity=$severity status=$statusValue url=$($svc.url)"
if (-not $ok) {
Record-AgentEvent "ai_service_unhealthy" $severity "name=$name status=$statusValue expected=$expectedStatus url=$($svc.url)" $result -Alert
}
$results += $result
} catch {
$severity = if ($svc.critical) { "critical" } else { "warning" }
$result = [pscustomobject]@{
name = $name
url = $svc.url
host = $svc.host
ok = $false
severity = $severity
status = $null
expectedStatus = if ($svc.expectedStatus) { [string]$svc.expectedStatus } else { "healthy" }
output = $_.Exception.Message
}
Write-AgentLog "ai_service name=$name error=$($_.Exception.Message)"
Record-AgentEvent "ai_service_unhealthy" $severity "name=$name error=$($_.Exception.Message) url=$($svc.url)" $result -Alert
$results += $result
}
}
$results
}
function Get-HarborState {
$inspectCommand = "docker inspect --format '{{.Name}}|{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' harbor-core redis registry"
$inspect = Invoke-SshText "192.168.0.110" $inspectCommand 30 1
$containerStates = @{}
foreach ($line in @(([string]$inspect.output) -split "`n")) {
$trimmed = $line.Trim()
if (-not $trimmed) { continue }
$parts = @($trimmed -split "\|")
if ($parts.Count -lt 3) { continue }
$name = $parts[0].Trim().TrimStart("/")
$containerStates[$name] = [pscustomobject]@{
name = $name
status = $parts[1].Trim()
health = $parts[2].Trim()
}
}
$core = $containerStates["harbor-core"]
$redis = $containerStates["redis"]
$registry = $containerStates["registry"]
$coreLine = if ($core) { "$($core.name) $($core.status) $($core.health)" } else { "harbor-core missing" }
$redisLine = if ($redis) { "$($redis.name) $($redis.status) $($redis.health)" } else { "redis missing" }
$registryLine = if ($registry) { "$($registry.name) $($registry.status) $($registry.health)" } else { "registry missing" }
$state = [pscustomobject]@{
coreHealthy = ($core -and $core.status -eq "running" -and $core.health -eq "healthy")
redisUp = ($redis -and $redis.status -eq "running")
registryHealthy = ($registry -and $registry.status -eq "running" -and $registry.health -eq "healthy")
coreOutput = $coreLine
redisOutput = $redisLine
registryOutput = $registryLine
inspectExitCode = $inspect.exitCode
healthOutput = $inspect.output
inspectOutput = "[redacted: raw docker inspect omitted]"
}
Write-AgentLog "harbor coreHealthy=$($state.coreHealthy) redisUp=$($state.redisUp) registryHealthy=$($state.registryHealthy)"
$state
}
function Get-AwoooState {
$nestedSsh = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=8 -l $SshUser 192.168.0.120"
$readback = Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl get deploy,pods -n awoooi-prod" 60 1
$state = [pscustomobject]@{
deployReady = (
$readback.output -match "deployment.apps/awoooi-api\s+1/1" -and
$readback.output -match "deployment.apps/awoooi-web\s+2/2" -and
$readback.output -match "deployment.apps/awoooi-worker\s+1/1"
)
imagePullFailure = ($readback.output -match "ImagePullBackOff|ErrImagePull")
crashFailure = ($readback.output -match "CrashLoopBackOff|Pending")
deployOutput = $readback.output
podOutput = $readback.output
readbackExitCode = $readback.exitCode
}
Write-AgentLog "awoooi deployReady=$($state.deployReady) imagePullFailure=$($state.imagePullFailure) crashFailure=$($state.crashFailure)"
$state
}
function Find-Vmrun {
if ($Config.vmrunPath -and (Test-Path $Config.vmrunPath)) {
return $Config.vmrunPath
}
foreach ($candidate in @(
"C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe",
"C:\Program Files\VMware\VMware Workstation\vmrun.exe"
)) {
if (Test-Path $candidate) { return $candidate }
}
return $null
}
function Start-ConfiguredVMs {
$vmrun = Find-Vmrun
$results = @()
if (-not $vmrun) {
Write-AgentLog "vmrun missing"
return @([pscustomobject]@{ ok = $false; reason = "vmrun_missing" })
}
foreach ($vm in $Config.vms) {
if (-not $vm.vmx) {
$results += [pscustomobject]@{ name = $vm.name; ok = $false; skipped = $true; reason = "missing_vmx_path" }
continue
}
$hostPing = $false
if ($vm.host) {
$hostPing = Test-Connection -ComputerName $vm.host -Count 1 -Quiet -ErrorAction SilentlyContinue
}
if ($hostPing) {
$results += [pscustomobject]@{ name = $vm.name; ok = $true; skipped = $true; reason = "host_already_reachable" }
continue
}
if (-not $ControlledApply) {
$results += [pscustomobject]@{ name = $vm.name; ok = $false; skipped = $true; reason = "controlled_apply_required" }
continue
}
Write-AgentLog "CONTROLLED_APPLY vm_start name=$($vm.name) vmx=$($vm.vmx)"
$output = & $vmrun start $vm.vmx nogui 2>&1
$results += [pscustomobject]@{
name = $vm.name
ok = ($LASTEXITCODE -eq 0)
skipped = $false
output = ($output -join "`n")
}
}
$results
}
function Repair-Harbor {
param([object]$Harbor)
if (-not $ControlledApply) {
Write-AgentLog "harbor repair skipped controlled_apply=false"
return $false
}
$changed = $false
if (-not $Harbor.redisUp) {
Write-AgentLog "CONTROLLED_APPLY harbor restart redis"
Invoke-SshText "192.168.0.110" "docker restart redis" | Out-Null
$changed = $true
}
if (-not $Harbor.coreHealthy) {
Write-AgentLog "CONTROLLED_APPLY harbor restart harbor-core"
Invoke-SshText "192.168.0.110" "docker restart harbor-core" | Out-Null
$changed = $true
}
return $changed
}
function Repair-Awooo {
param([object]$Awooo, [object]$Harbor)
if (-not $ControlledApply) {
Write-AgentLog "awoooi repair skipped controlled_apply=false"
return $false
}
if (-not $Harbor.coreHealthy) {
Write-AgentLog "awoooi repair skipped harbor_core_not_healthy"
return $false
}
if (-not $Awooo.imagePullFailure) {
Write-AgentLog "awoooi repair skipped no_image_pull_failure"
return $false
}
Write-AgentLog "CONTROLLED_APPLY awoooi delete failed pods"
$nestedSsh = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=8 -l $SshUser 192.168.0.120"
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-api" 25 | Out-Null
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-web" 25 | Out-Null
Invoke-SshText "192.168.0.110" "$nestedSsh sudo -n kubectl delete pod -n awoooi-prod --selector app=awoooi-worker" 25 | Out-Null
return $true
}
if ($SelfTestTelegramCard) {
Invoke-AgentTelegramCardSelfTest
}
if ($SelfTestTelegramDelivery) {
Invoke-AgentTelegramDeliverySelfTest
}
if ($SelfTestSensorGate) {
Invoke-AgentSensorGateSelfTest
}
if ($SelfTestOutcomeContract) {
Invoke-AgentOutcomeContractSelfTest
}
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 = @()
$host112Recovery = $null
$harbor = $null
$awooo = $null
$public = @()
$aiServices = @()
$performance = @()
$loadShedding = @()
$selfHealth = $null
$backupHealth = $null
$providerFreshness = $null
$securityTriage = $null
$controlTick = $null
$sshProcessGuard = $null
$bootState = $null
$recoveryTrigger = $null
$recoverySlo = $null
$recoveryCoordinator = $null
$recoveryPhases = @()
$recoveryStartedAt = if ($Mode -eq "Recover") { Get-Date } else { $null }
$recoveryRunId = if ($Mode -eq "Recover") { "agent99-recovery-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + ([guid]::NewGuid().ToString("N").Substring(0, 8)) } else { "" }
$recoveryConfig = Get-AgentRecoverySloConfig
if ($Mode -in @("Status", "Recover")) {
$bootState = Get-AgentBootState
}
if (
$Mode -eq "Status" -and
$bootState -and
($bootState.detected -or ($bootState.PSObject.Properties["pendingRecovery"] -and $bootState.pendingRecovery))
) {
$triggerReason = if ($bootState.detected) { "host_reboot_detected" } else { "host_reboot_recovery_pending" }
$recoveryTrigger = Start-AgentRecoveryFromObservation $triggerReason $bootState
}
if ($Mode -in @("Recover", "StartVMs")) {
$vmResults = Start-ConfiguredVMs
if ($Mode -eq "Recover") {
$vmFailures = @($vmResults | Where-Object { $_.ok -eq $false -and -not $_.skipped }).Count
$recoveryPhases += New-AgentRecoveryPhase "vm_start" $(if ($vmFailures -eq 0) { "ok" } else { "degraded" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover")) {
$hostResults = Test-HostReachability
$unreachableHosts = @($hostResults | Where-Object { -not $_.ping })
if ($Mode -eq "Recover") {
$recoveryPhases += New-AgentRecoveryPhase "host_reachability" $(if ($unreachableHosts.Count -eq 0) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
$autoRecovery = Get-AgentAutoRecoveryConfig
if ($Mode -eq "Status" -and -not $recoveryTrigger -and $autoRecovery.triggerOnHostUnreachable -and $unreachableHosts.Count -ge $autoRecovery.minimumUnreachableHosts) {
$recoveryTrigger = Start-AgentRecoveryFromObservation "host_unreachable_detected" ([pscustomobject]@{ hosts = @($unreachableHosts.host); count = $unreachableHosts.Count })
}
}
if ($Mode -in @("Status", "Recover")) {
$host112Recovery = Invoke-AgentHost112GuestRecovery
if ($Mode -eq "Recover") {
$recoveryPhases += New-AgentRecoveryPhase "host112_guest_readiness" $(if ($host112Recovery.verified) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
} elseif (-not $host112Recovery.verified -and -not $recoveryTrigger) {
$recoveryTrigger = Start-AgentRecoveryFromObservation "host112_guest_not_ready" $host112Recovery.after
}
}
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 -eq "Recover") {
$harborReady = [bool]($harbor.coreHealthy -and $harbor.redisUp -and $harbor.registryHealthy)
$recoveryPhases += New-AgentRecoveryPhase "harbor_registry" $(if ($harborReady) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
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 -eq "Recover") {
$workloadsReady = [bool]($awooo.deployReady -and -not $awooo.imagePullFailure -and -not $awooo.crashFailure)
$recoveryPhases += New-AgentRecoveryPhase "k3s_workloads" $(if ($workloadsReady) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover", "PublicSmoke")) {
$public = Test-PublicRoutes
if ($Mode -eq "Recover") {
$recoveryPhases += New-AgentRecoveryPhase "public_routes" $(if (@($public | Where-Object { -not $_.ok }).Count -eq 0) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -eq "Recover") {
$windowsBootAgeSeconds = -1
if ($bootState -and $bootState.ok -and $bootState.bootTime) {
try {
$windowsBootAgeSeconds = [math]::Max(0, [int64]((Get-Date) - ([datetime]$bootState.bootTime)).TotalSeconds)
} catch {
$windowsBootAgeSeconds = -1
}
}
$requireFreshRebootWindow = [bool](
($bootState -and $bootState.detected) -or
($bootState -and $bootState.PSObject.Properties["pendingRecovery"] -and $bootState.pendingRecovery) -or
($windowsBootAgeSeconds -ge 0 -and $windowsBootAgeSeconds -le ($recoveryConfig.targetMinutes * 60))
)
$coordinatorEvidenceNotBefore = $recoveryStartedAt
if ($requireFreshRebootWindow -and $bootState -and $bootState.bootTime) {
try { $coordinatorEvidenceNotBefore = [datetime]$bootState.bootTime } catch { $coordinatorEvidenceNotBefore = $recoveryStartedAt }
}
$recoveryCoordinator = Invoke-AgentRecoveryCoordinatorReadback $coordinatorEvidenceNotBefore $requireFreshRebootWindow
$coordinatorStatus = if ($recoveryCoordinator.verified) { "ok" } else { "failed" }
$coordinatorEvidence = if ($recoveryCoordinator.PSObject.Properties["artifactDir"]) { [string]$recoveryCoordinator.artifactDir } else { "" }
$recoveryPhases += New-AgentRecoveryPhase "full_sop_scorecard" $coordinatorStatus (((Get-Date) - $recoveryStartedAt).TotalSeconds) $coordinatorEvidence
}
if ($Mode -in @("Status", "Recover", "Perf")) {
$aiServices = Test-AiServices
}
if ($Mode -in @("Perf", "LoadShed") -and $ControlledApply) {
$sshProcessGuard = Invoke-AgentStaleSshProcessGuard
}
if ($Mode -in @("Status", "Recover", "Perf", "LoadShed")) {
$suppressPerfAlerts = [bool]($ControlledApply -and $Mode -in @("Perf", "LoadShed"))
$performance = Get-HostPerformance -SuppressAlerts:$suppressPerfAlerts
}
if ($Mode -in @("Perf", "LoadShed")) {
$loadShedding = Invoke-LoadShedding $performance
if ($ControlledApply) {
Record-PerformanceIssuesWithoutRemediation $performance $loadShedding
}
}
if ($Mode -in @("SelfCheck")) {
$selfHealth = Test-AgentSelfHealth
}
if ($Mode -in @("BackupCheck")) {
$backupHealth = Test-AgentBackupHealth
}
if ($Mode -in @("ProviderFreshness")) {
$providerFreshness = Invoke-AgentProviderFreshnessTriage
}
if ($Mode -in @("SecurityTriage")) {
$securityTriage = Invoke-AgentSecurityTriage
}
if ($Mode -in @("ControlTick")) {
$controlTick = Invoke-AgentControlTick
}
$publicFailures = @($public | Where-Object { $_ -and -not $_.ok }).Count
$aiFailures = @($aiServices | Where-Object { $_ -and -not $_.ok }).Count
$perfCritical = @($performance | Where-Object { $_ -and $_.severity -eq "critical" }).Count
$perfWarning = @($performance | Where-Object { $_ -and $_.severity -eq "warning" }).Count
$selfHealthCritical = if ($selfHealth -and $selfHealth.severity -eq "critical") { 1 } else { 0 }
$selfHealthWarning = if ($selfHealth -and $selfHealth.severity -eq "warning") { 1 } else { 0 }
$backupCritical = if ($backupHealth -and $backupHealth.severity -eq "critical") { 1 } else { 0 }
$backupWarning = if ($backupHealth -and $backupHealth.severity -eq "warning") { 1 } else { 0 }
$providerFreshnessWarning = if ($providerFreshness) { 1 } else { 0 }
$securityTriageWarning = if ($securityTriage -and $securityTriage.hitCount -gt 0) { 1 } else { 0 }
$summarySeverity = if ($publicFailures -gt 0 -or $aiFailures -gt 0 -or $perfCritical -gt 0 -or $selfHealthCritical -gt 0 -or $backupCritical -gt 0) { "critical" } elseif ($perfWarning -gt 0 -or $selfHealthWarning -gt 0 -or $backupWarning -gt 0 -or $providerFreshnessWarning -gt 0 -or $securityTriageWarning -gt 0) { "warning" } else { "info" }
if ($Mode -eq "Recover") {
$elapsedSeconds = [math]::Round(((Get-Date) - $recoveryStartedAt).TotalSeconds, 1)
$unreachableCount = @($hostResults | Where-Object { -not $_.ping }).Count
$builtInRecoveryCompleted = [bool]($unreachableCount -eq 0 -and $host112Recovery -and $host112Recovery.verified -and $harbor -and $harbor.coreHealthy -and $harbor.redisUp -and $harbor.registryHealthy -and $awooo -and $awooo.deployReady -and -not $awooo.imagePullFailure -and -not $awooo.crashFailure -and $publicFailures -eq 0)
$coordinatorVerified = [bool]($recoveryCoordinator -and $recoveryCoordinator.verified)
$requiresFreshWindow = [bool]($recoveryCoordinator -and $recoveryCoordinator.requireFreshRebootWindow)
$rebootSloClaimed = [bool]($requiresFreshWindow -and $recoveryCoordinator.rebootSloClaimed)
$recoveryCompleted = [bool]($builtInRecoveryCompleted -and $coordinatorVerified)
$withinSlo = [bool]($recoveryCompleted -and $elapsedSeconds -le ($recoveryConfig.targetMinutes * 60) -and (-not $requiresFreshWindow -or $rebootSloClaimed))
$recoveryScope = if ($requiresFreshWindow) { "full_host_reboot" } else { "service_recovery" }
$recoverySlo = [pscustomobject]@{
schemaVersion = "agent99_recovery_slo_v2"
runId = $recoveryRunId
scope = $recoveryScope
targetMinutes = $recoveryConfig.targetMinutes
startedAt = $recoveryStartedAt.ToString("o")
completedAt = (Get-Date).ToString("o")
elapsedSeconds = $elapsedSeconds
deadline = $recoveryStartedAt.AddMinutes($recoveryConfig.targetMinutes).ToString("o")
completed = $recoveryCompleted
withinSlo = $withinSlo
builtInRecoveryCompleted = $builtInRecoveryCompleted
coordinatorVerified = $coordinatorVerified
rebootSloClaimed = $rebootSloClaimed
phases = @($recoveryPhases)
}
$updatedBootState = Update-AgentBootRecoveryState $recoverySlo
if ($updatedBootState) { $bootState = $updatedBootState }
$sloSeverity = if ($withinSlo) { "info" } elseif ($recoveryCompleted) { "warning" } else { "critical" }
$sloMessage = if ($withinSlo -and $requiresFreshWindow) {
"全主機恢復完成,耗時 $elapsedSecondsfresh reboot scorecard 已證明符合 $($recoveryConfig.targetMinutes) 分鐘 SLA。"
} elseif ($withinSlo) {
"一般服務恢復完成,耗時 $elapsedSeconds 秒;本輪沒有 fresh reboot event不宣稱全主機重啟 SLA。"
} elseif ($recoveryCompleted) {
"恢復 verifier 已通過但超過 $($recoveryConfig.targetMinutes) 分鐘目標,耗時 $elapsedSeconds 秒。"
} else {
"恢復尚未完成,耗時 $elapsedSecondsAgent99 已保留 full SOP scorecard blockers 與 evidence。"
}
Record-AgentEvent "recovery_slo_result" $sloSeverity $sloMessage $recoverySlo -Alert
}
Record-AgentEvent "agent_complete" $summarySeverity "mode=$Mode publicFailures=$publicFailures aiFailures=$aiFailures perfCritical=$perfCritical perfWarning=$perfWarning selfHealth=$($selfHealth.severity) backupHealth=$($backupHealth.severity) providerFreshness=$($providerFreshness.status) securityTriage=$($securityTriage.status) evidence=$jsonPath" $null -NoAlert
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
mode = $Mode
controlledApply = [bool]$ControlledApply
sshProcessGuard = $sshProcessGuard
bootState = $bootState
recoveryTrigger = $recoveryTrigger
recoverySlo = $recoverySlo
recoveryCoordinator = $recoveryCoordinator
vmResults = $vmResults
hosts = $hostResults
host112Recovery = $host112Recovery
harbor = $harbor
awoooi = $awooo
public = $public
aiServices = $aiServices
performance = $performance
loadShedding = $loadShedding
selfHealth = $selfHealth
backupHealth = $backupHealth
providerFreshness = $providerFreshness
securityTriage = $securityTriage
controlTick = $controlTick
events = $script:Events
telegram = $script:TelegramAttempts
evidenceLog = $logPath
}
$result | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $jsonPath
Write-AgentLog "agent99_complete json=$jsonPath"
exit 0