Files
JesseMarkowitzandClaude Opus 5.5 805689c649 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
2026-10-07 06:05:20 -04:00

151 lines
6.3 KiB
PowerShell

<#
.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