#!/bin/sh
# dl 沙箱内自更新 (npm 版本) —— 替代 dl-selfupdate.sh 的 GCS 路径。
#
# wrapper 在 exec dl 之前调一次本脚本。任何步骤失败一律 swallow,永远 exit 0,
# 绝不阻塞业务 dl 调用。当次仍用旧 bundle,新 bundle 装完后**下次** dl 调用才生效
# (agent 失败重试天然吃到新版)。
#
# dist-tag 来源,优先级从高到低:
#   1. PI_DL_DIST_TAG  —— hono-pi-e2b 每条命令注入 (e2b-operations.ts),反映
#      **当前**这条 lane。
#   2. DL_NPM_DIST_TAG —— template build 时按 alias 烤进 wrapper 第二行
#      (prod=latest,staging=staging;PR preview 留空 → 本脚本直接 exit,沙箱内
#      dl 锁在 template 烤进去那版,便于 PR 间复现 / 互不污染)。
#
# 1 必须压过 2:project → sandbox_id 这条记录是 prod / staging 共享的 (staging
# 切片连的是同一个 pi-agent-prod-pg),切换环境时会 resume 到对方那台沙箱,而 2
# 跟着**建沙箱时**那个模板走 —— prod 建的沙箱被 staging resume 之后仍然会去装
# @latest。wrapper 是烤死在镜像里的改不动,但本脚本每次 npm install 后会从新包里
# 拷一份覆盖自己 (见下方"自我热更"),所以存量沙箱两代之内就能吃到这条优先级。
#
# 鉴权: 不需要。包发到 npmjs.com 的 @pawlogic scope (public),npm 默认 registry
# 就是 npmjs.com,匿名 download 直接通,沙箱没有 ~/.npmrc。
#
# TTL 300s 缓存:同一 sandbox 5 分钟内多次 dl 调用只查一次。LLM 调用链常一分钟 10+
# 次,不加缓存白白打满 npm registry。
#
# 观测: 每条分支结束 fire-and-forget POST 一条事件到 callback-server
#   $CALLBACK_SERVER_URL/sandbox-rpc/dl/selfupdate-event,backend 仅转发到 Honeycomb,
#   schema 校验由本脚本端负责。TTL 命中频繁(每个沙箱 5min 内 N 次), 按 1/10 采样上报;
#   其余分支(install / no_tag / no_npm / lock_skip)全量上报。
#   鉴权: 复用 $DL_PROXY_TOKEN (沙箱 env, hono-pi-e2b 注入), Authorization Bearer。
#   上报失败一律 swallow,绝不让观测链阻塞自更新本身。

set -u

STAMP=/tmp/dl-npm-checkedat
LOCK=/tmp/dl-npm-update.lock
TTL=300
PACKAGE="@pawlogic/dl"

# 注入值优先,回落到 wrapper 烤死的值(理由见文件头)。
#
# 逐个来源单独校验、非法就继续往下回落,而不是先选后校验 —— 后者会让一个
# 写坏的 PI_DL_DIST_TAG 把整台沙箱的自更新彻底关掉,而不是退回模板烤死的值。
# 规则跟 self-update.ts 的 resolveDistTag 保持一致(首字符必须是字母数字)。
# 这个值会流到 npm install 的参数和 stamp 文件里,不合法一律当没给。
_valid_tag() {
  case "${1:-}" in
    '' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;;
    *) return 0 ;;
  esac
}
TAG=""
if _valid_tag "${PI_DL_DIST_TAG:-}"; then
  TAG="$PI_DL_DIST_TAG"
elif _valid_tag "${DL_NPM_DIST_TAG:-}"; then
  TAG="$DL_NPM_DIST_TAG"
fi

