# lab_lib.sh — lab_start.sh / lab_stop.sh / lab_train.sh が読む共通の関数 (単体では動かさない)
#
# 鍵の扱い:
#   - Runpod の API キーは curl のヘッダーを <(printf ...) で渡す (printf は bash の組み込みなので
#     ps にもシェルの履歴にも出ない)
#   - Hugging Face の鍵は ssh の標準入力で Pod の ~/.cache/huggingface/token に書く
#   - Pod の IP とポートは画面・ログに出さない. Pod に入る ssh の出力は lab_mask で <POD_IP>・<SSH_PORT> に置き換える

LAB_ROOT=/Volumes/KIOXIA/r2r2r-data/lab
LAB_CURRENT="$LAB_ROOT/.lab_current"      # 今使っている Log 名 (lab_stop.sh が --log なしで読む)
RUNPOD_KEY_FILE=/Volumes/KIOXIA/keys/runpod_api_key.txt
HF_TOKEN_FILE=/Volumes/KIOXIA/keys/hf_token.txt
POD_SSH_KEY="$HOME/.ssh/id_ed25519"
RUNPOD_API=https://rest.runpod.io/v1
TUNNEL_PORT=8000
YEN_PER_USD=158
RUNPOD_HTTP=""             # runpod_api が最後に受けた HTTP の番号 ($(...) の中で呼ぶと親には残らない)

lab_need_key() {            # 鍵のファイルがない・空なら, どこに書けばよいかを出して止まる
    local f="$1" what="$2"
    if [ ! -s "$f" ]; then
        echo "[ERROR] $what がありません (または空です)." >&2
        echo "        次のファイルに鍵だけを1行で書いてください: $f" >&2
        exit 1
    fi
}

runpod_api() {              # runpod_api METHOD PATH [JSON]  → 本文を出す. 失敗の HTTP は戻り値 1
    local method="$1" path="$2" data="${3:-}" out code
    out=$(mktemp)
    if [ -n "$data" ]; then
        code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" \
            -H @<(printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' < "$RUNPOD_KEY_FILE")") \
            -H 'Content-Type: application/json' --data "$data" "$RUNPOD_API$path") || code=000
    else
        code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" \
            -H @<(printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' < "$RUNPOD_KEY_FILE")") \
            "$RUNPOD_API$path") || code=000
    fi
    cat "$out"
    rm -f "$out"
    RUNPOD_HTTP=$code
    case "$code" in 2??) return 0 ;; *) return 1 ;; esac
}

lab_pod_addr() {            # POD_ID から POD_IP / SSH_PORT を API で受け取る (画面には出さない)
    local j
    j=$(runpod_api GET "/pods/$POD_ID") || return 1
    POD_IP=$(jq -r '.publicIp // ""' <<<"$j")
    SSH_PORT=$(jq -r '.portMappings["22"] // ""' <<<"$j")
    POD_STATUS=$(jq -r '.desiredStatus // ""' <<<"$j")
    [ -n "$POD_IP" ] && [ -n "$SSH_PORT" ]
}

lab_mask() {                # Pod の IP とポートを置き換えて流す
    if [ -n "${POD_IP:-}" ] && [ -n "${SSH_PORT:-}" ]; then
        sed -l -e "s/${POD_IP//./\\.}/<POD_IP>/g" \
               -e "s/\([^0-9]\)${SSH_PORT}\([^0-9]\)/\1<SSH_PORT>\2/g" \
               -e "s/\([^0-9]\)${SSH_PORT}\$/\1<SSH_PORT>/"
    else
        cat
    fi
}

# known_hosts は使わない (Pod ごとに IP が変わる). LogLevel=ERROR で「Permanently added <IP>」も出さない
lab_ssh_opts() {
    SSH_OPTS=(-i "$POD_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
              -o LogLevel=ERROR -o ConnectTimeout=10 -o ServerAliveInterval=30)
}

