| 1 | |
| 2 | set -Eeuo pipefail |
| 3 | |
| 4 | STATE_DB="${HOMELAB_RAG_STATE_DB:-/var/tmp/homelab-rag/state.sqlite}" |
| 5 | PROJECT_DIR="${HOMELAB_RAG_PROJECT_DIR:-/shared/projects/homelab-rag}" |
| 6 | SSH_KEY="${HOME:-/root}/.ssh/termius_homelab" |
| 7 | HOST="100.76.195.52" |
| 8 | SSH_USER="zion" |
| 9 | WALKER_REMOTE_WIN='C:\Users\zion\AppData\Local\Temp\homelab-rag-walker.ps1' |
| 10 | WALKER_REMOTE_PS='C:\\Users\\zion\\AppData\\Local\\Temp\\homelab-rag-walker.ps1' |
| 11 | |
| 12 | DRY_RUN=0 |
| 13 | for arg in "$@"; do [ "$arg" = "--dry-run" ] && DRY_RUN=1; done |
| 14 | |
| 15 | log() { printf '[%s] %s\n' "$(date -Is)" "$*" >&2; } |
| 16 | |
| 17 | SSH_BASE="ssh -o BatchMode=yes -o ConnectTimeout=12 -o StrictHostKeyChecking=accept-new -i ${SSH_KEY} ${SSH_USER}@${HOST}" |
| 18 | |
| 19 | WALKER_LOCAL=$(mktemp /tmp/homelab-rag-walker-XXXXXX.ps1) |
| 20 | trap 'rm -f "$WALKER_LOCAL"' EXIT |
| 21 | |
| 22 | cat > "$WALKER_LOCAL" <<'WALKER_BODY' |
| 23 | param([string]$PayloadJson) |
| 24 | $p = $PayloadJson | ConvertFrom-Json |
| 25 | $sid = [string]$p.source_id |
| 26 | $roots = @($p.roots) |
| 27 | $excl = @($p.exclude | ForEach-Object { ([string]$_).Replace('\','/').ToLowerInvariant() }) |
| 28 | $maxD = [int]$p.max_depth |
| 29 | $maxF = [int]$p.max_files |
| 30 | $maxB = [int64]$p.max_file_bytes |
| 31 | $script:recs = New-Object System.Collections.Generic.List[object] |
| 32 | $script:st = [ordered]@{ filesSeen=0; skipped=0; errors=0; truncated=$false } |
| 33 | $ep = [datetime]'1970-01-01T00:00:00Z' |
| 34 | function cslash([string]$v) { $v.Replace('\','/') } |
| 35 | function is_excl([string]$path, [string]$name) { |
| 36 | $n = (cslash $path).ToLowerInvariant(); $l = $name.ToLowerInvariant() |
| 37 | foreach ($x in $excl) { if ($l -eq $x -or $n.EndsWith('/'+$x) -or $n.Contains('/'+$x+'/')) { return $true } } |
| 38 | return $false |
| 39 | } |
| 40 | function kh([string]$path) { |
| 41 | $l = $path.ToLowerInvariant() |
| 42 | if ($l -match '\.(md|txt|rst|docx|pdf|pptx)$') { return 'doc' } |
| 43 | if ($l -match '\.(json|ya?ml|toml|ini|conf|env|service|ps1)$') { return 'config' } |
| 44 | if ($l -match '\.(log|out|err)$') { return 'log' } |
| 45 | if ($l -match '\.(js|ts|py|sh|go|rs|html|css)$') { return 'code' } |
| 46 | return 'other' |
| 47 | } |
| 48 | function walk([string]$root, [int]$depth) { |
| 49 | if ($script:st['truncated']) { return } |
| 50 | if ($maxD -ge 0 -and $depth -gt $maxD) { return } |
| 51 | try { $entries = Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop } catch { $script:st['errors'] += 1; return } |
| 52 | foreach ($entry in $entries) { |
| 53 | if ($script:st['truncated']) { return } |
| 54 | if (is_excl $entry.FullName $entry.Name) { continue } |
| 55 | if ($entry.PSIsContainer) { walk ([string]$entry.FullName) ($depth + 1); continue } |
| 56 | try { |
| 57 | $sz = [int64]$entry.Length |
| 58 | $mt = [int64](($entry.LastWriteTimeUtc - $ep).TotalMilliseconds) |
| 59 | $fp = cslash $entry.FullName |
| 60 | $sk = if ($sz -gt $maxB) { 'large>' + $maxB } else { '' } |
| 61 | if ($sk) { $script:st['skipped'] += 1 } else { $script:st['filesSeen'] += 1 } |
| 62 | $script:recs.Add([ordered]@{ |
| 63 | source_id = $sid; path = $fp; normalized_path = $fp |
| 64 | size = $sz; mtime_ms = $mt; kind_hint = (kh $entry.FullName) |
| 65 | seen_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') |
| 66 | skip_reason = $sk |
| 67 | }) | Out-Null |
| 68 | if ($maxF -gt 0 -and $script:recs.Count -ge $maxF) { $script:st['truncated'] = $true; return } |
| 69 | } catch { $script:st['errors'] += 1 } |
| 70 | } |
| 71 | } |
| 72 | foreach ($r in $roots) { walk ([string]$r) 0 } |
| 73 | $script:st['records'] = $script:recs.ToArray() |
| 74 | $script:st | ConvertTo-Json -Depth 8 -Compress |
| 75 | WALKER_BODY |
| 76 | |
| 77 | log "Walker size: $(wc -c < "$WALKER_LOCAL") bytes" |
| 78 | |
| 79 | if [ "$DRY_RUN" -eq 1 ]; then |
| 80 | log "[DRY-RUN] Would deploy walker to $HOST and run sync for all desktop-qi823gr* sources" |
| 81 | exit 0 |
| 82 | fi |
| 83 | |
| 84 | log "Deploying walker to $HOST:$WALKER_REMOTE_WIN" |
| 85 | DEPLOY_RESULT=$(cat "$WALKER_LOCAL" | $SSH_BASE \ |
| 86 | "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"\$content=[Console]::In.ReadToEnd(); [System.IO.File]::WriteAllText('$WALKER_REMOTE_WIN',\$content,[System.Text.Encoding]::UTF8); Write-Output 'DEPLOYED_OK'\"" 2>&1 || true) |
| 87 | if ! echo "$DEPLOY_RESULT" | grep -q "DEPLOYED_OK"; then |
| 88 | log "ERROR: Deploy failed. Output: ${DEPLOY_RESULT:0:300}" |
| 89 | exit 1 |
| 90 | fi |
| 91 | log "Walker deployed OK" |
| 92 | |
| 93 | run_source() { |
| 94 | local SOURCE_ID="$1" |
| 95 | local PAYLOAD="$2" |
| 96 | local SOURCE_META="$3" |
| 97 | |
| 98 | log " Running walker for $SOURCE_ID" |
| 99 | local PAYLOAD_PS="${PAYLOAD//\'/\'\'}" |
| 100 | local RUN_PS=". '$WALKER_REMOTE_PS' '$PAYLOAD_PS'" |
| 101 | local RUN_ENC |
| 102 | RUN_ENC=$(python3 -c "import base64,sys; print(base64.b64encode(sys.argv[1].encode('utf-16-le')).decode())" "$RUN_PS") |
| 103 | log " EncodedCommand length: ${#RUN_ENC}" |
| 104 | |
| 105 | local MANIFEST_RAW |
| 106 | MANIFEST_RAW=$($SSH_BASE "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $RUN_ENC" 2>&1 || true) |
| 107 | |
| 108 | local MANIFEST_JSON |
| 109 | MANIFEST_JSON=$(python3 -c " |
| 110 | import sys, re, json |
| 111 | data = sys.stdin.read() |
| 112 | for cand in re.findall(r'(\{[^\x00]{10,}\})', data, re.DOTALL): |
| 113 | try: |
| 114 | obj = json.loads(cand) |
| 115 | if 'filesSeen' in obj: |
| 116 | print(cand) |
| 117 | break |
| 118 | except Exception: |
| 119 | pass |
| 120 | " <<< "$MANIFEST_RAW" 2>/dev/null || true) |
| 121 | |
| 122 | if [ -z "$MANIFEST_JSON" ]; then |
| 123 | log " ERROR: No JSON in manifest output for $SOURCE_ID. Raw (first 400):" |
| 124 | echo "${MANIFEST_RAW:0:400}" >&2 |
| 125 | return 1 |
| 126 | fi |
| 127 | |
| 128 | local FILES_SEEN |
| 129 | FILES_SEEN=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('filesSeen',0))" <<< "$MANIFEST_JSON" 2>/dev/null || echo "?") |
| 130 | log " Files seen: $FILES_SEEN" |
| 131 | |
| 132 | local STARTED_AT |
| 133 | STARTED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") |
| 134 | |
| 135 | local BRIDGE_OUT |
| 136 | BRIDGE_OUT=$(python3 - <<PYEOF |
| 137 | import json, sys, subprocess, os |
| 138 | |
| 139 | state_db = "$STATE_DB" |
| 140 | source_id = "$SOURCE_ID" |
| 141 | host = "$HOST" |
| 142 | started_at = "$STARTED_AT" |
| 143 | project_dir = "$PROJECT_DIR" |
| 144 | |
| 145 | manifest = json.loads(r"""$MANIFEST_JSON""") |
| 146 | records = manifest.get("records", []) |
| 147 | errors = manifest.get("errors", 0) |
| 148 | |
| 149 | source_meta = json.loads(r"""$SOURCE_META""") |
| 150 | |
| 151 | payload = {"action":"sync","statePath":state_db,"source":source_meta,"startedAt":started_at,"records":records,"errors":errors} |
| 152 | bridge = os.path.join(project_dir, "src", "sqlite_bridge.py") |
| 153 | result = subprocess.run(["python3", bridge], input=json.dumps(payload).encode(), capture_output=True, timeout=60) |
| 154 | if result.returncode != 0: |
| 155 | print("BRIDGE_ERROR:" + result.stderr.decode()[:300], file=sys.stderr) |
| 156 | sys.exit(1) |
| 157 | print(result.stdout.decode().strip()) |
| 158 | PYEOF |
| 159 | ) |
| 160 | log " Bridge result for $SOURCE_ID: $BRIDGE_OUT" |
| 161 | local DELTA |
| 162 | DELTA=$(python3 -c " |
| 163 | import json,sys |
| 164 | d=json.loads(sys.argv[1]) |
| 165 | print('new=%d changed=%d unchanged=%d tombstoned=%d' % (d.get('newFiles',0),d.get('changedFiles',0),d.get('unchangedFiles',0),d.get('tombstonedFiles',0))) |
| 166 | " "$BRIDGE_OUT" 2>/dev/null || echo "parse error") |
| 167 | log " Change-only delta ($SOURCE_ID): $DELTA" |
| 168 | } |
| 169 | |
| 170 | run_source \ |
| 171 | "desktop-qi823gr" \ |
| 172 | '{"source_id":"desktop-qi823gr","roots":["C:\\Users\\zion\\Documents","C:\\Users\\zion\\Desktop"],"exclude":["AppData","node_modules","cache",".git","Videos","Downloads"],"max_depth":4,"max_files":25000,"max_file_bytes":67108864}' \ |
| 173 | '{"id":"desktop-qi823gr","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":6,"cadence":"2h","canonical":false,"enabled":true,"notes":"White PC over Tailscale. Darwin BUILD-1. SSH stdin deploy workaround.","roots":["C:\\\\Users\\\\zion\\\\Documents","C:\\\\Users\\\\zion\\\\Desktop"],"exclude":["AppData","node_modules","cache",".git","Videos","Downloads"],"maxDepth":4,"maxFiles":25000,"user":"zion"}' |
| 174 | |
| 175 | run_source \ |
| 176 | "desktop-qi823gr-worky" \ |
| 177 | '{"source_id":"desktop-qi823gr-worky","roots":["C:\\Users\\worky\\Documents","C:\\Users\\worky\\Desktop","C:\\Users\\worky\\Downloads","C:\\Users\\worky\\AppData\\Roaming\\Microsoft\\Windows\\PowerShell\\PSReadLine"],"exclude":["AppData","node_modules",".git","cache","Videos"],"max_depth":3,"max_files":10000,"max_file_bytes":67108864}' \ |
| 178 | '{"id":"desktop-qi823gr-worky","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":8,"cadence":"1h","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. White PC worky user files + PSReadLine history.","roots":["C:\\\\Users\\\\worky\\\\Documents","C:\\\\Users\\\\worky\\\\Desktop","C:\\\\Users\\\\worky\\\\Downloads","C:\\\\Users\\\\worky\\\\AppData\\\\Roaming\\\\Microsoft\\\\Windows\\\\PowerShell\\\\PSReadLine"],"exclude":["AppData","node_modules",".git","cache","Videos"],"maxDepth":3,"maxFiles":10000,"user":"zion"}' |
| 179 | |
| 180 | run_source \ |
| 181 | "desktop-qi823gr-browser-brave" \ |
| 182 | '{"source_id":"desktop-qi823gr-browser-brave","roots":["C:\\Users\\worky\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB","VideoDecodeStats","optimization_guide_hint_cache_store","optimization_guide_model_metadata_store","GCM Store","Sync Data","Extension Rules","Extension Scripts","Platform Notifications"],"max_depth":2,"max_files":5000,"max_file_bytes":67108864}' \ |
| 183 | '{"id":"desktop-qi823gr-browser-brave","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":9,"cadence":"30m","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Brave browser profile for White PC worky user. 25.7MB History, active daily.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Local\\\\BraveSoftware\\\\Brave-Browser\\\\User Data\\\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB"],"maxDepth":2,"maxFiles":5000,"user":"zion"}' |
| 184 | |
| 185 | run_source \ |
| 186 | "desktop-qi823gr-browser-edge" \ |
| 187 | '{"source_id":"desktop-qi823gr-browser-edge","roots":["C:\\Users\\worky\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB","VideoDecodeStats","optimization_guide_hint_cache_store","optimization_guide_model_metadata_store","GCM Store","Sync Data","Extension Rules","Extension Scripts","Platform Notifications"],"max_depth":2,"max_files":5000,"max_file_bytes":67108864}' \ |
| 188 | '{"id":"desktop-qi823gr-browser-edge","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":7,"cadence":"1h","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Edge browser profile for White PC worky user. 288KB History.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Local\\\\Microsoft\\\\Edge\\\\User Data\\\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB"],"maxDepth":2,"maxFiles":5000,"user":"zion"}' |
| 189 | |
| 190 | run_source \ |
| 191 | "desktop-qi823gr-browser-firefox" \ |
| 192 | '{"source_id":"desktop-qi823gr-browser-firefox","roots":["C:\\Users\\worky\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\ojo1adxx.default-release"],"exclude":["cache2","startupCache","thumbnails","storage","datareporting","crashes","minidumps"],"max_depth":2,"max_files":3000,"max_file_bytes":67108864}' \ |
| 193 | '{"id":"desktop-qi823gr-browser-firefox","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":9,"cadence":"30m","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Firefox places.sqlite for White PC worky user. 5MB, last modified 2026-06-15.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Roaming\\\\Mozilla\\\\Firefox\\\\Profiles\\\\ojo1adxx.default-release"],"exclude":["cache2","startupCache","thumbnails","storage","datareporting","crashes","minidumps"],"maxDepth":2,"maxFiles":3000,"user":"zion"}' |
| 194 | |
| 195 | log "All desktop-qi823gr* sources complete" |