#!/usr/bin/env bash # svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 取源(本地 / Dev 提交 / Release # 版本)→ 构建 / 校验 → 启动,并登记启动记录。 # # 用法: # scripts/svc.sh [action] [参数...] # env :任意环境名(^[a-z][a-z0-9-]{0,15}$;dev/prod 有默认配置,其余自动派生) # action:deploy(默认)| stop | start | restart | status | logs [行数] # scripts/svc.sh status # 全环境总览(扫描 runtime/* 下已登记环境) # scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N 默认 20) # scripts/svc.sh history [env] [N] # 启动/部署历史(默认全部环境,最近 20 条) # # 部署来源(仅 deploy;默认 local): # --from local 构建当前工作区(make build),复制产物到环境目录 # --from dev 构建 Dev 仓(本仓)当前 HEAD 的 gitlink 组合 # --from dev@ 在隔离 worktree(runtime/.worktrees/)物化该提交 # 的子模块组合并构建——不触碰当前工作区;子模块对象缺失时 # 需网络拉取 # --from release Releases 仓最新版本制品(定位:SVC_RELEASES_DIR > # ../Agent-System-Releases) # --from release@<版本|sha> 指定 Releases 版本;制品取 build//,sha256 对 # versions//manifest.json 校验,不符即中止 # --pick [dev|release] 交互式编号选择提交后部署(仅 TTY;非交互请用 --from) # --dry-run 只解析并打印部署计划,不执行 # # 其余参数(优先级:命令行 > --env 配置文件 > 环境变量/.svc.env > 默认值): # --env FILE 环境配置文件(dotenv:KEY=VALUE,支持 # 注释、成对引号、 # export 前缀;只识别合法变量名,不执行文件内容)。 # 任意环境 X 的变量名为 SVC__ADDR/DATA/DSN/AUTH/MOCK # (如环境 staging → SVC_STAGING_ADDR) # --addr host:port 监听地址(整体覆盖) # --host host 只改主机部分(如 0.0.0.0 对外开放) # --port port 只改端口部分 # --data dir 数据目录(SQLite 与工件) # --dsn DSN PostgreSQL 档 DSN(dev 外环境;含密码时建议用环境变量传递) # --auth on|off|auto API key 认证(默认 auto:PG 档开、嵌入式关) # --mock 演示模式(dev 常用) # --plugins dir 插件目录(默认 /plugins,随 deploy 复制) # --model-base URL 启动后自动播种主模型配置(settings.models.chat.baseUrl) # --model-key KEY 主模型 API Key(播种用;勿写入脚本/仓库) # --model-name NAME 主模型名称 # --light-base/--light-key/--light-name 轻量模型(标题生成等;可选) # --tavily-keys K1,K2 网络搜索密钥池(播种 tools.tavily.apiKeys) # -- <剩余参数> 原样透传给 agent-system serve(escape hatch) # # 环境与登记: # - dev 默认 127.0.0.1:8788、prod 默认 127.0.0.1:8787;自定义环境默认端口按环境名 # 确定性派生(8800 起,与已登记端口/监听端口冲突时自动递增),可 SVC__ADDR 覆盖 # - 每个环境自包含:runtime//{bin/agent-system, plugins/, data/, svc.pid, # svc.log, svc.env, history.jsonl}——跨环境互不串版本,回退 = 重新 deploy 旧来源 # - 启动登记:svc.env(当前态:pid/addr/data/bin/source/version/started)+ # history.jsonl(追加式事件流:deploy/start/stop);status 与 history 从这些文件 # 汇总,不依赖脚本内存状态 # - start/restart 使用环境目录内产物;环境无产物时先 deploy(旧版脚本部署过的环境 # 无环境产物,同样需要重新 deploy 一次) # # 一次性配置:.svc.env(自动加载)或 --env 指定文件(如 .env.dev/.env.prod, # 均建议 gitignore)。文件示例(.env.dev): # SVC_DEV_ADDR=0.0.0.0:8790 # SVC_MODEL_BASE=https://api.xxx.com/v1 # SVC_MODEL_KEY=sk-xx # SVC_MODEL_NAME=glm-4.7 # # 示例: # scripts/svc.sh commits dev # 看 Dev 仓可部署提交 # scripts/svc.sh commits release # 看 Releases 仓版本 # scripts/svc.sh staging deploy --pick # 交互选提交部署到新环境 staging # scripts/svc.sh prod deploy --from dev@73b2802 # 部署 Dev 仓指定提交 # scripts/svc.sh prod deploy --from release@0.1.0 # 部署(回退到)发布版本 # scripts/svc.sh dev --env ./.env.dev # 用配置文件启动 # scripts/svc.sh dev --env ./.env.dev --port 9000 # scripts/svc.sh status && scripts/svc.sh history # make svc ENV=dev ARGS="--env .env.dev --port 9000" # # 安全约束: # - 只停自己启动过的进程(pidfile + /proc//cmdline 双重校验),绝不动他人现场; # - 端口被非本脚本进程占用时明确报错并给出占用者信息,不抢不杀; # - dev@ 构建在隔离 worktree 进行,不改写当前工作区与子模块检出。 # <> set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUNTIME="$ROOT/runtime" BIN="$ROOT/bin/agent-system" c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*"; } c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*"; } c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*"; } c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; } usage() { sed -n '2,/^# <>$/p' "${BASH_SOURCE[0]}" | sed '$d;s/^# \{0,1\}//' } # load_env_file FILE:dotenv 安全解析——逐行 KEY=VALUE 赋值(剥 export 前缀与成对 # 引号,忽略注释/空行/非法行),不 source 执行文件内容。 load_env_file() { local f="$1" line k v [ -f "$f" ] || { c_err "环境配置文件不存在:$f"; exit 2; } while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}" case "$line" in ''|'#'*) continue ;; esac [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue k="${line%%=*}" k="${k#"${k%%[![:space:]]*}"}" # 左去空白 k="${k#export}" k="${k#"${k%%[![:space:]]*}"}" v="${line#*=}" v="${v#"${v%%[![:space:]]*}"}" # 值左去空白 v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}" # 剥成对引号 printf -v "$k" '%s' "$v" done < "$f" } # ---- 命令行参数(覆盖层)---- CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN="" CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N="" CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME="" CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY="" CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0 need_value() { [ $# -ge 2 ] || { c_err "参数 $1 需要值"; exit 2; }; } parse_args() { while [ $# -gt 0 ]; do case "$1" in --addr) need_value "$@"; CLI_ADDR="$2"; shift 2 ;; --host) need_value "$@"; CLI_HOST="$2"; shift 2 ;; --port) need_value "$@"; CLI_PORT="$2"; shift 2 ;; --data) need_value "$@"; CLI_DATA="$2"; shift 2 ;; --dsn) need_value "$@"; CLI_DSN="$2"; shift 2 ;; --auth) need_value "$@"; CLI_AUTH="$2"; shift 2 ;; --plugins) need_value "$@"; CLI_PLUGINS="$2"; shift 2 ;; --mock) CLI_MOCK=1; shift ;; --model-base) need_value "$@"; CLI_MBASE="$2"; shift 2 ;; --model-key) need_value "$@"; CLI_MKEY="$2"; shift 2 ;; --model-name) need_value "$@"; CLI_MNAME="$2"; shift 2 ;; --light-base) need_value "$@"; CLI_LBASE="$2"; shift 2 ;; --light-key) need_value "$@"; CLI_LKEY="$2"; shift 2 ;; --light-name) need_value "$@"; CLI_LNAME="$2"; shift 2 ;; --tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;; --from) need_value "$@"; CLI_FROM="$2"; shift 2 ;; --pick) CLI_PICK="dev" case "${2:-}" in dev|release) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;; --dry-run) CLI_DRYRUN=1; shift ;; --) shift; CLI_EXTRA="$*"; break ;; -h|--help) usage; exit 0 ;; *) c_err "未知参数:$1"; usage; exit 2 ;; esac done } # addr_compose 基础值(host:port)按 CLI 的 --addr/--host/--port 组合出最终地址。 addr_compose() { local base="$1" host port host="${base%:*}"; port="${base##*:}" [ -n "$CLI_ADDR" ] && { echo "$CLI_ADDR"; return 0; } [ -n "$CLI_HOST" ] && host="$CLI_HOST" [ -n "$CLI_PORT" ] && port="$CLI_PORT" echo "$host:$port" } # ---- 环境泛化 ---- RESERVED_ENVS=" status commits history deploy stop start restart logs help version " # valid_env NAME:环境名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用作环境名)。 valid_env() { case "$RESERVED_ENVS" in *" $1 "*) return 1 ;; esac [[ "$1" =~ ^[a-z][a-z0-9-]{0,15}$ ]] } # env_var ENV SUFFIX:取 SVC__ 变量值(无则空)。 env_var() { local v="SVC_$(printf '%s' "$1" | tr 'a-z-' 'A-Z_')_$2" printf '%s' "${!v:-}" } # ports_recorded PORT SKIP_ENV:除 SKIP_ENV 外,是否有环境登记了该端口(svc.env addr)。 ports_recorded() { local p="$1" skip="$2" d e a for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" [ "$e" = "$skip" ] && continue a="$(sed -n 's/^addr=.*:\([0-9]\+\)$/\1/p' "${d}svc.env")" [ "$a" = "$p" ] && return 0 done return 1 } # port_listening PORT:端口是否处于监听(ss 可用时)。 port_listening() { local p="$1" command -v ss >/dev/null 2>&1 || return 1 ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' } # derive_port NAME:由环境名确定性派生端口(8800+hash%300),与已登记/监听端口冲突 # 时在 8800..9099 内递增扫描;找不到空闲端口则失败。 derive_port() { local name="$1" p i=0 p=$(( 8800 + ($(printf '%s' "$name" | cksum | cut -d' ' -f1) % 300) )) while [ "$i" -le 300 ]; do if ! ports_recorded "$p" "$name" && ! port_listening "$p"; then echo "$p"; return 0 fi p=$(( 8800 + (p - 8800 + 1) % 300 )) i=$((i + 1)) done return 1 } env_config() { local name="$1" valid_env "$name" || { c_err "非法环境名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/commits/history 等不可用)" exit 2 } ENV_NAME="$name" ENV_DIR="$RUNTIME/$name" ENV_PID="$ENV_DIR/svc.pid" ENV_LOG="$ENV_DIR/svc.log" ENV_BIN_DIR="$ENV_DIR/bin" ENV_BIN="$ENV_BIN_DIR/agent-system" ENV_PLUGINS="${CLI_PLUGINS:-$ENV_DIR/plugins}" local base_addr base_data base_addr="$(env_var "$name" ADDR)" base_data="$(env_var "$name" DATA)" case "$name" in dev) [ -z "$base_addr" ] && base_addr="127.0.0.1:8788" [ -z "$base_data" ] && base_data="$RUNTIME/dev/data" ;; prod) [ -z "$base_addr" ] && base_addr="127.0.0.1:8787" [ -z "$base_data" ] && base_data="$RUNTIME/prod/data" ;; *) if [ -z "$base_addr" ]; then local p p="$(derive_port "$name")" || { c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定" exit 1 } base_addr="127.0.0.1:$p" fi [ -z "$base_data" ] && base_data="$RUNTIME/$name/data" ;; esac ENV_ADDR="$(addr_compose "$base_addr")" ENV_DATA="${CLI_DATA:-$base_data}" # 运行参数:mock/DSN/auth(CLI 与环境变量取并集,CLI 优先;dev 保持只认 mock)。 ENV_ARGS="" if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$name" MOCK)" = "1" ]; then ENV_ARGS="--mock" elif [ "$name" != dev ]; then local dsn="${CLI_DSN:-$(env_var "$name" DSN)}" if [ -n "$dsn" ]; then ENV_ARGS="$ENV_ARGS --dsn $dsn"; fi local auth="${CLI_AUTH:-$(env_var "$name" AUTH)}" [ -z "$auth" ] && auth=auto ENV_ARGS="$ENV_ARGS --auth $auth" fi } # pid_alive :进程存在且 cmdline 是我们的 serve(防 PID 复用误杀)。 pid_alive() { local pid="$1" [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1 local cmd cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1 case "$cmd" in *agent-system*serve*) return 0 ;; *) return 1 ;; esac } read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; } # ---- 启动登记(svc.env 当前态 + history.jsonl 事件流)---- # jclean S:剥 JSON 字符串里不能出现的字符(本脚本只写简单 ASCII 值)。 jclean() { printf '%s' "$1" | tr -d '"\001-\037'; } # hist_event EVENT RESULT PID DETAIL:向 runtime//history.jsonl 追加一条事件。 hist_event() { mkdir -p "$ENV_DIR" 2>/dev/null || return 0 printf '{"ts":"%s","event":"%s","env":"%s","source":"%s","result":"%s","pid":"%s","addr":"%s","detail":"%s"}\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "$(jclean "$1")" "$(jclean "$ENV_NAME")" "$(jclean "${SRC_LABEL:-}")" \ "$(jclean "$2")" "$(jclean "$3")" "$(jclean "$ENV_ADDR")" "$(jclean "$4")" \ >> "$ENV_DIR/history.jsonl" 2>/dev/null || true } # record_state:成功启动后把当前态写入 svc.env(status/commits 标注从此读取)。 record_state() { local pid prev_src pid="$(read_pid)" prev_src="" [ -f "$ENV_DIR/svc.env" ] && prev_src="$(sed -n 's/^source=//p' "$ENV_DIR/svc.env")" SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}" mkdir -p "$ENV_DIR" printf 'env=%s\npid=%s\naddr=%s\ndata=%s\nbin=%s\nsource=%s\nversion=%s\nstarted=%s\n' \ "$ENV_NAME" "${pid:-}" "$ENV_ADDR" "$ENV_DATA" "$ENV_BIN" \ "${SVC_SOURCE:-}" "${SVC_VERSION:-}" "$(date -Is)" > "$ENV_DIR/svc.env" } do_stop() { local pid; pid="$(read_pid)" if [ -z "$pid" ]; then c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例" return 0 fi if ! pid_alive "$pid"; then c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" rm -f "$ENV_PID" return 0 fi c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" kill -TERM "$pid" for _ in $(seq 1 100); do pid_alive "$pid" || { c_ok "[$ENV_NAME] 已停止"; rm -f "$ENV_PID"; hist_event stop ok "$pid" "SIGTERM 优雅退出"; return 0; } sleep 0.1 done c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底" kill -KILL "$pid" 2>/dev/null || true rm -f "$ENV_PID" c_ok "[$ENV_NAME] 已强制停止" hist_event stop ok "$pid" "SIGKILL 强制停止" } port_owner() { local port="${ENV_ADDR##*:}" if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0; exit}' fi } do_build() { c_info "[$ENV_NAME] 构建最新组合(make build:前端 + 内嵌后端)" if ! make -C "$ROOT" build; then c_err "[$ENV_NAME] 构建失败——旧服务已停止,本次部署中止(修复后重跑)" return 1 fi } # seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等; # 只写明确给出的字段;设置持久化在环境数据目录——播种一次后续启动无需再传)。 seed_settings() { local host="$1" port="$2" local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}" local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" [ -z "$cb$ck$cn$lb$lk$ln$tk" ] && return 0 command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } local payload payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" <<'PY' import json, sys cb, ck, cn, lb, lk, ln, tk = sys.argv[1:8] out = {} def sub(base, key, name): d = {} if base: d["baseUrl"] = base if key: d["apiKey"] = key if name: d["model"] = name return d chat, light = sub(cb, ck, cn), sub(lb, lk, ln) models = {} if chat: models["chat"] = chat if light: models["lightweight"] = light if models: out["models"] = models if tk: out["tools"] = {"tavily": {"apiKeys": tk}} print(json.dumps(out)) PY )" local resp resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' -d "$payload")" if echo "$resp" | grep -q '"ok": *true'; then local did="" [ -n "$cb$ck$cn" ] && did="主模型" [ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型" [ -n "$tk" ] && did="${did:+$did、}Tavily" c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)" else c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(若开启了 API key 认证,需在设置页手动配置)" fi } # adjust_args_for_bin BIN ARGS:按目标二进制的 serve 用法过滤参数——旧版本产物可能 # 不认识 --dsn/--auth(探测 serve -h 输出);不支持的 flag 连同其值一并省略。 adjust_args_for_bin() { local bin="$1" args="$2" out out="$("$bin" serve -h 2>&1 || true)" local filtered="" w drop_next=0 for w in $args; do case "$w" in --dsn|--auth) if printf '%s\n' "$out" | grep -q -- "$w"; then filtered="$filtered $w" else drop_next=1 fi ;; *) if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi filtered="$filtered $w" ;; esac done printf '%s' "${filtered# }" } do_start() { if pid_alive "$(read_pid)"; then c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)" return 0 fi mkdir -p "$ENV_DATA" "$ENV_DIR" # 端口占用预检:他人现场不动,只报告。 local owner; owner="$(port_owner)" if [ -n "$owner" ]; then c_err "[$ENV_NAME] 端口被占用(非本脚本管理):$owner" c_err "处理:停掉该进程,或换端口(--port 8799 / --addr host:port)" return 1 fi if [ ! -x "$ENV_BIN" ]; then c_err "[$ENV_NAME] 环境产物缺失:$ENV_BIN" c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@]|release[@<版本>]]" return 1 fi SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)" if [ -n "$ENV_ARGS" ]; then local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$ENV_ARGS")" if [ "$adjusted" != "$ENV_ARGS" ]; then c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:$ENV_ARGS → ${adjusted:-<无>}" fi ENV_ARGS="$adjusted" fi c_info "[$ENV_NAME] 启动:$ENV_BIN serve --addr $ENV_ADDR --data $ENV_DATA --plugins $ENV_PLUGINS $ENV_ARGS $CLI_EXTRA" # 直接 nohup 简单命令(不经 cd 复合后台作业):$! 即服务进程本身,pidfile 语义才成立。 nohup "$ENV_BIN" serve --addr "$ENV_ADDR" --data "$ENV_DATA" --plugins "$ENV_PLUGINS" $ENV_ARGS $CLI_EXTRA \ >> "$ENV_LOG" 2>&1 & echo $! > "$ENV_PID" record_state # 健康等待:最多 20s;探测主机取绑定主机(0.0.0.0/:: → 127.0.0.1)。 local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" if [ "$host" = "0.0.0.0" ] || [ "$host" = "::" ]; then host="127.0.0.1"; fi for _ in $(seq 1 100); do if curl -s -o /dev/null -m 2 "http://$host:$port/" 2>/dev/null; then c_ok "[$ENV_NAME] 已启动 pid=$(cat "$ENV_PID") → http://$host:$port(来源 ${SVC_SOURCE:-?},日志:$ENV_LOG)" hist_event start ok "$(read_pid)" "http://$host:$port" seed_settings "$host" "$port" return 0 fi pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_LOG" hist_event start fail "$(read_pid)" "进程即退(日志 $ENV_LOG)" return 1 } sleep 0.2 done c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_LOG" hist_event start fail "$(read_pid)" "健康检查超时" return 1 } # ---- 提交列举与选择 ---- # releases_root:定位 Releases 仓(SVC_RELEASES_DIR > ../Agent-System-Releases)。 releases_root() { [ -n "${REL_ROOT:-}" ] && return 0 REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}" if [ ! -d "$REL_ROOT/.git" ]; then c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)" exit 2 fi } # deployed_envs KEY:source=KEY 的环境列表("a,b"),从各环境 svc.env 汇总。 deployed_envs() { local key="$1" d e s out="" for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" valid_env "$e" || continue s="$(sed -n 's/^source=//p' "${d}svc.env")" [ "$s" = "$key" ] && out="${out:+$out,}$e" done printf '%s' "$out" } # print_commit_list SRC N PICK:列出提交表;PICK=1 时同时填充 PICK_OPTS(供 --pick)。 print_commit_list() { local src="$1" count="$2" pick="$3" local sep=$'\037' if [ "$src" = dev ]; then c_info "Dev 仓提交($ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from dev@" printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题' local head_short; head_short="$(git -C "$ROOT" rev-parse --short HEAD)" local n=0 h date subject marks envs while IFS="$sep" read -r h date subject; do [ -z "$h" ] && continue n=$((n + 1)) marks="" [ "$h" = "$head_short" ] && marks="${marks}[HEAD]" envs="$(deployed_envs "dev@$h")" [ -n "$envs" ] && marks="${marks}[已部署:$envs]" printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks" [ "$pick" = 1 ] && PICK_OPTS+=("$h") done < <(git -C "$ROOT" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short) else c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from release@<版本>" printf ' %3s %-9s %-10s %-8s %-9s %s\n' '#' 'sha' '日期' '版本' 'dev提交' '主题' local head_full; head_full="$(git -C "$REL_ROOT" rev-parse HEAD)" local n=0 full h date subject ver dc marks envs while IFS="$sep" read -r full h date subject; do [ -z "$full" ] && continue n=$((n + 1)) ver="$(git -C "$REL_ROOT" ls-tree --name-only "$full:versions" 2>/dev/null | sort -V | tail -1 || true)" dc="" [ -n "$ver" ] && dc="$(git -C "$REL_ROOT" show "$full:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" [ -z "$ver" ] && ver="-" marks="" [ "$full" = "$head_full" ] && marks="${marks}[HEAD]" envs="$(deployed_envs "release@$ver")" [ -n "$envs" ] && marks="${marks}[已部署:$envs]" printf ' %3d %-9s %-10s %-8s %-9s %s %s\n' "$n" "$h" "$date" "$ver" "${dc:0:8}" "$subject" "$marks" [ "$pick" = 1 ] && PICK_OPTS+=("$ver") done < <(git -C "$REL_ROOT" log -n "$count" --format="%H${sep}%h${sep}%ad${sep}%s" --date=short) fi return 0 } cmd_commits() { local src="${1:-dev}" count="${2:-20}" case "$src" in dev|release) ;; *) c_err "用法:scripts/svc.sh commits [dev|release] [N]"; exit 2 ;; esac case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac [ "$src" = release ] && releases_root print_commit_list "$src" "$count" 0 } # pick_commit SRC:交互式编号选择,stdout 只回显 "dev@" / "release@<版本>" # (列表与提示走 stderr,避免被调用方的命令替换捕获);取消则退出 0。 pick_commit() { local src="$1" [ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@"; exit 2; } PICK_OPTS=() print_commit_list "$src" 20 1 >&2 local sel printf '\n输入序号选择要部署的提交(1-%s,回车取消):' "${#PICK_OPTS[@]}" >&2 read -r sel || { printf '\n' >&2; c_info "已取消"; exit 0; } case "$sel" in ''|*[!0-9]*) c_info "已取消(未选择)"; exit 0 ;; *) [ "$sel" -ge 1 ] && [ "$sel" -le "${#PICK_OPTS[@]}" ] || { c_err "序号超范围:$sel"; exit 2; } ;; esac echo "$src@${PICK_OPTS[$((sel - 1))]}" } # ---- 部署来源解析与物化 ---- # resolve_release REF:解析 Releases 引用(空 = 最新;版本号 / 提交号 / tag), # 设 REL_COMMIT / REL_VERSION,并顺手取 manifest 里的 devCommit 到 SRC_DEVCOMMIT。 resolve_release() { local ref="${1:-}" commit ver if [ -z "$ref" ]; then commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD)" [ -n "$commit" ] || { c_err "Releases 仓无法解析 HEAD:$REL_ROOT"; exit 2; } elif commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet "${ref}^{commit}")"; then : elif git -C "$REL_ROOT" cat-file -e "HEAD:versions/$ref/manifest.json" 2>/dev/null; then commit="$(git -C "$REL_ROOT" rev-parse HEAD)" else c_err "Releases 仓无此引用:$ref(可用版本号或提交号;先看可用项:scripts/svc.sh commits release)" exit 2 fi ver="$(git -C "$REL_ROOT" ls-tree --name-only "$commit:versions" 2>/dev/null | sort -V | tail -1 || true)" [ -n "$ver" ] || { c_err "提交 $(git -C "$REL_ROOT" rev-parse --short "$commit") 下无 versions/ 快照,无法确定版本号"; exit 2; } REL_COMMIT="$commit"; REL_VERSION="$ver" SRC_DEVCOMMIT="$(git -C "$REL_ROOT" show "$commit:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" } # resolve_source:解析 --from / --pick → SRC_KIND(local|dev|release) 与统一标签 # SRC_LABEL(local@ | dev@ | release@<版本>,进 svc.env/history)。 resolve_source() { local spec="${CLI_FROM:-local}" if [ -n "$CLI_PICK" ]; then [ "$CLI_PICK" = release ] && releases_root spec="$(pick_commit "$CLI_PICK")" fi case "$spec" in local) SRC_KIND=local SRC_LABEL="local@$(git -C "$ROOT" rev-parse --short HEAD)" ;; dev|dev@*) local ref="HEAD" case "$spec" in dev@*) ref="${spec#dev@}" ;; esac local full full="$(git -C "$ROOT" rev-parse --verify --quiet "${ref}^{commit}")" || { c_err "Dev 仓无此提交:$ref(若是远端新提交,先 git -C $ROOT fetch;可用项:scripts/svc.sh commits dev)" exit 2 } SRC_KIND=dev SRC_FULL="$full" SRC_SHA="$(git -C "$ROOT" rev-parse --short "$full")" SRC_LABEL="dev@$SRC_SHA" ;; release|release@*) releases_root local ref="" case "$spec" in release@*) ref="${spec#release@}" ;; esac resolve_release "$ref" SRC_KIND=release SRC_LABEL="release@$REL_VERSION" ;; *) c_err "无法解析 --from $spec(可用:local | dev[@] | release[@<版本|sha>])" exit 2 ;; esac } # install_artifacts SRCBIN SRCPLUGINS:把构建产物复制进环境目录(环境自包含)。 install_artifacts() { local srcbin="$1" srcplug="$2" mkdir -p "$ENV_BIN_DIR" rm -rf "$ENV_DIR/plugins" cp -f "$srcbin" "$ENV_BIN" || return 1 if [ -d "$srcplug" ]; then cp -r "$srcplug" "$ENV_DIR/plugins" || return 1 else c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)" mkdir -p "$ENV_DIR/plugins" fi c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)" } materialize_local() { c_info "[$ENV_NAME] 构建当前工作区(make build)并复制到环境目录" do_build || return 1 install_artifacts "$BIN" "$ROOT/plugins" } materialize_dev_commit() { local wt="$RUNTIME/.worktrees/$SRC_SHA" if [ -d "$wt" ]; then c_info "[$ENV_NAME] 复用已有构建 worktree:$wt" else c_info "[$ENV_NAME] 物化 Dev 提交 $SRC_SHA → 隔离 worktree(不触碰当前工作区)" git -C "$ROOT" worktree add --detach "$wt" "$SRC_FULL" >/dev/null || return 1 fi c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)" git -C "$wt" submodule update --init --recursive || { c_err "[$ENV_NAME] 子模块物化失败(对象缺失时先 git -C $ROOT fetch origin)" return 1 } c_info "[$ENV_NAME] 构建(make build:npm ci + go build,需网络)" make -C "$wt" build || return 1 install_artifacts "$wt/bin/agent-system" "$wt/plugins" || return 1 c_info "[$ENV_NAME] 回收构建 worktree" git -C "$ROOT" worktree remove --force "$wt" >/dev/null 2>&1 \ || c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $ROOT worktree remove --force $wt" } materialize_release() { local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system" local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" if [ ! -f "$bin" ]; then c_err "[$ENV_NAME] Releases 仓无制品:$bin" c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" return 1 fi if [ -f "$mf" ]; then local want have want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)" have="$(sha256sum "$bin" | cut -d' ' -f1)" if [ -n "$want" ] && [ "$want" != "$have" ]; then c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署" return 1 fi [ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)" else c_warn "[$ENV_NAME] 无 manifest 可校验:$mf" fi [ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT" install_artifacts "$bin" "$art/plugins" || return 1 } materialize_source() { case "$SRC_KIND" in local) materialize_local ;; dev) materialize_dev_commit ;; release) materialize_release ;; esac } print_deploy_plan() { c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" printf ' 环境:%s → %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA" case "$SRC_KIND" in local) printf ' 来源:%s(构建当前工作区 make build)\n' "$SRC_LABEL" printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;; dev) printf ' 来源:%s(完整提交 %s)\n' "$SRC_LABEL" "$SRC_FULL" printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" printf ' 产物:%s ← 该 worktree 构建输出\n' "$ENV_BIN" ;; release) printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")" [ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT" local art="$REL_ROOT/build/$REL_VERSION/agent-system" if [ -f "$art" ]; then printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art" else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art" fi printf ' 产物:%s ← Releases 制品\n' "$ENV_BIN" ;; esac printf ' 启动:%s serve --addr %s --data %s --plugins %s%s%s\n' \ "$ENV_BIN" "$ENV_ADDR" "$ENV_DATA" "$ENV_PLUGINS" "${ENV_ARGS:+ $ENV_ARGS}" "${CLI_EXTRA:+ $CLI_EXTRA}" } do_deploy() { resolve_source if [ "$CLI_DRYRUN" = 1 ]; then print_deploy_plan exit 0 fi hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" do_stop if ! materialize_source; then hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)" c_err "[$ENV_NAME] 部署失败:来源物化未完成(上方日志)" exit 1 fi do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; exit 1; } hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" } # ---- 状态与历史 ---- env_status_line() { local pid; pid="$(read_pid)" # 运行态显示实际启动参数(svc.env 由 start 落盘);未运行显示当前配置默认值。 local addr="$ENV_ADDR" data="$ENV_DATA" src="" a if [ -f "$ENV_DIR/svc.env" ]; then a="$(sed -n 's/^addr=//p' "$ENV_DIR/svc.env")"; [ -n "$a" ] && addr="$a" a="$(sed -n 's/^data=//p' "$ENV_DIR/svc.env")"; [ -n "$a" ] && data="$a" src="$(sed -n 's/^source=//p' "$ENV_DIR/svc.env")" fi if pid_alive "$pid"; then printf ' %-10s 运行中 pid=%-7s %s 数据 %s%s\n' "$ENV_NAME" "$pid" "$addr" "$data" "${src:+ 来源 $src}" else printf ' %-10s 未运行 %s 数据 %s%s\n' "$ENV_NAME" "$addr" "$data" "${src:+ 上次 $src}" fi } global_status() { c_info "服务总览(root: $ROOT)" local envs=(dev prod) e d for d in "$RUNTIME"/*/; do [ -d "$d" ] || continue e="$(basename "$d")" [ "$e" = ".worktrees" ] && continue valid_env "$e" || continue case " ${envs[*]} " in *" $e "*) continue ;; esac envs+=("$e") done for e in "${envs[@]}"; do ENV_NAME="$e"; env_config "$e"; env_status_line done } # jfield KEY LINE:从 history.jsonl 一行里取字符串字段值。 jfield() { printf '%s' "$2" | sed -n "s/.*\"$1\": *\"\([^\"]*\)\".*/\1/p"; } cmd_history() { local env="${1:-}" n="${2:-20}" files=() f case "$n" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac if [ -n "$env" ]; then valid_env "$env" || { c_err "非法环境名:$env"; exit 2; } if [ -f "$RUNTIME/$env/history.jsonl" ]; then files=("$RUNTIME/$env/history.jsonl") else c_info "[$env] 暂无历史(未登记过任何事件)" exit 0 fi else for f in "$RUNTIME"/*/history.jsonl; do [ -f "$f" ] && files+=("$f") done if [ "${#files[@]}" -eq 0 ]; then c_info "暂无历史(任何环境都未登记过事件)" exit 0 fi fi c_info "启动/部署历史($([ -n "$env" ] && echo "环境 $env" || echo "全部环境"),最近 $n 条)" cat "${files[@]}" | sort | tail -n "$n" | while IFS= read -r l; do [ -n "$l" ] || continue printf ' %s %-10s %-8s %-6s %-22s %-6s %s\n' \ "$(jfield ts "$l")" "$(jfield env "$l")" "$(jfield event "$l")" \ "$(jfield result "$l")" "$(jfield source "$l")" "$(jfield pid "$l")" "$(jfield detail "$l")" done } main() { [ $# -ge 1 ] || { usage; exit 2; } # 预扫 --env(支持 --env FILE 与 --env=FILE,可出现在任意位置): # 配置层优先级 = CLI 参数 > --env 文件 > .svc.env > 系统环境变量 > 默认值。 local svc_envfile="" a local pre=() while [ $# -gt 0 ]; do case "$1" in --env) [ $# -ge 2 ] || { c_err "--env 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;; --env=*) svc_envfile="${1#--env=}"; shift ;; *) pre+=("$1"); shift ;; esac done set -- ${pre[@]+"${pre[@]}"} [ -f "$ROOT/.svc.env" ] && load_env_file "$ROOT/.svc.env" [ -n "$svc_envfile" ] && load_env_file "$svc_envfile" case "$1" in -h|--help|help) usage; exit 0 ;; esac if [ "$1" = status ] && [ $# -eq 1 ]; then global_status exit 0 fi case "$1" in commits) shift; cmd_commits "$@"; exit 0 ;; history) shift; cmd_history "$@"; exit 0 ;; esac ENV_NAME="$1"; shift # 动作词可省略(省略 = deploy);flags 可直接跟随环境名。 local action="deploy" case "${1:-}" in deploy|stop|start|restart|status|logs) action="$1"; shift ;; esac # logs 的行数(兼容 svc.sh dev logs 100)。 if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then CLI_LOGS_N="$1"; shift fi parse_args "$@" if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ]; } && [ "$action" != deploy ]; then c_err "--from/--pick/--dry-run 仅用于 deploy 动作" exit 2 fi env_config "$ENV_NAME" case "$action" in deploy) do_deploy ;; stop) do_stop ;; start) do_start ;; restart) do_stop; do_start ;; status) env_status_line ;; logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;; esac } main "$@"