pod_ssh() {                 # pod_ssh 'コマンド'  (標準入力はそのまま Pod へ)
    { ssh "${SSH_OPTS[@]}" -p "$SSH_PORT" "root@$POD_IP" "$@" 2>&1; } | lab_mask
}

pod_scp() {                 # pod_scp 送る元... 送り先 (送り先の「:」の前に root@IP を付ける)
    { scp "${SSH_OPTS[@]}" -P "$SSH_PORT" "$@" 2>&1; } | lab_mask
}

lab_tunnel_pids() {         # このスクリプトが張った 8000 番のトンネル (ssh -N -L 8000:localhost:8000)
    pgrep -f "ssh -N -L ${TUNNEL_PORT}:localhost:${TUNNEL_PORT}" || true
}

# ---- lab_start.sh のつなぎ直し (依頼 44) ----

lab_valid_pods() {          # lab_valid_pods JSON → GET /pods の答えが「id を持つものの配列」なら 0 (読めない答えを「ない」にしない)
    jq -e 'type == "array" and all(.[]; type == "object" and (.id | type) == "string")' <<<"$1" >/dev/null 2>&1
}

lab_pod_kind() {            # lab_pod_kind ID → LAB_POD_KIND=running|stopped|gone と LAB_POD_STATUS (API の desiredStatus)
    # running = RUNNING. stopped = EXITED (止まっていてもディスクの料金がかかる). gone = TERMINATED,
    # または 404 で一覧にもない. それ以外 (つながらない・404 以外の失敗・答えが読めない・id が違う・知らない状態) は
    # 戻り値 1 (二重に借りないよう, 呼ぶ側で止まる)
    local id="$1" out j list rc
    LAB_POD_KIND=""; LAB_POD_STATUS=""
    out=$(mktemp)
    runpod_api GET "/pods/$id" > "$out"     # $(...) に入れない (RUNPOD_HTTP をこのシェルに残す)
    j=$(cat "$out"); rm -f "$out"
    case "$RUNPOD_HTTP" in
        2??) ;;
        404)    # 一覧にもないことを確かめてから「ない」とする
            list=$(runpod_api GET /pods) || return 1
            lab_valid_pods "$list" || return 1
            jq -e --arg id "$id" 'any(.[]; .id == $id and .desiredStatus != "TERMINATED")' <<<"$list" >/dev/null 2>&1
            rc=$?
            [ $rc = 1 ] && { LAB_POD_KIND=gone; return 0; }
            return 1 ;;
        *) return 1 ;;
    esac
    jq -e --arg id "$id" 'type == "object" and .id == $id' <<<"$j" >/dev/null 2>&1 || return 1
    LAB_POD_STATUS=$(jq -r '.desiredStatus // ""' <<<"$j")
    case "$LAB_POD_STATUS" in
        RUNNING) LAB_POD_KIND=running ;;
        EXITED) LAB_POD_KIND=stopped ;;
        TERMINATED) LAB_POD_KIND=gone ;;
        *) return 1 ;;
    esac
}

