diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74577bb2d..703623835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 with: go-version: "1.25.10" @@ -30,12 +32,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 with: go-version: "1.25.10" - name: Run e2e tests run: go test -tags e2e ./internal/server/... + + wrapper-tests-windows: + name: Cloud Sync Wrapper Tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 + with: + go-version: "1.25.10" + - run: go test ./tools/ -run TestCloudSyncWrappers -v diff --git a/DOCS.md b/DOCS.md index a0971d245..bf2348705 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1476,7 +1476,43 @@ For a step-by-step recovery guide covering `chunk_id does not match payload cont --- ---- +## Scheduled Explicit Cloud Sync Wrappers + +The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running. They run `engram sync --cloud --project ` once per explicitly named project. **Choose ONE mode** -- native autosync (recommended) when a daemon is feasible, OR these wrappers for the no-daemon case. Do **not** run both at once. Cloud `--all` is intentionally unsupported; projects are never inferred from cwd or an env var. + +### Bash: `tools/cloud-sync-projects.sh` + +```sh +./tools/cloud-sync-projects.sh my-project my-other-project +./tools/cloud-sync-projects.sh --log /var/log/engram-cloud-sync.log my-project +``` + +Exit `0` if all syncs and log succeeded; `1` if any project or logging op failed; `2` on usage error. Default durable log `$ENGRAM_DATA_DIR/cloud-sync-projects.log` (`~/.engram` fallback); override `--log` > `ENGRAM_CLOUD_SYNC_LOG` > default. Status lines go to both timestamped console and log; command stdout+stderr preserved on console and appended to log. Nothing retried or silenced. + +### PowerShell: `tools/cloud-sync-projects.ps1` + +```powershell +pwsh ./tools/cloud-sync-projects.ps1 my-project my-other-project +pwsh ./tools/cloud-sync-projects.ps1 -LogPath C:\logs\engram-cloud-sync.log my-project +``` + +Requires PowerShell 7 (`pwsh`); 5.1 is not supported. Same behavior, exit codes, and log defaults as Bash; override `-LogPath` > `ENGRAM_CLOUD_SYNC_LOG` > default. + +### Inspecting the last failure + +`project FAILURE project= exit=` records the exact exit code from `engram sync --cloud --project `: + +```sh +grep 'project FAILURE' "${ENGRAM_DATA_DIR:-$HOME/.engram}/cloud-sync-projects.log" | tail -n 5 +``` + +```powershell +# PowerShell 7 ($env:ENGRAM_DATA_DIR or $HOME/.engram fallback) +$d = if ($env:ENGRAM_DATA_DIR) { $env:ENGRAM_DATA_DIR } else { Join-Path $HOME '.engram' } +Select-String 'project FAILURE' (Join-Path $d 'cloud-sync-projects.log') | Select-Object -Last 5 +``` + +Pass the failing project to [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) -- the wrappers record and propagate, not interpret or retry. ## Cloud Sync Audit Log diff --git a/tools/cloud-sync-projects.ps1 b/tools/cloud-sync-projects.ps1 new file mode 100755 index 000000000..9cb7ed0b5 --- /dev/null +++ b/tools/cloud-sync-projects.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [string]$LogPath, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$Projects +) + +$ErrorActionPreference = 'Stop' +$defaultLogName = 'cloud-sync-projects.log' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: PowerShell 7 (pwsh) is required. 5.1 is not supported.') + exit 2 +} + +function Write-Usage { + @' +Usage: cloud-sync-projects.ps1 [-LogPath ] [ ...] +Run `engram sync --cloud --project ` once per explicitly named project. +Exit 0 if all succeed, 1 if any project/log op fails, 2 on usage error. + -LogPath Overrides default and ENGRAM_CLOUD_SYNC_LOG. + -Help Show this help. +Requires PowerShell 7 (pwsh); 5.1 is not supported. +'@ | Out-Host +} + +$helpRequested = $false +$cleanProjects = @() +foreach ($a in $Projects) { if ($a -in @('-Help', '--help', '-h')) { $helpRequested = $true } else { $cleanProjects += $a } } +$Projects = $cleanProjects +if ($helpRequested) { Write-Usage; exit 0 } +if ($Projects.Count -eq 0) { + [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: at least one project is required'); exit 2 +} + +$resolvedLog = $LogPath +if ([string]::IsNullOrEmpty($resolvedLog)) { $resolvedLog = $env:ENGRAM_CLOUD_SYNC_LOG } +if ([string]::IsNullOrEmpty($resolvedLog)) { + $dataDir = if ($env:ENGRAM_DATA_DIR) { $env:ENGRAM_DATA_DIR } else { (Join-Path $HOME '.engram') } + $resolvedLog = Join-Path $dataDir $defaultLogName +} +$resolvedLog = [System.IO.Path]::GetFullPath($resolvedLog) +if (-not (Test-Path -LiteralPath ([System.IO.Path]::GetDirectoryName($resolvedLog)) -PathType Container)) { + [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: log directory does not exist: $resolvedLog"); exit 2 +} + +function Write-LogLine { + param([string]$Message) + $line = "[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')] $Message" + try { Add-Content -LiteralPath $resolvedLog -Value $line -Encoding UTF8 -ErrorAction Stop } + catch { [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: failed to append to log: $resolvedLog"); return $false } + Write-Host $line + return $true +} + +function Invoke-Project { + param([string]$Project) + if (-not (Write-LogLine "project START project=$Project")) { return -1 } + $exitCode = 0 + $prevPref = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & engram sync --cloud --project $Project 2>&1 | Tee-Object -FilePath $resolvedLog -Append -ErrorAction Stop | ForEach-Object { Write-Host $_ } + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } catch { + [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: invoke/tee failed for '$Project': $($_.Exception.Message)") + return -1 + } finally { + $ErrorActionPreference = $prevPref + } + if ($exitCode -eq 0) { if (-not (Write-LogLine "project SUCCESS project=$Project exit=0")) { return -1 } } + else { if (-not (Write-LogLine "project FAILURE project=$Project exit=$exitCode")) { return -1 } } + return $exitCode +} + +$overall = 0 +if (-not (Write-LogLine "wrapper START projects=$($Projects.Count) log=$resolvedLog")) { $overall = 1 } +foreach ($proj in $Projects) { if ((Invoke-Project -Project $proj) -ne 0) { $overall = 1 } } +if ($overall -eq 0) { if (-not (Write-LogLine 'wrapper END result=success')) { $overall = 1 } } +else { if (-not (Write-LogLine "wrapper END result=failure overall=$overall")) { $overall = 1 } } +exit $overall diff --git a/tools/cloud-sync-projects.sh b/tools/cloud-sync-projects.sh new file mode 100755 index 000000000..f8612ad4b --- /dev/null +++ b/tools/cloud-sync-projects.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -uo pipefail + +PROG_NAME="cloud-sync-projects.sh" +DEFAULT_LOG_NAME="cloud-sync-projects.log" + +usage() { + cat <<'USAGE' +Usage: cloud-sync-projects.sh [--log ] [ ...] +Run `engram sync --cloud --project ` once per explicitly named project. +Exit 0 if all succeed, 1 if any project/log op fails, 2 on usage error. + --log Overrides default and ENGRAM_CLOUD_SYNC_LOG. + -h, --help Show this help. +Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). +USAGE +} + +die_usage() { printf '%s: error: %s\n' "$PROG_NAME" "$*" >&2; exit 2; } +log_path="" +projects=() +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --log) [ $# -ge 2 ] || die_usage "--log requires a path argument"; log_path="$2"; shift 2 ;; + --log=*) log_path="${1#--log=}"; [ -n "$log_path" ] || die_usage "--log requires a non-empty path"; shift ;; + --) shift; while [ $# -gt 0 ]; do projects+=("$1"); shift; done ;; + -*) die_usage "unknown option: $1" ;; + *) projects+=("$1"); shift ;; + esac +done + +[ "${#projects[@]}" -gt 0 ] || die_usage "at least one project is required" + +[ -z "$log_path" ] && log_path="${ENGRAM_CLOUD_SYNC_LOG:-}" +if [ -z "$log_path" ]; then + log_path="${ENGRAM_DATA_DIR:-$HOME/.engram}/$DEFAULT_LOG_NAME" +fi +case "$log_path" in /*) ;; *) log_path="$PWD/$log_path" ;; esac # absolute + +log_dir="$(dirname "$log_path")" +[ -d "$log_dir" ] || { printf '%s: error: log directory does not exist: %s\n' "$PROG_NAME" "$log_dir" >&2; exit 2; } + +logline() { + local ts; ts="$(date '+%Y-%m-%dT%H:%M:%S%z')" || return 1 + printf '[%s] %s\n' "$ts" "$*" >>"$log_path" || return 1 + printf '[%s] %s\n' "$ts" "$*" +} + +run_project() { + local proj="$1" rc tee_rc + local -a statuses + logline "project START project=$proj" || return 1 + engram sync --cloud --project "$proj" 2>&1 | tee -a "$log_path" + statuses=("${PIPESTATUS[@]}") # snapshot before any other command mutates it + rc=${statuses[0]:-1}; tee_rc=${statuses[1]:-1} + if [ "$rc" -eq 0 ]; then + logline "project SUCCESS project=$proj exit=0" || return 1 + else + logline "project FAILURE project=$proj exit=$rc" || return 1 + fi + [ "$tee_rc" -ne 0 ] && [ "$rc" -eq 0 ] && return 1 # tee/log failed + return "$rc" +} + +overall=0 +logline "wrapper START projects=${#projects[@]} log=$log_path" || overall=1 +for proj in "${projects[@]}"; do + run_project "$proj" || overall=1 +done +if [ "$overall" -eq 0 ]; then + logline "wrapper END result=success" || overall=1 +else + logline "wrapper END result=failure overall=$overall" || overall=1 +fi +exit "$overall" diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go new file mode 100644 index 000000000..ae563a19e --- /dev/null +++ b/tools/cloud_sync_projects_test.go @@ -0,0 +1,180 @@ +package tools_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func wrapperAbs(t *testing.T, name string) string { + t.Helper() + abs, err := filepath.Abs(name) + if err != nil { + t.Fatal(err) + } + return abs +} + +func assertContains(t *testing.T, label, out string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(out, w) { + t.Fatalf("%s missing %q:\n%s", label, w, out) + } + } +} +func fakeEngram(t *testing.T, dir, failProj string) { + t.Helper() + if runtime.GOOS == "windows" { + body := "@echo off\r\nset PROJ=\r\n:parse\r\nif \"%1\"==\"\" goto run\r\nif \"%1\"==\"--project\" (set PROJ=%~2& shift & shift & goto parse)\r\nshift\r\ngoto parse\r\n:run\r\necho stdout: syncing project=%PROJ%\r\necho stderr: project=%PROJ% 1>&2\r\n" + if failProj != "" { + body += "if \"%PROJ%\"==\"" + failProj + "\" (echo fake: forced failure for %PROJ% 1>&2 & exit 1)\r\n" + } + if err := os.WriteFile(filepath.Join(dir, "engram.cmd"), []byte(body+"exit 0\r\n"), 0o755); err != nil { + t.Fatal(err) + } + return + } + s := "#!/usr/bin/env bash\nproj=\"\"; while [ $# -gt 0 ]; do case \"$1\" in --project) proj=\"$2\"; shift 2 ;; *) shift ;; esac; done\nprintf 'stdout: syncing project=%s\\n' \"$proj\"; printf 'stderr: project=%s\\n' \"$proj\" >&2\n" + if failProj != "" { + s += fmt.Sprintf("if [ \"$proj\" = %q ]; then echo \"fake: forced failure for $proj\" >&2; exit 1; fi\n", failProj) + } + if err := os.WriteFile(filepath.Join(dir, "engram"), []byte(s+"exit 0\n"), 0o755); err != nil { + t.Fatal(err) + } +} +func run(t *testing.T, interp, wrapper, fakeDir string, add, args []string) (int, string) { + t.Helper() + env := []string{} + for _, e := range os.Environ() { + k, v, ok := strings.Cut(e, "=") + if !ok || strings.EqualFold(k, "ENGRAM_CLOUD_SYNC_LOG") || strings.EqualFold(k, "ENGRAM_DATA_DIR") { + continue + } + if fakeDir != "" && strings.EqualFold(k, "PATH") { + e = "PATH=" + fakeDir + string(os.PathListSeparator) + v + } + env = append(env, e) + } + env = append(env, add...) + argv := append([]string{wrapper}, args...) + if interp != "bash" { + argv = append([]string{"-NoProfile", "-File", wrapper}, args...) + } + cmd := exec.Command(interp, argv...) + cmd.Env = env + out, err := cmd.CombinedOutput() + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), string(out) + } + if err != nil { + t.Fatalf("run %s: %v; output:\n%s", interp, err, string(out)) + } + return 0, string(out) +} + +type wcase struct { + name string + projects []string + failProj, envLog, explicitLog, wantLogPath string + wantExit int + wantIn, wantLog, wantNotIn []string +} + +func TestCloudSyncWrappers(t *testing.T) { + type interp struct{ name, file, flag string } + var interps []interp + if runtime.GOOS != "windows" { + if _, err := exec.LookPath("bash"); err != nil { + t.Fatal("bash is required to test cloud-sync-projects.sh") + } + interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) + } else if p, err := exec.LookPath("pwsh"); err != nil { + t.Fatal("pwsh is required to test cloud-sync-projects.ps1") + } else { + interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) + } + for _, it := range interps { + t.Run(it.file, func(t *testing.T) { + wrapper := wrapperAbs(t, it.file) + tmp := t.TempDir() + fakeDir, dataDir := filepath.Join(tmp, "bin"), filepath.Join(tmp, "data") + for _, d := range []string{fakeDir, dataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + defLog := filepath.Join(dataDir, "cloud-sync-projects.log") + envLog, envLogUnused, explicitLog, pfLog := filepath.Join(tmp, "env.log"), filepath.Join(tmp, "env-unused.log"), filepath.Join(tmp, "explicit.log"), filepath.Join(tmp, "pf.log") + cases := []wcase{ + {name: "DefaultLogPath", projects: []string{"alpha"}, wantExit: 0, wantLogPath: defLog, wantIn: []string{"stdout: syncing project=alpha", "stderr: project=alpha", "project SUCCESS project=alpha exit=0"}, wantLog: []string{"] project SUCCESS project=alpha exit=0", "stderr: project=alpha"}}, + {name: "EnvLogOverride", projects: []string{"beta"}, envLog: envLog, wantExit: 0, wantLogPath: envLog, wantIn: []string{"stdout: syncing project=beta", "project SUCCESS project=beta exit=0"}, wantLog: []string{"] project SUCCESS project=beta exit=0"}}, + {name: "ExplicitLogPrecedence", projects: []string{"gamma"}, envLog: envLogUnused, explicitLog: explicitLog, wantExit: 0, wantLogPath: explicitLog, wantIn: []string{"stdout: syncing project=gamma", "project SUCCESS project=gamma exit=0"}, wantLog: []string{"] project SUCCESS project=gamma exit=0"}}, + {name: "PartialFailureContinuesAggregate1", projects: []string{"good", "mid", "tail"}, failProj: "mid", envLog: pfLog, wantExit: 1, wantLogPath: pfLog, wantIn: []string{"project FAILURE project=mid exit=1", "project START project=tail", "wrapper END result=failure overall=1"}, wantLog: []string{"] project FAILURE project=mid exit=1"}}, + {name: "SpaceInProjectName", projects: []string{"my project"}, wantExit: 0, wantLogPath: defLog, wantIn: []string{"stdout: syncing project=my project", "project SUCCESS project=my project exit=0"}, wantLog: []string{"] project SUCCESS project=my project exit=0"}}, + {name: "MissingArgsUsage2", wantExit: 2, wantIn: []string{"at least one project is required"}}, + {name: "InvalidLogExits1", projects: []string{"alpha"}, explicitLog: dataDir, wantExit: 1, wantNotIn: []string{"stdout: syncing project=alpha"}}, + } + if it.file == "cloud-sync-projects.sh" { + cases = append(cases, wcase{name: "HelpExits0_-h", projects: []string{"-h"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_--help", projects: []string{"--help"}, wantIn: []string{"Usage:"}}) + } else { + cases = append(cases, wcase{name: "HelpExits0_-Help", projects: []string{"-Help"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_--help", projects: []string{"--help"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_-h", projects: []string{"-h"}, wantIn: []string{"Usage:"}}) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeEngram(t, fakeDir, tc.failProj) + add := []string{"ENGRAM_DATA_DIR=" + dataDir} + if tc.envLog != "" { + add = append(add, "ENGRAM_CLOUD_SYNC_LOG="+tc.envLog) + } + args := tc.projects + if tc.explicitLog != "" { + args = append([]string{it.flag, tc.explicitLog}, args...) + } + exit, out := run(t, it.name, wrapper, fakeDir, add, args) + if exit != tc.wantExit { + t.Fatalf("exit=%d want %d; output:\n%s", exit, tc.wantExit, out) + } + assertContains(t, "console", out, tc.wantIn...) + for _, n := range tc.wantNotIn { + if strings.Contains(out, n) { + t.Fatalf("console unexpectedly contains %q:\n%s", n, out) + } + } + if tc.wantLogPath != "" { + lb, rerr := os.ReadFile(tc.wantLogPath) + if rerr != nil { + t.Fatalf("read expected log %s: %v", tc.wantLogPath, rerr) + } + assertContains(t, "log", string(lb), tc.wantLog...) + if tc.explicitLog != "" { + if _, err := os.Stat(tc.envLog); err == nil { + t.Fatalf("env log %s should not exist when explicit override used", tc.envLog) + } + } + } + }) + } + }) + } + // PowerShell 5.1 rejection (Windows-only; separate from the pwsh-only matrix): powershell.exe must exit 2 with the exact PS7-required diagnostic. + if runtime.GOOS == "windows" { + t.Run("PS5Rejection", func(t *testing.T) { + ps, err := exec.LookPath("powershell.exe") + if err != nil { + t.Skip("powershell.exe not available") + } + exit, out := run(t, ps, wrapperAbs(t, "cloud-sync-projects.ps1"), "", nil, []string{"my-project"}) + if exit != 2 { + t.Fatalf("exit=%d want 2; output:\n%s", exit, out) + } + if want := "PowerShell 7 (pwsh) is required"; !strings.Contains(out, want) { + t.Fatalf("missing %q diagnostic:\n%s", want, out) + } + }) + } +}