#!/bin/bash # ============================================================================================== # ============================= AI Token Ledger Sync =========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Pulls each partner's AI token ledger into a RAM cache at /tmp/.cache/vv/ai/, so the AI tab # can report usage for the whole fleet instead of only the host the browser happens to be on. # # Each host records its own turns to data/ai_token_history.db and nothing syncs that file, so # without this a host can only ever see its own totals. The tab is careful to say "not collected # here" rather than 0 for a partner it cannot see; this script is what turns that into a number. # # Same trick as conf_sync.sh, and deliberately so — resolve the partner over Tailscale, scp one # small file into a tmpfs cache, let the reader treat a missing file as "unknown". # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # 1. Gates — PARTNERSHIP_ENABLED, AI_ENABLED, AI_TOKEN_SYNC_ENABLED # 2. Per partner: # a. Resolve their Tailscale IP # b. Resolve their SCRIPTS_DIR from their varaverk.cfg (they may be in appdata mode) # c. scp their data/ai_token_history.db → $AI_TOKEN_CACHE_DIR/.tokens.db # # Pull only, no push. conf_sync.sh pushes as well because a partner may be unable to reach us # and still needs our credentials; nothing here is needed by anyone else, and a reader that # fetches its own data controls its own freshness rather than depending on the partner's cron. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # An unreachable partner is not a failure. # HOST2 is expected to be down for long stretches during onboarding. A warn every four # hours would train the operator to ignore this script's output, and the AI diagnostic # path treats every log WARN as actionable. Unresolvable partners are counted and # reported once at info level; only a partner that resolves and then fails to transfer # is treated as an error. # # The cache is never written directly. # scp lands on a .part file that is renamed into place, so a transfer interrupted halfway # cannot leave the reader parsing half a ledger. A truncated final row would be skipped by # the field-count check on the PHP side, but a torn file should not reach it at all. # # Nothing is ever written back to the partner. # This script only reads. A bug here cannot corrupt a partner's accounting. # # The cache is tmpfs and deliberately not preserved. # Unlike the conf cache there is no save/restore pair. Stale counters are worse than # absent ones: absent reads as "not collected here", stale reads as fact. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master.conf # # AI_ENABLED Whole AI subsystem gate # AI_TOKEN_SYNC_ENABLED This script's own toggle (default: true) # PARTNERSHIP_ENABLED Checked via require_partnership() # SSH_KEY Key used for all partner ssh/scp operations # # load_config.sh # # AI_TOKEN_CACHE_DIR tmpfs directory the tab reads partner ledgers from # # host*.conf # # HOST* — hostnames used to build the partner list via detect_hosts() # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # ai_token_sync.sh Pull every reachable partner's ledger # ai_token_sync.sh --dry-run Report what would be pulled, transfer nothing # ai_token_sync.sh --log Verbose output # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi acquire_lock detect_hosts require_partnership if [[ "${AI_ENABLED:-false}" != true ]]; then log "AI_ENABLED=false — skipping" exit 0 fi if [[ "${AI_TOKEN_SYNC_ENABLED:-true}" == false ]]; then log "AI_TOKEN_SYNC_ENABLED=false — skipping" exit 0 fi CACHE_DIR="$AI_TOKEN_CACHE_DIR" SSH_TIMEOUT=10 # Mirrors conf_sync.sh — the remote may be in appdata storage mode, so its ledger is not # necessarily under /boot. _remote_scripts_dir() { local ip="$1" local cfg line sd cfg=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \ "root@${ip}" "cat /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null" 2>/dev/null) || true while IFS= read -r line; do [[ "$line" == SCRIPTS_DIR=* ]] || continue sd="${line#SCRIPTS_DIR=}"; sd="${sd//\"/}"; sd="${sd//\'/}" echo "$sd"; return done <<< "$cfg" echo "/boot/config/plugins/varaverk" } [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" if [[ "$DRY_RUN" == false ]]; then mkdir -p "$CACHE_DIR" && chmod 755 "$CACHE_DIR" fi PULLED=0 OFFLINE=0 FAILED=0 for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do partner_host="${!host_var}" [[ -z "$partner_host" ]] && continue [[ "${host_var,,}" == "${MY_ID,,}" ]] && continue partner_slot="${host_var,,}" partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true) if [[ -z "$partner_ip" ]]; then log "$partner_host — unresolvable, leaving any cached ledger as-is" (( OFFLINE++ )) continue fi # Liveness and ledger presence are separate questions, probed separately on purpose. # Tailscale hands back an IP for a peer that is registered but powered off, so resolving # proves nothing. And a single `ssh test -f` answers both questions at once: it fails # identically whether the host is down or the file is simply absent. Treating that one # failure as "no ledger" would delete a perfectly good cached copy every time the partner # blinked — turning "synced 3h ago" into "not collected here" on a transient. if ! timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \ "root@${partner_ip}" true 2>/dev/null; then log "$partner_host — not answering, keeping any cached ledger as-is" (( OFFLINE++ )) continue fi remote_sd=$(_remote_scripts_dir "$partner_ip") remote_db="${remote_sd}/data/ai_token_history.db" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would pull $partner_host:$remote_db → $CACHE_DIR/${partner_slot}.tokens.db" continue fi # Reachable, but nothing recorded there — a partner with AI off, or one that has simply # never been asked anything. Now that liveness is established this is a real answer, so the # stale copy goes: the tab should say "not collected here", not quote a number from before. if ! timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \ "root@${partner_ip}" "[[ -f '$remote_db' ]]" 2>/dev/null; then log "$partner_host — reachable, but no ledger there yet" rm -f "$CACHE_DIR/${partner_slot}.tokens.db" (( OFFLINE++ )) continue fi if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \ -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \ "root@${partner_ip}:${remote_db}" \ "$CACHE_DIR/${partner_slot}.tokens.db.part" 2>/dev/null \ && mv -f "$CACHE_DIR/${partner_slot}.tokens.db.part" "$CACHE_DIR/${partner_slot}.tokens.db"; then chmod 644 "$CACHE_DIR/${partner_slot}.tokens.db" 2>/dev/null echo "Pulled ${partner_slot} ledger from $partner_host ✅" (( PULLED++ )) else rm -f "$CACHE_DIR/${partner_slot}.tokens.db.part" warn "Could not pull ${partner_slot} ledger from $partner_host" (( FAILED++ )) fi done # Zero counts are omitted rather than printed. ${VAR:+...} keeps "0" because it is a non-empty # string, and a summary that always ends "0 failed" is what teaches you to stop reading it. _summary="AI token sync complete — pulled $PULLED" (( OFFLINE > 0 )) && _summary+=", $OFFLINE unavailable" (( FAILED > 0 )) && _summary+=", $FAILED failed" info "$_summary" # Only a partner that answered and then failed the transfer is worth an exit code. An absent # partner is the normal state while HOST2 is being rebuilt. [[ "$FAILED" -gt 0 ]] && exit 1 exit 0