lab_probe_cmd() {           # lab_probe_cmd OPENPI_COMMIT モデルのフォルダ assets の norm_stats.json (フォルダからの相対) → Pod の中で流すコマンド (結果は PROBE の1行)
    # serve  = serve_policy.py のプロセスがある ([s] は, このコマンドを流す bash -c 自身に当たらないため)
    # listen = 8000 番 (16進 1F40) が LISTEN (0A). ss がないこともあるので /proc/net/tcp を見る
    # ckpt   = 3c の hf download が置く中身がある (依頼 47): _CHECKPOINT_METADATA・params/ の _METADATA と
    #          manifest.ocdbt・ocdbt.process_0/manifest.ocdbt・3f が写す norm_stats.json. 取りかけ
    #          (.cache/huggingface/download の *.incomplete) が残っている・探せないときは 0
    # mark   = hf download が最後まで済んだ印 (.lab_download_ok. 依頼 44 からの 3c が付ける. それより前に作った Pod にはない)
    # openpi = /workspace/openpi の HEAD が OPENPI_COMMIT
    # venv   = .venv の python と uv の env がある
    cat <<EOF
s=0; pgrep -f '[s]cripts/serve_policy\.py' >/dev/null && s=1
l=0; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk '\$4 == "0A" && \$2 ~ /:1F40\$/ {f=1} END {exit !f}' && l=1
c=0; [ -f $2/_CHECKPOINT_METADATA ] && [ -f $2/params/_METADATA ] && [ -f $2/params/manifest.ocdbt ] \\
  && [ -f $2/params/ocdbt.process_0/manifest.ocdbt ] && [ -f $2/$3 ] \\
  && { [ ! -d $2/.cache/huggingface ] || { r=\$(find $2/.cache/huggingface -name '*.incomplete' -print -quit 2>/dev/null) && [ -z "\$r" ]; }; } && c=1
m=0; [ -f $2/.lab_download_ok ] && m=1
o=0; h=\$(git -C /workspace/openpi rev-parse HEAD 2>/dev/null) && case "\$h" in $1*) o=1 ;; esac
v=0; [ -x /workspace/openpi/.venv/bin/python ] && [ -f \$HOME/.local/bin/env ] && v=1
echo "PROBE serve=\$s listen=\$l ckpt=\$c mark=\$m openpi=\$o venv=\$v"
EOF
}

lab_pod_probe() {           # lab_pod_probe OPENPI_COMMIT モデルのフォルダ norm_stats.json → PROBE_SERVE / PROBE_LISTEN / PROBE_CKPT / PROBE_MARK / PROBE_OPENPI / PROBE_VENV (1 か 0)
    local out line k
    out=$(pod_ssh "$(lab_probe_cmd "$1" "$2" "$3")") || return 1
    line=$(grep -E '^PROBE serve=[01] listen=[01] ckpt=[01] mark=[01] openpi=[01] venv=[01]$' <<<"$out" | tail -1)
    [ -n "$line" ] || return 1
    for k in serve listen ckpt mark openpi venv; do
        printf -v "PROBE_$(tr '[:lower:]' '[:upper:]' <<<"$k")" '%s' "$(sed -E "s/.* $k=([01]).*/\1/" <<<"$line")"
    done
}

lab_serve_stop_cmd() {      # Pod の中で serve_policy.py を止めるコマンド (30 秒まで終わるのを待ち, 残れば -9)
    # 起動のコマンド (scripts/serve_policy.py をそのまま含む) とは別の ssh で流す. 同じ bash -c に入れると
    # pkill がその bash -c 自身に当たる (依頼 47 の codex R1). このコマンドには [s] の形でしか書かない
    cat <<'EOF'
tmux kill-session -t serve 2>/dev/null; pkill -f '[s]cripts/serve_policy\.py'
for i in $(seq 1 30); do pgrep -f '[s]cripts/serve_policy\.py' >/dev/null || break; sleep 1; done
pkill -9 -f '[s]cripts/serve_policy\.py'; sleep 1; ! pgrep -f '[s]cripts/serve_policy\.py' >/dev/null
EOF
}

lab_archive_state() {       # lab_archive_state FILE → 消さずに <名前>_<時刻>.json に変えて残す. 新しい名前は LAB_ARCHIVED
    local f="$1" dest
    dest="${f%.json}_$(date +%Y%m%d_%H%M%S).json"
    [ -e "$dest" ] && dest="${f%.json}_$(date +%Y%m%d_%H%M%S)_$$.json"
    mv "$f" "$dest" && LAB_ARCHIVED="$dest"
}

# ---- Pod の準備で流すコマンド (lab_start.sh 3b〜3f と lab_train.sh が同じものを流す. 依頼 49 で lab_start.sh から移した) ----
LAB_OPENPI_COMMIT=215abfb
LAB_TEMPLATE_ID=runpod-torch-v280
LAB_IMAGE=runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404

lab_cmd_hf_install() { echo "pip install -q -U --break-system-packages huggingface_hub"; }

