@@ -1,25 +1,41 @@
#!/usr/bin/env bash
# svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 构建最新 → 启动。
# svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 取源(本地 / Dev 提交 / Release
# 版本)→ 构建 / 校验 → 启动,并登记启动记录。
#
# 用法:
# scripts/svc.sh <dev|prod > [action] [参数...]
# 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 # 全环境总览
# scripts/svc.sh status # 全环境总览(扫描 runtime/* 下已登记环境)
# scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N 默认 20)
# scripts/svc.sh history [env] [N] # 启动/部署历史(默认全部环境,最近 20 条)
#
# 参数(优先级:命令行 > --env 配置文件 > 环境变量/.svc.env > 默认值 ):
# 部署来源(仅 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 前缀;只识别合法变量名,不执行文件内容)。
# 文件里写 SVC_DEV_ADDR/SVC_MODEL_* 等,CLI 参数可覆盖其中任意项
# 任意环境 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( 数据库地址/端口/库名都在 DSN 内,如
# postgres://user:pass@dbhost:5432/agentdb)——含密码时建议
# 改用环境变量 SVC_PROD_DSN 传递,避免进 shell 历史
# --dsn DSN PostgreSQL 档 DSN( dev 外环境;含密码时建议用环境变量传递)
# --auth on|off|auto API key 认证(默认 auto:PG 档开、嵌入式关)
# --mock 演示模式(dev 常用)
# --plugins dir 插件目录(默认 <repo >/plugins)
# --plugins dir 插件目录(默认 <env >/plugins,随 deploy 复制 )
# --model-base URL 启动后自动播种主模型配置(settings.models.chat.baseUrl)
# --model-key KEY 主模型 API Key(播种用;勿写入脚本/仓库)
# --model-name NAME 主模型名称
@@ -27,9 +43,16 @@
# --tavily-keys K1,K2 网络搜索密钥池(播种 tools.tavily.apiKeys)
# -- <剩余参数> 原样透传给 agent-system serve( escape hatch)
#
# 环境变量默认值 :
# dev: 127.0.0.1:8788 数据 runtime/dev/data; SVC_DEV_ADDR/DATA/MOCK=1 可覆盖
# prod: 127.0.0.1:8787 数据 runtime/prod/data; SVC_PROD_ADDR/DATA/DSN/AUTH 可 覆盖
# 环境与登记 :
# - 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):
@@ -39,14 +62,21 @@
# SVC_MODEL_NAME=glm-4.7
#
# 示例:
# scripts/svc.sh dev --env ./.env.dev # 用配置文件启动
# scripts/svc.sh dev --env ./.env.dev --port 9000 # CLI 覆盖文件里的端口
# scripts/svc.sh prod --host 0.0.0.0 --dsn postgres://u:p@127.0.0.1:5432/as
# 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 ) "
@@ -60,7 +90,7 @@ 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,46 p' " ${ BASH_SOURCE [0] } " | sed 's/^# \{0,1\}//'
sed -n '2,/^# <<usage-end>>$/ p' " ${ BASH_SOURCE [0] } " | sed '$d; s/^# \{0,1\}//'
}
# load_env_file FILE: dotenv 安全解析——逐行 KEY=VALUE 赋值(剥 export 前缀与成对
@@ -88,6 +118,7 @@ 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; } ; }
@@ -109,6 +140,10 @@ parse_args() {
--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 ; ;
@@ -126,34 +161,103 @@ addr_compose() {
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( ) {
case " $1 " in
dev)
ENV_ADDR = " $( addr_compose " ${ SVC_DEV_ADDR :- 127 .0.0.1 : 8788 } " ) "
ENV_DATA = " ${ CLI_DATA :- ${ SVC_DEV_DATA :- $RUNTIME /dev/data } } "
ENV_DIR = " $RUNTIME /dev "
; ;
prod)
ENV_ADDR = " $( addr_compose " ${ SVC_PROD_ADDR :- 127 .0.0.1 : 8787 } " ) "
ENV_DATA = " ${ CLI_DATA :- ${ SVC_PROD_DATA :- $RUNTIME /prod/data } } "
ENV_DIR = " $RUNTIME /prod "
; ;
*) c_err " 未知环境: $1 (可用:dev | prod) " ; usage; exit 2 ; ;
esac
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_PLUGINS = " ${ CLI_PLUGINS :- $ROOT /plugins } "
ENV_BIN_DIR = " $ENV_DIR /bin "
ENV_BIN = " $ENV_BIN_DIR /agent-system "
ENV_PLUGINS = " ${ CLI_PLUGINS :- $ENV_DIR /plugins } "
# 运行参数:mock/DSN/auth( CLI 与环境变量取并集,CLI 优先)。
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 [ " $ENV_NAME " = dev ] ; then
if [ " ${ SVC_DEV_MOCK :- 0 } " = "1" ] || [ " $CLI_MOCK " = 1 ] ; then
ENV_ARGS = "--mock"
fi
else
local dsn = " ${ CLI_DSN :- ${ SVC_PROD_DSN :- } } "
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
ENV_ARGS = " $ENV_ARGS --auth ${ CLI_AUTH :- ${ SVC_PROD_AUTH :- auto } } "
local auth = " ${ CLI_AUTH :- $( env_var " $name " AUTH ) } "
[ -z " $auth " ] && auth = auto
ENV_ARGS = " $ENV_ARGS --auth $auth "
fi
}
@@ -171,6 +275,34 @@ pid_alive() {
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
@@ -185,13 +317,14 @@ do_stop() {
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 " ; return 0; }
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( ) {
@@ -205,7 +338,7 @@ do_build() {
c_info " [ $ENV_NAME ] 构建最新组合(make build:前端 + 内嵌后端) "
if ! make -C " $ROOT " build; then
c_err " [ $ENV_NAME ] 构建失败——旧服务已停止,本次部署中止(修复后重跑) "
exit 1
return 1
fi
}
@@ -251,6 +384,30 @@ PY
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) "
@@ -262,46 +419,391 @@ do_start() {
if [ -n " $owner " ] ; then
c_err " [ $ENV_NAME ] 端口被占用(非本脚本管理): $owner "
c_err "处理:停掉该进程,或换端口(--port 8799 / --addr host:port) "
exit 1
return 1
fi
[ -x " $BIN " ] || { c_err " [ $ENV_NAME ] 缺少二进制 $BIN ——先构建(deploy 会自动构建) " ; exit 1 ; }
c_info " [ $ENV_NAME ] 启动: $BIN serve --addr $ENV_ADDR --data $ENV_DATA --plugins $ENV_PLUGINS $ENV_ARGS $CLI_EXTRA "
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 " $BIN " serve --addr " $ENV_ADDR " --data " $ENV_DATA " --plugins " $ENV_PLUGINS " $ENV_ARGS $CLI_EXTRA \
nohup " $ENV_ BIN " serve --addr " $ENV_ADDR " --data " $ENV_DATA " --plugins " $ENV_PLUGINS " $ENV_ARGS $CLI_EXTRA \
>> " $ENV_LOG " 2>& 1 &
echo $! > " $ENV_PID "
printf 'addr=%s\ndata=%s\n' " $ENV_ADDR " " $ENV_DATA " > " $ENV_DIR /svc.env "
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 (日志: $ENV_LOG ) "
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 " ; exit 1 ; }
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 "
exit 1
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 "
if pid_alive " $pid " && [ -f " $ENV_DIR /svc.env " ] ; then
addr = " $( sed -n 's/^addr=//p' " $ENV_DIR /svc.env " ) "
data = " $( sed -n 's/^data=//p' " $ENV_DIR /svc.env " ) "
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 ' %-5 s 运行中 pid=%-7s %s 数据 %s\n' " $ENV_NAME " " $pid " " $addr " " $data "
printf ' %-10 s 运行中 pid=%-7s %s 数据 %s %s\n' " $ENV_NAME " " $pid " " $addr " " $data " " ${ src : + 来源 $src } "
else
printf ' %-5 s 未运行 %s 数据 %s\n' " $ENV_NAME " " $addr " " $data "
printf ' %-10 s 未运行 %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,可出现在任意位置):
@@ -318,11 +820,17 @@ main() {
set -- ${ pre [@]+ " ${ pre [@] } " }
[ -f " $ROOT /.svc.env " ] && load_env_file " $ROOT /.svc.env "
[ -n " $svc_envfile " ] && load_env_file " $svc_envfile "
if [ " $1 " = "status" ] && [ $# -eq 1 ] ; then
c_info " 服务总览(root: $ROOT ) "
for e in dev prod; do ENV_NAME = " $e " ; env_config " $e " ; env_status_line; done
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"
@@ -330,13 +838,17 @@ main() {
deploy| stop| start| restart| status| logs) action = " $1 " ; shift ; ;
esac
# logs 的行数(兼容 svc.sh dev logs 100)。
if [ " $action " = " logs" ] && [ " ${ 1 :- } " != "" ] && [ [ " $1 " = ~ ^[ 0-9] +$ ] ] ; then
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_stop ; do_build ; do_start ; ;
deploy) do_deploy ; ;
stop) do_stop ; ;
start) do_start ; ;
restart) do_stop; do_start ; ;