# ── 观测上报: fire-and-forget JSON POST,失败 swallow ────────────────────────
# args: outcome duration_ms [error_tail_file]
# error_tail_file 仅 npm_fail 分支传入,读末尾 ~200 字节带上去 —— 之前 npm stderr
# 全 swallow,根本看不到为什么失败,fleet 升级率 0.24% 都查不到根因。
report_event() {
  _outcome="$1"
  _dur="${2:-null}"
  _errfile="${3:-}"
  [ -z "${CALLBACK_SERVER_URL:-}" ] && return 0
  [ -z "${DL_PROXY_TOKEN:-}" ] && return 0
  command -v curl >/dev/null 2>&1 || return 0
  _tag="${TAG:-unknown}"
  case "$_tag" in
    latest|staging) ;;
    *) _tag="unknown" ;;
  esac
  _ver=""
  _verfile="/home/user/.local/lib/node_modules/@pawlogic/dl/package.json"
  if [ -f "$_verfile" ] && command -v node >/dev/null 2>&1; then
    _ver=$(node -e 'try{console.log(require("'"$_verfile"'").version||"")}catch(e){}' 2>/dev/null || echo "")
  fi
  # 上下文维度: 全部从 hono-pi-e2b 注入的 PI_* env 里读 (e2b-operations.ts:582-627),
  # 加上 E2B sandbox hostname (= sandbox ID)。校验只允许 [A-Za-z0-9_-], 防奇怪字符
  # 破坏 JSON 后续没办法 escape; 不合法 / 空一律 null。
  #
  # 维度用途:
  #   - project_id   per-project 去重 (COUNT_DISTINCT) 是 fleet 升级率口径
  #   - mode         dramaland / ilands / ... 按产品线切升级率
  #   - user_tier    starter / pro / ... 看付费用户升级率差异
  #   - user_id      单用户排障 (高基数)
  #   - prompt_id    LLM turn 级聚合 (高基数)
  #   - tool_call_id 单次工具调用关联到自更新 outcome (高基数)
  #   - sandbox_id   单沙箱排障; E2B 内 hostname 就是 sandbox ID
  _json_or_null() {
    case "${1:-}" in
      "") echo "null" ;;
      *[!A-Za-z0-9_-]*) echo "null" ;;
      *) printf '"%s"' "$1" ;;
    esac
  }
  _proj_json=$(_json_or_null "${PI_PROJECT_ID:-}")
  _mode_json=$(_json_or_null "${PI_MODE:-}")
  _tier_json=$(_json_or_null "${PI_USER_TIER:-}")
  _user_json=$(_json_or_null "${PI_USER_ID:-}")
  _prompt_json=$(_json_or_null "${PI_PROMPT_ID:-}")
  _toolcall_json=$(_json_or_null "${PI_TOOL_CALL_ID:-}")
  _sandbox_json=$(_json_or_null "${HOSTNAME:-$(hostname 2>/dev/null || echo)}")
  # error_tail: 末尾 200 字节, JSON 不安全字符 (\ " 控制符 / 换行 / tab) 一律压成
  # 单空格; 长度上限挡住 LB 滥用,字符替换让 JSON 不需要复杂 escape。
  _err_json="null"
  if [ -n "$_errfile" ] && [ -f "$_errfile" ]; then
    _err_raw=$(tail -c 200 "$_errfile" 2>/dev/null | tr '\n\r\t\\"' '     ' | tr -d '\000-\037')
    if [ -n "$_err_raw" ]; then
      _err_json="\"$_err_raw\""
    fi
  fi
  # 后台 curl, -m 2 上限 2s; 不解析响应; stderr/stdout 全丢。
  (
    curl -fsS -m 2 -X POST "${CALLBACK_SERVER_URL%/}/sandbox-rpc/dl/selfupdate-event" \
      -H 'content-type: application/json' \
      -H "authorization: Bearer ${DL_PROXY_TOKEN}" \
      -d "{\"trigger\":\"wrapper\",\"dist_tag\":\"$_tag\",\"outcome\":\"$_outcome\",\"duration_ms\":$_dur,\"installed_version\":\"$_ver\",\"build_short_sha\":null,\"error_tail\":$_err_json,\"project_id\":$_proj_json,\"mode\":$_mode_json,\"user_tier\":$_tier_json,\"user_id\":$_user_json,\"prompt_id\":$_prompt_json,\"tool_call_id\":$_toolcall_json,\"sandbox_id\":$_sandbox_json}" \
      >/dev/null 2>&1 || true
  ) &
}

# 采样 TTL hit 上报: 10 选 1。awk 比 $RANDOM 更便携 (busybox sh 默认没 $RANDOM)。
should_sample_ttl() {
  awk 'BEGIN{srand(); exit !(int(rand()*10)==0)}' 2>/dev/null
}

# 环境不全 → no-op。staging / prod 两个来源至少有一个有值,PR preview / 本地裸跑空。
if [ -z "$TAG" ]; then
  report_event no_tag null
  exit 0
fi
if ! command -v npm >/dev/null 2>&1; then
  report_event no_npm null
  exit 0
fi

# TTL 拦截。stamp 格式 `<epoch> <tag>`,stamp 损坏当 0 处理。
#
# tag 也要存:只比时间戳的话,切完 lane 的头 300s 会被 TTL short-circuit 掉,
# 整个 turn 都还在用另一条 lane 的 dl —— 自愈就不"立刻"了。tag 跟本次解析出
# 来的不一致直接跳过 TTL 重装。老格式(只有 epoch)解出来的 tag 是空串,同样
# 判成不一致,升级后第一次调用自然会重装一遍。
now=$(date +%s 2>/dev/null) || exit 0
last=0
last_tag=""
if [ -f "$STAMP" ]; then
  _stamp_line=$(head -n1 "$STAMP" 2>/dev/null || echo "")
  last="${_stamp_line%% *}"
  case "$_stamp_line" in
    *' '*) last_tag="${_stamp_line#* }" ;;
    *) last_tag="" ;;
  esac
  case "$last" in
    ''|*[!0-9]*) last=0 ;;
  esac