lab_send_hf_token() {       # Hugging Face の鍵を ssh の標準入力で渡す (コマンドの引数・履歴に残らない). hf はこのファイルを読む
    tr -d '\r\n' < "$HF_TOKEN_FILE" | pod_ssh 'umask 077; mkdir -p ~/.cache/huggingface; cat > ~/.cache/huggingface/token'
}

lab_cmd_ckpt() {            # lab_cmd_ckpt リポジトリ 置く場所 → hf download (最後まで済んだら .lab_download_ok を付ける)
    echo "rm -f $2/.lab_download_ok && hf download $1 --local-dir $2 && touch $2/.lab_download_ok && du -sh $2"
}

lab_cmd_openpi() {          # lab_cmd_openpi コミット
    echo "cd /workspace && { [ -d openpi/.git ] || git clone -q --recurse-submodules https://github.com/Physical-Intelligence/openpi.git; } && cd openpi && git checkout -q $1 && git log --oneline -1"
}

lab_cmd_uv_sync() {
    echo "curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null && source \$HOME/.local/bin/env && cd /workspace/openpi && GIT_LFS_SKIP_SMUDGE=1 uv sync"
}

lab_cmd_addon() {           # 3f. 何度流しても同じ結果 (install_addon.py・add_trim_config.py とも二重に入れない)
    echo "cd /workspace/openpi && python3 /workspace/r2r2r/pi05/openpi_addon/install_addon.py /workspace/openpi && python3 /workspace/r2r2r/add_trim_config.py /workspace/openpi && mkdir -p assets/pi05_mecharm_trim_lora/local && cp -r /workspace/ckpt/3999/assets/local/mecharm_redblock_box_teach_v1_trim2 assets/pi05_mecharm_trim_lora/local/"
}

lab_cost_line() {           # lab_cost_line 秒 1時間のドル → 「借りていた時間 × 料金 = ドル (円)」の1行
    awk -v s="$1" -v c="$2" -v y="$YEN_PER_USD" 'BEGIN {
        h = s / 3600; usd = h * c
        printf "借りていた時間 %d 分 %d 秒 (%.2f 時間) × 1時間 %.2f ドル = 約 %.2f ドル (約 %d 円. 1ドル=%d円)\n",
               int(s / 60), s % 60, h, c, usd, int(usd * y + 0.5), y }'
}

# ---- lab_train.sh (依頼 49) が Pod の中で流すコマンド. テストでこの Mac の bash でも流すので, GNU と BSD の両方で動く書き方にする ----
# pgrep / pkill -f は, そのコマンドを流している bash -c 自身の引数にも当たる (依頼 47 の codex R1).
# 裏で回すものは「bash <パス>」「nvidia-smi --query-gpu=timestamp」で始まる形で起動し, ^ を付けた形で探す
# (bash -c の引数は「bash -c」で始まるので当たらない)
LAB_RUN_PAT='^bash /workspace/r2r2r/train_run\.sh$'
LAB_WD_PAT='^bash /workspace/r2r2r/lab_watchdog\.sh$'
LAB_GPU_PAT='^nvidia-smi --query-gpu=timestamp'

lab_train_start_cmd() {     # lab_train_start_cmd 設定 exp 学習のログ GPU のログ 回したログ 終わりの印 → 学習を setsid nohup で始める (STARTED が出れば始まった)
    cat <<EOF
: lt=start_train
rm -f $6
cat > /workspace/r2r2r/train_run.sh <<'RUN_EOF'
#!/bin/bash
# lab_train.sh が置く. train.py を回し, 終わったら終了コードを $6 に書く
echo "\$(date '+%F %T') 始める" >> $5
{ cd /workspace/openpi && source \$HOME/.local/bin/env \\
  && XLA_PYTHON_CLIENT_MEM_FRACTION=0.9 uv run scripts/train.py $1 --exp-name=$2 --overwrite > $3 2>&1; }
rc=\$?
echo "\$(date '+%F %T') 終わった (終了コード \$rc)" >> $5
echo \$rc > $6
RUN_EOF
setsid nohup bash /workspace/r2r2r/train_run.sh >/dev/null 2>&1 < /dev/null &
pkill -f '$LAB_GPU_PAT'
setsid nohup nvidia-smi --query-gpu=timestamp,name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw --format=csv -l 30 > $4 2>&1 < /dev/null &
sleep 3
pgrep -f '$LAB_RUN_PAT' >/dev/null && echo STARTED
EOF
}

