#!/usr/bin/env bash # 103 production: Docker goosed pool -> native launchd pool (18006-18014). # Keeps goosed-prod-1 container stopped as Docker hot standby. # # Usage on 103: # bash scripts/goosed-native-103-migrate.sh backup # bash scripts/goosed-native-103-migrate.sh migrate # bash scripts/goosed-native-103-migrate.sh verify # bash scripts/goosed-native-103-migrate.sh rollback # emergency Docker standby on 18006 only set -euo pipefail export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}" ROOT="${MEMIND_ROOT:-/Users/john/Project/Memind}" GOOSED_NATIVE_ROOT="${GOOSED_NATIVE_ROOT:-/Users/john/Project/tkmind_go-native}" GOOSED_PROD_ROOT="${GOOSED_PROD_ROOT:-/Users/john/Project/goosed-prod}" PORTS=(18006 18007 18008 18009 18010 18011 18012 18013 18014) DOCKER_STANDBY="goosed-prod-1" LAUNCHD_DIR="${HOME}/Library/LaunchAgents" GUI="gui/$(id -u)" BACKUP_ROOT="${ROOT}/backups/goosed-native-migration" STAMP="$(date +%Y%m%d-%H%M%S)" BACKUP_DIR="${BACKUP_ROOT}/pre-native-${STAMP}" log() { printf '[goosed-native-103] %s\n' "$*"; } die() { log "ERROR: $*"; exit 1; } require_103() { [[ "$(hostname -s 2>/dev/null || hostname)" == "johndeMac-Studio" || "$(hostname)" == *Studio* ]] || true [[ -d "${ROOT}" ]] || die "Memind root not found: ${ROOT}" } free_disk_headroom() { log "Rotating oversized logs to free disk headroom before backup" for f in \ "${HOME}/Library/Logs/memind-agent-run-worker.log" \ "${HOME}/Library/Logs/memind-runtime-heartbeat.log" \ "${HOME}/Library/Logs/memind-frontend-5173.log"; do if [[ -f "${f}" ]] && [[ "$(wc -c <"${f}" | tr -d ' ')" -gt 50000000 ]]; then mv "${f}" "${f}.bak-${STAMP}" : >"${f}" log "rotated ${f}" fi done df -h "${ROOT}" | tail -1 } phase_backup() { require_103 free_disk_headroom mkdir -p "${BACKUP_DIR}" log "Backup directory: ${BACKUP_DIR}" { echo "stamp=${STAMP}" echo "hostname=$(hostname)" date -Iseconds cat "${ROOT}/.release-manifest.txt" 2>/dev/null || true grep '^TKMIND_API_TARGETS=' "${ROOT}/.env" || true grep '^GOOSED_TAG=' "${GOOSED_PROD_ROOT}/.env" 2>/dev/null || true docker ps -a --format '{{.Names}}\t{{.ID}}\t{{.Status}}\t{{.Ports}}' | grep goosed || true } >"${BACKUP_DIR}/baseline.txt" cp "${ROOT}/.env" "${BACKUP_DIR}/portal.env" cp "${GOOSED_PROD_ROOT}/.env" "${BACKUP_DIR}/goosed-prod.env" 2>/dev/null || true cp "${GOOSED_PROD_ROOT}/docker-compose.prod.yml" "${BACKUP_DIR}/" 2>/dev/null || true log "pg_dump memind_sessions" local session_url session_url="$(grep '^GOOSE_SESSION_DB_URL=' "${GOOSED_PROD_ROOT}/.env" 2>/dev/null | cut -d= -f2- | sed 's|@host.docker.internal|@127.0.0.1|' || true)" if [[ -z "${session_url}" ]]; then session_url="postgresql://boot:@Abc888888@127.0.0.1:5432/memind_sessions" fi pg_dump "${session_url}" | gzip -1 >"${BACKUP_DIR}/memind_sessions.sql.gz" log "pg_dump mindspace_userdata_prod" local userdata_url userdata_url="$(grep '^MINDSPACE_USERDATA_PG_URL=' "${ROOT}/.env" | cut -d= -f2-)" pg_dump "${userdata_url}" | gzip -1 >"${BACKUP_DIR}/mindspace_userdata_prod.sql.gz" log "persisted assets tar" tar -czf "${BACKUP_DIR}/memind-persisted-${STAMP}.tar.gz" \ -C "${ROOT}" \ .env MindSpace data users .tailscale public/plaza-covers logs 2>/dev/null \ || tar -czf "${BACKUP_DIR}/memind-persisted-${STAMP}.tar.gz" \ -C "${ROOT}" .env data users logs 2>/dev/null log "MindSpace storage tar" tar -czf "${BACKUP_DIR}/mindspace-storage-${STAMP}.tar.gz" \ -C /Users/john/MindSpace data/mindspace 2>/dev/null || true log "goosed-prod directory tar" tar -czf "${BACKUP_DIR}/goosed-prod-${STAMP}.tar.gz" \ -C "${GOOSED_PROD_ROOT}" .env docker-compose.prod.yml README.md 2>/dev/null || true log "tkmind_go-native directory tar" tar -czf "${BACKUP_DIR}/tkmind_go-native-${STAMP}.tar.gz" \ -C "${GOOSED_NATIVE_ROOT}" \ .env.18006 run-goosed-18006.sh deploy releases goosed 2>/dev/null || true log "docker inspect goosed containers" docker ps -aq --filter 'name=goosed-prod' | xargs docker inspect >"${BACKUP_DIR}/docker-goosed-inspect.json" 2>/dev/null || true log "full Memind source tar (may take a few minutes)" tar -czf "${BACKUP_DIR}/memind-full-${STAMP}.tar.gz" \ --exclude='./node_modules' \ --exclude='./backups' \ --exclude='./.git' \ -C "${ROOT}" . 2>/dev/null || log "WARN: full tar skipped or partial — check disk space" ln -sfn "${BACKUP_DIR}" "${BACKUP_ROOT}/latest" du -sh "${BACKUP_DIR}"/* df -h "${ROOT}" | tail -1 log "Backup complete: ${BACKUP_DIR}" } install_native_env_files() { local port base="${GOOSED_NATIVE_ROOT}/.env.18006" [[ -f "${base}" ]] || die "missing ${base}" for port in "${PORTS[@]}"; do local env_file="${GOOSED_NATIVE_ROOT}/.env.${port}" if [[ ! -f "${env_file}" ]]; then sed "s/^GOOSE_PORT=.*/GOOSE_PORT=${port}/" "${base}" >"${env_file}" log "created ${env_file}" fi done } install_native_run_script() { local script="${GOOSED_NATIVE_ROOT}/run-goosed-native.sh" cat >"${script}" <<'EOF' #!/usr/bin/env bash set -euo pipefail PORT="${1:?missing port argument}" ROOT="/Users/john/Project/tkmind_go-native" cd "${ROOT}" set -a # shellcheck source=/dev/null source "${ROOT}/.env.${PORT}" set +a exec "${ROOT}/goosed" agent EOF chmod +x "${script}" } install_native_launchd() { local port label plist install_native_run_script for port in "${PORTS[@]}"; do label="cn.tkmind.goosed-native-${port}" plist="${LAUNCHD_DIR}/${label}.plist" cat >"${plist}" < Label ${label} ProgramArguments ${GOOSED_NATIVE_ROOT}/run-goosed-native.sh ${port} WorkingDirectory ${GOOSED_NATIVE_ROOT} RunAtLoad KeepAlive StandardOutPath ${HOME}/Library/Logs/goosed-native-${port}.log StandardErrorPath ${HOME}/Library/Logs/goosed-native-${port}.log EOF plutil -lint "${plist}" >/dev/null launchctl bootout "${GUI}/${label}" 2>/dev/null || true launchctl bootstrap "${GUI}" "${plist}" launchctl enable "${GUI}/${label}" launchctl kickstart -k "${GUI}/${label}" 2>/dev/null || launchctl kickstart "${GUI}/${label}" log "launchd ${label} installed" done } phase_start_deepseek_proxy() { local plist="${LAUNCHD_DIR}/cn.tkmind.memind-deepseek-no-think.plist" cat >"${plist}" < Labelcn.tkmind.memind-deepseek-no-think ProgramArguments /opt/homebrew/opt/node@24/bin/node ${ROOT}/deepseek-no-think-proxy.mjs EnvironmentVariables MEMIND_DEEPSEEK_PROXY_ENTRYPOINT1 MEMIND_DEEPSEEK_DISABLE_THINKING1 MEMIND_DEEPSEEK_NO_THINK_PORT18036 MEMIND_DEEPSEEK_NO_THINK_HOST127.0.0.1 WorkingDirectory${ROOT} RunAtLoad KeepAlive StandardOutPath${HOME}/Library/Logs/memind-deepseek-no-think.log StandardErrorPath${HOME}/Library/Logs/memind-deepseek-no-think.log EOF launchctl bootout "${GUI}/cn.tkmind.memind-deepseek-compat-candidate" 2>/dev/null || true launchctl bootout "${GUI}/cn.tkmind.memind-deepseek-no-think" 2>/dev/null || true launchctl bootstrap "${GUI}" "${plist}" curl -fsS "http://127.0.0.1:18036/health" >/dev/null || die "DeepSeek no-think proxy :18036 failed" log "DeepSeek no-think proxy listening on :18036" } phase_reconfigure_monitor() { local plist="${LAUNCHD_DIR}/cn.tkmind.goosed-monitor.plist" local monitor_script="${ROOT}/scripts/monitor-goosed-fds.mjs" [[ -f "${monitor_script}" ]] || die "missing ${monitor_script}" cp "${plist}" "${plist}.bak-docker-${STAMP}" 2>/dev/null || true cat >"${plist}" < EnvironmentVariables GOOSED_NATIVE_POOL_PORTS 18006,18007,18008,18009,18010,18011,18012,18013,18014 GOOSED_NATIVE_LAUNCHD_LABEL_TEMPLATE cn.tkmind.goosed-native-{port} GOOSED_FD_RESTART 3200 GOOSED_FD_WARN 180 PATH /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin SANDBOX_MCP_MAX_PER_ROOT 2 SANDBOX_MCP_MAX_TOTAL 80 Label cn.tkmind.goosed-monitor ProgramArguments /opt/homebrew/opt/node@24/bin/node ${monitor_script} RunAtLoad StandardErrorPath ${HOME}/Library/Logs/goosed-monitor.log StandardOutPath ${HOME}/Library/Logs/goosed-monitor.log StartInterval 60 WorkingDirectory ${ROOT} EOF plutil -lint "${plist}" >/dev/null launchctl bootout "${GUI}/cn.tkmind.goosed-monitor" 2>/dev/null || true launchctl bootstrap "${GUI}" "${plist}" log "goosed-monitor switched to native FD monitor" } stop_docker_pool_keep_standby() { local name for name in goosed-prod-{1..9}; do docker update --restart=no "${name}" 2>/dev/null || true done for name in goosed-prod-{2..9}; do if docker ps -q --filter "name=^/${name}$" | grep -q .; then log "stopping docker ${name}" docker stop "${name}" >/dev/null fi done if docker ps -q --filter "name=^/${DOCKER_STANDBY}$" | grep -q .; then log "stopping docker standby ${DOCKER_STANDBY} (container kept, restart=no)" docker stop "${DOCKER_STANDBY}" >/dev/null fi docker ps -a --filter 'name=goosed-prod' --format '{{.Names}}\t{{.Status}}' || true } update_portal_env() { local env_file="${ROOT}/.env" local targets="" local port for port in "${PORTS[@]}"; do targets="${targets}https://127.0.0.1:${port}," done targets="${targets%,}" cp "${env_file}" "${env_file}.bak-native-${STAMP}" if grep -q '^GOOSED_RUNTIME=' "${env_file}"; then sed -i '' 's/^GOOSED_RUNTIME=.*/GOOSED_RUNTIME=native/' "${env_file}" else printf '\nGOOSED_RUNTIME=native\n' >>"${env_file}" fi sed -i '' "s|^TKMIND_API_TARGETS=.*|TKMIND_API_TARGETS=${targets}|" "${env_file}" sed -i '' 's|^TKMIND_API_TARGET=.*|TKMIND_API_TARGET=https://127.0.0.1:18006|' "${env_file}" if grep -q '^GOOSED_MCP_NODE_PATH=' "${env_file}"; then sed -i '' 's|^GOOSED_MCP_NODE_PATH=.*|GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node|' "${env_file}" fi if grep -q '^MEMIND_GOOSED_HOST_GATEWAY=' "${env_file}"; then sed -i '' 's|^MEMIND_GOOSED_HOST_GATEWAY=.*|MEMIND_GOOSED_HOST_GATEWAY=127.0.0.1|' "${env_file}" fi if grep -q '^TKMIND_SEARCH_MCP_HOST_GATEWAY=' "${env_file}"; then sed -i '' 's|^TKMIND_SEARCH_MCP_HOST_GATEWAY=.*|TKMIND_SEARCH_MCP_HOST_GATEWAY=127.0.0.1|' "${env_file}" fi grep -E '^(GOOSED_RUNTIME|TKMIND_API_TARGET|TKMIND_API_TARGETS|GOOSED_MCP_NODE_PATH|MEMIND_GOOSED_HOST_GATEWAY)=' "${env_file}" \ | tee "${BACKUP_ROOT}/latest-portal-env-snippet.txt" 2>/dev/null || true } wait_for_status() { local port deadline=$((SECONDS + 90)) while (( SECONDS < deadline )); do if curl -kfsS --connect-timeout 2 "https://127.0.0.1:${port}/status" >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } phase_migrate() { require_103 [[ -L "${BACKUP_ROOT}/latest" || -d "${BACKUP_ROOT}" ]] || die "run backup first" install_native_env_files free_disk_headroom stop_docker_pool_keep_standby phase_reconfigure_monitor phase_start_deepseek_proxy install_native_launchd sleep 5 for port in "${PORTS[@]}"; do wait_for_status "${port}" || die "native /status failed on ${port}" log "${port}: OK" done update_portal_env log "restarting Portal" launchctl kickstart -k "${GUI}/cn.tkmind.memind-portal" sleep 5 curl -fsS "http://127.0.0.1:8081/api/status" >/dev/null || die "Portal /api/status failed" log "syncing LLM provider to all native targets" ( cd "${ROOT}" node <<'NODE' import fs from 'node:fs'; import path from 'node:path'; import { createDbPool } from './db.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; function loadEnv(file) { if (!fs.existsSync(file)) return; for (const line of fs.readFileSync(file, 'utf8').split('\n')) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const eq = t.indexOf('='); if (eq < 0) continue; const k = t.slice(0, eq).trim(); const v = t.slice(eq + 1).trim(); if (!process.env[k]) process.env[k] = v; } } loadEnv(path.join(process.cwd(), '.env')); const targets = String(process.env.TKMIND_API_TARGETS ?? '') .split(',') .map((v) => v.trim()) .filter(Boolean); const pool = createDbPool(); const svc = createLlmProviderService(pool, { apiTarget: targets[0] ?? 'https://127.0.0.1:18006', apiTargets: targets, apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', }); const synced = await svc.syncSelectedToGoosed(); if (!synced.ok) { console.error('provider sync failed:', synced.message); process.exit(1); } console.log('provider synced to', synced.targets?.join(', ')); await pool.end(); NODE ) || log "WARN: provider sync failed — verify manually" log "Migration complete. Docker standby: docker start ${DOCKER_STANDBY} (requires stopping native 18006 first)" } phase_verify() { require_103 for port in "${PORTS[@]}"; do printf '%s: ' "${port}" curl -kfsS --connect-timeout 3 "https://127.0.0.1:${port}/status" && echo || echo FAIL done echo "---" curl -fsS "http://127.0.0.1:8081/api/status" | head -c 400; echo echo "---" docker ps -a --filter 'name=goosed-prod' --format '{{.Names}}\t{{.Status}}' launchctl list | grep 'goosed-native' || true } phase_rollback() { require_103 log "Emergency rollback: stop native pool, start Docker standby on 18006 only" local port label for port in "${PORTS[@]}"; do label="cn.tkmind.goosed-native-${port}" launchctl bootout "${GUI}/${label}" 2>/dev/null || true done docker start "${DOCKER_STANDBY}" if [[ -f "${ROOT}/.env.bak-native-${STAMP}" ]]; then cp "${ROOT}/.env.bak-native-${STAMP}" "${ROOT}/.env" elif [[ -f "${BACKUP_ROOT}/latest/portal.env" ]]; then cp "${BACKUP_ROOT}/latest/portal.env" "${ROOT}/.env" fi launchctl kickstart -k "${GUI}/cn.tkmind.memind-portal" curl -kfsS --connect-timeout 5 "https://127.0.0.1:18006/status" log "Rollback to Docker standby on 18006 done. Full pool: cd goosed-prod && docker compose up -d" } cmd="${1:-}" case "${cmd}" in backup) phase_backup ;; migrate) phase_migrate ;; verify) phase_verify ;; rollback) phase_rollback ;; *) echo "Usage: $0 {backup|migrate|verify|rollback}" >&2 exit 2 ;; esac