diff --git a/.cursor/skills/playwright-testing/browser-apis/iframes.md b/.cursor/skills/playwright-testing/browser-apis/iframes.md index 145e050ff..155cc1c1b 100644 --- a/.cursor/skills/playwright-testing/browser-apis/iframes.md +++ b/.cursor/skills/playwright-testing/browser-apis/iframes.md @@ -372,7 +372,7 @@ test("mock iframe response", async ({ page }) => {

Mocked Widget

- +

Mocked widget content

`, diff --git a/.cursor/skills/playwright-testing/core/locators.md b/.cursor/skills/playwright-testing/core/locators.md index f806635d6..afe3af361 100644 --- a/.cursor/skills/playwright-testing/core/locators.md +++ b/.cursor/skills/playwright-testing/core/locators.md @@ -100,7 +100,7 @@ use: { Usage: ```typescript -// HTML: +// React: page.getByTestId("submit-btn"); ``` diff --git a/.cursor/skills/vercel-react-best-practices/AGENTS.md b/.cursor/skills/vercel-react-best-practices/AGENTS.md index 94c3c8441..2b839ab51 100644 --- a/.cursor/skills/vercel-react-best-practices/AGENTS.md +++ b/.cursor/skills/vercel-react-best-practices/AGENTS.md @@ -549,6 +549,8 @@ Preload heavy bundles before they're needed to reduce perceived latency. **Example: preload on hover/focus** ```tsx +import { Button } from '@/components/ui/button' + function EditorButton({ onClick }: { onClick: () => void }) { const preload = () => { if (typeof window !== 'undefined') { @@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) { } return ( - + ) } ``` @@ -1239,11 +1241,12 @@ function StaticContent() { **For mutations:** ```tsx +import { Button } from '@/components/ui/button' import { useSWRMutation } from 'swr/mutation' function UpdateButton() { const { trigger } = useSWRMutation('/api/user', updateUser) - return + return } ``` @@ -1369,6 +1372,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i **Incorrect: subscribes to all searchParams changes** ```tsx +import { Button } from '@/components/ui/button' + function ShareButton({ chatId }: { chatId: string }) { const searchParams = useSearchParams() @@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) { shareChat(chatId, { ref }) } - return + return } ``` **Correct: reads on demand, no subscription** ```tsx +import { Button } from '@/components/ui/button' + function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { const params = new URLSearchParams(window.location.search) @@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) { shareChat(chatId, { ref }) } - return + return } ``` @@ -1549,6 +1556,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r **Incorrect: event modeled as state + effect** ```tsx +import { Button } from '@/components/ui/button' + function Form() { const [submitted, setSubmitted] = useState(false) const theme = useContext(ThemeContext) @@ -1560,13 +1569,15 @@ function Form() { } }, [submitted, theme]) - return + return } ``` **Correct: do it in the handler** ```tsx +import { Button } from '@/components/ui/button' + function Form() { const theme = useContext(ThemeContext) @@ -1575,7 +1586,7 @@ function Form() { showToast('Registered', theme) } - return + return } ``` diff --git a/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md b/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md index 700050406..0662ef81b 100644 --- a/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md +++ b/.cursor/skills/vercel-react-best-practices/rules/bundle-preload.md @@ -12,6 +12,8 @@ Preload heavy bundles before they're needed to reduce perceived latency. **Example (preload on hover/focus):** ```tsx +import { Button } from "@/components/ui/button" + function EditorButton({ onClick }: { onClick: () => void }) { const preload = () => { if (typeof window !== 'undefined') { @@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) { } return ( - + ) } ``` diff --git a/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md b/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md index 2a430f27f..22d419bca 100644 --- a/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md +++ b/.cursor/skills/vercel-react-best-practices/rules/client-swr-dedup.md @@ -45,11 +45,12 @@ function StaticContent() { **For mutations:** ```tsx +import { Button } from '@/components/ui/button' import { useSWRMutation } from 'swr/mutation' function UpdateButton() { const { trigger } = useSWRMutation('/api/user', updateUser) - return + return } ``` diff --git a/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md b/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md index e867c95f0..94410bc5b 100644 --- a/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +++ b/.cursor/skills/vercel-react-best-practices/rules/rerender-defer-reads.md @@ -12,6 +12,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i **Incorrect (subscribes to all searchParams changes):** ```tsx +import { Button } from '@/components/ui/button' + function ShareButton({ chatId }: { chatId: string }) { const searchParams = useSearchParams() @@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) { shareChat(chatId, { ref }) } - return + return } ``` **Correct (reads on demand, no subscription):** ```tsx +import { Button } from '@/components/ui/button' + function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { const params = new URLSearchParams(window.location.search) @@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) { shareChat(chatId, { ref }) } - return + return } ``` diff --git a/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md b/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md index dd58a1af0..299815d69 100644 --- a/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +++ b/.cursor/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md @@ -12,6 +12,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r **Incorrect (event modeled as state + effect):** ```tsx +import { Button } from '@/components/ui/button' + function Form() { const [submitted, setSubmitted] = useState(false) const theme = useContext(ThemeContext) @@ -23,13 +25,15 @@ function Form() { } }, [submitted, theme]) - return + return } ``` **Correct (do it in the handler):** ```tsx +import { Button } from '@/components/ui/button' + function Form() { const theme = useContext(ThemeContext) @@ -38,7 +42,7 @@ function Form() { showToast('Registered', theme) } - return + return } ``` diff --git a/.gitignore b/.gitignore index ac2ff94c9..a99954efe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,16 +6,15 @@ node_modules/ .venv .pnpm-store .DS_Store -deepagents/ debug.log -opencode/ + +references/ +references # Playwright (E2E test artifacts) surfsense_web/playwright/.auth/ surfsense_web/playwright-report/ surfsense_web/test-results/ surfsense_web/blob-report/ -hermes-agent -hermes-agent/ content_research/ diff --git a/VERSION b/VERSION index df5db66fe..b056f4120 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.23 +0.0.24 diff --git a/docker/docker-compose.deps-only.yml b/docker/docker-compose.deps-only.yml index ee09a4d5b..2be0bfe6e 100644 --- a/docker/docker-compose.deps-only.yml +++ b/docker/docker-compose.deps-only.yml @@ -20,6 +20,18 @@ # - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888} # - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0 # - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848} +# +# IMPORTANT — schema migrations: +# This compose file does NOT build the backend image and therefore cannot +# run a `migrations` service. You MUST run alembic on the host before +# bringing zero-cache up, or zero-cache will crash-loop with +# `Unknown or invalid publications. Specified: [zero_publication]`. +# +# First-time / after-pull: +# cd surfsense_backend && uv run alembic upgrade head +# +# The other compose files (docker-compose.yml, docker-compose.dev.yml) +# handle this automatically via a dedicated `migrations` service. # ============================================================================= name: surfsense-deps @@ -82,8 +94,12 @@ services: timeout: 5s retries: 5 + # NOTE: zero-cache requires the `zero_publication` Postgres publication to + # exist before it starts. In this deps-only stack there is no backend + # container to run migrations, so you must run `uv run alembic upgrade head` + # from `surfsense_backend/` on the host BEFORE `docker compose up -d`. zero-cache: - image: rocicorp/zero:0.26.2 + image: rocicorp/zero:1.4.0 ports: - "${ZERO_CACHE_PORT:-4848}:4848" extra_hosts: diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 28b00a044..53b8ea1a9 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -34,6 +34,25 @@ services: timeout: 5s retries: 5 + # Short-lived schema runner; see docker/docker-compose.yml `migrations` + # service for the full rationale. Builds from the same backend context as + # the dev backend/celery services. + migrations: + build: *backend-build + env_file: + - ../surfsense_backend/.env + environment: + - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} + - PYTHONPATH=/app + - SERVICE_ROLE=migrate + - MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-900} + volumes: + - zero_init:/zero-init + depends_on: + db: + condition: service_healthy + restart: "no" + pgadmin: image: dpage/pgadmin4 ports: @@ -111,8 +130,10 @@ services: condition: service_healthy searxng: condition: service_healthy + migrations: + condition: service_completed_successfully healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "curl", "-f", "http://localhost:8000/ready"] interval: 15s timeout: 5s retries: 30 @@ -141,6 +162,8 @@ services: condition: service_healthy redis: condition: service_healthy + migrations: + condition: service_completed_successfully backend: condition: service_healthy @@ -160,6 +183,8 @@ services: condition: service_healthy redis: condition: service_healthy + migrations: + condition: service_completed_successfully celery_worker: condition: service_started @@ -179,14 +204,16 @@ services: # - celery_worker zero-cache: - image: rocicorp/zero:0.26.2 + image: rocicorp/zero:1.4.0 ports: - "${ZERO_CACHE_PORT:-4848}:4848" extra_hosts: - "host.docker.internal:host-gateway" depends_on: - backend: + db: condition: service_healthy + migrations: + condition: service_completed_successfully environment: - ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} @@ -201,6 +228,12 @@ services: - ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} volumes: - zero_cache_data:/data + - zero_init:/zero-init + # Wrapper: see docker/docker-compose.yml `zero-cache` for rationale. + entrypoint: ["sh", "-c"] + # Pass the script as a single list element so Compose does not tokenize it. + command: + - 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache' restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"] @@ -238,3 +271,5 @@ volumes: name: surfsense-dev-shared-temp zero_cache_data: name: surfsense-dev-zero-cache + zero_init: + name: surfsense-dev-zero-init diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 10cace249..82d77f826 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -27,6 +27,28 @@ services: timeout: 5s retries: 5 + # Short-lived schema runner. Executes `alembic upgrade head` and verifies + # that the `zero_publication` Postgres logical-replication publication + # exists, then exits 0. Downstream services (backend, celery_*, zero-cache) + # gate on this with `condition: service_completed_successfully` so a failed + # migration halts the whole stack instead of silently producing a half-built + # system that crash-loops zero-cache on missing publications. + migrations: + image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest} + env_file: + - .env + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} + PYTHONPATH: /app + SERVICE_ROLE: migrate + MIGRATION_TIMEOUT: ${MIGRATION_TIMEOUT:-900} + volumes: + - zero_init:/zero-init + depends_on: + db: + condition: service_healthy + restart: "no" + redis: image: redis:8-alpine volumes: @@ -88,9 +110,11 @@ services: condition: service_healthy searxng: condition: service_healthy + migrations: + condition: service_completed_successfully restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "curl", "-f", "http://localhost:8000/ready"] interval: 15s timeout: 5s retries: 30 @@ -118,6 +142,8 @@ services: condition: service_healthy redis: condition: service_healthy + migrations: + condition: service_completed_successfully backend: condition: service_healthy labels: @@ -140,6 +166,8 @@ services: condition: service_healthy redis: condition: service_healthy + migrations: + condition: service_completed_successfully celery_worker: condition: service_started labels: @@ -163,7 +191,7 @@ services: # restart: unless-stopped zero-cache: - image: rocicorp/zero:0.26.2 + image: rocicorp/zero:1.4.0 ports: - "${ZERO_CACHE_PORT:-5929}:4848" extra_hosts: @@ -182,10 +210,21 @@ services: ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} volumes: - zero_cache_data:/data + - zero_init:/zero-init + # Wrapper: if the migrations service flagged a publication change via + # /zero-init/needs_reset, wipe the SQLite replica before starting so + # zero-cache does a clean initial sync. Recovers from the half-built + # replica state (`_zero.tableMetadata` missing) caused by earlier crashes. + entrypoint: ["sh", "-c"] + # Pass the script as a single list element so Compose does not tokenize it. + command: + - 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache' restart: unless-stopped depends_on: - backend: + db: condition: service_healthy + migrations: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"] interval: 10s @@ -221,3 +260,5 @@ volumes: name: surfsense-shared-temp zero_cache_data: name: surfsense-zero-cache + zero_init: + name: surfsense-zero-init diff --git a/docker/scripts/install.ps1 b/docker/scripts/install.ps1 index 0eb3886a2..60c4fd5df 100644 --- a/docker/scripts/install.ps1 +++ b/docker/scripts/install.ps1 @@ -97,6 +97,161 @@ function Wait-ForPostgres { Write-Ok "PostgreSQL is ready." } +# ── Stack health helpers ──────────────────────────────────────────────────── + +function Get-ComposeServices { + Push-Location $InstallDir + try { + $raw = Invoke-NativeSafe { docker compose ps -a --format json 2>$null } + } finally { + Pop-Location + } + if ([string]::IsNullOrWhiteSpace($raw)) { return @() } + + # Compose v2.21+ emits a JSON array; older versions emit one object per line. + try { + $parsed = $raw | ConvertFrom-Json + if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) { + return @($parsed) + } + return @($parsed) + } catch { + $services = @() + foreach ($line in ($raw -split "`r?`n")) { + $line = $line.Trim() + if (-not $line) { continue } + try { $services += ($line | ConvertFrom-Json) } catch { } + } + return $services + } +} + +function Wait-StackHealthy { + param([int]$TimeoutSec = 300) + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $lastReport = "" + + while ((Get-Date) -lt $deadline) { + $services = Get-ComposeServices + if (-not $services -or $services.Count -eq 0) { + Start-Sleep -Seconds 3 + continue + } + + $bad = @() + $waiting = @() + $good = @() + + foreach ($svc in $services) { + $name = $svc.Service + $state = $svc.State + $health = if ($svc.PSObject.Properties.Name -contains 'Health') { $svc.Health } else { '' } + $exit = if ($svc.PSObject.Properties.Name -contains 'ExitCode') { $svc.ExitCode } else { $null } + + if ($name -eq 'migrations') { + if ($state -eq 'exited' -and $exit -eq 0) { $good += $name } + elseif ($state -eq 'exited') { $bad += "${name} (exit=${exit})" } + else { $waiting += "${name} (${state})" } + continue + } + + if ($state -eq 'running') { + if ([string]::IsNullOrEmpty($health) -or $health -eq 'healthy') { + $good += $name + } elseif ($health -eq 'starting') { + $waiting += "${name} (starting)" + } elseif ($health -eq 'unhealthy') { + $bad += "${name} (unhealthy)" + } else { + $waiting += "${name} (${health})" + } + } elseif ($state -eq 'restarting') { + $bad += "${name} (restarting)" + } elseif ($state -eq 'exited') { + $bad += "${name} (exited, code=${exit})" + } else { + $waiting += "${name} (${state})" + } + } + + if ($bad.Count -gt 0) { + return @{ Ok = $false; Reason = 'failure'; Bad = $bad; Waiting = $waiting; Good = $good } + } + if ($waiting.Count -eq 0) { + return @{ Ok = $true; Reason = 'all_healthy'; Good = $good } + } + + $report = "Waiting on: " + ($waiting -join ', ') + if ($report -ne $lastReport) { + Write-Info $report + $lastReport = $report + } + Start-Sleep -Seconds 5 + } + + return @{ Ok = $false; Reason = 'timeout'; Bad = $bad; Waiting = $waiting; Good = $good } +} + +function Test-StaleZeroCacheVolume { + $raw = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null } + if ([string]::IsNullOrWhiteSpace($raw)) { return $false } + $names = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $hasZeroCache = $names -contains 'surfsense-zero-cache' + $hasZeroInit = $names -contains 'surfsense-zero-init' + # Pre-fix installs created surfsense-zero-cache but never surfsense-zero-init. + # Such a volume may hold a half-initialized SQLite replica from an earlier + # crash-loop. Wiping it forces zero-cache to do a fresh initial sync. + return ($hasZeroCache -and -not $hasZeroInit) +} + +function Invoke-StaleZeroCacheCleanup { + if (-not (Test-StaleZeroCacheVolume)) { return } + + Write-Warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that" + Write-Warn "predates the migrations-service fix. It may contain a half-initialized" + Write-Warn "SQLite replica that would block zero-cache from starting." + Write-Warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel." + Start-Sleep -Seconds 5 + + Push-Location $InstallDir + Invoke-NativeSafe { docker compose down --remove-orphans 2>$null } | Out-Null + Pop-Location + Invoke-NativeSafe { docker volume rm surfsense-zero-cache 2>$null } | Out-Null + Write-Ok "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start." +} + +function Write-Err-NoExit { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red +} + +function Invoke-StackFailureReport { + param([hashtable]$Result) + + Write-Host "" + Write-Err-NoExit "Stack did not reach a healthy state." + if ($Result.Bad.Count -gt 0) { Write-Host (" Failed: " + ($Result.Bad -join ', ')) } + if ($Result.Waiting.Count -gt 0) { Write-Host (" Stuck: " + ($Result.Waiting -join ', ')) } + + Write-Host "" + Write-Info "Recent logs from migrations / zero-cache / backend:" + Push-Location $InstallDir + try { + Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host + } finally { + Pop-Location + } + + Write-Host "" + Write-Host "Recovery hints:" -ForegroundColor Yellow + Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations" + Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'" + Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d" + Write-Host "" + exit 1 +} + # ── Download files ────────────────────────────────────────────────────────── Write-Step "Downloading SurfSense files" @@ -191,6 +346,8 @@ if (-not (Test-Path $envPath)) { # ── Start containers ──────────────────────────────────────────────────────── +Invoke-StaleZeroCacheCleanup + if ($MigrationMode) { $envContent = Get-Content $envPath $DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1 @@ -251,7 +408,13 @@ if ($MigrationMode) { Push-Location $InstallDir Invoke-NativeSafe { docker compose up -d } Pop-Location - Write-Ok "All services started." + Write-Ok "All containers started; waiting for stack to become healthy..." + + $waitResult = Wait-StackHealthy -TimeoutSec 300 + if (-not $waitResult.Ok) { + Invoke-StackFailureReport -Result $waitResult + } + Write-Ok "All services healthy." Remove-Item $KeyFile -ErrorAction SilentlyContinue @@ -260,7 +423,13 @@ if ($MigrationMode) { Push-Location $InstallDir Invoke-NativeSafe { docker compose up -d } Pop-Location - Write-Ok "All services started." + Write-Ok "All containers started; waiting for stack to become healthy..." + + $waitResult = Wait-StackHealthy -TimeoutSec 300 + if (-not $waitResult.Ok) { + Invoke-StackFailureReport -Result $waitResult + } + Write-Ok "All services healthy." } # ── Watchtower (auto-update) ──────────────────────────────────────────────── diff --git a/docker/scripts/install.sh b/docker/scripts/install.sh index fcab4d55a..db81f95eb 100644 --- a/docker/scripts/install.sh +++ b/docker/scripts/install.sh @@ -97,6 +97,163 @@ wait_for_pg() { success "PostgreSQL is ready." } +# ── Stack health helpers ───────────────────────────────────────────────────── + +# Enumerate compose services for project `surfsense` as `service|state|health|exitcode` +# lines. Uses `docker inspect` so we don't depend on `jq`, `python3`, or the +# exact ordering of fields in `docker compose ps --format json` output. +get_compose_services() { + local containers + containers=$(docker ps -a --filter "label=com.docker.compose.project=surfsense" --format '{{.Names}}' 2>/dev/null) || true + [[ -z "$containers" ]] && return 0 + + while IFS= read -r container; do + [[ -z "$container" ]] && continue + local svc state health code + svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$container" 2>/dev/null || echo "") + state=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "unknown") + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || echo "") + code=$(docker inspect -f '{{.State.ExitCode}}' "$container" 2>/dev/null || echo "") + [[ -z "$svc" ]] && continue + printf '%s|%s|%s|%s\n' "$svc" "$state" "$health" "$code" + done <<< "$containers" +} + +# Globals populated by wait_stack_healthy / consumed by stack_failure_report. +STACK_BAD=() +STACK_WAITING=() +STACK_GOOD=() +STACK_TIMEOUT=false + +wait_stack_healthy() { + local timeout_sec=${1:-300} + local deadline=$(($(date +%s) + timeout_sec)) + local last_report="" + local bad=() + local waiting=() + local good=() + + while [[ $(date +%s) -lt $deadline ]]; do + local lines + lines=$(get_compose_services) + if [[ -z "$lines" ]]; then + sleep 3 + continue + fi + + bad=() + waiting=() + good=() + + while IFS='|' read -r name state health code; do + [[ -z "$name" ]] && continue + if [[ "$name" == "migrations" ]]; then + if [[ "$state" == "exited" && "$code" == "0" ]]; then + good+=("$name") + elif [[ "$state" == "exited" ]]; then + bad+=("${name} (exit=${code})") + else + waiting+=("${name} (${state})") + fi + continue + fi + + if [[ "$state" == "running" ]]; then + if [[ -z "$health" || "$health" == "healthy" ]]; then + good+=("$name") + elif [[ "$health" == "starting" ]]; then + waiting+=("${name} (starting)") + elif [[ "$health" == "unhealthy" ]]; then + bad+=("${name} (unhealthy)") + else + waiting+=("${name} (${health})") + fi + elif [[ "$state" == "restarting" ]]; then + bad+=("${name} (restarting)") + elif [[ "$state" == "exited" ]]; then + bad+=("${name} (exited, code=${code})") + else + waiting+=("${name} (${state})") + fi + done <<< "$lines" + + if (( ${#bad[@]} > 0 )); then + STACK_BAD=("${bad[@]}") + STACK_WAITING=("${waiting[@]}") + STACK_GOOD=("${good[@]}") + return 1 + fi + if (( ${#waiting[@]} == 0 )); then + STACK_GOOD=("${good[@]}") + return 0 + fi + + local report="Waiting on: ${waiting[*]}" + if [[ "$report" != "$last_report" ]]; then + info "$report" + last_report="$report" + fi + sleep 5 + done + + # bad/waiting/good are declared at function scope so referencing them is + # safe even if the polling loop never executed its body. + STACK_BAD=() + [[ ${#bad[@]} -gt 0 ]] && STACK_BAD=("${bad[@]}") + STACK_WAITING=() + [[ ${#waiting[@]} -gt 0 ]] && STACK_WAITING=("${waiting[@]}") + STACK_GOOD=() + [[ ${#good[@]} -gt 0 ]] && STACK_GOOD=("${good[@]}") + STACK_TIMEOUT=true + return 1 +} + +stack_failure_report() { + echo "" + echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state." + if (( ${#STACK_BAD[@]} > 0 )) && [[ -n "${STACK_BAD[0]}" ]]; then + echo " Failed: ${STACK_BAD[*]}" + fi + if (( ${#STACK_WAITING[@]} > 0 )) && [[ -n "${STACK_WAITING[0]}" ]]; then + echo " Stuck: ${STACK_WAITING[*]}" + fi + echo "" + info "Recent logs from migrations / zero-cache / backend:" + (cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true + echo "" + echo "Recovery hints:" + echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations" + echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'" + echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d" + echo "" + exit 1 +} + +# True if `surfsense-zero-cache` exists but `surfsense-zero-init` does not. +# That signals an install that predates the migrations-service fix; the old +# replica may be half-initialized and would block zero-cache on next start. +test_stale_zero_cache_volume() { + local has_zc has_zi + has_zc=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-cache' || true) + has_zi=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-init' || true) + [[ -n "$has_zc" && -z "$has_zi" ]] +} + +invoke_stale_zero_cache_cleanup() { + if ! test_stale_zero_cache_volume; then + return 0 + fi + warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that" + warn "predates the migrations-service fix. It may contain a half-initialized" + warn "SQLite replica that would block zero-cache from starting." + warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel." + sleep 5 + + (cd "${INSTALL_DIR}" && ${DC} down --remove-orphans 2>/dev/null) || true + docker volume rm surfsense-zero-cache 2>/dev/null || true + success "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start." +} + # ── Download files ─────────────────────────────────────────────────────────── step "Downloading SurfSense files" @@ -186,6 +343,8 @@ fi # ── Start containers ───────────────────────────────────────────────────────── +invoke_stale_zero_cache_cleanup + if $MIGRATION_MODE; then # Read DB credentials from .env (fall back to defaults from docker-compose.yml) DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true) @@ -243,7 +402,12 @@ if $MIGRATION_MODE; then step "Starting all SurfSense services" (cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null - success "All services started." + success "All containers started; waiting for stack to become healthy..." + + if ! wait_stack_healthy 300; then + stack_failure_report + fi + success "All services healthy." # Key file is no longer needed — SECRET_KEY is now in .env rm -f "${KEY_FILE}" @@ -251,7 +415,12 @@ if $MIGRATION_MODE; then else step "Starting SurfSense" (cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null - success "All services started." + success "All containers started; waiting for stack to become healthy..." + + if ! wait_stack_healthy 300; then + stack_failure_report + fi + success "All services healthy." fi # ── Watchtower (auto-update) ───────────────────────────────────────────────── diff --git a/surfsense_backend/Dockerfile b/surfsense_backend/Dockerfile index 6e1b2481e..0c783f403 100644 --- a/surfsense_backend/Dockerfile +++ b/surfsense_backend/Dockerfile @@ -167,10 +167,14 @@ COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh # SERVICE_ROLE controls which process this container runs: -# api – FastAPI backend only (runs migrations on startup) +# migrate – Run alembic upgrade head, verify zero_publication exists, exit 0. +# Used by the dedicated `migrations` service in docker-compose.yml +# so downstream services gate on `service_completed_successfully`. +# api – FastAPI backend only (does NOT run migrations) # worker – Celery worker only # beat – Celery beat scheduler only -# all – All three (legacy / dev default) +# all – migrations + api + worker + beat (legacy / dev default; +# fails fast on migration error) ENV SERVICE_ROLE=all # Celery worker tuning (only used when SERVICE_ROLE=worker or all) diff --git a/surfsense_backend/alembic/versions/116_create_zero_publication.py b/surfsense_backend/alembic/versions/116_create_zero_publication.py index ff74952a9..927673c35 100644 --- a/surfsense_backend/alembic/versions/116_create_zero_publication.py +++ b/surfsense_backend/alembic/versions/116_create_zero_publication.py @@ -5,6 +5,17 @@ queries via Zero, instead of replicating all tables in public schema. See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications +NOTE for future migration authors: this is the ONLY migration allowed +to use bare ``CREATE PUBLICATION``. All subsequent mutations of +``zero_publication`` MUST use the ``COMMENT ON PUBLICATION`` bookend +pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the +``upgrade()`` function from migration +``143_force_zero_publication_resync.py`` as your starting template. +Raw ``DROP``/``CREATE PUBLICATION`` in new migrations would +re-introduce bug #1355 (zero-cache stuck on a stale replica snapshot +because Zero >= 1.0's change-streamer never sees the schema-change +event). + Revision ID: 116 Revises: 115 """ diff --git a/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py b/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py index 3ad5a043b..c21ed2bf0 100644 --- a/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py +++ b/surfsense_backend/alembic/versions/117_optimize_zero_publication_column_lists.py @@ -17,6 +17,16 @@ IMPORTANT — before AND after running this migration: 3. Delete / reset the zero-cache data volume 4. Restart zero-cache (it will do a fresh initial sync) +DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE +PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >= +1.0 it does not reliably wake the zero-cache change-streamer and can +leave the replica pinned to a stale snapshot. This file is +grandfathered in because it has already shipped to users; new +publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend +pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the +``upgrade()`` function from migration +``143_force_zero_publication_resync.py`` as your starting template. + Revision ID: 117 Revises: 116 """ diff --git a/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py b/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py index 1fef9fbcb..1dce24e56 100644 --- a/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py +++ b/surfsense_backend/alembic/versions/118_add_local_folder_sync_and_versioning.py @@ -1,5 +1,16 @@ """Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table +DO NOT COPY THIS PATTERN. The bare ``ALTER PUBLICATION ... ADD/DROP +TABLE`` calls below pre-date the ``COMMENT ON PUBLICATION`` bookend +fix for bug #1355: on Zero >= 1.0 they do not reliably wake the +zero-cache change-streamer and can leave the replica pinned to a +stale snapshot. This file is grandfathered in because it has already +shipped to users; new publication mutations MUST use the +``COMMENT ON PUBLICATION`` bookend pattern wrapping an +``ALTER PUBLICATION ... SET TABLE`` -- copy the ``upgrade()`` function +from migration ``143_force_zero_publication_resync.py`` as your +starting template. + Revision ID: 118 Revises: 117 """ diff --git a/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py b/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py index 83c96a429..646049e3c 100644 --- a/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py +++ b/surfsense_backend/alembic/versions/139_add_user_to_zero_publication.py @@ -21,6 +21,16 @@ IMPORTANT - before AND after running this migration: 3. Delete / reset the zero-cache data volume 4. Restart zero-cache (it will do a fresh initial sync) +DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE +PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >= +1.0 it does not reliably wake the zero-cache change-streamer and can +leave the replica pinned to a stale snapshot. This file is +grandfathered in because it has already shipped to users; new +publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend +pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the +``upgrade()`` function from migration +``143_force_zero_publication_resync.py`` as your starting template. + Revision ID: 139 Revises: 138 """ diff --git a/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py b/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py index 64aa699e8..ff88ac34e 100644 --- a/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py +++ b/surfsense_backend/alembic/versions/140_premium_tokens_to_credit_micros.py @@ -32,6 +32,16 @@ Skipping the zero-cache stop will deadlock at the ACCESS EXCLUSIVE LOCK on "user". Skipping the data-volume reset will leave IndexedDB clients seeing column-not-found errors from a stale catalog snapshot. +DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE +PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >= +1.0 it does not reliably wake the zero-cache change-streamer and can +leave the replica pinned to a stale snapshot. This file is +grandfathered in because it has already shipped to users; new +publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend +pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the +``upgrade()`` function from migration +``143_force_zero_publication_resync.py`` as your starting template. + Revision ID: 140 Revises: 139 """ diff --git a/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py b/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py new file mode 100644 index 000000000..147cbde56 --- /dev/null +++ b/surfsense_backend/alembic/versions/143_force_zero_publication_resync.py @@ -0,0 +1,142 @@ +"""force zero-cache to resync after upgrading to Zero >= 1.0 + +Re-emits the current ``zero_publication`` shape using +``ALTER PUBLICATION ... SET TABLE`` wrapped in +``COMMENT ON PUBLICATION`` bookends. This is the publication-change +hook documented for Zero ``>=1.0``: + + https://zero.rocicorp.dev/docs/connecting-to-postgres#publication-changes + +Background +---------- +Migrations 117 / 139 / 140 mutated ``zero_publication`` using +``DROP PUBLICATION`` + ``CREATE PUBLICATION``. On Zero 0.26.2 that +sequence did not reliably wake the zero-cache change-streamer, so +affected installs ended up with a SQLite replica file (in the +``surfsense-zero-cache`` volume) that was snapshotted against the +pre-``user`` publication. The frontend Zero schema includes a +``userTable`` query, which then failed with +``SchemaVersionNotSupported`` and triggered the default +``onUpdateNeeded`` -> ``location.reload()`` every WebSocket keepalive +interval (~60s). See bug #1355. + +This migration emits the canonical publication shape one more time, +this time using a pattern that fires Postgres event triggers and +Zero's schema-change hook. With ``ZERO_AUTO_RESET=true`` (the default) +and Zero ``>=1.0``, zero-cache responds by wiping its replica and +doing a fresh initial sync from the corrected publication. + +The publication shape itself is unchanged versus migration 140 -- on +installs whose replica is already correct, this is a no-op aside +from the harmless event-trigger fire. + +Revision ID: 143 +Revises: 142 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "143" +down_revision: str | None = "142" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PUBLICATION_NAME = "zero_publication" + +# Must stay in sync with the column lists in migrations 117 / 139 / 140. +DOCUMENT_COLS = [ + "id", + "title", + "document_type", + "search_space_id", + "folder_id", + "created_by_id", + "status", + "created_at", + "updated_at", +] + +USER_COLS = [ + "id", + "pages_limit", + "pages_used", + "premium_credit_micros_limit", + "premium_credit_micros_used", +] + + +def _has_zero_version(conn, table: str) -> bool: + return ( + conn.execute( + sa.text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = :tbl AND column_name = '_0_version'" + ), + {"tbl": table}, + ).fetchone() + is not None + ) + + +def _build_set_table_ddl( + *, documents_has_zero_ver: bool, user_has_zero_ver: bool +) -> str: + doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else []) + user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else []) + doc_col_list = ", ".join(doc_cols) + user_col_list = ", ".join(user_cols) + return ( + f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE " + f"notifications, " + f"documents ({doc_col_list}), " + f"folders, " + f"search_source_connectors, " + f"new_chat_messages, " + f"chat_comments, " + f"chat_session_state, " + f'"user" ({user_col_list})' + ) + + +def upgrade() -> None: + conn = op.get_bind() + + exists = conn.execute( + sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"), + {"name": PUBLICATION_NAME}, + ).fetchone() + if not exists: + return + + documents_has_zero_ver = _has_zero_version(conn, "documents") + user_has_zero_ver = _has_zero_version(conn, "user") + + # The COMMENT-ALTER-COMMENT trio MUST run in a single transaction so + # Zero observes them as one schema-change event. Alembic's outer + # transaction already covers us, but a SAVEPOINT keeps the trio + # atomic with asyncpg, matching the pattern used in migrations + # 117 / 139 / 140. + tx = conn.begin_nested() if conn.in_transaction() else conn.begin() + with tx: + conn.execute( + sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-143-resync'") + ) + conn.execute( + sa.text( + _build_set_table_ddl( + documents_has_zero_ver=documents_has_zero_ver, + user_has_zero_ver=user_has_zero_ver, + ) + ) + ) + conn.execute( + sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-143-resync'") + ) + + +def downgrade() -> None: + """No-op. The publication shape is unchanged versus migration 140.""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py index 0d702be4c..ccc5c49e2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument from app.utils.document_converters import embed_text +from app.utils.surfsense_docs import surfsense_docs_public_url def format_surfsense_docs_results(results: list[tuple]) -> str: @@ -19,13 +20,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str: # Group chunks by document grouped: dict[int, dict] = {} for chunk, doc in results: + public_url = surfsense_docs_public_url(doc.source) if doc.id not in grouped: grouped[doc.id] = { "document_id": f"doc-{doc.id}", "document_type": "SURFSENSE_DOCS", "title": doc.title, - "url": doc.source, - "metadata": {"source": doc.source}, + "url": public_url, + "metadata": {"source": doc.source, "public_url": public_url}, "chunks": [], } grouped[doc.id]["chunks"].append( diff --git a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py index 2965f2f02..d8a0efac7 100644 --- a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py +++ b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py @@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker from app.utils.document_converters import embed_text +from app.utils.surfsense_docs import surfsense_docs_public_url def format_surfsense_docs_results(results: list[tuple]) -> str: @@ -40,13 +41,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str: # Group chunks by document grouped: dict[int, dict] = {} for chunk, doc in results: + public_url = surfsense_docs_public_url(doc.source) if doc.id not in grouped: grouped[doc.id] = { "document_id": f"doc-{doc.id}", "document_type": "SURFSENSE_DOCS", "title": doc.title, - "url": doc.source, - "metadata": {"source": doc.source}, + "url": public_url, + "metadata": {"source": doc.source, "public_url": public_url}, "chunks": [], } grouped[doc.id]["chunks"].append( diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 5057e7d00..fc6242643 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -945,6 +945,36 @@ async def health_check(): return {"status": "ok"} +@app.get("/ready", tags=["health"]) +@limiter.exempt +async def readiness_check(): + """Readiness probe. + + Verifies that the schema state required by downstream services is + present. Specifically checks that the ``zero_publication`` Postgres + logical-replication publication exists; without it zero-cache crash-loops + on `Unknown or invalid publications`. + + Returns 200 when ready, 503 otherwise. Used by the docker-compose + backend healthcheck and by ``install.ps1`` / ``install.sh`` post-up + verification. + """ + from sqlalchemy import text + + from app.db import async_session_maker + + async with async_session_maker() as session: + result = await session.execute( + text("SELECT 1 FROM pg_publication WHERE pubname = 'zero_publication'") + ) + if result.first() is None: + raise HTTPException( + status_code=503, + detail="zero_publication missing; run alembic upgrade head", + ) + return {"status": "ready"} + + @app.get("/verify-token") async def authenticated_route( user: User = Depends(current_active_user), diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py index e1713e8a3..0d5428dec 100644 --- a/surfsense_backend/app/routes/surfsense_docs_routes.py +++ b/surfsense_backend/app/routes/surfsense_docs_routes.py @@ -24,6 +24,7 @@ from app.schemas.surfsense_docs import ( SurfsenseDocsDocumentWithChunksRead, ) from app.users import current_active_user +from app.utils.surfsense_docs import surfsense_docs_public_url router = APIRouter() @@ -76,6 +77,7 @@ async def get_surfsense_doc_by_chunk_id( id=document.id, title=document.title, source=document.source, + public_url=surfsense_docs_public_url(document.source), content=document.content, chunks=[ SurfsenseDocsChunkRead(id=c.id, content=c.content) @@ -146,6 +148,7 @@ async def list_surfsense_docs( id=doc.id, title=doc.title, source=doc.source, + public_url=surfsense_docs_public_url(doc.source), content=doc.content, created_at=doc.created_at, updated_at=doc.updated_at, diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py index ce32c0ef8..3adf25032 100644 --- a/surfsense_backend/app/schemas/surfsense_docs.py +++ b/surfsense_backend/app/schemas/surfsense_docs.py @@ -22,6 +22,7 @@ class SurfsenseDocsDocumentRead(BaseModel): id: int title: str source: str + public_url: str content: str created_at: datetime | None = None updated_at: datetime | None = None @@ -35,6 +36,7 @@ class SurfsenseDocsDocumentWithChunksRead(BaseModel): id: int title: str source: str + public_url: str content: str chunks: list[SurfsenseDocsChunkRead] diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 2219ad022..9a69b6164 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import ( ) from app.utils.content_utils import bootstrap_history_from_db from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap +from app.utils.surfsense_docs import surfsense_docs_public_url from app.utils.user_message_multimodal import build_human_message_content _background_tasks: set[asyncio.Task] = set() @@ -216,14 +217,17 @@ def format_mentioned_surfsense_docs_as_context( ) for doc in documents: - metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) + public_url = surfsense_docs_public_url(doc.source) + metadata_json = json.dumps( + {"source": doc.source, "public_url": public_url}, ensure_ascii=False + ) context_parts.append("") context_parts.append("") context_parts.append(f" doc-{doc.id}") context_parts.append(" SURFSENSE_DOCS") context_parts.append(f" <![CDATA[{doc.title}]]>") - context_parts.append(f" ") + context_parts.append(f" ") context_parts.append( f" " ) @@ -1487,14 +1491,20 @@ async def stream_new_chat( # Resolve @-mention chips to canonical virtual paths and rewrite # the user-typed text so the LLM sees ``\`/documents/...\``` instead - # of bare ``@title``. The persisted user-message text keeps - # ``@title`` so chip rendering on reload is unchanged — see - # ``persistence._build_user_content``. + # of bare ``@title``. The substitution lands in ``agent_user_query`` + # ONLY — the original ``user_query`` (with ``@title`` tokens) flows + # untouched into ``persist_user_turn`` below so chip rendering on + # reload still works (``UserTextPart`` → ``parseMentionSegments`` + # matches ``@title``, not ``\`/documents/...\```). It also feeds + # the human-readable surfaces — SSE "Processing X" status, auto + # thread title, memory seed — which all want what the user typed. + # See ``persistence._build_user_content``. # # Cloud mode only: local-folder mode keeps the legacy # ``@title`` text path; mention support there is a follow-up # task because the path scheme (mount-rooted) and the picker # UI both need separate work. + agent_user_query = user_query accepted_folder_ids: list[int] = [] if fs_mode == FilesystemMode.CLOUD.value and ( mentioned_document_ids @@ -1529,11 +1539,13 @@ async def stream_new_chat( mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids, mentioned_folder_ids=mentioned_folder_ids, ) - user_query = substitute_in_text(user_query, resolved.token_to_path) + agent_user_query = substitute_in_text(user_query, resolved.token_to_path) accepted_folder_ids = resolved.mentioned_folder_ids - # Format the user query with context (SurfSense docs + reports only) - final_query = user_query + # Format the user query with context (SurfSense docs + reports only). + # Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths + # instead of bare ``@title`` tokens. + final_query = agent_user_query context_parts = [] if mentioned_surfsense_docs: @@ -1564,7 +1576,7 @@ async def stream_new_chat( if context_parts: context = "\n\n".join(context_parts) - final_query = f"{context}\n\n{user_query}" + final_query = f"{context}\n\n{agent_user_query}" if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name: final_query = f"**[{current_user_display_name}]:** {final_query}" diff --git a/surfsense_backend/app/utils/surfsense_docs.py b/surfsense_backend/app/utils/surfsense_docs.py new file mode 100644 index 000000000..9a6ab11a9 --- /dev/null +++ b/surfsense_backend/app/utils/surfsense_docs.py @@ -0,0 +1,13 @@ +"""Utilities for SurfSense's built-in documentation index.""" + +from pathlib import PurePosixPath + +DOCS_PUBLIC_ROOT = PurePosixPath("/docs") + + +def surfsense_docs_public_url(source: str) -> str: + """Return the public docs route for an indexed documentation source path.""" + docs_path = PurePosixPath(source).with_suffix("") + if docs_path.name == "index": + docs_path = docs_path.parent + return (DOCS_PUBLIC_ROOT / docs_path).as_posix() diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 523a8a1ac..26fee1bc3 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "surf-new-backend" -version = "0.0.23" +version = "0.0.24" description = "SurfSense Backend" requires-python = ">=3.12" dependencies = [ diff --git a/surfsense_backend/scripts/docker/entrypoint.sh b/surfsense_backend/scripts/docker/entrypoint.sh index 7bfcfce86..81db1ae84 100644 --- a/surfsense_backend/scripts/docker/entrypoint.sh +++ b/surfsense_backend/scripts/docker/entrypoint.sh @@ -4,10 +4,15 @@ set -e # ───────────────────────────────────────────────────────────── # SERVICE_ROLE controls which process(es) this container runs. # -# api – FastAPI backend only (runs migrations on startup) +# migrate – Run `alembic upgrade head`, verify zero_publication, +# then exit 0. Used by the dedicated `migrations` service +# in docker-compose.yml so downstream services can gate +# on `condition: service_completed_successfully`. +# api – FastAPI backend only (does NOT run migrations) # worker – Celery worker only # beat – Celery beat scheduler only -# all – All three in one container (legacy / dev default) +# all – migrations + api + worker + beat in one container +# (legacy / dev default; fails fast on migration error) # # Set SERVICE_ROLE as an environment variable in Coolify for # each service deployment. @@ -41,7 +46,13 @@ cleanup() { trap cleanup SIGTERM SIGINT -# ── Database migrations (only for api / all) ───────────────── +# ── Database migrations (only for migrate / all) ───────────── +# Fail-fast contract: +# - alembic upgrade head must succeed within ${MIGRATION_TIMEOUT:-900}s +# - zero_publication must exist in pg_publication afterwards +# Either failure exits non-zero so the dedicated `migrations` compose +# service exits non-zero, halting the rest of the stack instead of +# silently producing a half-built system that crash-loops zero-cache. run_migrations() { echo "Running database migrations..." for i in {1..30}; do @@ -53,11 +64,66 @@ run_migrations() { sleep 1 done - if timeout 300 alembic upgrade head 2>&1; then - echo "Migrations completed successfully." - else - echo "WARNING: Migration failed or timed out. Continuing anyway..." - echo "You may need to run migrations manually: alembic upgrade head" + local timeout_secs="${MIGRATION_TIMEOUT:-900}" + echo "Running alembic upgrade head (timeout=${timeout_secs}s)..." + if ! timeout "${timeout_secs}" alembic upgrade head; then + echo "ERROR: alembic upgrade head failed (or exceeded ${timeout_secs}s timeout)." >&2 + echo "Refusing to start. Inspect the error above and re-run." >&2 + exit 1 + fi + echo "Migrations completed successfully." + + echo "Verifying zero_publication exists in Postgres..." + local pub_oid + pub_oid=$(python <<'PY' 2>/dev/null || true +import asyncio +import sys +from sqlalchemy import text +from app.db import engine + + +async def get_oid(): + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT oid FROM pg_publication WHERE pubname = 'zero_publication'") + ) + row = result.first() + if row is None: + sys.exit(1) + print(int(row[0])) + + +asyncio.run(get_oid()) +PY +) + if [ -z "${pub_oid}" ]; then + echo "ERROR: zero_publication is missing from Postgres after running alembic." >&2 + echo "This usually means migration 116 (or a later publication migration) did not run." >&2 + echo "Inspect alembic state with:" >&2 + echo " docker compose exec db psql -U \"\$DB_USER\" -d \"\$DB_NAME\" -c 'SELECT * FROM alembic_version;'" >&2 + exit 1 + fi + echo "zero_publication verified (oid=${pub_oid})." + + # Stale-replica safety net: if /zero-init is mounted (i.e. we are the + # dedicated `migrations` compose service), drop a marker file when the + # publication oid changed (or on first run) so the wrapped zero-cache + # entrypoint can wipe /data/zero.db before starting. This recovers from + # the case where a previous zero-cache crashed mid-init and left a + # half-built SQLite replica without a `_zero.tableMetadata` table. + if [ -d /zero-init ]; then + local stored_oid="" + [ -f /zero-init/last_pub_oid ] && stored_oid=$(cat /zero-init/last_pub_oid 2>/dev/null || true) + if [ -z "${stored_oid}" ] || [ "${stored_oid}" != "${pub_oid}" ]; then + echo "Publication oid changed (stored=${stored_oid:-}, current=${pub_oid}); writing /zero-init/needs_reset." + : > /zero-init/needs_reset + chmod 666 /zero-init/needs_reset 2>/dev/null || true + fi + echo "${pub_oid}" > /zero-init/last_pub_oid + chmod 666 /zero-init/last_pub_oid 2>/dev/null || true + # World-writable dir so the (possibly non-root) zero-cache container + # can `rm -f /zero-init/needs_reset` after acting on the marker. + chmod 777 /zero-init 2>/dev/null || true fi } @@ -102,8 +168,12 @@ start_beat() { # ── Main: run based on role ────────────────────────────────── case "${SERVICE_ROLE}" in - api) + migrate) run_migrations + echo "Migrations complete; exiting cleanly." + exit 0 + ;; + api) start_api ;; worker) @@ -121,7 +191,7 @@ case "${SERVICE_ROLE}" in start_beat ;; *) - echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all" + echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: migrate, api, worker, beat, or all" exit 1 ;; esac diff --git a/surfsense_backend/tests/e2e/fakes/composio_module.py b/surfsense_backend/tests/e2e/fakes/composio_module.py index 38c4d4c46..16a93d0f1 100644 --- a/surfsense_backend/tests/e2e/fakes/composio_module.py +++ b/surfsense_backend/tests/e2e/fakes/composio_module.py @@ -350,6 +350,25 @@ def _drive_list_files(args: dict[str, Any]) -> dict[str, Any]: folder id and serve the matching fixture list. """ q = args.get("q", "") + if "in owners" in q: + return { + "data": { + "files": [ + { + "id": "fake-file-owner-probe", + "name": "owner-probe", + "owners": [ + { + "me": True, + "emailAddress": "e2e-fake@surfsense.example", + } + ], + } + ], + "nextPageToken": None, + } + } + folder_id = "root" if "in parents" in q: # q looks like: '' in parents and trashed = false ... diff --git a/surfsense_backend/tests/integration/composio/conftest.py b/surfsense_backend/tests/integration/composio/conftest.py index 779e7bdb2..44d707ec3 100644 --- a/surfsense_backend/tests/integration/composio/conftest.py +++ b/surfsense_backend/tests/integration/composio/conftest.py @@ -1,13 +1,11 @@ """Composio route integration fixtures. -The sys.modules hijack happens at module import time, before importing -app.app, so production `from composio import Composio` bindings resolve to -the strict E2E fake in this pytest process too. +The `composio` sys.modules hijack lives in the parent integration conftest +so it runs before any sibling suite imports `app.routes`. """ from __future__ import annotations -import sys from collections.abc import AsyncGenerator import httpx @@ -16,19 +14,15 @@ import pytest_asyncio from httpx import ASGITransport from sqlalchemy.ext.asyncio import AsyncSession -from tests.e2e.fakes import composio_module as _fake_composio - -sys.modules["composio"] = _fake_composio - -from app.app import app, limiter # noqa: E402 -from app.config import config # noqa: E402 -from app.db import ( # noqa: E402 +from app.app import app, limiter +from app.config import config +from app.db import ( SearchSourceConnector, SearchSourceConnectorType, User, get_async_session, ) -from app.users import current_active_user # noqa: E402 +from app.users import current_active_user pytestmark = pytest.mark.integration diff --git a/surfsense_backend/tests/integration/conftest.py b/surfsense_backend/tests/integration/conftest.py index d9d7cacae..e03101e63 100644 --- a/surfsense_backend/tests/integration/conftest.py +++ b/surfsense_backend/tests/integration/conftest.py @@ -1,3 +1,5 @@ +import importlib +import sys import uuid from unittest.mock import AsyncMock, MagicMock @@ -7,17 +9,27 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.pool import NullPool -from app.config import config as app_config -from app.db import ( - Base, - DocumentType, - SearchSourceConnector, - SearchSourceConnectorType, - SearchSpace, - User, -) -from app.indexing_pipeline.connector_document import ConnectorDocument -from tests.conftest import TEST_DATABASE_URL +# Hijack `composio` before any `from app.*` import; the `from composio import +# Composio` in app.services.composio_service binds once at first import. +from tests.e2e.fakes import composio_module as _fake_composio + +sys.modules["composio"] = _fake_composio + +app_config = importlib.import_module("app.config").config +app_db = importlib.import_module("app.db") +Base = app_db.Base +DocumentType = app_db.DocumentType +SearchSourceConnector = app_db.SearchSourceConnector +SearchSourceConnectorType = app_db.SearchSourceConnectorType +SearchSpace = app_db.SearchSpace +User = app_db.User +ConnectorDocument = importlib.import_module( + "app.indexing_pipeline.connector_document" +).ConnectorDocument +create_default_roles_and_membership = importlib.import_module( + "app.routes.search_spaces_routes" +).create_default_roles_and_membership +TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL _EMBEDDING_DIM = app_config.embedding_model_instance.dimension @@ -105,6 +117,9 @@ async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpac ) db_session.add(space) await db_session.flush() + # Mirror POST /searchspaces so routes guarded by check_permission find a membership. + await create_default_roles_and_membership(db_session, space.id, db_user.id) + await db_session.flush() return space @@ -145,6 +160,10 @@ def patched_chunk_text(monkeypatch) -> MagicMock: "app.indexing_pipeline.indexing_pipeline_service.chunk_text", mock, ) + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid", + mock, + ) return mock diff --git a/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py index 1c8f7f990..143c9e252 100644 --- a/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py +++ b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py @@ -204,6 +204,7 @@ class TestStripeCheckoutSessionCreation: assert ( fake_client.last_params["success_url"] == f"http://localhost:3000/dashboard/{search_space_id}/purchase-success" + "?session_id={CHECKOUT_SESSION_ID}" ) assert ( fake_client.last_params["cancel_url"] diff --git a/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py b/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py index 795f0d564..44ff5c48a 100644 --- a/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py +++ b/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py @@ -7,7 +7,7 @@ mocked at their system boundaries. from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest import pytest_asyncio @@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration _COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789" _INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer" +_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token" @pytest_asyncio.fixture @@ -69,32 +70,29 @@ async def native_calendar(async_engine): await cleanup_space(async_engine, data["search_space_id"]) +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") -async def test_composio_calendar_uses_composio_credentials( - mock_build_creds, +@patch(f"{_INDEXER_MODULE}.ComposioService") +async def test_composio_calendar_uses_composio_service( + mock_composio_service_cls, mock_cal_cls, mock_tl_cls, + mock_get_access_token, async_engine, composio_calendar, ): - """Calendar indexer calls build_composio_credentials for a Composio connector.""" + """Calendar indexer uses Composio tools directly for a Composio connector.""" from app.tasks.connector_indexers.google_calendar_indexer import ( index_google_calendar_events, ) data = composio_calendar - mock_creds = MagicMock(name="composio-creds") - mock_build_creds.return_value = mock_creds + mock_composio_service = MagicMock() + mock_composio_service.get_calendar_events = AsyncMock(return_value=([], None)) + mock_composio_service_cls.return_value = mock_composio_service mock_tl_cls.return_value = mock_task_logger() - mock_cal_instance = MagicMock() - mock_cal_instance.get_all_primary_calendar_events = AsyncMock( - return_value=([], None) - ) - mock_cal_cls.return_value = mock_cal_instance - maker = make_session_factory(async_engine) async with maker() as session: await index_google_calendar_events( @@ -104,17 +102,25 @@ async def test_composio_calendar_uses_composio_credentials( user_id=data["user_id"], ) - mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) - mock_cal_cls.assert_called_once() - _, kwargs = mock_cal_cls.call_args - assert kwargs.get("credentials") is mock_creds + mock_composio_service_cls.assert_called_once() + mock_composio_service.get_calendar_events.assert_called_once_with( + connected_account_id=_COMPOSIO_ACCOUNT_ID, + entity_id=f"surfsense_{data['user_id']}", + time_min=ANY, + time_max=ANY, + max_results=250, + ) + mock_cal_cls.assert_not_called() + mock_get_access_token.assert_not_called() +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") +@patch(f"{_INDEXER_MODULE}.ComposioService") async def test_composio_calendar_without_account_id_returns_error( - mock_build_creds, + mock_composio_service_cls, mock_tl_cls, + mock_get_access_token, async_engine, composio_calendar_no_id, ): @@ -138,20 +144,23 @@ async def test_composio_calendar_without_account_id_returns_error( assert count == 0 assert error is not None assert "composio" in error.lower() - mock_build_creds.assert_not_called() + mock_composio_service_cls.assert_not_called() + mock_get_access_token.assert_not_called() +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") +@patch(f"{_INDEXER_MODULE}.ComposioService") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") -async def test_native_calendar_does_not_use_composio_credentials( - mock_build_creds, +async def test_native_calendar_uses_google_calendar_connector( mock_cal_cls, + mock_composio_service_cls, mock_tl_cls, + mock_get_access_token, async_engine, native_calendar, ): - """Calendar indexer does NOT call build_composio_credentials for a native connector.""" + """Native Calendar connector uses GoogleCalendarConnector with no Composio path.""" from app.tasks.connector_indexers.google_calendar_indexer import ( index_google_calendar_events, ) @@ -174,4 +183,6 @@ async def test_native_calendar_does_not_use_composio_credentials( user_id=data["user_id"], ) - mock_build_creds.assert_not_called() + mock_cal_cls.assert_called_once() + mock_composio_service_cls.assert_not_called() + mock_get_access_token.assert_not_called() diff --git a/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py b/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py index afb3e64c3..b869f5607 100644 --- a/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py +++ b/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py @@ -7,7 +7,7 @@ mocked at their system boundaries. from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest import pytest_asyncio @@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration _COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456" _INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer" +_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token" @pytest_asyncio.fixture @@ -69,30 +70,32 @@ async def native_gmail(async_engine): await cleanup_space(async_engine, data["search_space_id"]) +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") -async def test_composio_gmail_uses_composio_credentials( - mock_build_creds, +@patch(f"{_INDEXER_MODULE}.ComposioService") +async def test_composio_gmail_uses_composio_service( + mock_composio_service_cls, mock_gmail_cls, mock_tl_cls, + mock_get_access_token, async_engine, composio_gmail, ): - """Gmail indexer calls build_composio_credentials for a Composio connector.""" + """Gmail indexer uses Composio tools directly for a Composio connector.""" from app.tasks.connector_indexers.google_gmail_indexer import ( index_google_gmail_messages, ) data = composio_gmail - mock_creds = MagicMock(name="composio-creds") - mock_build_creds.return_value = mock_creds + mock_composio_service = MagicMock() + mock_composio_service.get_gmail_messages = AsyncMock( + return_value=([], None, None, None) + ) + mock_composio_service.get_gmail_message_detail = AsyncMock(return_value=({}, None)) + mock_composio_service_cls.return_value = mock_composio_service mock_tl_cls.return_value = mock_task_logger() - mock_gmail_instance = MagicMock() - mock_gmail_instance.get_recent_messages = AsyncMock(return_value=([], None)) - mock_gmail_cls.return_value = mock_gmail_instance - maker = make_session_factory(async_engine) async with maker() as session: await index_google_gmail_messages( @@ -102,17 +105,25 @@ async def test_composio_gmail_uses_composio_credentials( user_id=data["user_id"], ) - mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) - mock_gmail_cls.assert_called_once() - args, _ = mock_gmail_cls.call_args - assert args[0] is mock_creds + mock_composio_service_cls.assert_called_once() + mock_composio_service.get_gmail_messages.assert_called_once_with( + connected_account_id=_COMPOSIO_ACCOUNT_ID, + entity_id=f"surfsense_{data['user_id']}", + query=ANY, + max_results=ANY, + page_token=None, + ) + mock_gmail_cls.assert_not_called() + mock_get_access_token.assert_not_called() +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") +@patch(f"{_INDEXER_MODULE}.ComposioService") async def test_composio_gmail_without_account_id_returns_error( - mock_build_creds, + mock_composio_service_cls, mock_tl_cls, + mock_get_access_token, async_engine, composio_gmail_no_id, ): @@ -136,20 +147,23 @@ async def test_composio_gmail_without_account_id_returns_error( assert count == 0 assert error is not None assert "composio" in error.lower() - mock_build_creds.assert_not_called() + mock_composio_service_cls.assert_not_called() + mock_get_access_token.assert_not_called() +@patch(_GET_ACCESS_TOKEN) @patch(f"{_INDEXER_MODULE}.TaskLoggingService") +@patch(f"{_INDEXER_MODULE}.ComposioService") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") -@patch(f"{_INDEXER_MODULE}.build_composio_credentials") -async def test_native_gmail_does_not_use_composio_credentials( - mock_build_creds, +async def test_native_gmail_uses_google_gmail_connector( mock_gmail_cls, + mock_composio_service_cls, mock_tl_cls, + mock_get_access_token, async_engine, native_gmail, ): - """Gmail indexer does NOT call build_composio_credentials for a native connector.""" + """Native Gmail connector uses GoogleGmailConnector with no Composio path.""" from app.tasks.connector_indexers.google_gmail_indexer import ( index_google_gmail_messages, ) @@ -170,4 +184,6 @@ async def test_native_gmail_does_not_use_composio_credentials( user_id=data["user_id"], ) - mock_build_creds.assert_not_called() + mock_gmail_cls.assert_called_once() + mock_composio_service_cls.assert_not_called() + mock_get_access_token.assert_not_called() diff --git a/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py b/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py index 9fc802aa6..6bb1d2094 100644 --- a/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py +++ b/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py @@ -200,7 +200,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker): """Reindexing replaces old chunks with new content rather than appending.""" mocker.patch( - "app.indexing_pipeline.indexing_pipeline_service.chunk_text", + "app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid", side_effect=[["Original chunk."], ["Updated chunk."]], ) diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py index 59b6cec9c..07e388836 100644 --- a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py +++ b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py @@ -37,7 +37,12 @@ def _make_orm_doc(connector_doc, doc_id): async def test_index_calls_embed_and_chunk_via_to_thread( pipeline, make_connector_document, monkeypatch ): - """index() runs embed_texts and the chunker via asyncio.to_thread, not blocking the loop.""" + """index() runs the chunker and embed_texts via asyncio.to_thread, not blocking the loop. + + Routing between ``chunk_text`` (code path) and ``chunk_text_hybrid`` (default + path, see issue #1334) is verified separately in + ``test_non_code_documents_use_hybrid_chunker``. + """ to_thread_calls = [] original_to_thread = asyncio.to_thread @@ -51,12 +56,6 @@ async def test_index_calls_embed_and_chunk_via_to_thread( "app.indexing_pipeline.indexing_pipeline_service.summarize_document", AsyncMock(return_value="Summary."), ) - mock_chunk = MagicMock(return_value=["chunk1"]) - mock_chunk.__name__ = "chunk_text" - monkeypatch.setattr( - "app.indexing_pipeline.indexing_pipeline_service.chunk_text", - mock_chunk, - ) mock_chunk_hybrid = MagicMock(return_value=["chunk1"]) mock_chunk_hybrid.__name__ = "chunk_text_hybrid" monkeypatch.setattr( @@ -71,6 +70,11 @@ async def test_index_calls_embed_and_chunk_via_to_thread( "app.indexing_pipeline.indexing_pipeline_service.embed_texts", mock_embed, ) + # Bypass set_committed_value, which requires a real ORM instance (not MagicMock). + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document", + MagicMock(), + ) connector_doc = make_connector_document( document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, @@ -83,11 +87,62 @@ async def test_index_calls_embed_and_chunk_via_to_thread( await pipeline.index(document, connector_doc, llm=MagicMock()) - # Non-code documents now route through the table-aware hybrid chunker - # (see commit 2f3a33c9). Either chunker entry point satisfies the - # "chunking runs off the event loop" contract this test guards. + # Either chunker entry point satisfies the "chunking runs off the event + # loop" contract this test guards. Routing between the two is verified + # in test_non_code_documents_use_hybrid_chunker. assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls) assert "embed_texts" in to_thread_calls + assert document.status == DocumentStatus.ready() + + +async def test_non_code_documents_use_hybrid_chunker( + pipeline, make_connector_document, monkeypatch +): + """Non-code documents route through ``chunk_text_hybrid`` (issue #1334). + + The hybrid chunker preserves Markdown table integrity by avoiding splits + mid-row. Only documents flagged with ``should_use_code_chunker=True`` + should take the ``chunk_text`` path. + """ + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.summarize_document", + AsyncMock(return_value="Summary."), + ) + mock_chunk_hybrid = MagicMock(return_value=["chunk1"]) + mock_chunk_hybrid.__name__ = "chunk_text_hybrid" + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid", + mock_chunk_hybrid, + ) + mock_chunk_code = MagicMock(return_value=["chunk1"]) + mock_chunk_code.__name__ = "chunk_text" + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.chunk_text", + mock_chunk_code, + ) + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.embed_texts", + MagicMock(side_effect=lambda texts: [[0.1] * _EMBEDDING_DIM for _ in texts]), + ) + monkeypatch.setattr( + "app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document", + MagicMock(), + ) + + connector_doc = make_connector_document( + document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, + unique_id="msg-1", + search_space_id=1, + should_use_code_chunker=False, + ) + document = MagicMock(spec=Document) + document.id = 1 + document.status = DocumentStatus.pending() + + await pipeline.index(document, connector_doc, llm=MagicMock()) + + mock_chunk_hybrid.assert_called_once() + mock_chunk_code.assert_not_called() def _mock_session_factory(orm_docs_by_id): diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 812be636a..c4e6b5c89 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -7947,7 +7947,7 @@ wheels = [ [[package]] name = "surf-new-backend" -version = "0.0.23" +version = "0.0.24" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json index 82c0a349a..028e653b3 100644 --- a/surfsense_browser_extension/package.json +++ b/surfsense_browser_extension/package.json @@ -1,7 +1,7 @@ { "name": "surfsense_browser_extension", "displayName": "Surfsense Browser Extension", - "version": "0.0.23", + "version": "0.0.24", "description": "Extension to collect Browsing History for SurfSense.", "author": "https://github.com/MODSetter", "engines": { diff --git a/surfsense_desktop/assets/icon-128.png b/surfsense_desktop/assets/icon-128.png new file mode 100644 index 000000000..8be6ee21a Binary files /dev/null and b/surfsense_desktop/assets/icon-128.png differ diff --git a/surfsense_desktop/assets/iconTemplate.png b/surfsense_desktop/assets/iconTemplate.png new file mode 100644 index 000000000..13d1c9840 Binary files /dev/null and b/surfsense_desktop/assets/iconTemplate.png differ diff --git a/surfsense_desktop/assets/iconTemplate@2x.png b/surfsense_desktop/assets/iconTemplate@2x.png new file mode 100644 index 000000000..70710f739 Binary files /dev/null and b/surfsense_desktop/assets/iconTemplate@2x.png differ diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index b1fff79a5..68032e9f4 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,6 +1,6 @@ { "name": "surfsense-desktop", - "version": "0.0.23", + "version": "0.0.24", "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 5fb1acbdf..f0221fe53 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -11,11 +11,20 @@ let registeredGeneralAssist: string | null = null; let registeredScreenshotAssist: string | null = null; function getTrayIcon(): NativeImage { - const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'; + const iconName = + process.platform === 'darwin' + ? 'iconTemplate.png' + : process.platform === 'win32' + ? 'icon.ico' + : 'icon.png'; const iconPath = app.isPackaged ? path.join(process.resourcesPath, 'assets', iconName) : path.join(__dirname, '..', 'assets', iconName); const img = nativeImage.createFromPath(iconPath); + if (process.platform === 'darwin') { + img.setTemplateImage(true); + return img; + } return img.resize({ width: 16, height: 16 }); } diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 8b7c02133..5317005d5 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -7,6 +7,7 @@ import { setActiveSearchSpaceId } from './active-search-space'; const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; +const isMac = process.platform === 'darwin'; let mainWindow: BrowserWindow | null = null; let isQuitting = false; @@ -35,7 +36,12 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { webviewTag: false, }, show: false, - titleBarStyle: 'hiddenInset', + ...(isMac + ? { + titleBarStyle: 'hidden' as const, + trafficLightPosition: { x: 12, y: 10 }, + } + : {}), }); mainWindow.once('ready-to-show', () => { diff --git a/surfsense_web/app/(home)/announcements/layout.tsx b/surfsense_web/app/(home)/announcements/layout.tsx index e5102b85e..157666ba4 100644 --- a/surfsense_web/app/(home)/announcements/layout.tsx +++ b/surfsense_web/app/(home)/announcements/layout.tsx @@ -2,20 +2,20 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; export const metadata: Metadata = { - title: "Announcements | SurfSense", + title: "What's New | SurfSense", description: "Latest product updates, feature releases, and news from SurfSense.", alternates: { canonical: "https://www.surfsense.com/announcements", }, openGraph: { - title: "Announcements | SurfSense", + title: "What's New | SurfSense", description: "Latest product updates, feature releases, and news from SurfSense.", url: "https://www.surfsense.com/announcements", type: "website", }, twitter: { card: "summary_large_image", - title: "Announcements | SurfSense", + title: "What's New | SurfSense", description: "Latest product updates, feature releases, and news from SurfSense.", }, }; diff --git a/surfsense_web/app/(home)/announcements/page.tsx b/surfsense_web/app/(home)/announcements/page.tsx index 966c09f77..f287e43d1 100644 --- a/surfsense_web/app/(home)/announcements/page.tsx +++ b/surfsense_web/app/(home)/announcements/page.tsx @@ -24,7 +24,7 @@ export default function AnnouncementsPage() {

- Announcements + What's New

diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index e22fc2798..581bfe17f 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -1,15 +1,47 @@ "use client"; -import { IconBrandGoogleFilled } from "@tabler/icons-react"; -import { motion } from "motion/react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { Logo } from "@/components/Logo"; +import { Button } from "@/components/ui/button"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { AmbientBackground } from "./AmbientBackground"; +function GoogleGLogo({ className }: { className?: string }) { + return ( + + ); +} + export function GoogleLoginButton() { const t = useTranslations("auth"); + const [isRedirecting, setIsRedirecting] = useState(false); const handleGoogleLogin = () => { + if (isRedirecting) return; + setIsRedirecting(true); + // Track Google login attempt trackLoginAttempt("google"); @@ -73,21 +105,15 @@ export function GoogleLoginButton() { */} - -
-
-
-
-
-
- + {t("continue_with_google")} -
+ ); diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index a0326e39b..9692d35e1 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -7,6 +7,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; @@ -120,11 +121,13 @@ export function LocalLoginForm() {

{error.title}

{error.message}

- + )} @@ -191,21 +194,23 @@ export function LocalLoginForm() { }`} disabled={isLoggingIn} /> - + - + {authType === "LOCAL" && ( diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index c336e757c..42a9182e9 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { Logo } from "@/components/Logo"; +import { Button } from "@/components/ui/button"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { setRedirectPath } from "@/lib/auth-utils"; @@ -154,10 +155,12 @@ function LoginContent() {

{urlError.title}

{urlError.message}

- + )} diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 00f142567..1fd1a4ecb 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { type ExternalToast, toast } from "sonner"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getBearerToken } from "@/lib/auth-utils"; @@ -199,11 +200,13 @@ export default function RegisterPage() {

{error.title}

{error.message}

- + )} @@ -295,18 +298,18 @@ export default function RegisterPage() { /> - +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx index 0c5662712..74bcaff2e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx @@ -4,7 +4,7 @@ import { motion } from "motion/react"; import { useState } from "react"; import { BuyPagesContent } from "@/components/settings/buy-pages-content"; import { BuyTokensContent } from "@/components/settings/buy-tokens-content"; -import { cn } from "@/lib/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const TABS = [ { id: "pages", label: "Pages" }, @@ -17,33 +17,38 @@ export default function BuyMorePage() { const [activeTab, setActiveTab] = useState("pages"); return ( -
- + { + setActiveTab(value as TabId); + }} + className="relative min-h-[37rem] w-full" > -
+ {TABS.map((tab) => ( - + ))} -
+ - {activeTab === "pages" ? : } -
-
+ + + + + + + + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index d95aab6e8..759539ce3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -144,6 +144,19 @@ export function DashboardClientLayout({ const electronAPI = useElectronAPI(); + useEffect(() => { + const htmlBackground = document.documentElement.style.backgroundColor; + const bodyBackground = document.body.style.backgroundColor; + + document.documentElement.style.backgroundColor = "var(--panel)"; + document.body.style.backgroundColor = "var(--panel)"; + + return () => { + document.documentElement.style.backgroundColor = htmlBackground; + document.body.style.backgroundColor = bodyBackground; + }; + }, []); + useEffect(() => { if (!electronAPI?.onChatScreenCapture) return; return electronAPI.onChatScreenCapture((dataUrl: string) => { @@ -163,12 +176,13 @@ export function DashboardClientLayout({ setActiveSearchSpaceIdState(activeSeacrhSpaceId); // Sync to Electron store if stored value is null (first navigation) - if (electronAPI?.setActiveSearchSpace) { + if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) { + const setActiveSearchSpace = electronAPI.setActiveSearchSpace; electronAPI - .getActiveSearchSpace?.() - .then((stored) => { + .getActiveSearchSpace() + .then((stored: string | null) => { if (!stored) { - electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId); + setActiveSearchSpace(activeSeacrhSpaceId); } }) .catch(() => {}); diff --git a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx index c5be2b590..dea1e16c2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx @@ -17,7 +17,6 @@ import { } from "@tanstack/react-table"; import { useAtomValue } from "jotai"; import { - Activity, AlertCircle, AlertTriangle, Bug, @@ -38,6 +37,7 @@ import { RefreshCw, Terminal, Trash, + Workflow, X, Zap, } from "lucide-react"; @@ -133,7 +133,6 @@ const logStatusConfig = { function MessageDetails({ message, taskName, - metadata, createdAt, children, }: { @@ -623,7 +622,7 @@ function LogsSummaryDashboard({ {t("total_logs")} - +
{summary.total_logs}
@@ -739,7 +738,7 @@ function LogsFilters({
{Boolean(filterInput) && ( + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx index 6eb9223ca..108671662 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx @@ -2,42 +2,59 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { return ( -
-
- {/* User message */} -
- +
+
+
+
+ {/* User message */} +
+ +
+ + {/* Assistant message */} +
+ + + +
+ + {/* User message */} +
+ +
+ + {/* Assistant message */} +
+ + + +
+ + {/* User message */} +
+ +
- {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
- - {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
-
- - {/* Input bar */} -
-
- + {/* Input bar */} +
+
+ +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 4dba3bbb6..3e3f41deb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -151,7 +151,7 @@ export default function OnboardPage() { } return ( -
+
{/* Header */}
@@ -165,7 +165,7 @@ export default function OnboardPage() {
{/* Form card */} -
+
}) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx new file mode 100644 index 000000000..b300f8078 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx @@ -0,0 +1,6 @@ +import { ImageModelManager } from "@/components/settings/image-model-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx new file mode 100644 index 000000000..96d77d131 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { + BookText, + Bot, + Brain, + CircleUser, + Earth, + ImageIcon, + ListChecks, + ScanEye, + UserKey, +} from "lucide-react"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; +import { useTranslations } from "next-intl"; +import type React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +export type SearchSpaceSettingsTab = + | "general" + | "roles" + | "models" + | "image-models" + | "vision-models" + | "team-roles" + | "prompts" + | "team-memory" + | "public-links"; + +const DEFAULT_TAB: SearchSpaceSettingsTab = "general"; + +interface SearchSpaceSettingsLayoutShellProps { + searchSpaceId: string; + children: React.ReactNode; +} + +export function SearchSpaceSettingsLayoutShell({ + searchSpaceId, + children, +}: SearchSpaceSettingsLayoutShellProps) { + const t = useTranslations("searchSpaceSettings"); + const segment = useSelectedLayoutSegment(); + const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start"); + + const handleTabScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + const navItems = useMemo( + () => [ + { + value: "general" as const, + label: t("nav_general"), + icon: , + }, + { + value: "roles" as const, + label: t("nav_role_assignments"), + icon: , + }, + { + value: "models" as const, + label: t("nav_agent_models"), + icon: , + }, + { + value: "image-models" as const, + label: t("nav_image_models"), + icon: , + }, + { + value: "vision-models" as const, + label: t("nav_vision_models"), + icon: , + }, + { + value: "team-roles" as const, + label: t("nav_team_roles"), + icon: , + }, + { + value: "prompts" as const, + label: t("nav_system_instructions"), + icon: , + }, + { + value: "team-memory" as const, + label: "Team Memory", + icon: , + }, + { + value: "public-links" as const, + label: t("nav_public_links"), + icon: , + }, + ], + [t] + ); + + const activeTab: SearchSpaceSettingsTab = + segment && navItems.some((item) => item.value === segment) + ? (segment as SearchSpaceSettingsTab) + : DEFAULT_TAB; + const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title"); + + const hrefFor = (tab: SearchSpaceSettingsTab) => + `/dashboard/${searchSpaceId}/search-space-settings/${tab}`; + + return ( +
+
+

{t("title")}

+ +
+
+ {navItems.map((item) => ( + + {item.icon} + {item.label} + + ))} +
+
+
+ +
+
+

{selectedLabel}

+ +
+
{children}
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx new file mode 100644 index 000000000..330158da7 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx @@ -0,0 +1,19 @@ +import type React from "react"; +import { use } from "react"; +import { SearchSpaceSettingsLayoutShell } from "./layout-shell"; + +export default function SearchSpaceSettingsLayout({ + params, + children, +}: { + params: Promise<{ search_space_id: string }>; + children: React.ReactNode; +}) { + const { search_space_id } = use(params); + + return ( + + {children} + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx new file mode 100644 index 000000000..d68194782 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx @@ -0,0 +1,6 @@ +import { AgentModelManager } from "@/components/settings/agent-model-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx new file mode 100644 index 000000000..27c59328b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function SearchSpaceSettingsPage({ + params, +}: { + params: Promise<{ search_space_id: string }>; +}) { + const { search_space_id } = await params; + redirect(`/dashboard/${search_space_id}/search-space-settings/general`); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx new file mode 100644 index 000000000..cc837299d --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx @@ -0,0 +1,6 @@ +import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/public-links/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/public-links/page.tsx new file mode 100644 index 000000000..2cddfe3e0 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/public-links/page.tsx @@ -0,0 +1,6 @@ +import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx new file mode 100644 index 000000000..5bad50cd3 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx @@ -0,0 +1,6 @@ +import { LLMRoleManager } from "@/components/settings/llm-role-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-memory/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-memory/page.tsx new file mode 100644 index 000000000..0652b012e --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-memory/page.tsx @@ -0,0 +1,6 @@ +import { TeamMemoryManager } from "@/components/settings/team-memory-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx new file mode 100644 index 000000000..a343eaacb --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx @@ -0,0 +1,6 @@ +import { RolesManager } from "@/components/settings/roles-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx new file mode 100644 index 000000000..06aea003a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx @@ -0,0 +1,6 @@ +import { VisionModelManager } from "@/components/settings/vision-model-manager"; + +export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { + const { search_space_id } = await params; + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx new file mode 100644 index 000000000..c75eaf4e4 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -0,0 +1,15 @@ +import { TeamContent } from "./team-content"; + +export default async function TeamPage({ + params, +}: { + params: Promise<{ search_space_id: string }>; +}) { + const { search_space_id } = await params; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx index d9ca9efb3..f003dde1b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue } from "jotai"; import { Calendar, Check, @@ -20,6 +20,7 @@ import { UserPlus, Users, } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -31,7 +32,6 @@ import { updateMemberMutationAtom, } from "@/atoms/members/members-mutation.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { AlertDialog, AlertDialogAction, @@ -240,46 +240,77 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { if (accessLoading || membersLoading) { return ( -
-
- - +
+
+ + +
+ + members +
-
+
- - - + + + + + Name + - - + + + + Last logged in + -
- -
+ + + Role +
- {SKELETON_KEYS.map((id) => ( - - + {SKELETON_KEYS.slice(0, 2).map((id) => ( + +
-
- - -
+
- +
- +
@@ -294,41 +325,63 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { return (
- {rolesLoading ? ( - - ) : ( - canInvite && ( + {canInvite && + (rolesLoading ? ( + + ) : ( - ) - )} - {invitesLoading ? ( - - ) : ( - canInvite && - activeInvites.length > 0 && ( - - ) - )} + ))} + {canInvite && + (invitesLoading ? ( + + ) : ( + activeInvites.length > 0 && ( + + ) + ))}

{members.length} {members.length === 1 ? "member" : "members"}

-
+
- - + + Name - + Last logged in @@ -346,6 +399,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { {owners.map((member) => ( ( Promise; onRemoveMember: (membershipId: number) => Promise; }) { - const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); + const router = useRouter(); const initials = getAvatarInitials(member); const displayName = member.user_display_name || member.user_email || "Unknown"; const roleName = member.is_owner ? "Owner" : member.role?.name || "No role"; const showActions = !member.is_owner && (canManageRoles || canRemove); return ( - - + +
{member.user_avatar_url && ( )} - {initials} + + {initials} +

{displayName}

@@ -474,7 +533,7 @@ function MemberRow({
- + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} @@ -482,18 +541,20 @@ function MemberRow({ {showActions ? ( - + e.preventDefault()} - className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" + className="min-w-[120px]" > {canManageRoles && roles @@ -536,13 +597,10 @@ function MemberRow({ )} - + - setSearchSpaceSettingsDialog({ - open: true, - initialTab: "team-roles", - }) + router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`) } > Manage Roles @@ -707,7 +765,7 @@ function CreateInviteDialog({
- Allow — run without asking - Ask — pause for approval - Deny — block silently + Allow (run without asking) + Ask (pause for approval) + Deny (block silently)

{ACTION_DESCRIPTIONS[formData.action]}

- -
- - -
+ + + + + + + + + {isLoading && ( +
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( + + + + + + + + ))}
)} - {sortedRules.length === 0 && !showForm && ( + {isError && ( + + + Failed to load rules + + {error instanceof Error ? error.message : "Unknown error."} + + + )} + + {!isLoading && !isError && sortedRules.length === 0 && !showForm && (

No rules yet

@@ -343,8 +381,8 @@ export function AgentPermissionsContent() {
)} - {sortedRules.length > 0 && ( -
+ {!isLoading && !isError && sortedRules.length > 0 && ( +
{sortedRules.map((rule) => { const badge = ACTION_BADGE[rule.action]; const isUpdating = @@ -352,14 +390,14 @@ export function AgentPermissionsContent() { const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id; return ( -
-
+
- + {rule.permission} {rule.pattern !== "*" && ( @@ -374,7 +412,7 @@ export function AgentPermissionsContent() {

-
+
@@ -152,21 +171,23 @@ export function DesktopContent() { No search spaces found. Create one first.

)} - - +
+ - - - + + +
+
+

Launch on Startup - - +

+

Automatically start SurfSense when you sign in to your computer so global shortcuts and folder sync are always available. - - - -

+

+
+
+
-
+
- - +
+
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/HotkeysContent.tsx similarity index 80% rename from surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx rename to surfsense_web/app/dashboard/[search_space_id]/user-settings/components/HotkeysContent.tsx index f1679cb15..9916f6cda 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/HotkeysContent.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { ShortcutKbd } from "@/components/ui/shortcut-kbd"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; @@ -78,7 +79,7 @@ function HotkeyRow({ ); return ( -
+
@@ -90,38 +91,39 @@ function HotkeyRow({ )} - +
); } -export function DesktopShortcutsContent() { +export function HotkeysContent() { const api = useElectronAPI(); const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); const [shortcutsLoaded, setShortcutsLoaded] = useState(false); @@ -178,17 +180,19 @@ export function DesktopShortcutsContent() { return shortcutsLoaded ? (
- {HOTKEY_ROWS.map((row) => ( - updateShortcut(row.key, accel)} - onReset={() => resetShortcut(row.key)} - /> + {HOTKEY_ROWS.map((row, index) => ( +
+ updateShortcut(row.key, accel)} + onReset={() => resetShortcut(row.key)} + /> + {index < HOTKEY_ROWS.length - 1 ? : null} +
))}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx index 3d0550b6c..3542f0925 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx @@ -177,9 +177,9 @@ export function MemoryContent() { return (
- - - + + +

SurfSense uses this personal memory to personalize your responses across all conversations. @@ -222,7 +222,9 @@ export function MemoryContent() { onClick={handleEdit} disabled={editing || !editQuery.trim()} className={`h-11 w-11 shrink-0 rounded-full ${ - editing ? "" : "bg-muted-foreground/15 hover:bg-muted-foreground/20" + editing + ? "" + : "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground" }`} > {editing ? ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx index b7a594f01..89bc362eb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx @@ -11,8 +11,17 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar"; -function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { +function AvatarDisplay({ + url, + fallback, + bgColor, +}: { + url?: string; + fallback: string; + bgColor: string; +}) { const [errorUrl, setErrorUrl] = useState(); const hasError = errorUrl === url; @@ -23,15 +32,19 @@ function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { alt="Avatar" width={64} height={64} - className="h-16 w-16 rounded-xl object-cover" + className="h-16 w-16 rounded-full object-cover select-none" onError={() => setErrorUrl(url)} + referrerPolicy="no-referrer" unoptimized /> ); } return ( -

+
{fallback}
); @@ -50,11 +63,6 @@ export function ProfileContent() { } }, [user]); - const getInitials = (email: string) => { - const name = email.split("@")[0]; - return name.slice(0, 2).toUpperCase(); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -69,6 +77,7 @@ export function ProfileContent() { }; const hasChanges = displayName !== (user?.display_name || ""); + const avatarBgColor = getUserAvatarColor(user?.email || ""); return (
@@ -78,13 +87,13 @@ export function ProfileContent() {
) : (
-
+
-
@@ -114,7 +123,7 @@ export function ProfileContent() { type="submit" variant="outline" disabled={isPending || !hasChanges} - className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" + className="relative gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black" > {t("profile_save")} {isPending && } diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index c78d4f9f0..e9415a1f2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertTriangle, Globe, Lock, Pencil, Sparkles, Trash2 } from "lucide-react"; +import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { @@ -10,6 +10,7 @@ import { updatePromptMutationAtom, } from "@/atoms/prompts/prompts-mutation.atoms"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertDialog, AlertDialogAction, @@ -21,9 +22,32 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { ShortcutKbd } from "@/components/ui/shortcut-kbd"; +import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import type { PromptRead } from "@/contracts/types/prompts.types"; @@ -123,24 +147,6 @@ export function PromptsContent() { const list = prompts ?? []; - if (isLoading) { - return ( -
- -
- ); - } - - if (isError) { - return ( -
- -

Failed to load prompts

-

Please try refreshing the page.

-
- ); - } - return (
@@ -148,97 +154,150 @@ export function PromptsContent() { Create prompt templates triggered with in the chat composer.

- {!showForm && ( - - )} +
- {showForm && ( -
-

- {editingId !== null ? "Edit prompt" : "New prompt"} -

+ { + setShowForm(open); + if (!open) { + setFormData(EMPTY_FORM); + setEditingId(null); + } + }} + > + + + {editingId !== null ? "Edit prompt" : "New prompt"} + + Create prompt templates triggered with / in the chat composer. + + -
- - setFormData((p) => ({ ...p, name: e.target.value }))} - placeholder="e.g. Fix grammar" - /> +
+
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + placeholder="e.g. Fix grammar" + /> +
+ +
+ +