Files
Agent-System-Dev/scripts/svc.sh
T
weichen 60261a835a Generalize svc.sh into a multi-kind service manager with tags and persistence
Why:
svc.sh only managed agent-system environments; databases, standalone
frontend serving, per-service tags and reusable start configs were out of
scope, and the user asked for a unified service lifecycle on this host.

Evidence:
- Service model v2: kind=agent|static|postgres registered in
  runtime/<name>/svc.env (dotenv, mode 600); legacy entries without kind=
  are adopted as agent.
- postgres: "db create <name> --port/--user/--password/--db/--auth-mode/
  --tag" runs initdb (scram-sha-256, pwfile 0600), start/stop/restart use
  pg_ctl (-m fast, never SIGKILL on postmaster) with pg_isready checks and
  first-start database creation; "db dsn <name> [--reveal]" prints a
  composable DSN (password masked by default). Verified end to end against
  a real postgresql@18 instance: data survives restart, wrong passwords
  rejected, stop/restart clean.
- static: "deploy --kind static --from dev[@sha]|local" builds frontend
  dist into htdocs/ in an isolated worktree, or "--dir PATH" serves an
  existing directory; verified serving HTTP 200, restart, stop, dry-run
  plan, and explicit rejection of release sources.
- agent: unchanged three deploy sources plus --tag; bare start/restart now
  reuses the persisted exec= from svc.env (verified: same custom port and
  args across restart); old binaries lacking --dsn/--auth get those flags
  auto-omitted with a warning.
- "list|status [--tag]" shows NAME/KIND/STATE/PID/ADDR/SOURCE/TAGS for all
  registered services; "<name> status" gives per-kind detail; "rm <name>
  [--purge]" unregisters (and optionally deletes) only runtime/<name>/.
- Fixed during verification: psql arg mangling (-c -c), tags lost because
  record_state read svc.env inside its own redirect block, postgres
  --version field parsing, unbound vars for unregistered names.

Impact:
Same invariants as before: writes stay under runtime/<name>/ and
runtime/.worktrees/ (only --from local also writes main-checkout build
outputs, all gitignored), stops only own pidfile+cmdline-verified
processes, port conflicts reported not seized. Prompt output moved to
stderr so stdout (tables, DSNs) stays pipeable. Makefile gains svc-list.

Refs: .task/feature-svc-services-registry.md(任务卡=PR 描述)
2026-09-30 13:44:58 +00:00

