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