lab_train_kill_cmd() {      # 学習を止める (片付けの前. 書き終わったログを取るため)
    echo ": lt=kill_train; pkill -f '$LAB_RUN_PAT'; pkill -f '[s]cripts/train\.py'; sleep 3; pkill -9 -f '[s]cripts/train\.py'; true"
}

lab_train_status_cmd() {    # lab_train_status_cmd 学習のログ 終わりの印 チェックポイントのフォルダ ステップ数 → STATUS の1行
    # run  = 学習を回すスクリプト (train_run.sh) が動いている. train.py が終わったあと終わりの印を書いてから終わるので,
    #        run=0 で exit=- なら「書く前に消えた」(殺された)
    # exit = train.py の終了コード (まだなら -)
    # age  = 学習のログが最後に書かれてからの秒 (tqdm が 1 ステップごとに書くので, 長く書かれなければ止まっている)
    # bar  = tqdm の「N/ステップ数」の N.  logstep / loss = 最後の「Step N: ... loss=...」の行
    # ckpt = 数字だけのフォルダのいちばん大きい番号 (orbax の書きかけ *.orbax-checkpoint-tmp-* は数えない)
    # cm   = そのフォルダに _CHECKPOINT_METADATA がある
    cat <<EOF
: lt=status
L=$1; E=$2; C=$3
r=0; pgrep -f "$LAB_RUN_PAT" >/dev/null && r=1
e=-; [ -f \$E ] && e=\$(tr -d ' \n' < \$E)
a=-; s=-; ls=-; lo=-
if [ -f \$L ]; then
  mt=\$(stat -c %Y \$L 2>/dev/null || date -r \$L +%s 2>/dev/null)     # GNU (Pod) は stat -c, Mac (テスト) は date -r
  case "\$mt" in ''|*[!0-9]*) a=- ;; *) a=\$(( \$(date +%s) - mt )) ;; esac
  t=\$(tail -c 400000 \$L | tr '\r' '\n')
  s=\$(printf '%s\n' "\$t" | grep -oE '[0-9]+/$4 \[' | tail -1 | cut -d/ -f1)
  ln=\$(printf '%s\n' "\$t" | grep -E '^Step [0-9]+:' | tail -1)
  [ -n "\$ln" ] && { ls=\$(printf '%s\n' "\$ln" | sed -E 's/^Step ([0-9]+):.*/\1/'); lo=\$(printf '%s\n' "\$ln" | grep -oE '(^|[ ,])loss=[0-9.eE+-]+' | head -1 | sed 's/.*=//'); }
fi
c=\$(ls \$C 2>/dev/null | grep -E '^[0-9]+\$' | sort -n | tail -1)
m=0; [ -n "\$c" ] && [ -f \$C/\$c/_CHECKPOINT_METADATA ] && m=1
echo "STATUS run=\$r exit=\${e:--} age=\${a:--} bar=\${s:--} logstep=\${ls:--} loss=\${lo:--} ckpt=\${c:--} cm=\$m"
EOF
}

lab_train_parse_status() {  # lab_train_parse_status 出力 → S_RUN S_EXIT S_AGE S_BAR S_LOGSTEP S_LOSS S_CKPT S_CM. STATUS の行がなければ 1
    local line k v
    line=$(grep -E '^STATUS run=[01] ' <<<"$1" | tail -1)
    [ -n "$line" ] || return 1
    for k in run exit age bar logstep loss ckpt cm; do
        v=$(sed -nE "s/.* $k=([^ ]*).*/\1/p" <<<" ${line#STATUS }")
        printf -v "S_$(tr '[:lower:]' '[:upper:]' <<<"$k")" '%s' "${v:--}"
    done
}