fi
if [ "$last_tag" = "$TAG" ]; then
  delta=$((now - last))
  if [ "$delta" -ge 0 ] && [ "$delta" -lt "$TTL" ]; then
    if should_sample_ttl; then
      report_event ttl_hit null
    fi
    exit 0
  fi
fi

# 单飞锁:并发 dl 调用同时进来时只让一个跑 npm install,其他直接退出。
# flock 不一定有(busybox sh) → mkdir 兜底原子锁。
run_install() {
  _t0=$(date +%s 2>/dev/null || echo 0)
  # stderr 写到独立文件 npm_fail 分支带末尾给观测端;stdout 仍丢掉。
  _errlog=$(mktemp 2>/dev/null || echo "/tmp/dl-npm-err.$$")
  # 必须带 --prefix=/home/user/.local 跟 HOME=/home/user,跟 sandbox-template
  # 首次安装那一段对齐 (packages/sandbox-template/scripts/publish-e2b-template.ts
  # 里 wrapper 写法)。沙箱进程 owner 是 user,无 /usr/lib/node_modules 写权限;
  # 不带 prefix → npm 走默认全局路径 → EACCES 1 秒内挂掉, stderr 又被吞 → fleet
  # 自更新 100% 失败但完全黑盒。这是 PR #729 漏掉 prefix 留下的 bug。
  #
  # --force 必须带: 本脚本 install 后会 `ln -sf /usr/local/bin/dl
  # /home/user/.local/bin/dl` 把 npm shim 顶成软链 (修 PATH 顺序, 见下方
  # ln -sf 那两行)。下次 install 时 npm 看 /home/user/.local/bin/dl 已经存在
  # 且不是它自己管理的, 默认 EEXIST 拒绝覆盖 (error 信息里直接写 "run npm with
  # --force to overwrite"); --force 允许 npm 覆盖。Honeycomb 上 staging
  # dl-selfupdate dataset 看到的所有 npm_fail outcome (#741 接通后) 都是这条,
  # 加 --force 就清零。
  if HOME=/home/user npm install -g --prefix=/home/user/.local \
      --no-audit --no-fund --no-update-notifier --force \
      "$PACKAGE@$TAG" >/dev/null 2>"$_errlog"; then
    _outcome=ok
  else
    _outcome=npm_fail
  fi
  _t1=$(date +%s 2>/dev/null || echo 0)
  _dur_ms=$(( (_t1 - _t0) * 1000 ))
  # npm install -g 会重生成 /home/user/.local/bin/{dl,ilands} shim 顶掉 wrapper
  # 软链, 立刻 re-link 回 /usr/local/bin/{dl,ilands} wrapper —— 不然 PATH 命中
  # npm shim, 下次调用绕过 wrapper, dl 的 DL_NPM_DIST_TAG export 跑不到, 自更新
  # 链路死掉; ilands 也跟着丢 wrapper(虽然 ilands 自己不需要 env, 但 update.sh
  # 入口被绕过, 跟 dl 同时挂)。
  ln -sf /usr/local/bin/dl /home/user/.local/bin/dl >/dev/null 2>&1 || true
  ln -sf /usr/local/bin/ilands /home/user/.local/bin/ilands >/dev/null 2>&1 || true
  # 自我热更: @pawlogic/dl npm 包里 ship 了本脚本的最新版 (packages/dl/sandbox-assets/
  # dl-npm-update.sh), npm install 完成后覆盖一份到 wrapper 调用的固定路径。
  # 1 代 lag: 本次仍跑老逻辑, 新逻辑下次 dl 调用生效 —— 比"等 e2b template republish
  # + 老 snapshot 自然下线"快好几个量级, snapshot resume 的沙箱也能跟上。
  # 当前进程文件是 shell 解释器一次性读入内存的, 覆盖自身不会撕裂正在执行的循环。
  if [ -f /home/user/.local/lib/node_modules/@pawlogic/dl/dl-npm-update.sh ]; then
    cp /home/user/.local/lib/node_modules/@pawlogic/dl/dl-npm-update.sh \
       /home/user/.local/lib/dl-npm-update.sh 2>/dev/null || true
    chmod +x /home/user/.local/lib/dl-npm-update.sh 2>/dev/null || true
  fi
  echo "$now $TAG" > "$STAMP"
  if [ "$_outcome" = "npm_fail" ]; then
    report_event "$_outcome" "$_dur_ms" "$_errlog"
  else
    report_event "$_outcome" "$_dur_ms"
  fi
  rm -f "$_errlog" 2>/dev/null || true
}

if command -v flock >/dev/null 2>&1; then
  (
    if ! flock -n 9; then
      report_event lock_skip null
      exit 0
    fi
    run_install
  ) 9>"$LOCK"
else
  if mkdir "$LOCK" 2>/dev/null; then
    trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT INT TERM
    run_install
  else
    report_event lock_skip null
  fi
fi

exit 0
