feat(agent99): ship incident lifecycle cards

This commit is contained in:
ogt
2026-07-11 20:38:12 +08:00
parent 0a671bd46a
commit 83f7838131
7 changed files with 926 additions and 124 deletions

View File

@@ -0,0 +1,158 @@
[CmdletBinding()]
param(
[string]$AgentRoot = "C:\Wooo\Agent99",
[string]$NotBefore = ""
)
$ErrorActionPreference = "Stop"
$notBeforeTime = [datetime]::MinValue
if ($NotBefore) {
$notBeforeTime = [datetime]::Parse($NotBefore)
}
$evidenceDir = Join-Path $AgentRoot "evidence"
$file = Get-ChildItem -Path $evidenceDir -Filter "agent99-Recover-*.json" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -ge $notBeforeTime } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $file) {
throw "new_recover_evidence_missing"
}
$raw = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
$phases = @($raw.recoverySlo.phases | ForEach-Object {
[pscustomobject]@{
name = [string]$_.name
status = [string]$_.status
elapsedSeconds = [double]$_.elapsedSeconds
}
})
$telegram = @($raw.telegram | ForEach-Object {
$visualPath = if ($_.PSObject.Properties["visualPath"]) { [string]$_.visualPath } else { "" }
[pscustomobject]@{
eventType = [string]$_.eventType
severity = [string]$_.severity
sent = [bool]$_.sent
suppressed = if ($_.PSObject.Properties["suppressed"]) { [bool]$_.suppressed } else { $false }
reason = if ($_.PSObject.Properties["reason"]) { [string]$_.reason } else { "" }
messageFormat = if ($_.PSObject.Properties["messageFormat"]) { [string]$_.messageFormat } else { "" }
incidentId = if ($_.PSObject.Properties["incidentId"]) { [string]$_.incidentId } else { "" }
fingerprint = if ($_.PSObject.Properties["fingerprint"]) { [string]$_.fingerprint } else { "" }
lifecycle = if ($_.PSObject.Properties["lifecycle"]) { [string]$_.lifecycle } else { "" }
stateKey = if ($_.PSObject.Properties["stateKey"]) { [string]$_.stateKey } else { "" }
visualSent = if ($_.PSObject.Properties["visualSent"]) { [bool]$_.visualSent } else { $false }
messageIdPresent = [bool]($_.PSObject.Properties["messageId"] -and $_.messageId)
replyThreaded = [bool]($_.PSObject.Properties["replyToMessageId"] -and $_.replyToMessageId)
incidentStateWritten = [bool]($_.PSObject.Properties["incidentStateWritten"] -and $_.incidentStateWritten)
visualFile = if ($visualPath) { Split-Path -Leaf $visualPath } else { "" }
visualExists = [bool]($visualPath -and (Test-Path -LiteralPath $visualPath))
}
})
$phaseFailures = @($phases | Where-Object { $_.status -ne "ok" })
$sentCount = @($telegram | Where-Object { $_.sent }).Count
$suppressedCount = @($telegram | Where-Object { $_.suppressed }).Count
$lifecycleReceipts = @($telegram | Where-Object { $_.eventType -eq "recovery_slo_result" -and ($_.sent -or $_.suppressed) })
$sentLifecycleReceipts = @($lifecycleReceipts | Where-Object { $_.sent })
$notificationReceipted = [bool]($lifecycleReceipts.Count -gt 0)
$humanReadableCardReady = [bool](
$lifecycleReceipts.Count -gt 0 -and
@($lifecycleReceipts | Where-Object {
$_.messageFormat -ne "incident_card_zh_tw_v5" -or
$_.incidentId -notmatch "^INC-[A-F0-9]{8}$" -or
$_.lifecycle -notin @("remediating", "recovered", "blocked")
}).Count -eq 0
)
$visualLifecycleReady = [bool](@($sentLifecycleReceipts | Where-Object { -not $_.visualSent }).Count -eq 0)
$incidentStateReady = [bool](@($sentLifecycleReceipts | Where-Object { -not $_.messageIdPresent -or -not $_.incidentStateWritten }).Count -eq 0)
$recoveryVerified = [bool](
$raw.mode -eq "Recover" -and
$raw.recoverySlo.completed -and
$raw.recoverySlo.coordinatorVerified -and
$phases.Count -gt 0 -and
$phaseFailures.Count -eq 0
)
$recentReceipts = @(Get-ChildItem -Path $evidenceDir -Filter "agent99-Recover-*.json" -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 5 |
ForEach-Object {
$historyFile = $_
try {
$history = Get-Content -Path $historyFile.FullName -Raw | ConvertFrom-Json
$historyTelegram = @($history.telegram)
[pscustomobject]@{
evidenceFile = $historyFile.Name
evidenceLastWriteTime = $historyFile.LastWriteTime.ToString("o")
runId = [string]$history.recoverySlo.runId
completed = [bool]$history.recoverySlo.completed
telegramAttemptCount = $historyTelegram.Count
telegramSentCount = @($historyTelegram | Where-Object { $_.sent }).Count
telegramSuppressedCount = @($historyTelegram | Where-Object { $_.PSObject.Properties["suppressed"] -and $_.suppressed }).Count
visualSentCount = @($historyTelegram | Where-Object { $_.PSObject.Properties["visualSent"] -and $_.visualSent }).Count
visualFiles = @($historyTelegram | Where-Object { $_.PSObject.Properties["visualPath"] -and $_.visualPath } | ForEach-Object { Split-Path -Leaf ([string]$_.visualPath) })
}
} catch {
[pscustomobject]@{
evidenceFile = $historyFile.Name
evidenceLastWriteTime = $historyFile.LastWriteTime.ToString("o")
parseError = $_.Exception.GetType().Name
}
}
})
$result = [pscustomobject]@{
schemaVersion = "agent99_recover_receipt_readback_v1"
observedAt = (Get-Date).ToString("o")
evidenceFile = $file.Name
evidenceLastWriteTime = $file.LastWriteTime.ToString("o")
secretValueRead = $false
remoteWritePerformed = $false
mode = [string]$raw.mode
controlledApply = [bool]$raw.controlledApply
vmCount = @($raw.vmResults).Count
vmOkCount = @($raw.vmResults | Where-Object { $_.ok -or $_.skipped }).Count
hostCount = @($raw.hosts).Count
hostReachableCount = @($raw.hosts | Where-Object { $_.ping }).Count
publicCount = @($raw.public).Count
publicOkCount = @($raw.public | Where-Object { $_.ok }).Count
recovery = [pscustomobject]@{
runId = [string]$raw.recoverySlo.runId
scope = [string]$raw.recoverySlo.scope
completed = [bool]$raw.recoverySlo.completed
withinSlo = [bool]$raw.recoverySlo.withinSlo
elapsedSeconds = [double]$raw.recoverySlo.elapsedSeconds
coordinatorVerified = [bool]$raw.recoverySlo.coordinatorVerified
rebootSloClaimed = [bool]$raw.recoverySlo.rebootSloClaimed
phases = $phases
}
telegramAttemptCount = $telegram.Count
telegramSentCount = $sentCount
telegramSuppressedCount = $suppressedCount
lifecycleReceiptCount = $lifecycleReceipts.Count
notificationReceipted = $notificationReceipted
humanReadableCardReady = $humanReadableCardReady
visualLifecycleReady = $visualLifecycleReady
incidentStateReady = $incidentStateReady
telegram = $telegram
recentReceipts = $recentReceipts
recoveryVerified = $recoveryVerified
ok = [bool]($recoveryVerified -and $notificationReceipted -and $humanReadableCardReady -and $visualLifecycleReady -and $incidentStateReady)
}
Write-Output "RECOVER_RECEIPT_OK=$([int]$result.ok)"
Write-Output "RECOVERY_VERIFIED=$([int]$result.recoveryVerified)"
Write-Output "NOTIFICATION_RECEIPTED=$([int]$result.notificationReceipted)"
Write-Output "HUMAN_READABLE_CARD_READY=$([int]$result.humanReadableCardReady)"
Write-Output "VISUAL_LIFECYCLE_READY=$([int]$result.visualLifecycleReady)"
Write-Output "INCIDENT_STATE_READY=$([int]$result.incidentStateReady)"
Write-Output "TELEGRAM_ATTEMPTS=$($result.telegramAttemptCount)"
Write-Output "TELEGRAM_SENT=$($result.telegramSentCount)"
Write-Output "TELEGRAM_SUPPRESSED=$($result.telegramSuppressedCount)"
Write-Output "SECRET_VALUE_READ=0"
Write-Output "REMOTE_WRITE_PERFORMED=0"
Write-Output "JSON_BEGIN"
$result | ConvertTo-Json -Depth 10 -Compress
Write-Output "JSON_END"
if ($result.ok) { exit 0 }
exit 2

View File

@@ -147,3 +147,23 @@ def test_live_preflight_adapts_inbox_evidence_without_raw_message_readback() ->
assert safe_field in preflight
for forbidden in ("messageText", "rawUpdate", "botToken", "chatId"):
assert forbidden not in preflight
def test_recover_receipt_readback_requires_verifier_and_notification() -> None:
readback = read("scripts/reboot-recovery/agent99-recover-receipt-readback.ps1")
assert 'schemaVersion = "agent99_recover_receipt_readback_v1"' in readback
assert '$raw.recoverySlo.coordinatorVerified' in readback
assert '$phaseFailures.Count -eq 0' in readback
assert '$notificationReceipted' in readback
assert '$humanReadableCardReady' in readback
assert '$visualLifecycleReady' in readback
assert '$incidentStateReady' in readback
assert 'incident_card_zh_tw_v5' in readback
assert '$recoveryVerified -and $notificationReceipted -and $humanReadableCardReady' in readback
assert '$recentReceipts' in readback
assert 'visualSentCount' in readback
assert 'secretValueRead = $false' in readback
assert 'remoteWritePerformed = $false' in readback
for forbidden in ("botToken", "chatId", "messageText", "rawUpdate", ".env"):
assert forbidden not in readback