lab_train_list_cmd() {      # lab_train_list_cmd フォルダ → 「F<TAB>パス<TAB>バイト数」を名前順に (train_state/ は数えない)
    echo ": lt=list_ckpt; set -o pipefail; cd $1 && find . -type f ! -path './train_state/*' -printf '%P\t%s\n' | LC_ALL=C sort | sed 's/^/F\t/'"
}

lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
    python3 -I -c '
import os, sys
root = sys.argv[1]
out = []
def err(e):
    raise e                 # 読めないフォルダを黙って飛ばさない (一覧が足りないまま「合った」としない)
for d, dirs, files in os.walk(root, onerror=err):
    rel = os.path.relpath(d, root)
    if rel == ".":
        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
    for f in files:
        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
            continue
        p = os.path.normpath(os.path.join(rel, f))
        out.append((p, os.path.getsize(os.path.join(d, f))))
for p, s in sorted(out, key=lambda x: x[0].encode()):
    print(f"{p}\t{s}")
' "$1"
}

lab_watchdog_script() {     # Pod 側の上限の見張り (lab_train.sh が /workspace/r2r2r/lab_watchdog.sh に置き, setsid nohup で裏で回す)
    cat <<'WATCHDOG_EOF'
#!/bin/bash
# lab_watchdog.sh — 上限の時刻を過ぎたら, この Pod を自分で消す (lab_train.sh が置く. 依頼 49)
# Mac が止まって lab_train.sh が Pod を消せなくても, Pod が残り続けないための保険.
# 鍵は Mac から送らない. Runpod が Pod に入れる RUNPOD_POD_ID と Pod 用の鍵 RUNPOD_API_KEY を使う
# (ssh のシェルには入っていないことがあるので, 1 番のプロセスの環境と /etc/rp_environment からも探す). 鍵は書き出さない
F=${LAB_WD_DEADLINE:-/workspace/logs/.lab_deadline}     # 上限の時刻 (epoch). lab_train.sh が書き換えて延ばす
L=${LAB_WD_LOG:-/workspace/logs/watchdog.log}
RUNPOD_REST=${LAB_WD_API:-https://rest.runpod.io/v1}
penv() {
    { tr '\0' '\n' < /proc/1/environ; sed -E 's/^export //; s/"//g' /etc/rp_environment; } 2>/dev/null \
        | sed -n "s/^$1=//p" | head -1
}
while :; do
    d=$(cat "$F" 2>/dev/null)
    case "$d" in ''|*[!0-9]*) ;; *) [ "$(date +%s)" -ge "$d" ] && break ;; esac
    sleep "${LAB_WD_POLL:-60}"
done
echo "$(date '+%F %T') 上限の時刻を過ぎた. この Pod を消します" >> "$L"
id=${RUNPOD_POD_ID:-$(penv RUNPOD_POD_ID)}
key=${RUNPOD_API_KEY:-$(penv RUNPOD_API_KEY)}
[ -n "$id" ] || { echo "RUNPOD_POD_ID が見つからない. 消せない" >> "$L"; exit 1; }
# 通信が一時だめでも残らないよう, DELETE と runpodctl remove を間を置いてくり返す (runpodctl の出力は鍵を含まないが, 念のため捨てる)
for i in $(seq 1 "${LAB_WD_TRIES:-30}"); do
    code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' -X DELETE \
        -H @<(printf 'Authorization: Bearer %s\n' "$key") "$RUNPOD_REST/pods/$id" 2>/dev/null) || code=000
    echo "$(date '+%F %T') ($i) DELETE → HTTP $code" >> "$L"
    case "$code" in 2??) exit 0 ;; esac
    if command -v runpodctl >/dev/null 2>&1 && RUNPOD_API_KEY="$key" timeout "${LAB_WD_CTL_TIMEOUT:-60}" runpodctl remove pod "$id" >/dev/null 2>&1; then
        echo "$(date '+%F %T') ($i) runpodctl remove pod で消した" >> "$L"; exit 0
    fi
    sleep "${LAB_WD_RETRY:-60}"
