fix: give the Windows scheduled task the same FAILED-push backstop
The task now runs scheduling\run-sync.ps1 instead of an inline cmd.exe loop: every provider is attempted, a run that dies without the app's own report pushes a high-priority FAILED with the exception class only, and each run's output is appended to cache\logs\scheduled-sync.log because Task Scheduler keeps none. Verified under PowerShell 7.6 on Linux with a stand-in cmd.exe; not yet run under Windows PowerShell 5.1 or Task Scheduler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
23c6e1f512
commit
805689c649
@@ -0,0 +1,150 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run `ai-chat-exporter sync` once per provider - the action of the scheduled
|
||||
task that Register-AiChatSyncTask.ps1 creates.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows counterpart to run-sync.sh, and there for the same reason.
|
||||
|
||||
Every provider is attempted even after one fails, and the exit code is
|
||||
non-zero if any failed - Task Scheduler reports only the last action's
|
||||
result, so one action per provider would hide an earlier failure.
|
||||
|
||||
The app pushes its own ntfy result, but only from the end of a run it
|
||||
finished. A crash, a non-zero exit before the sync starts (the terms-of-
|
||||
service gate, a cache error) or a launcher that can't build its venv sends
|
||||
nothing, and because each provider pushes separately, the providers that did
|
||||
succeed still send "OK" - so a broken one looks like a quiet day. This
|
||||
script pushes a FAILED notification for any run that exited non-zero without
|
||||
the app having reported it. (Its "Sync completed with failures" banner
|
||||
prints right after its push.)
|
||||
|
||||
The push carries the provider, the exit code and, for a crash, the
|
||||
exception's class name - never its message. Same counts-only rule as
|
||||
src/notify.py: on a public ntfy topic anyone who guesses the name can read
|
||||
it, and exception text can carry conversation titles.
|
||||
|
||||
Task Scheduler keeps no output, so each run's output is appended to
|
||||
cache\logs\scheduled-sync.log (rolled over to .1 past 1 MB). That is where
|
||||
the traceback is.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scheduling\run-sync.ps1 chatgpt claude
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$Provider
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$launcher = Join-Path $repo 'ai-chat-exporter.cmd'
|
||||
$envFile = Join-Path $repo '.env'
|
||||
$logDir = Join-Path $repo 'cache\logs'
|
||||
$runLog = Join-Path $logDir 'scheduled-sync.log'
|
||||
|
||||
# -File passes "chatgpt,claude" as one string; accept it as well as a list.
|
||||
$Provider = @($Provider | ForEach-Object { $_ -split ',' } | Where-Object { $_ })
|
||||
if ($Provider.Count -eq 0) { $Provider = @('all') }
|
||||
foreach ($p in $Provider) {
|
||||
# Each name is spliced into a cmd.exe command line below.
|
||||
if ($p -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid provider name: '$p'" }
|
||||
}
|
||||
|
||||
# Under Task Scheduler stdout is a file, not a console, so Python would encode
|
||||
# it in the ANSI code page and die on the first character outside it.
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
|
||||
# NTFY_* as the app resolves them: the environment wins, then .env.
|
||||
function Get-EnvValue([string]$Name) {
|
||||
$value = [Environment]::GetEnvironmentVariable($Name)
|
||||
if (-not $value -and (Test-Path -LiteralPath $envFile)) {
|
||||
foreach ($line in Get-Content -LiteralPath $envFile) {
|
||||
if ($line -match "^\s*$Name\s*=\s*(.*)$") { $value = $Matches[1] }
|
||||
}
|
||||
if ($value) { $value = ($value -replace '\s+#.*$', '').Trim().Trim('"', "'") }
|
||||
}
|
||||
return "$value"
|
||||
}
|
||||
|
||||
function Send-FailurePush([string]$Body) {
|
||||
$topic = Get-EnvValue 'NTFY_TOPIC'
|
||||
$policy = (Get-EnvValue 'NTFY_NOTIFY').ToLower()
|
||||
if (-not $topic -or $policy -eq 'off') { return }
|
||||
$server = Get-EnvValue 'NTFY_SERVER'
|
||||
if (-not $server) { $server = 'https://ntfy.sh' }
|
||||
$token = Get-EnvValue 'NTFY_TOKEN'
|
||||
|
||||
# The same short name src/notify.py puts in its titles.
|
||||
$machine = [System.Net.Dns]::GetHostName().Split('.')[0]
|
||||
$headers = @{
|
||||
Title = "AI archive FAILED - $machine"
|
||||
Tags = 'rotating_light'
|
||||
Priority = 'high'
|
||||
}
|
||||
if ($token) { $headers.Authorization = "Bearer $token" }
|
||||
|
||||
try {
|
||||
# Windows PowerShell 5.1 on an older .NET may not offer TLS 1.2 by default.
|
||||
[Net.ServicePointManager]::SecurityProtocol = `
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 15 `
|
||||
-Uri "$($server.TrimEnd('/'))/$topic" -Headers $headers `
|
||||
-ContentType 'text/plain; charset=utf-8' `
|
||||
-Body ([Text.Encoding]::UTF8.GetBytes($Body)) | Out-Null
|
||||
} catch {
|
||||
Write-Warning "run-sync: could not send the failure notification ($($_.Exception.Message))"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
if ((Test-Path -LiteralPath $runLog) -and (Get-Item -LiteralPath $runLog).Length -gt 1MB) {
|
||||
Move-Item -LiteralPath $runLog -Destination "$runLog.1" -Force
|
||||
}
|
||||
|
||||
$rc = 0
|
||||
foreach ($p in $Provider) {
|
||||
$out = [IO.Path]::GetTempFileName()
|
||||
try {
|
||||
# cmd.exe does the redirect, so stdout and stderr stay interleaved and
|
||||
# PowerShell's native-command stderr handling never sees either. /s strips
|
||||
# exactly the outer pair of quotes, leaving the quoted paths inside intact.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' }
|
||||
$psi.Arguments = "/d /s /c `"`"$launcher`" sync --provider $p --joplin-optional > `"$out`" 2>&1`""
|
||||
$psi.WorkingDirectory = $repo
|
||||
$psi.UseShellExecute = $false
|
||||
$proc = [Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
$status = $proc.ExitCode
|
||||
|
||||
$text = [IO.File]::ReadAllText($out, [Text.Encoding]::UTF8)
|
||||
Write-Host $text
|
||||
Add-Content -LiteralPath $runLog -Encoding UTF8 -Value (
|
||||
"===== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') sync --provider $p (exit $status) =====`r`n$text")
|
||||
|
||||
if ($status -ne 0) {
|
||||
$rc = 1
|
||||
if (-not $text.Contains('Sync completed with failures')) {
|
||||
$crash = [regex]::Matches($text, '(?m)^[A-Za-z_][A-Za-z0-9_.]*(Error|Exception)\b') |
|
||||
Select-Object -Last 1
|
||||
if ($crash) {
|
||||
$reason = "crashed ($($crash.Value))"
|
||||
} else {
|
||||
$reason = "exited $status before reporting a result"
|
||||
}
|
||||
Send-FailurePush "${p}: $reason`ncache\logs\scheduled-sync.log"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# This script failing is a run that reports nothing, too.
|
||||
$rc = 1
|
||||
Write-Warning "run-sync: $p - $($_.Exception.Message)"
|
||||
Send-FailurePush "${p}: run-sync.ps1 failed ($($_.Exception.GetType().Name))"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
exit $rc
|
||||
Reference in New Issue
Block a user