#!/usr/bin/env bash set -euo pipefail ROOT="${ROOT_OVERRIDE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" ENV_FILE="${ENV_FILE:-${ROOT}/.env}" MODFILE="${MODFILE:-${ROOT}/Modelfile}" SQLAI_BIN="${SQLAI_BIN:-${ROOT}/bin/sqlai}" OLLAMA_BIN="${OLLAMA_BIN:-ollama}" LOG_FILE="" LOCK_FD="" _ts_prefix(){ date -Is; } ts(){ _ts_prefix; } log(){ echo "[$(ts)] $*"; } ok(){ log "OK $*"; } warn(){ log "WARN $*"; } fail(){ local msg="$1" local code="${2:-1}" log "FAIL $msg" exit "$code" } need_cmd(){ command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" 10 } init_log(){ local name="$1" local log_dir="${ROOT}/logs" mkdir -p "$log_dir" LOG_FILE="${log_dir}/${name}-$(date -Iseconds).log" export LOG_FILE exec > >(tee -a "$LOG_FILE") 2>&1 } acquire_lock(){ local name="${1:-jr-sql-ai-native.lock}" local wait_seconds="${LOCK_WAIT_SECONDS:-600}" local lock_dir="${ROOT}/.locks" mkdir -p "$lock_dir" local lock_path="${lock_dir}/${name}" if command -v flock >/dev/null 2>&1; then exec 8>"$lock_path" flock -w "$wait_seconds" 8 || fail "could not acquire lock ${lock_path}" 11 LOCK_FD="8" return 0 fi local i for ((i=0; i/dev/null; then trap 'rmdir "'$lock_path'.lock" >/dev/null 2>&1 || true' EXIT return 0 fi sleep 1 done fail "could not acquire lock ${lock_path}.lock" 11 } load_env(){ if [[ ! -f "$ENV_FILE" && -f "${ROOT}/.env.example" ]]; then cp -n "${ROOT}/.env.example" "$ENV_FILE" || true fi if [[ -f "$ENV_FILE" ]]; then set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a fi : "${OLLAMA_URL:=http://127.0.0.1:11434}" : "${EXPERT_MODEL:=jr-sql-expert}" : "${BASE_MODEL:=}" : "${EXTRA_MODELS:=}" : "${OLLAMA_BIN:=ollama}" : "${OLLAMA_AUTOSTART:=1}" : "${OLLAMA_SERVE_FALLBACK:=1}" : "${OLLAMA_SYSTEMD_SERVICE:=ollama.service}" : "${OLLAMA_SYSTEMD_SCOPE:=auto}" : "${OLLAMA_API_MAX_WAIT:=120}" : "${OLLAMA_API_START_WAIT:=45}" export OLLAMA_URL EXPERT_MODEL BASE_MODEL EXTRA_MODELS OLLAMA_BIN OLLAMA_AUTOSTART OLLAMA_SERVE_FALLBACK OLLAMA_SYSTEMD_SERVICE OLLAMA_SYSTEMD_SCOPE OLLAMA_API_MAX_WAIT OLLAMA_API_START_WAIT export OLLAMA_HOST="$OLLAMA_URL" } is_json(){ python -c 'import json,sys; json.load(sys.stdin)' >/dev/null 2>&1 } http_get(){ local path="$1" curl -fsS --connect-timeout 3 --max-time 20 "${OLLAMA_URL}${path}" } wait_for_ollama_api(){ local max_wait="${1:-120}" local i for ((i=1; i<=max_wait; i++)); do if http_get "/api/tags" >/dev/null 2>&1; then return 0 fi sleep 1 done return 1 } fetch_tags(){ http_get "/api/tags" } fetch_version(){ http_get "/api/version" } model_in_tags(){ local model="$1" local tags="$2" TAGS_JSON="$tags" python - "$model" <<'PY' import json import os import sys m = sys.argv[1] obj = json.loads(os.environ['TAGS_JSON']) names = {it.get('name') for it in obj.get('models', []) if it.get('name')} ok = ( m in names or f"{m}:latest" in names or (m.endswith(':latest') and m[:-7] in names) ) sys.exit(0 if ok else 1) PY } render_modelfile(){ local out_file="$1" [[ -f "$MODFILE" ]] || fail "Modelfile not found at $MODFILE" 20 [[ -n "$BASE_MODEL" ]] || fail "BASE_MODEL is empty" 21 python - "$MODFILE" "$out_file" "$BASE_MODEL" <<'PY' from pathlib import Path import sys src = Path(sys.argv[1]).read_text(encoding='utf-8') out = src.replace('${BASE_MODEL}', sys.argv[3]) Path(sys.argv[2]).write_text(out, encoding='utf-8') PY } ollama_cmd(){ OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" "$@" } ollama_service_exists(){ local scope="$1" if [[ "$scope" == "user" ]]; then systemctl --user cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1 else systemctl cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1 fi } start_ollama_via_systemd(){ command -v systemctl >/dev/null 2>&1 || return 1 if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "user" ]]; then if ollama_service_exists user; then log "starting Ollama via systemd user service ${OLLAMA_SYSTEMD_SERVICE}" systemctl --user start "$OLLAMA_SYSTEMD_SERVICE" return 0 fi fi if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "system" ]]; then if ollama_service_exists system; then log "starting Ollama via systemd system service ${OLLAMA_SYSTEMD_SERVICE}" systemctl start "$OLLAMA_SYSTEMD_SERVICE" return 0 fi fi return 1 } start_ollama_fallback(){ [[ "$OLLAMA_SERVE_FALLBACK" == "1" ]] || return 1 if pgrep -fa '(^|/)ollama( |$).*serve' >/dev/null 2>&1; then warn "ollama serve process already exists but API is not reachable yet" return 0 fi local serve_log="${ROOT}/logs/ollama-serve-fallback.log" mkdir -p "${ROOT}/logs" log "starting Ollama via detached fallback process (${OLLAMA_BIN} serve)" nohup env OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" serve >>"$serve_log" 2>&1 & return 0 } ensure_ollama_ready(){ local max_wait="${1:-$OLLAMA_API_MAX_WAIT}" local start_wait="${2:-$OLLAMA_API_START_WAIT}" if wait_for_ollama_api 3; then return 0 fi [[ "$OLLAMA_AUTOSTART" == "1" ]] || return 1 if start_ollama_via_systemd; then wait_for_ollama_api "$start_wait" && return 0 warn "Ollama service start issued, but API is still not reachable" fi if start_ollama_fallback; then wait_for_ollama_api "$max_wait" && return 0 fi return 1 } iter_models(){ if [[ -n "$BASE_MODEL" ]]; then printf '%s\n' "$BASE_MODEL" fi if [[ -n "$EXTRA_MODELS" ]]; then printf '%s\n' "$EXTRA_MODELS" | tr ' ' '\n' | sed '/^$/d' fi } pull_configured_models(){ local model while IFS= read -r model; do [[ -n "$model" ]] || continue log "pulling model ${model}" ollama_cmd pull "$model" done < <(iter_models) } check_for_legacy_docker_ollama(){ if ! command -v docker >/dev/null 2>&1; then return 0 fi if docker ps --format '{{.Names}}' | grep -qx 'ollama'; then fail "legacy docker container 'ollama' is still running; run cleanup-docker-ollama.sh first" 30 fi if docker ps -a --format '{{.Names}}' | grep -qx 'ollama'; then warn "legacy docker container 'ollama' still exists in stopped state; cleanup recommended" fi if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx 'ollama/ollama:latest'; then warn "legacy docker image ollama/ollama:latest still exists; cleanup recommended" fi } warmup_sqlai(){ if [[ -x "$SQLAI_BIN" ]]; then log "warmup via ${SQLAI_BIN}" "$SQLAI_BIN" ask --text "Warmup: reply with exactly 'OK'." --no-metrics || warn "warmup failed" else warn "${SQLAI_BIN} not executable; skipping warmup" fi } verify_models_available(){ local tags tags="$(fetch_tags)" printf '%s' "$tags" | is_json || fail "/api/tags did not return valid JSON" 40 [[ -z "$BASE_MODEL" ]] || model_in_tags "$BASE_MODEL" "$tags" || fail "base model missing: ${BASE_MODEL}" 41 model_in_tags "$EXPERT_MODEL" "$tags" || fail "expert model missing: ${EXPERT_MODEL}" 42 } print_header(){ echo "================================================================================" log "$1 ROOT=$ROOT" } print_footer(){ log "$1" echo "================================================================================" }