1351 lines
54 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 —— 本机服务管理(服务模型 v2):按提交/版本部署并启动 后端(agent) / 前端(static)
# / 数据库(postgres) 三类服务,统一登记(svc.env + history.jsonl)、标签、配置分层、
# 生命周期(start/stop/restart/rm),全程不触碰主检出与其他已启动服务。
#
# 用法:
# scripts/svc.sh <name> [action] [参数...] # 服务级操作(name 见命名规则)
# action:deploy(默认,agent/static)| start | stop | restart | status | logs [行数]
# scripts/svc.sh list [--tag T] # 全服务总览(status 等价)
# scripts/svc.sh db create <name> [参数] # 创建 PostgreSQL 服务(initdb)
# scripts/svc.sh db dsn <name> [--reveal] [--db X] # 打印数据库连接串(默认掩码密码)
# scripts/svc.sh rm <name> [--purge] # 注销服务(--purge 连数据一起删)
# scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N=20)
# scripts/svc.sh history [name] [N] # 事件历史(默认全部,最近 20 条)
#
# 服务模型:
# - name:^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev/prod 有默认端口,其余按名字
# 确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME 大写>_ADDR 可覆盖
# - 每个服务自包含于 runtime/<name>/:产物(bin/htdocs)/数据(data/pgdata)/svc.pid/
# svc.log|pg.log/svc.env(登记+持久化配置,600)/history.jsonl(追加事件流)
# - kind(svc.env kind=,缺省 agent,兼容旧登记):
# agent 后端单二进制(内嵌前端);deploy 三源:local | dev[@<sha>] | release[@<v>]
# static 前端静态服务;deploy --from dev[@sha]|local 构建 frontend dist 复制到
# htdocs/,或 deploy --dir PATH 直接服务既有目录
# postgres 数据库;db create 后 start/stop/restart(pg_ctl 生命周期,绝不裸杀)
#
# 配置优先级(三类服务一致):命令行 > --config/--env 文件 > svc.env 持久化的上次配置
# > SVC_<NAME>_* 系统环境变量(.svc.env 同)> 默认值。裸 start/restart 复用上次持久化
# 配置(svc.env exec= 等),显式参数出现即覆盖。
#
# 常用参数(服务级):
# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;SVC_<NAME>_ADDR/DSN/...)
# --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP)
# --tag T 标签(可重复;登记于 svc.env,list --tag 过滤)
# --kind agent|static deploy 时指定类型(postgres 用 db create 建)
# --from SOURCE 部署来源(见上);--dir PATH(static)直接服务目录
# --dry-run 只打印部署计划;--pick [dev|release] 交互式选提交
# --plugins/--dsn/--auth/--mock/--data 与 --model-* / --light-* / --tavily-keys
# (agent 播种/运行参数)同上一版
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
# db create 专属:--user U(默认 agent)--password P(缺省自动生成,db dsn --reveal 查看)
# --db NAME(默认 agent)--auth-mode scram-sha-256|md5(默认 scram)
# rm 专属:--purge 删除 runtime/<name>/ 整个目录(含数据与历史)
#
# 示例:
# scripts/svc.sh db create pg1 --port 8890 --tag core # 建库
# scripts/svc.sh pg1 start && scripts/svc.sh db dsn pg1 # 起库 + 看 DSN
# scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
# scripts/svc.sh prod deploy --from dev@<sha> --dsn "$(scripts/svc.sh db dsn pg1 --reveal)"
# scripts/svc.sh ui-r4 deploy --kind static --from dev@<sha> --tag ui
# scripts/svc.sh ui-qa deploy --kind static --dir ../some/dist
# scripts/svc.sh list --tag core && scripts/svc.sh history
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
#
# 安全约束:
# - 只写 runtime/<name>/ 与 runtime/.worktrees/;不改主检出 git 状态、不动他人现场;
# --from local 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore)
# 的路径,dev@sha/release/static 构建全部在隔离 worktree;
# - 只停自己启动的进程:pidfile + /proc/<pid>/cmdline 标记双校验;postgres 走
# pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster;
# - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀;
# - svc.env 含密码时权限 600;DSN 默认掩码密码(--reveal 才显示)。
# <<usage-end>>
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="$ROOT/runtime"
BIN="$ROOT/bin/agent-system"
SVC_ENVFILE=""
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; }
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
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH=""
CLI_TAGS=()
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 ;;
--kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;;
--dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;;
--tag) need_value "$@"; CLI_TAGS+=("$2"); shift 2 ;;
--purge) CLI_PURGE=1; shift ;;
--reveal) CLI_REVEAL=1; shift ;;
--user) need_value "$@"; CLI_DB_USER="$2"; shift 2 ;;
--password) need_value "$@"; CLI_DB_PASSWORD="$2"; shift 2 ;;
--db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;;
--auth-mode) need_value "$@"; CLI_DB_AUTH="$2"; shift 2 ;;
--) 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 list add db rm create dsn 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 NAME SUFFIX:取 SVC_<NAME 大写,- → _>_<SUFFIX> 变量值(无则空)。
env_var() {
local v="SVC_$(printf '%s' "$1" | tr 'a-z-' 'A-Z_')_$2"
printf '%s' "${!v:-}"
}
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() {
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
}
# svc_get KEY:读当前服务 svc.env 的键值(无则空)。
svc_get() {
[ -f "$ENV_DIR/svc.env" ] || return 0
sed -n "s/^$1=//p" "$ENV_DIR/svc.env" | head -1
}
# svc_kind NAME:服务类型(svc.env kind=,无登记或无 kind 键 → agent,兼容旧登记)。
svc_kind() {
local k=""
[ -f "$RUNTIME/$1/svc.env" ] && k="$(sed -n 's/^kind=//p' "$RUNTIME/$1/svc.env" | head -1)"
[ -n "$k" ] && printf '%s' "$k" || printf agent
}
# ---- 登记与事件 ----
jclean() { printf '%s' "$1" | tr -d '"\001-\037'; }
# hist_event EVENT RESULT PID DETAIL:向 runtime/<name>/history.jsonl 追加事件。
hist_event() {
mkdir -p "$ENV_DIR" 2>/dev/null || return 0
printf '{"ts":"%s","event":"%s","env":"%s","kind":"%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 "$SVC_KIND")" "$(jclean "${SRC_LABEL:-}")" \
"$(jclean "$2")" "$(jclean "$3")" "$(jclean "$ENV_ADDR")" "$(jclean "$4")" \
>> "$ENV_DIR/history.jsonl" 2>/dev/null || true
}
# svc_merge_tags:合并已有 tags 与本次 --tag(去重,逗号连接)。
svc_merge_tags() {
local prev t out="" seen=" "
prev="$(svc_get tags)"
for t in $(printf '%s' "$prev" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do
[ -n "$t" ] || continue
case "$seen" in *" $t "*) continue ;; esac
seen="$seen $t "
out="${out:+$out,}$t"
done
printf '%s' "$out"
}
# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600)。
# 基本键:kind/env/pid/addr/tags/source/version/started/pidcheck/exec;
# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。
record_state() {
# 先读完旧文件再开重定向(块内 {} > svc.env 会先截断文件,读旧值必须在其之前)。
local pid prev_src merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
mkdir -p "$ENV_DIR"
{
printf 'kind=%s\n' "${SVC_KIND:-agent}"
printf 'env=%s\n' "$ENV_NAME"
printf 'pid=%s\n' "${pid:-}"
printf 'addr=%s\n' "$ENV_ADDR"
printf 'tags=%s\n' "$merged_tags"
printf 'source=%s\n' "${SVC_SOURCE:-}"
printf 'version=%s\n' "${SVC_VERSION:-}"
printf 'started=%s\n' "$(date -Is)"
printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}"
printf 'exec=%s\n' "${SVC_EXEC:-}"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$ENV_DIR/svc.env"
chmod 600 "$ENV_DIR/svc.env"
}
# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)----
pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
local pid="$1" mark="$2" cmd
[ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
}
# pid_alive PID:当前服务的 pid 校验(svc.env pidcheck 优先;旧登记回退 agent-system)。
pid_alive() {
local pid="$1" mark
mark="$(svc_get pidcheck)"
[ -z "$mark" ] && mark="agent-system"
pid_alive_mark "$pid" "$mark"
}
read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; }
# proc_stop PID(agent/static):TERM 优雅 → KILL 兜底(postgres 不走此路径)。
proc_stop() {
local pid="$1"
kill -TERM "$pid"
for _ in $(seq 1 100); do
pid_alive "$pid" || return 0
sleep 0.1
done
c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底"
kill -KILL "$pid" 2>/dev/null || true
return 0
}
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
}
check_port_free() {
local owner; owner="$(port_owner)"
if [ -n "$owner" ]; then
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
c_err "处理:停掉该进程,或换端口(--port / --addr)"
return 1
fi
}
# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)----
env_config() {
local name="$1"
valid_env "$name" || {
c_err "非法服务名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/list/db/rm 等不可用)"
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"
local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}"
[ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND"
# 类型与既有登记冲突防护:同名服务换类型必须先 rm。
if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then
c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME"
exit 2
fi
[ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log"
local base_addr base_data persisted_addr
base_addr="$(env_var "$name" ADDR)"
base_data="$(env_var "$name" DATA)"
persisted_addr=""
[ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)"
if [ -z "$base_addr" ]; then
if [ -n "$persisted_addr" ]; then
base_addr="$persisted_addr"
else
case "$name" in
dev) base_addr="127.0.0.1:8788" ;;
prod) base_addr="127.0.0.1:8787" ;;
*)
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"
;;
esac
fi
fi
if [ -z "$base_data" ]; then
case "$SVC_KIND" in
postgres) base_data="$ENV_DIR/pgdata" ;;
*) base_data="$ENV_DIR/data" ;;
esac
fi
ENV_ADDR="$(addr_compose "$base_addr")"
ENV_DATA="${CLI_DATA:-$base_data}"
return 0
}
# agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。
agent_has_explicit_cfg() {
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0
[ "$CLI_MOCK" = 1 ] && return 0
[ -n "$SVC_ENVFILE" ] && return 0
[ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0
return 1
}
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --dsn/--auth。
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# }"
}
# ---- 提交列举与选择(dev / release 两源)----
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() {
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:提交表到 stdout;PICK=1 时填充 PICK_OPTS
local src="$1" count="$2" pick="$3"
local sep=$'\037'
if [ "$src" = dev ]; then
c_info "Dev 仓提交($ROOT;倒序取 $count 条)——部署:scripts/svc.sh <name> 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 <name> 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)
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(空=最新) → REL_COMMIT/REL_VERSION/SRC_DEVCOMMIT
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() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/SRC_SHA/REL_*)
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_KINDSRC=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_KINDSRC=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_KINDSRC=release
SRC_LABEL="release@$REL_VERSION"
;;
*)
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>])"
exit 2
;;
esac
}
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
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_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree
# 路径(提示信息走 stderr)。TARGET=build(agent)| frontend(static)。
materialize_dev_commit() {
local full="$1" sha="$2" target="$3"
local wt="$RUNTIME/.worktrees/$sha"
if [ -d "$wt" ]; then
c_info "[$ENV_NAME] 复用已有构建 worktree:$wt"
else
c_info "[$ENV_NAME] 物化 Dev 提交 $sha → 隔离 worktree(不触碰当前工作区)"
git -C "$ROOT" worktree add --detach "$wt" "$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 $target:npm ci + 构建,需网络)"
make -C "$wt" "$target" || return 1
echo "$wt"
}
# recycle_worktree SHA:回收构建 worktree(尽力而为)。
recycle_worktree() {
local wt="$RUNTIME/.worktrees/$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
}
# ---- agent:部署与启动 ----
agent_do_deploy() {
resolve_source
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
do_stop_quiet
local wt
case "$SRC_KINDSRC" in
local)
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; }
install_artifacts "$BIN" "$ROOT/plugins" \
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
;;
dev)
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
install_artifacts "$wt/bin/agent-system" "$wt/plugins" \
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
recycle_worktree "$SRC_SHA"
;;
release)
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
;;
esac
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
return 0
}
print_agent_plan() {
c_info "[$ENV_NAME] 部署计划(dry-run,不执行)"
printf ' 服务:%s(kind=agent)→ %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA"
case "$SRC_KINDSRC" 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" ;;
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 ;;
esac
return 0
}
# 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
}
agent_do_start() {
mkdir -p "$ENV_DATA" "$ENV_DIR"
check_port_free || return 1
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)"
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=。
local exec_args
if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then
exec_args="$(svc_get exec)"
c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$exec_args"
else
local args=""
if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then
args="--mock"
elif [ "$ENV_NAME" != dev ]; then
local dsn="${CLI_DSN:-$(env_var "$ENV_NAME" DSN)}"
[ -n "$dsn" ] && args="$args --dsn $dsn"
local auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
[ -z "$auth" ] && auth=auto
args="$args --auth $auth"
fi
local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")"
if [ "$adjusted" != "$args" ]; then
c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}"
fi
args="$adjusted"
exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_DIR/plugins} $args $CLI_EXTRA"
exec_args="$(printf '%s' "$exec_args" | sed 's/[[:space:]]*$//')"
fi
SVC_EXEC="$exec_args"
SVC_PIDCHECK="$ENV_BIN"
c_info "[$ENV_NAME] 启动:$ENV_BIN serve $exec_args"
# 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
echo $! > "$ENV_PID"
record_state
# 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。
local probe="${ENV_ADDR}"
case "$exec_args" in *--addr\ *)
probe="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
esac
local phost="${probe%:*}"
[ "$phost" = "0.0.0.0" ] || [ "$phost" = "::" ] && phost="127.0.0.1"
for _ in $(seq 1 100); do
if curl -s -o /dev/null -m 2 "http://$phost:${probe##*:}/" 2>/dev/null; then
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
seed_settings "$phost" "${probe##*:}"
return 0
fi
pid_alive "$(read_pid)" || {
c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)"
return 1
}
sleep 0.2
done
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "健康检查超时"
return 1
}
# ---- static:前端静态服务 ----
static_do_deploy() {
# --dir:登记既有目录(不构建);否则 dev@sha/local 构建 frontend dist → htdocs/。
if [ -n "$CLI_DIR" ]; then
[ -d "$CLI_DIR" ] || { c_err "[$ENV_NAME] --dir 目录不存在:$CLI_DIR"; return 1; }
resolve_dir_deploy
return 0
fi
resolve_source
if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan; return 0; fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
do_stop_quiet
local src_dist wt
case "$SRC_KINDSRC" in
local)
c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)"
make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; }
src_dist="$ROOT/modules/frontend/dist"
;;
dev)
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
src_dist="$wt/modules/frontend/dist"
recycle_worktree "$SRC_SHA"
;;
release)
c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir"
hist_event deploy fail "$(read_pid)" "release 源不支持 static"
return 1
;;
esac
[ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; }
mkdir -p "$ENV_DIR"
rm -rf "${ENV_DIR:?}/htdocs"
cp -r "$src_dist" "$ENV_DIR/htdocs" || { hist_event deploy fail "$(read_pid)" "dist 复制失败"; return 1; }
write_static_reg "$ENV_DIR/htdocs"
c_ok "[$ENV_NAME] 前端产物就位:$ENV_DIR/htdocs(源 $SRC_LABEL)"
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
return 0
}
resolve_dir_deploy() { # --dir 路径:登记(含 dry-run 计划)后交由 start 提供服务
local dir; dir="$(cd "$CLI_DIR" && pwd)"
SRC_LABEL="dir@${dir}"
if [ "$CLI_DRYRUN" = 1 ]; then
print_static_plan
return 0
fi
do_stop_quiet
write_static_reg "$dir"
c_ok "[$ENV_NAME] 静态目录登记:$dir(source=$SRC_LABEL)"
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
return 0
}
# write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。
write_static_reg() {
local htdocs="$1"
local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)"
local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断
mkdir -p "$ENV_DIR"
{
printf 'kind=static\n'
printf 'env=%s\n' "$ENV_NAME"
printf 'pid=%s\n' "${old_pid:-}"
printf 'addr=%s\n' "$ENV_ADDR"
printf 'tags=%s\n' "$merged_tags"
printf 'source=%s\n' "$SRC_LABEL"
printf 'started=%s\n' "$(date -Is)"
printf 'htdocs=%s\n' "$htdocs"
} > "$ENV_DIR/svc.env"
chmod 600 "$ENV_DIR/svc.env"
}
print_static_plan() {
c_info "[$ENV_NAME] 部署计划(dry-run,不执行)"
printf ' 服务:%s(kind=static)→ %s(addr %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR"
if [ -n "$CLI_DIR" ]; then
printf ' 目录:%s(--dir 直接服务,不构建)\n' "$CLI_DIR"
else
case "$SRC_KINDSRC" in
local) printf ' 来源:%s(make frontend → htdocs/)\n' "$SRC_LABEL" ;;
dev) printf ' 来源:%s(完整提交 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \
"$SRC_LABEL" "$SRC_FULL" "$RUNTIME/.worktrees/$SRC_SHA" ;;
release) printf ' 来源:release —— 不支持(制品内嵌前端);请用 dev|local 或 --dir\n' ;;
esac
fi
return 0
}
static_do_start() {
mkdir -p "$ENV_DIR"
check_port_free || return 1
command -v python3 >/dev/null 2>&1 || { c_err "[$ENV_NAME] static 需要 python3"; return 1; }
local htdocs; htdocs="$(svc_get htdocs)"
[ -n "$htdocs" ] || htdocs="$ENV_DIR/htdocs"
[ -d "$htdocs" ] || { c_err "[$ENV_NAME] 静态目录缺失:$htdocs(先 deploy 或 --dir)"; return 1; }
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
SVC_EXEC="--directory $htdocs"
SVC_PIDCHECK="--directory $htdocs"
SVC_VERSION=""
c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs"
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 &
echo $! > "$ENV_PID"
record_state "htdocs=$htdocs"
local probe_host="$host"
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
for _ in $(seq 1 50); do
if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
hist_event start ok "$(read_pid)" "http://$probe_host:$port/"
return 0
fi
pid_alive "$(read_pid)" || {
c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "进程即退"
return 1
}
sleep 0.2
done
c_err "[$ENV_NAME] 10s 健康检查未通过"
hist_event start fail "$(read_pid)" "健康检查超时"
return 1
}
# ---- postgres:数据库服务 ----
pg_bindir() {
if [ -n "${SVC_PG_BIN:-}" ] && [ -x "${SVC_PG_BIN}/initdb" ]; then printf '%s' "$SVC_PG_BIN"; return 0; fi
local d
d="$(pg_config --bindir 2>/dev/null || true)"
[ -n "$d" ] && [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin; do
[ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
done
return 1
}
cmd_db() {
local sub="${1:-}"
[ $# -gt 0 ] && shift
case "$sub" in
create) db_create "$@" ;;
dsn) db_dsn "$@" ;;
*) c_err "用法:scripts/svc.sh db create <name> [--port --user --password --db --auth-mode --tag] | db dsn <name> [--reveal] [--db X]"; exit 2 ;;
esac
}
db_create() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db create <name> [...]"; exit 2; }
shift
parse_args "$@"
env_config "$name"
if [ -f "$ENV_DIR/svc.env" ]; then
c_err "[$ENV_NAME] 已有登记(kind=$(svc_kind "$name"));重建先 scripts/svc.sh rm $ENV_NAME [--purge]"
exit 2
fi
local pgbin
pgbin="$(pg_bindir)" || { c_err "找不到 postgres 工具链(initdb/pg_ctl);安装 postgresql 或设 SVC_PG_BIN=<bindir>"; exit 2; }
local user="${CLI_DB_USER:-agent}" dbname="${CLI_DB_NAME:-agent}" authmode="${CLI_DB_AUTH:-scram-sha-256}"
local password="${CLI_DB_PASSWORD:-$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n')}"
local pgdata="$ENV_DIR/pgdata"
if [ -e "$pgdata" ]; then
c_err "[$ENV_NAME] 数据目录已存在:$pgdata(rm --purge 后重建,或换名)"
exit 2
fi
check_port_free || exit 1
mkdir -p "$ENV_DIR"
chmod 700 "$ENV_DIR"
local pwf="$ENV_DIR/.pwfile"
( umask 077; printf '%s\n' "$password" > "$pwf" )
c_info "[$ENV_NAME] initdb(用户 $user,认证 $authmode,数据 $pgdata)"
if ! "$pgbin/initdb" -D "$pgdata" -U "$user" -A "$authmode" --pwfile="$pwf" -E UTF8 >/dev/null; then
rm -f "$pwf"
c_err "[$ENV_NAME] initdb 失败(上方输出)"
exit 1
fi
rm -f "$pwf"
SVC_KIND=postgres
SRC_LABEL="postgres@local"
local pgver merged_tags
pgver="$("$pgbin/postgres" --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' || true)"
merged_tags="$(svc_merge_tags)"
{
printf 'kind=postgres\n'
printf 'env=%s\n' "$ENV_NAME"
printf 'addr=%s\n' "$ENV_ADDR"
printf 'tags=%s\n' "$merged_tags"
printf 'source=%s\n' "$SRC_LABEL"
printf 'version=%s\n' "$pgver"
printf 'user=%s\n' "$user"
printf 'password=%s\n' "$password"
printf 'dbname=%s\n' "$dbname"
printf 'authmode=%s\n' "$authmode"
printf 'pgdata=%s\n' "$pgdata"
} > "$ENV_DIR/svc.env"
chmod 600 "$ENV_DIR/svc.env"
rm -f "$ENV_PID"
hist_event create ok "" "user=$user db=$dbname authmode=$authmode port=${ENV_ADDR##*:}"
c_ok "[$ENV_NAME] 数据库已创建(未启动):scripts/svc.sh $ENV_NAME start"
c_info "[$ENV_NAME] 连接串:scripts/svc.sh db dsn $ENV_NAME --reveal(密码存 svc.env,权限 600)"
return 0
}
# pg_psql SQL [tA]:以登记的管理员+密码走 TCP 执行;tA=取纯值输出。
pg_psql() {
local sql="$1" ta="${2:-}" pgbin
pgbin="$(pg_bindir)"
PGPASSWORD="$(svc_get password)" "$pgbin/psql" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" \
-U "$(svc_get user)" -d postgres ${ta:+-tA} -c "$sql" 2>/dev/null
}
pg_do_start() {
local pgbin
pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链;设 SVC_PG_BIN"; return 1; }
local pgdata; pgdata="$(svc_get pgdata)"
[ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$ENV_NAME] 无数据目录(先 db create)"; return 1; }
check_port_free || return 1
local listen="${ENV_ADDR%:*}"
[ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'"
c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)"
if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then
c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log"
hist_event start fail "" "pg_ctl start 失败"
return 1
fi
local pid; pid="$(head -1 "$pgdata/postmaster.pid" 2>/dev/null || true)"
[ -n "$pid" ] && echo "$pid" > "$ENV_PID"
SVC_PIDCHECK="$pgdata"
SVC_EXEC="pg_ctl -D $pgdata -p ${ENV_ADDR##*:}"
SVC_VERSION="$(svc_get version)"
# 首次启动:建默认库(已存在则跳过)。
local dbname; dbname="$(svc_get dbname)"
if [ -n "$dbname" ]; then
if ! pg_psql "SELECT 1 FROM pg_database WHERE datname='$dbname'" tA | grep -q 1; then
if pg_psql "CREATE DATABASE \"$dbname\""; then
c_ok "[$ENV_NAME] 已建库:$dbname"
else
c_warn "[$ENV_NAME] 建库 $dbname 未成功(可手动创建)"
fi
fi
fi
record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \
"authmode=$(svc_get authmode)" "pgdata=$pgdata"
if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then
c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))"
hist_event start ok "$(read_pid)" "pg_isready ok"
return 0
fi
c_warn "[$ENV_NAME] pg_isready 未通过(进程已起,查看 $ENV_DIR/pg.log)"
hist_event start warn "$(read_pid)" "进程起但 pg_isready 未过"
return 0
}
pg_do_stop() {
local pgbin pgdata pid
pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链"; return 1; }
pgdata="$(svc_get pgdata)"
pid="$(read_pid)"
if [ -z "$pid" ] || ! pid_alive "$pid"; then
c_info "[$ENV_NAME] postgres 未在运行"
rm -f "$ENV_PID"
return 0
fi
c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)"
if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then
rm -f "$ENV_PID"
c_ok "[$ENV_NAME] postgres 已停止"
hist_event stop ok "$pid" "pg_ctl fast"
return 0
fi
c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)"
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 100); do
pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
sleep 0.1
done
c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)"
hist_event stop fail "$pid" "未能停止"
return 1
}
db_dsn() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db dsn <name> [--reveal] [--db X]"; exit 2; }
shift
parse_args "$@"
env_config "$name"
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
local user dbname pass host port
user="$(svc_get user)"
dbname="${CLI_DB_NAME:-$(svc_get dbname)}"
pass="$(svc_get password)"
host="${ENV_ADDR%:*}"; port="${ENV_ADDR##*:}"
if [ "$CLI_REVEAL" = 1 ]; then
printf 'postgresql://%s:%s@%s:%s/%s\n' "$user" "$pass" "$host" "$port" "$dbname"
else
printf 'postgresql://%s:***@%s:%s/%s\n' "$user" "$host" "$port" "$dbname"
c_info "密码已登记于 $ENV_DIR/svc.env(--reveal 显示)"
fi
return 0
}
# ---- 生命周期分派 ----
do_start() {
if pid_alive "$(read_pid)"; then
c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)"
return 0
fi
case "$SVC_KIND" in
agent) agent_do_start ;;
static) static_do_start ;;
postgres) pg_do_start ;;
*) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;;
esac
}
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
case "$SVC_KIND" in
postgres) pg_do_stop ;;
*)
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
proc_stop "$pid"
rm -f "$ENV_PID"
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
esac
return 0
}
# do_stop_quiet:deploy 前置——未运行时静默返回。
do_stop_quiet() {
local pid; pid="$(read_pid)"
if [ -z "$pid" ]; then return 0; fi
if ! pid_alive "$pid"; then
rm -f "$ENV_PID"
return 0
fi
case "$SVC_KIND" in
postgres) pg_do_stop ;;
*)
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
proc_stop "$pid"
rm -f "$ENV_PID"
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
esac
return 0
}
cmd_rm() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh rm <name> [--purge]"; exit 2; }
shift
parse_args "$@"
env_config "$name"
if [ ! -f "$ENV_DIR/svc.env" ]; then
c_err "[$ENV_NAME] 无登记(runtime/$name/svc.env 不存在);rm 只管理本脚本登记的服务"
exit 2
fi
do_stop
hist_event rm ok "$(read_pid)" "${CLI_PURGE:+purge}"
rm -f "$ENV_DIR/svc.env" "$ENV_DIR/svc.pid"
if [ "$CLI_PURGE" = 1 ]; then
rm -rf "${ENV_DIR:?}"
c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)"
else
c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)"
fi
return 0
}
# ---- 总览与历史 ----
svc_pid_state() { # NAME → "alive <pid>" | "dead"
local d="$RUNTIME/$1" pid mark
[ -f "$d/svc.pid" ] || { printf dead; return 0; }
pid="$(cat "$d/svc.pid" 2>/dev/null || true)"
[ -n "$pid" ] || { printf dead; return 0; }
mark="$(sed -n 's/^pidcheck=//p' "$d/svc.env" 2>/dev/null | head -1)"
[ -z "$mark" ] && mark="agent-system"
if pid_alive_mark "$pid" "$mark"; then printf 'alive %s' "$pid"; else printf dead; fi
return 0
}
global_list() {
c_info "服务总览(root: $ROOT)"
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' NAME KIND STATE PID ADDR SOURCE TAGS
local names=() d e name kind addr src tags st
for d in "$RUNTIME"/*/; do
[ -f "${d}svc.env" ] || continue
e="$(basename "$d")"
[ "$e" = ".worktrees" ] && continue
valid_env "$e" || continue
names+=("$e")
done
for name in ${names[@]+"${names[@]}"}; do
d="$RUNTIME/$name"
kind="$(sed -n 's/^kind=//p' "$d/svc.env" | head -1)"; [ -n "$kind" ] || kind=agent
addr="$(sed -n 's/^addr=//p' "$d/svc.env" | head -1)"
src="$(sed -n 's/^source=//p' "$d/svc.env" | head -1)"
tags="$(sed -n 's/^tags=//p' "$d/svc.env" | head -1)"
if [ -n "${LIST_TAG:-}" ]; then
case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac
fi
st="$(svc_pid_state "$name")"
if [ "${st%% *}" = alive ]; then
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "${src:--}" "${tags:--}"
else
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "${src:--}" "${tags:--}"
fi
done
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
return 0
}
env_status_detail() {
local pid; pid="$(read_pid)"
local addr="$ENV_ADDR" a src="" ver="" tags=""
local kind="$SVC_KIND"
if [ -f "$ENV_DIR/svc.env" ]; then
a="$(svc_get addr)"; [ -n "$a" ] && addr="$a"
src="$(svc_get source)"; ver="$(svc_get version)"; tags="$(svc_get tags)"
fi
if pid_alive "$pid"; then
printf ' [%s] %s 运行中 pid=%s\n' "$ENV_NAME" "$kind" "$pid"
else
printf ' [%s] %s 未运行\n' "$ENV_NAME" "$kind"
fi
printf ' 地址 %s 数据 %s\n' "$addr" "$ENV_DATA"
[ -n "$src" ] && printf ' 来源 %s 版本 %s\n' "$src" "${ver:--}"
[ -n "$tags" ] && printf ' 标签 %s\n' "$tags"
case "$kind" in
postgres)
printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)"
printf ' DSN postgresql://%s:***@%s:%s/%s(db dsn %s --reveal 看密码)\n' \
"$(svc_get user)" "${addr%%:*}" "${addr##*:}" "$(svc_get dbname)" "$ENV_NAME" ;;
static)
printf ' 目录 %s\n' "$(svc_get htdocs)" ;;
esac
return 0
}
jfield() { printf '%s' "$2" | sed -n "s/.*\"$1\": *\"\([^\"]*\)\".*/\1/p"; }
cmd_history() {
local env="${1:-}" n="${2:-20}" files=() f
# 首参为纯数字 → 视为条数(svc.sh history 4 == history "" 4)。
case "$env" in ''|*[!0-9]*) ;; *) n="$env"; env=""; shift ;; esac
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 %-9s %-8s %-6s %-24s %-7s %s\n' \
"$(jfield ts "$l")" "$(jfield env "$l")" "$(jfield kind "$l")" "$(jfield event "$l")" \
"$(jfield result "$l")" "$(jfield source "$l")" "$(jfield pid "$l")" "$(jfield detail "$l")"
done
}
# ---- main ----
main() {
[ $# -ge 1 ] || { usage; exit 2; }
# 预扫 --config/--env(FILE 与 =FILE 两种写法,可出现在任意位置)。
local svc_envfile="" pre=()
while [ $# -gt 0 ]; do
case "$1" in
--config|--env) [ $# -ge 2 ] || { c_err "$1 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;;
--config=*|--env=*) svc_envfile="${1#*=}"; shift ;;
*) pre+=("$1"); shift ;;
esac
done
set -- ${pre[@]+"${pre[@]}"}
SVC_ENVFILE="$svc_envfile"
[ -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
case "$1" in
list|status)
shift
LIST_TAG=""
while [ $# -gt 0 ]; do
case "$1" in
--tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;;
*) c_err "list/status 只接受 --tag(未知参数:$1)"; exit 2 ;;
esac
done
global_list
exit 0
;;
db) shift; cmd_db "$@"; exit 0 ;;
rm) shift; cmd_rm "$@"; exit 0 ;;
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
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 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作"
exit 2
fi
env_config "$ENV_NAME"
case "$action" in
deploy)
case "$SVC_KIND" in
agent) agent_do_deploy ;;
static) static_do_deploy && static_do_start ;;
postgres) c_err "[$ENV_NAME] postgres 无 deploy;db create 建库、start 启动"; exit 2 ;;
esac ;;
stop) do_stop ;;
start) do_start ;;
restart) do_stop; do_start ;;
status) env_status_detail ;;
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
esac
}
main "$@"