← back to Pattern Vault

whimsical-compare/daily/scripts/daily_run.sh

97 lines

#!/bin/bash
# Fernwick Studio daily drop orchestrator.
#   - fires from launchd every 15 min during the day
#   - a per-day PLAN picks one random minute in [06:00, 18:00) local
#   - only when now >= planned time (and not already done today) does it run:
#       generate 4 settlement-passed originals -> (dry-run|armed) upload -> email report
#   - fail-closed: expired Spoonflower session => report says "re-auth needed", uploads nothing
#
# Arm live uploads by creating the file  daily/ARMED  (touch it). Absent => dry-run.
# Kill switch: create  daily/STOP  => the run refuses to start.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DAILY="$(dirname "$HERE")"
STATE="$DAILY/runs"
N=4
export OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434}"

TODAY=$(date +%Y-%m-%d)
NOW_EPOCH=$(date +%s)
HOUR=$(date +%H)
PLAN="$STATE/plan-$TODAY.json"
mkdir -p "$STATE"

[ -f "$DAILY/STOP" ] && { echo "STOP present — refusing"; exit 0; }

# window guard: only operate 06:00–18:00 local
if [ "$HOUR" -lt 6 ] || [ "$HOUR" -ge 18 ]; then echo "outside 6am-6pm window"; exit 0; fi

# make today's plan: random minute in [6*3600, 18*3600)
if [ ! -f "$PLAN" ]; then
  MID=$(date -j -f "%Y-%m-%d %H:%M:%S" "$TODAY 00:00:00" +%s)
  RAND_SEC=$(( 21600 + RANDOM % 43200 ))   # 6h .. 18h
  RUN_AT=$(( MID + RAND_SEC ))
  echo "{\"date\":\"$TODAY\",\"run_at\":$RUN_AT,\"run_at_h\":\"$(date -r $RUN_AT +%H:%M)\",\"done\":false}" > "$PLAN"
  echo "planned today's drop for $(date -r $RUN_AT +%H:%M)"
fi

RUN_AT=$(python3 -c "import json;print(json.load(open('$PLAN'))['run_at'])")
DONE=$(python3 -c "import json;print(json.load(open('$PLAN'))['done'])")

if [ "$DONE" = "True" ]; then echo "already dropped today"; exit 0; fi
if [ "$NOW_EPOCH" -lt "$RUN_AT" ]; then echo "waiting for planned time $(date -r $RUN_AT +%H:%M)"; exit 0; fi

echo "=== FERNWICK DAILY DROP $TODAY $(date +%H:%M) ==="
SEED_BASE=$(( NOW_EPOCH % 1000000 ))
mkdir -p "$STATE/$TODAY"
MANIFEST="$STATE/$TODAY/manifest.json"

# 0. pre-flight uploader health (born 2026-07-07 — the pipeline was silently broken for a long
# time: the stored session expired AND Spoonflower moved the upload page to /artists/designs/new,
# and NOTHING noticed). This read-only check surfaces both failure classes in every run's log.
HEALTH=$(node "$HERE/check_health.js" --json 2>/dev/null || echo '{"healthy":false,"reason":"health check crashed"}')
echo "uploader-health: $HEALTH"
echo "$HEALTH" > "$STATE/$TODAY/health.json"

# 1. generate — lanes: both (whimsical + trending GAP lanes) by default; override with FERNWICK_LANES
python3 "$HERE/gen_daily.py" --n $N --date "$TODAY" --seed-base "$SEED_BASE" --lanes "${FERNWICK_LANES:-both}" > "$STATE/$TODAY/gen.stdout" 2> "$STATE/$TODAY/gen.stderr" || true
[ -f "$MANIFEST" ] || { echo "no manifest — gen failed"; cat "$STATE/$TODAY/gen.stderr" | tail; exit 1; }

# 2. upload (armed only)
UPRESULT="$STATE/$TODAY/upload.json"
if [ -f "$DAILY/ARMED" ]; then
  echo "ARMED — live upload"
  node "$HERE/upload_sf.js" "$MANIFEST" --armed > "$UPRESULT" 2>"$STATE/$TODAY/upload.stderr" || true
else
  echo "DRY RUN — not armed"
  node "$HERE/upload_sf.js" "$MANIFEST" > "$UPRESULT" 2>"$STATE/$TODAY/upload.stderr" || true
fi

# 3. report to steve-office
node "$HERE/report.js" "$MANIFEST" "$UPRESULT" || echo "report send failed"

# 4. log to spoonflower_jobs + mark done
COUNT=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['count'])")
COST=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['est_cost_usd'])")
ARMED_STATE=$([ -f "$DAILY/ARMED" ] && echo true || echo false)
# real success signal = designs with a REAL Spoonflower id (dry-run entries have id:null → NOT counted,
# so a dry run never reports phantom "uploaded" designs — contrarian catch 2026-07-09)
UPLOADED=$(python3 -c "import json;print(len([u for u in json.load(open('$UPRESULT')).get('uploaded',[]) if u.get('id')]))" 2>/dev/null || echo 0)
FAILED=$(python3 -c "import json;print(len(json.load(open('$UPRESULT')).get('failed',[])))" 2>/dev/null || echo 0)

# false-green guard: an ARMED run that landed ZERO real uploads is a FAILURE — do NOT mark the
# day done (so the next launchd fire retries) and record it loudly. Dry-run success = generated.
if [ "$ARMED_STATE" = "true" ] && [ "$UPLOADED" -eq 0 ]; then
  JOB_STATUS="upload_failed"
  echo "!!! ARMED but 0 real uploads ($FAILED failed) — NOT marking done; will retry next fire"
  python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=False;d['upload_failed']=True;json.dump(d,open(p,'w'))"
else
  JOB_STATUS="done"
  python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=True;d.pop('upload_failed',None);json.dump(d,open(p,'w'))"
fi

psql "postgresql:///dw_unified?host=/tmp" -c \
  "INSERT INTO spoonflower_jobs (kind,status,dry_run,params,result,finished_at) VALUES ('fernwick_daily','$JOB_STATUS',$([ -f "$DAILY/ARMED" ] && echo false || echo true),'{\"date\":\"$TODAY\",\"n\":$COUNT}'::jsonb,'{\"count\":$COUNT,\"uploaded\":$UPLOADED,\"failed\":$FAILED,\"cost\":$COST,\"armed\":$ARMED_STATE}'::jsonb,now());" >/dev/null 2>&1 || true

echo "=== $JOB_STATUS: $COUNT generated, $UPLOADED uploaded, $FAILED failed, est \$$COST, armed=$ARMED_STATE ==="