done
# 消せなければ, せめて止める (GPU の料金は止まる. ディスクの料金は残る)
if command -v runpodctl >/dev/null 2>&1 && RUNPOD_API_KEY="$key" timeout "${LAB_WD_CTL_TIMEOUT:-60}" runpodctl stop pod "$id" >/dev/null 2>&1; then
    echo "$(date '+%F %T') 消せず, runpodctl stop pod で止めた (ディスクの料金は残る)" >> "$L"; exit 0
fi
echo "$(date '+%F %T') 消せなかった" >> "$L"
exit 1
WATCHDOG_EOF
}

lab_watchdog_start_cmd() {  # lab_watchdog_start_cmd 上限の時刻 → 見張りを (なければ) 起動し, 様子を WATCHDOG の1行で出す (鍵の中身は出さない)
    cat <<EOF
: lt=watchdog
mkdir -p /workspace/logs; echo $1 > /workspace/logs/.lab_deadline.tmp && mv /workspace/logs/.lab_deadline.tmp /workspace/logs/.lab_deadline; chmod +x /workspace/r2r2r/lab_watchdog.sh
pgrep -f '$LAB_WD_PAT' >/dev/null || setsid nohup bash /workspace/r2r2r/lab_watchdog.sh >/dev/null 2>&1 < /dev/null &
sleep 1
pe() { [ -n "\$(printenv \$1)" ] || { tr '\0' '\n' < /proc/1/environ; cat /etc/rp_environment; } 2>/dev/null | grep -qE "^(export )?\$1=.+"; }
w=0; pgrep -f '$LAB_WD_PAT' >/dev/null && w=1
p=0; pe RUNPOD_POD_ID && p=1
k=0; pe RUNPOD_API_KEY && k=1
c=0; command -v runpodctl >/dev/null 2>&1 && c=1
echo "WATCHDOG run=\$w pod=\$p key=\$k ctl=\$c"
EOF
}

# ---- lab_train.sh の Mac 側 (--dry-run とテストでは lab_train_fakes.sh が差し替える) ----

lab_have_hf_cli() { command -v hf >/dev/null 2>&1; }

lab_hf_download_mac() {     # lab_hf_download_mac リポジトリ 置く場所 → Mac の hf で取る. 鍵は HF_TOKEN_PATH でファイルの場所だけを渡す
    env -u HF_TOKEN HF_TOKEN_PATH="$HF_TOKEN_FILE" HF_HUB_DISABLE_TELEMETRY=1 hf download "$1" --local-dir "$2" > "$2.download.log" 2>&1
    local rc=$?
    tail -3 "$2.download.log"; rm -f "$2.download.log"
    return $rc
}

lab_notify() {              # lab_notify 題 文 終了コード → Mac の通知 (0 なら Glass, それ以外は Basso の音)
    local sound=Basso
    [ "$3" = 0 ] && sound=Glass
    osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv) sound name (item 3 of argv)' \
              -e 'end run' "$1" "$2" "$sound" >/dev/null 2>&1 \
        || echo "[WARN  ] Mac の通知を出せなかった"
}

lab_keep_awake() {          # このスクリプトが終わるまで Mac をスリープさせない (CAFF_PID)
    caffeinate -i -s -w $$ >/dev/null 2>&1 &
    CAFF_PID=$!
    echo "[MAC   ] 終わるまでスリープしないようにした (caffeinate pid $CAFF_PID). ふたは閉じない"
    if ! pmset -g batt 2>/dev/null | grep -q "'AC Power'"; then
        echo "[WARN  ] 電源につながっていない. バッテリーだとスリープを止めきれないので, 電源につないでください"
    fi
}
