Files
Agent-System-Dev/scripts/svc.sh
T
weichen 8ba1b0e715 Add commit-picked deploys and a service registry to svc.sh
Why:
svc.sh only knew two hardcoded environments (dev/prod) and always built
the current workspace, so there was no way to deploy a specific Dev- repo
commit or a Releases artifact, and no record of what was actually started.

Evidence:
- scripts/svc.sh commits dev|release lists deployable commits (release
  entries annotated with version + devCommit; both mark HEAD and the
  environments already running them).
- deploy --from local|dev@<sha>|release@<version|sha> materializes the
  chosen source: dev commits build in an isolated worktree
  (runtime/.worktrees/<sha>, submodule pins verified to land exactly),
  release artifacts are sha256-verified against versions/<v>/manifest.json
  (a forged manifest is refused); --pick offers an interactive numbered
  choice and --dry-run prints the plan.
- Environment names are generalized (^[a-z][a-z0-9-]{0,15}$, derived port
  8800-9099 on collision-free scan); each env is self-contained under
  runtime/<env>/{bin,plugins,data} with svc.env (current state) and
  history.jsonl (append-only deploy/start/stop events). status scans all
  registered envs and history [env] [N] merges the event stream.
- Old artifacts that predate --dsn/--auth have those flags auto-omitted
  (probed via serve usage); legacy svc.env files still render in status.
- Verified end to end in the task worktree: stub-binary lifecycle, a real
  release@0.1.0 deploy (HTTP 200, source shown in status, commits marked),
  failure paths (sha mismatch, immediate exit, non-TTY --pick).

Impact:
scripts/svc.sh users gain multi-version side-by-side environments and
rollback by redeploying an older source; existing dev/prod defaults and
safety constraints (own-pidfile-only stop, port-occupancy check) are
unchanged. Environments deployed by the previous script need one fresh
deploy before start/restart works again. Makefile gains svc-commits and
svc-history targets.

Refs: .task/feature-svc-deploy-source-registry.md(任务卡=PR 描述)
2026-09-30 13:20:32 +00:00

861 lines
35 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 取源(本地 / Dev 提交 / Release
# 版本)→ 构建 / 校验 → 启动,并登记启动记录。
#
# 用法:
# scripts/svc.sh <env> [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@<sha> 在隔离 worktree(runtime/.worktrees/<sha>)物化该提交
# 的子模块组合并构建——不触碰当前工作区;子模块对象缺失时
# 需网络拉取
# --from release Releases 仓最新版本制品(定位:SVC_RELEASES_DIR >
# ../Agent-System-Releases)
# --from release@<版本|sha> 指定 Releases 版本;制品取 build/<v>/,sha256 对
# versions/<v>/manifest.json 校验,不符即中止
# --pick [dev|release] 交互式编号选择提交后部署(仅 TTY;非交互请用 --from)
# --dry-run 只解析并打印部署计划,不执行
#
# 其余参数(优先级:命令行 > --env 配置文件 > 环境变量/.svc.env > 默认值):
# --env FILE 环境配置文件(dotenv:KEY=VALUE,支持 # 注释、成对引号、
# export 前缀;只识别合法变量名,不执行文件内容)。
# 任意环境 X 的变量名为 SVC_<X 大写,- → _>_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 插件目录(默认 <env>/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_<ENV>_ADDR 覆盖
# - 每个环境自包含:runtime/<env>/{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/<pid>/cmdline 双重校验),绝不动他人现场;
# - 端口被非本脚本进程占用时明确报错并给出占用者信息,不抢不杀;
# - dev@<sha> 构建在隔离 worktree 进行,不改写当前工作区与子模块检出。
# <<usage-end>>
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,/^# <<usage-end>>$/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 大写,- → _>_<SUFFIX> 变量值(无则空)。
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 <pid>:进程存在且 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/<env>/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[@<sha>]|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 <env> deploy --from dev@<sha>"
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 <env> 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@<sha>" / "release@<版本>"
# (列表与提示走 stderr,避免被调用方的命令替换捕获);取消则退出 0。
pick_commit() {
local src="$1"
[ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@<sha|版本>"; 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@<sha> | dev@<sha> | 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[@<sha>] | 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 "$@"