Files
Agent-System-Dev/scripts/svc.sh
T
weichenandCursor 67d6fd81cd
ci / svc-realcombo (pull_request) Successful in 1m45s
ci / test-pg (pull_request) Successful in 2m53s
ci / test (pull_request) Successful in 4m45s
Require the real lifecycle gate and fault rollback
Why: The live deploy journal never called fault injection, and test-lifecycle failed closed on login so CI could not require the real combination cases.
Evidence: SELF python3 -m unittest tests.test_svc_u2.U2ReleaseTests -q exit 0 (35 ok, 1 skipped) on 3602a87; receipt sha256 9b5785577751e8e9e7947cf9700fb8854b1c80142f34273ae25f3097ee4c9aa8.
Impact: test-pg builds the Dev binary and runs make test-lifecycle. Login and full-track deploy without SVC_RDM_ORIGIN stay NOT_RUN. No live data move and no account CLI.
Refs: recovery plan D2/D3; Dev #485
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 01:14:27 +00:00

5505 lines
252 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 —— 本机服务管理:后端(agent) / 前端(static) / 数据库(postgres) 三类服务的
# 部署、生命周期与数据库运维。每个服务自包含于 runtime/<name>/,登记与配置持久化
# (svc.env + history.jsonl),全程不触碰主检出 git 状态与其他已启动服务。
#
# ════════════════════ 命令一览 ════════════════════
# scripts/svc.sh <name> [action] [参数...] 服务级:deploy(默认)|start|stop|restart|status|logs [N]
# |db [pg名|--clear](agent 数据库绑定/切换)
# |config [file|--clear](agent 配置文件更新/生效)
# |backup [--out D] [--keep N](agent 数据目录快照)
# |backups [--prune](快照列表 / 按保留数清理)
# scripts/svc.sh list [--tag T] 服务总览(status 等价)
# scripts/svc.sh info [name] [--tag T] [--reveal] 综合报告(只读):服务/数据库/配置快照/最近事件
# scripts/svc.sh set <name> [--port N --apply] [--tag T] 变更登记(换端口/标签)
# scripts/svc.sh rm <name> [--purge] 注销服务(--purge 连数据目录删除)
# scripts/svc.sh db create <name> [参数] 创建 PostgreSQL 实例(initdb)
# scripts/svc.sh db dsn <name> [--reveal] [--db X] 连接串(默认掩码密码)
# scripts/svc.sh db backup <name> [--db X] [--out F] 备份(pg_dump -Fc + 自检)
# scripts/svc.sh db backups <name> 备份列表
# scripts/svc.sh db restore <name> --file F [--db X] [--force] 恢复(覆盖非空库须 --force)
# scripts/svc.sh db clone <src> <dst> [--port P] [--password PW] [--online] 克隆实例
# scripts/svc.sh db copy <src> <dst> [--db X] [--force] 跨实例搬库(双方须运行)
# scripts/svc.sh db ls <name> 实例内数据库列表
# scripts/svc.sh db set <name> [...] 等价于 set
# scripts/svc.sh commits [dev|release|rdm] [N] 可部署提交列表(默认 dev,N=20)
# scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条)
# scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关)
# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起
# (崩溃/OOM 被杀/重启后由 watchdog 定时调用)
# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover)
# scripts/svc.sh <name> init 显式建立/查看实例身份(manifest+数据 marker;U1)
# scripts/svc.sh help [主题] 分主题详细帮助
#
# ════════════════════ 部署来源(agent/static 的 deploy)════════════════════
# (缺省)dev@远端最新 不指定 --from 时默认部署源仓 origin/main 最新提交——
# 更新/部署不依赖当前工作区状态(脏树、任意分支均可)
# --from dev[@<sha>] 同缺省;@<sha> 显式指定提交。物化与构建在独立部署源仓的
# 隔离 worktree(runtime/.worktrees/<sha>)进行,不触碰当前工作区
# --from local 构建当前工作区(唯一写主检出构建产物的路径,均 gitignore)
# --from release[@<版本|sha>] Releases 仓制品(sha256 对 versions/<v>/manifest.json 校验)
# --from rdm[@<sha>] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后
# make build 出 agent-system-rdm 组合二进制;web/ 工作台静态
# 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用)
# --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划
#
# ════════════════════ 常用参数(服务级)════════════════════
# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;--env 为兼容别名)
# --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP)
# --tag T 标签(可重复;登记于 svc.env,list --tag 过滤)
# --kind agent|static deploy 时指定类型(postgres 用 db create 建)
# --dir PATH static 直接服务既有目录(不构建)
# --plugins/--dsn/--auth/--mock/--data agent 运行参数
# <name> db <pg名> [--db X] [--no-restart] agent 绑定/切换数据库(实例运行中缺库自动建;
# --clear 解绑回内嵌 SQLite;无参查看当前绑定与可用实例)
# <name> config <file.json> [--no-restart] agent 更换使用的配置文件(schema 校验通过才切换,
# 运行中自动重启即时生效;--clear 移除;无参查看当前配置与生效值)
# --model-base/--model-key/--model-name 与 --light-*/--tavily-keys 启动后播种设置
# (认证开启时自动铸临时密钥完成写入,--seed-owner 指定目标账号)
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
# db create:--user U(默认 agent)--password P(缺省自动生成)--db NAME --auth-mode scram-sha-256|md5
# db backup:--db X --out F db restore:--file F --db X --force
# db clone:--port P --host H --password PW --tag T --online
# db copy:--db X --force set:--port N --host H --apply --tag T
# backup:--out DIR --keep N backups:--prune --keep N(agent 数据目录快照)
#
# ════════════════════ 配置优先级 ════════════════════
# 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=/dbref=/dsn=/db=)
# > SVC_<NAME>_* 环境变量(.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用
# 上次启动配置;显式参数出现即覆盖。任意环境 X 的变量名:SVC_<X 大写,- → _>_
# ADDR/DATA/DSN/AUTH/MOCK,以及 INSECURE_LISTEN / ALLOWED_HOSTS(传给进程的
# AGENT_SYSTEM_*,旧二进制会忽略)。
# 数据库配置统一解析:托管绑定(dbref=)从实例登记重建 DSN,外部直连存受保护
# dsn= 字段;连接串不进 exec=/命令行,经 AGENT_SYSTEM_DSN 环境变量交付。绑定失效
# 或配置冲突(直接 DSN 与 dbref 不同库、mock 与绑定并存、db=pg 无可恢复 DSN)时
# 拒绝启动,绝不静默回退内嵌 SQLite;显式解绑(db=sqlite)后环境残留不再重新选库。
#
# ════════════════════ 服务模型与登记 ════════════════════
# - 服务名 ^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev=8788 / prod=8787 默认端口,
# 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME>_ADDR 可覆盖
# - runtime/<name>/:bin|htdocs|data|pgdata|plugins|backups + svc.pid +
# svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流)
# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态)
# - runtime-backups/<name>/<UTC时间戳>/:agent 数据目录快照(svc.env+history+data;
# *.db 经 sqlite3 .backup 一致性导出并自检,MANIFEST 记逐文件 sha256。放 runtime/
# 之外——runtime 被整体误删时快照仍在,rm --purge 随服务一并删除)
# - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库);
# 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物
#
# ════════════════════ 示例 ════════════════════
# 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 set pg1 --port 8895 --apply # 换端口并重启生效
# scripts/svc.sh db backup pg1 && scripts/svc.sh db backups pg1
# scripts/svc.sh dev backup && scripts/svc.sh dev backups # agent 数据目录快照 + 列表
# scripts/svc.sh db clone pg1 pg2 --port 8896 # 物理克隆一套到新端口
# scripts/svc.sh dev db pg1 --db dev # 把 dev 服务绑定到 pg1/dev 库(自动重启)
# scripts/svc.sh dev db pg2 --no-restart # 切换但不重启(下次 start 生效)
# scripts/svc.sh dev db --clear # 解绑回内嵌 SQLite 档
# scripts/svc.sh dev config .svc/dev.json # 更换配置文件(校验+重启即时生效)
# scripts/svc.sh dev config # 查看当前配置文件与生效值
# scripts/svc.sh db copy pg1 pg2 --db app # 把 pg1 的 app 库搬到 pg2
# scripts/svc.sh db restore pg2 --file <dump> --db app --force
# scripts/svc.sh list --tag core && scripts/svc.sh history
# scripts/svc.sh info && scripts/svc.sh info pg1 --reveal # 全景报告 / 单服务深度(含掩码值)
# scripts/svc.sh watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回
# kill -9 <pid> && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
#
# ════════════════════ 安全约束 ════════════════════
# - 只写 runtime/<name>/、runtime/.worktrees/、runtime-backups/<name>/ 与独立部署源仓(SVC_SRC_DIR,默认
# ~/.cache/agent-system-svc/src,可随时删除重建);不改主检出 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 才显示);
# - 备份/克隆只增不删(backup --keep / backups --prune 的显式保留策略除外);
# restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
# pg 备份在 runtime/<name>/backups/,agent 快照在 runtime-backups/<name>/(在
# runtime/ 之外,防整删事故连带),均随 rm --purge 一并删除;
# - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。
#
# 完整文档:docs/svc.md
# <<usage-end>>
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="$ROOT/runtime"
BIN="$ROOT/bin/agent-system"
SVC_ENVFILE=""
# 独立部署源仓:脚本自有的干净克隆(默认 ~/.cache/agent-system-svc/src,SVC_SRC_DIR 覆盖)。
# dev 源的解析/列举/worktree 物化全部基于它——更新与部署因此不依赖当前工作区状态
# (脏树、任意分支均可);目录可随时删除,下次使用自动重建。
SRC_DIR="${SVC_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/src}"
# 独立 RDM 源仓(下游组合仓 Agent-System-RDM 的干净克隆;SVC_RDM_SRC_DIR 覆盖路径,
# SVC_RDM_ORIGIN 覆盖克隆来源)。rdm 源的解析/列举/物化全部基于它,语义与 SRC_DIR 一致。
RDM_SRC_DIR="${SVC_RDM_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/rdm-src}"
RDM_ORIGIN_DEFAULT="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git"
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
# c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败
# 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。
c_event() { printf '\033[1;36m[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\}//'
}
# usage_short:一屏速览(无参数 / -h / 用法错误时显示;完整帮助走 --help / help <主题>)。
usage_short() {
cat >&2 <<'EOF'
svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(postgres) 统一部署、生命周期与运维
常用:
scripts/svc.sh list [--tag T] 服务总览
scripts/svc.sh info [name] [--tag T --reveal] 综合报告(只读):服务/数据库/配置快照
scripts/svc.sh <name> deploy [--from dev@<sha>] 部署并启动(缺省=远端 main 最新)
scripts/svc.sh <name> start|stop|restart|logs [N] 生命周期与日志
scripts/svc.sh <name> status 服务详情(版本/来源/DSN 等)
scripts/svc.sh <name> db [pg名|--clear] agent 数据库绑定/切换(缺省查看)
scripts/svc.sh <name> backup [--out D] [--keep N] agent 数据目录快照(SQLite 一致性备份)
scripts/svc.sh <name> backups [--prune] 快照列表 / 按保留数清理
scripts/svc.sh <name> config [file|--clear] agent 配置文件更新/即时生效(缺省查看)
scripts/svc.sh db create pg1 --port 8890 建数据库
scripts/svc.sh db dsn|backup|backups|restore|clone|copy|ls|set pg1 ... 数据库运维
scripts/svc.sh set pg1 --port 8895 --apply 换端口(--tag 改标签)
scripts/svc.sh rm pg1 [--purge] 注销(--purge 连数据删除)
scripts/svc.sh commits [dev|release] [N] 可部署提交
scripts/svc.sh history [name] [N] 事件历史
scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态)
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
scripts/svc.sh <name> user <subcommand> [...] 账户 CLI(绑定实例;见 help accounts)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
文档:docs/svc.md
EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle accounts db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
case "$topic" in
""|topics|list)
cat >&2 <<EOF
svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --help;文档 docs/svc.md)
model 服务模型:命名、目录布局、svc.env 字段、history 事件、pidcheck
deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir
lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
db 数据库服务:create/start/dsn/ls/set(换端口)
dbops 数据库运维:backup/backups/restore/clone/copy
config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数
recovery 自恢复:recover、watchdog、autostart
examples 配方:常见场景的完整命令序列
safety 安全约束与边界
EOF
return 0 ;;
model)
cat >&2 <<'EOF'
【model】服务模型与登记
服务名:^[a-z][a-z0-9-]{0,15}$(小写字母开头,可含数字与连字符,≤16 位)。
保留字不可用作服务名:status list add set db rm create dsn commits history
update deploy stop start restart logs help info version。
目录布局(每个服务自包含,互不串版本):
runtime/<name>/
bin/agent-system agent 产物(deploy 复制就位)
htdocs/ static 前端产物(--dir 模式则登记外部目录)
data/ agent 数据目录(SQLite 与工件;--data 可改)
pgdata/ postgres 数据目录(initdb 产物)
plugins/ agent 插件(随 deploy 复制)
backups/ pg_dump 备份(0600)
svc.pid pidfile(只停 cmdline 校验通过的进程)
svc.log | pg.log 日志(postgres 用 pg.log)
svc.env 登记 + 持久化配置(dotenv,600)
history.jsonl 事件流(append-only,一行一个 JSON)
.start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态)
runtime-backups/<name>/<UTC时间戳>/
agent 数据目录快照:svc.env + history.jsonl + data/(*.db 经 sqlite3 .backup
导出一致性副本并入 WAL 内容,integrity_check 自检;-wal/-shm 不入快照;
MANIFEST 记逐文件 sha256)。位于 runtime/ 之外——runtime 被整体误删时快照
仍在;rm --purge 随服务一并删除。
svc.env 字段:
kind agent|static|postgres(旧登记无此键视为 agent)
env 服务名
pid 最近启动的 pid
addr 监听地址 host:port(持久化——下次 start 复用)
tags 标签(逗号分隔)
source 部署来源(local@<sha> | dev@<sha> | release@<版本> | dir@<路径> |
postgres@local | clone@<名>)
version 产物版本串(agent-system version 输出)
started 最近启动时间
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。
只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键
autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留——
recover/watchdog 据此拉回,见 help recovery)
db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键
dbref agent 托管数据库绑定 <pg名>/<库名>(启动时由实例登记重建 DSN)
dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用)
user/password/dbname/authmode/pgdata postgres 专属(密码 600 保存)
htdocs static 专属
history.jsonl 事件(scripts/svc.sh history 查看):
create db create / 物理克隆登记
deploy begin|ok|fail(含来源)
start ok|fail|warn(fail 含原因:进程即退/健康检查超时)
recover ok|fail(watchdog/手动 recover 拉回死亡服务)
stop ok|fail
set 登记变更(addr 旧 → 新)
backup ok|fail(pg:库/文件/大小;agent:快照目录/大小)
restore ok|fail(库、文件、表数)
clone ok|fail(物理/在线)
copy ok|fail
rm 注销(purge 与否)
端口规则:dev 默认 127.0.0.1:8788、prod 127.0.0.1:8787;其余按名字确定性派生
(8800-9099,与已登记端口/监听端口冲突时递增扫描);SVC_<NAME>_ADDR 显式覆盖。
EOF
return 0 ;;
deploy)
cat >&2 <<'EOF'
【deploy】部署与启动(agent / static;省略 action 即 deploy)
语法:scripts/svc.sh <name> deploy [--kind agent|static] [--from 源] [参数...]
部署来源(--from,缺省=dev 远端最新):
(缺省)/ dev 部署独立部署源仓的 origin/main 最新提交——与当前工作区
状态无关(脏树、任意分支均可)。
dev@<sha> 显式指定提交,物化与构建同上。
两者统一流程:在独立部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src,
首次使用自动从 origin 克隆)上 git worktree add --detach 出
runtime/.worktrees/<sha>,递归物化子模块组合(精确落位 gitlink),
make build/frontend,产物复制后回收 worktree。全程不触碰当前工作区。
local 构建当前工作区(make build / make frontend),产物复制到
服务目录。注意:这是唯一会写主检出构建产物(bin/、
node_modules/、dist/——均 gitignore)的路径,也是唯一
依赖当前工作区状态的来源。
release[@<版本|sha>] 取 Releases 仓 build/<v>/agent-system(+plugins),
sha256 对 versions/<v>/manifest.json 校验,不符即拒绝。
rdm[@<sha>] 部署下游组合仓 Agent-System-RDM:独立 RDM 源仓
(SVC_RDM_SRC_DIR,默认 ~/.cache/agent-system-svc/rdm-src,
首次按 SVC_RDM_ORIGIN——缺省 RDM 远端——克隆)worktree 物化
pins/ 子模块后 make build(纯 Go,无需 npm),产物
bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装
(组合插件编译进二进制),web/ 工作台静态资产随环境落
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
注意:rdm 源不支持 --mock 与 --config;模型设置播种
(--model-*/--tavily-keys)仅基座产物支持。
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
--dry-run 只打印部署计划不执行
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
同种类换 sha(含 update)不需要
行为细节:
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
- 部署收尾按源种类做 UI 探活:dev/local/release 要求本服务 GET /=200;
rdm 要求伴生 <name>web GET /=200(失败记 ui-missing,deploy 非 ok)
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
拒绝部署/启动(不静默按内嵌 SQLite 运行)
- static 的 release 源不支持(制品内嵌前端);--dir PATH 可直接登记并服务
既有目录(不构建、不复制,source=dir@<路径>)
- deploy 全程事件入 history.jsonl(begin/ok/fail)
示例:
scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
scripts/svc.sh prod deploy --from dev@73b2802 --dry-run
scripts/svc.sh dev deploy --from rdm@310c3fe --allow-source-switch
scripts/svc.sh ui-r4 deploy --kind static --from dev@73b2802
scripts/svc.sh ui-qa deploy --kind static --dir ../frontend/dist
EOF
return 0 ;;
lifecycle)
cat >&2 <<'EOF'
【lifecycle】生命周期与登记变更
scripts/svc.sh <name> start 启动(agent/static/postgres 分别分派;已在运行则提示)
scripts/svc.sh <name> stop 停止(agent/static: TERM→KILL;postgres: pg_ctl -m fast)
scripts/svc.sh <name> restart 停止 + 启动(裸 restart 复用上次配置)
scripts/svc.sh <name> db [pg名|--clear] [--db X] agent 数据库绑定:缺省查看当前
绑定与可用实例;给 pg名=绑定/切换(dbref= 登记,启动时由实例登记
重建 DSN 并经环境变量交付,实例运行中缺库自动建,服务运行中自动
重启生效);--clear 显式解绑(db=sqlite,清除直接 DSN,环境残留
SVC_*_DSN 不再重新选库);--no-restart 只改登记
scripts/svc.sh <name> config [file|--clear] [--no-restart] agent 配置文件:缺省查看当前
文件与生效值;给 file=更换(agent 二进制 --config,JSON schema 校验
通过才切换,运行中自动重启即时生效);--clear 移除回命令行参数形态。
绑定后改文件内容 + restart 即生效(agent 无热重载,重启秒级)
scripts/svc.sh <name> logs [N] 跟随日志(默认 50 行起;如 svc.sh pg1 logs 200)
scripts/svc.sh <name> status 单服务详情(kind/状态/地址/来源/版本/标签/DSN/目录)
scripts/svc.sh list [--tag T] 全服务表格:NAME KIND STATE PID ADDR SOURCE TAGS
scripts/svc.sh info [name] [--tag T --reveal] 综合报告(只读):服务表+运行汇总 /
postgres 实例与内部库 / 配置文件内容快照(敏感值默认掩码)/
最近事件;带 name 时输出单服务深度报告
scripts/svc.sh history [name] [N] 事件历史(首参为纯数字时视为条数)
scripts/svc.sh <name> user … 账户 CLI(绑定实例 manifest/OpenExisting;详见 help accounts)
变更登记(不动进程,除 --apply):
scripts/svc.sh set <name> --tag T... 三类服务通用,合并去重
scripts/svc.sh set <pg名> --port N [--host H] 未运行:直接改登记
scripts/svc.sh set <pg名> --port N --apply 运行中:停止→改登记→重启
- 运行中的 postgres 改端口必须 --apply(防止登记与实际监听不一致)
- agent/static 换端口用 restart --port N(进程与登记同步更新)
- 新端口会先校验:未被其他服务登记占用且未在监听
注销:
scripts/svc.sh rm <name> 停止并移除登记(目录/数据/历史保留)
scripts/svc.sh rm <name> --purge 连 runtime/<name>/ 整个目录一起删除
数据目录快照(agent;SQLite 一致性导出,详见 docs/svc.md):
scripts/svc.sh <name> backup [--out DIR] [--keep N] 快照到 runtime-backups/<name>/
scripts/svc.sh <name> backups [--prune] [--keep N] 列表 / 按保留数清理旧快照
自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery):
scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起
scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover
自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关):
scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较)
scripts/svc.sh update 更新所有"过时且运行中"的服务
scripts/svc.sh update dev prod 点名更新(已停止的过时服务也会更新并启动)
scripts/svc.sh update --all 含已停止服务;--no-fetch 跳过网络
- local@/dev@ 源:一律重新部署为 dev@<远端 main 最新>(独立部署源仓 + 隔离
worktree 构建;主检出脏净、所在分支均不影响,也不 pull 主检出)
- 领先远端 main / 与远端分叉 / 提交不在远端:报告但不动作(不盲目降级)
- release@源:部署 Releases 仓最新版本(sha256 校验)
- postgres / static --dir 不参与更新;每次更新写 history(update 事件)
EOF
return 0 ;;
accounts|account|user)
cat >&2 <<'EOF'
【accounts】实例绑定的账户 CLI(U1 已接线;帮助可发现性)
绑定合同(禁止回落默认 ./data):
- 必须先有实例登记与 data 根(manifest identity.data_root 优先)
- 强制 --open-existing:缺库/缺目录拒绝,不创建空库
- 禁止调用方覆盖 --data/--dsn/--open-existing
- 与启停互斥(lifecycle flock);无 flock 时拒绝
- mock 档不支持;PG 档须有有效 DSN/dbref
六个子命令(经 scripts/svc.sh <env> user … 转发到实例二进制):
scripts/svc.sh <env> user create --owner <id> [--role user|admin] [--password …]
scripts/svc.sh <env> user list
scripts/svc.sh <env> user reset-password --owner <id> [--password …]
scripts/svc.sh <env> user issue-login-key --owner <id> [--purpose activate|recover]
scripts/svc.sh <env> user revoke-login-key --id <key-id>
scripts/svc.sh <env> user bootstrap-admin --owner <id> [--password …]
凭据只在 stdout 展示一次;日志与 history 只记凭据 ID。后端原子开户/改密/审计
补齐见 Documents 方案 U7(本帮助主题不替代该增量)。
更完整说明:docs/svc.md、docs/svc-deploy/u1-instance-account-binding.md
EOF
return 0 ;;
db)
cat >&2 <<'EOF'
【db】数据库服务:创建 / 连接 / 换端口
scripts/svc.sh db create <name> [--port P] [--host H] [--user U] [--password P]
[--db NAME] [--auth-mode scram-sha-256|md5] [--tag T...]
initdb 到 runtime/<name>/pgdata(目录 700,密码文件即用即删);
默认用户/库均为 agent,缺省密码自动生成(db dsn --reveal 查看);
只登记不启动——scripts/svc.sh <name> start 启动。
scripts/svc.sh <name> start|stop|restart
pg_ctl 生命周期:start 带 -p <port> -k <环境目录>(socket 不落 pgdata)、
listen_addresses 随 --host;首次启动自动创建登记库(已存在则跳过);
就绪判定 pg_isready;stop 用 -m fast 优雅回写,失败退 SIGTERM,绝不 KILL。
scripts/svc.sh db dsn <name> [--reveal] [--db X]
连接串 postgresql://user:pass@host:port/db;默认掩码密码(stdout 纯净可组合)。
scripts/svc.sh db ls <name>
实例内数据库列表(须运行中)。
scripts/svc.sh db set <name> [...] / scripts/svc.sh set <name> [...]
换端口/主机/标签:见 help lifecycle(运行中改端口需 --apply)。
连接其他服务:DSN 可直接组合进 agent 部署——
scripts/svc.sh prod deploy --from dev@<sha> --dsn "$(scripts/svc.sh db dsn pg1 --reveal)"
(旧版本产物不识别 --dsn 时会自动省略并提示。)
更常用:让已部署的 agent 服务绑定/切换数据库(不用重新 deploy)——
scripts/svc.sh prod db pg1 # 绑定 pg1 默认库(运行中自动重启生效)
scripts/svc.sh prod db pg1 --db prod # 绑定 pg1/prod 库(缺库自动建)
scripts/svc.sh prod db pg2 --no-restart # 切换实例但暂不重启(下次 start 生效)
scripts/svc.sh prod db # 查看当前绑定 + 可用实例
scripts/svc.sh prod db --clear # 显式解绑(db=sqlite,回内嵌档)
绑定登记于 svc.env(dbref= + db=pg,600;DSN 启动时由实例登记重建,经环境变量
交付);info / list 的 DB 列与数据库节 "绑定服务" 行展示服务↔实例对应关系。
密码管理:密码明文存 runtime/<name>/svc.env(600);--password 未给时自动生成
(head -c 18 /dev/urandom)。auth-mode 默认 scram-sha-256(pg_hba 全线)。
EOF
return 0 ;;
dbops)
cat >&2 <<'EOF'
【dbops】数据库运维:备份 / 恢复 / 克隆 / 搬库
备份:
scripts/svc.sh db backup <name> [--db X] [--out FILE]
pg_dump -Fc 走 TCP(实例须运行中)→ 默认 runtime/<name>/backups/<UTC时间戳>-<库>.dump
(目录 700、文件 600);完成后 pg_restore --list 自检归档,失败即删。
scripts/svc.sh db backups <name>
列出备份(时间 / 大小 / 文件名)。
恢复(唯一可能覆盖数据的操作,非空库必须 --force):
scripts/svc.sh db restore <name> --file F [--db X] [--force]
目标实例须运行中;F 须为合法 pg_dump 归档(先自检);
目标库不存在→建库后恢复;存在但空→直接恢复;存在且非空→拒绝(--force 才
DROP WITH (FORCE) + 重建 + 恢复);完成后回显表数。
克隆(整实例,绑定新端口):
scripts/svc.sh db clone <src> <dst> [--port P] [--host H] [--password PW] [--tag T] [--online]
默认物理克隆:短暂停止源(一致性)→ cp -a pgdata(剔除 postmaster.pid/.opts)→
登记目标(新端口:--port 或派生;用户/库/认证继承,密码可换;标签继承+可加;
source=clone@<src>)→ 自动复启源。目标不自动启动(提示 start 命令)。
--online:不停源,dump/restore 逻辑复制源登记库到目标——要求目标已
db create + start。
搬库(单库、跨实例):
scripts/svc.sh db copy <src> <dst> [--db X] [--force]
双方都须运行中;--db X 同时命名源与目标库(默认取源登记库);
目标库非空须 --force(语义同 restore)。
注意:备份/克隆只增不删;备份随 rm --purge 一并删除;所有操作入 history.jsonl。
EOF
return 0 ;;
config)
cat >&2 <<'EOF'
【config】配置分层与持久化
优先级(高 → 低):
1. 命令行参数(--port/--dsn/--tag/...)
2. --config FILE(或 --env FILE,兼容别名;=FILE 写法亦可)
3. svc.env 持久化(addr=、exec=、dbref=/dsn=/db=——数据库配置专键)
4. SVC_<NAME>_* 环境变量与 .svc.env(自动加载,同层)
5. 默认值(dev=8788 / prod=8787 / 其余派生端口)
dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只识别合法变量名,
不 source 执行文件内容。建议 .env.dev/.env.prod 等加入 gitignore。
环境变量(X 为服务名大写,- → _):
SVC_X_ADDR / SVC_X_DATA / SVC_X_DSN / SVC_X_AUTH / SVC_X_MOCK
SVC_RELEASES_DIR Releases 仓路径(默认 ../Agent-System-Releases)
SVC_SRC_DIR 独立部署源仓路径(默认 ~/.cache/agent-system-svc/src)
SVC_PG_BIN postgres 工具链 bindir(默认 pg_config --bindir 探测)
SVC_MODEL_BASE/KEY/NAME 主模型(agent 启动健康后播种)
SVC_LIGHT_BASE/KEY/NAME 轻量模型
SVC_TAVILY_KEYS Tavily 密钥池
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
认证开启(PG 档默认)时播种自动铸临时密钥(name=svc-seed,用后即回收),
不依赖手动到设置页配置。
持久化语义:
- deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600,临时文件原子替换);
启动失败不覆盖上次配置——裸 start 仍按上次可用参数重试
- 裸 start/restart 复用 exec=(上次完整参数);任何显式参数(或 --config 文件、
SVC_* 变量)出现即整体重建参数;两种路径共用同一数据库配置解析——改端口/地址/
版本不改变数据库选择,conffile 引用随重建保留
- 数据库配置可恢复:托管绑定 dbref= 在启动时由实例登记重建 DSN(实例端口/密码
变更自动跟随);外部直连存受保护 dsn= 字段;显式解绑记 db=sqlite(环境残留
SVC_*_DSN 不再重新选库)。DSN 经 AGENT_SYSTEM_DSN 环境变量交付,不进 exec=
与命令行;存量 exec= 内的 --dsn 启动时自动迁移(幂等)
- 绑定失效或配置冲突时拒绝启动(不静默回退 SQLite):dbref 实例缺失/登记不全、
直接 DSN 与 dbref 指向不同库、mock 与绑定并存、db=pg 无可恢复 DSN、产物不
消费环境 DSN;restart/deploy 在停止旧进程前先做这些静态检查
- tags 随每次登记合并去重,跨重启保留
服务级配置文件(与上述 svc.sh dotenv 层不同——这是 agent 二进制自己的 JSON 配置):
scripts/svc.sh <name> config [file|--clear] 更换/移除/查看(见 help lifecycle);
切换前 schema 校验(agent-system config explain -config <file>),运行中自动重启
即时生效;登记于 svc.env conffile=,info/status 可见。
EOF
return 0 ;;
recovery)
cat >&2 <<'EOF'
【recovery】自恢复:autostart 期望态 + recover + watchdog
期望运行态(autostart 键,svc.env):
- start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1
- 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图)
- 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复
- rm 注销后无 svc.env,recover 不再触碰
scripts/svc.sh recover [name] [--quiet]
扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起),
对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start
(复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。
逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。
进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。
scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover):
~/.config/systemd/user/agent-system-svc-recover.{service,timer}
(OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖
loginctl enable-linger,未开时安装会提示)
scripts/svc.sh watchdog remove 停用并移除 timer
scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单
无 systemd 用户会话的环境可退化为 cron:
* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1
语义边界:
- recover 只用既有登记拉起,不重建产物、不改变任何配置;
- deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入,
watchdog 不盲目复活(前置停机已清 autostart);
- 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。
EOF
return 0 ;;
examples)
cat >&2 <<'EOF'
【examples】常见配方
① 起一套演示后端(缺省部署远端 main 最新;--from local 才构建当前工作区):
scripts/svc.sh dev deploy --mock
scripts/svc.sh dev deploy --from local --mock
② 建库 + 后端连库:
scripts/svc.sh db create pg1 --port 8890
scripts/svc.sh pg1 start
scripts/svc.sh prod deploy --from dev@<sha> \
--dsn "$(scripts/svc.sh db dsn pg1 --reveal)"
③ 按提交部署 + 回退:
scripts/svc.sh commits dev # 看可部署提交
scripts/svc.sh v1 deploy --from dev@73b2802 --tag test
scripts/svc.sh v1 deploy --from release@0.1.0 # 回退到发布版本
④ 双版本对比(同机并行):
scripts/svc.sh a deploy --from dev@<shaA> --port 8801
scripts/svc.sh b deploy --from dev@<shaB> --port 8802
⑤ 前端静态服务:
scripts/svc.sh ui deploy --kind static --from dev@<sha> # 构建并服务 dist
scripts/svc.sh ui2 deploy --kind static --dir ../frontend/dist # 直接服务目录
⑥ 备份 → 改坏数据 → 恢复:
scripts/svc.sh db backup pg1
... (随意操作)
scripts/svc.sh db restore pg1 --file runtime/pg1/backups/<最新>.dump --force
⑦ 克隆一套隔离环境(不动原库):
scripts/svc.sh db clone pg1 pg1test --port 8896
scripts/svc.sh pg1test start
scripts/svc.sh qa deploy --from dev@<sha> --dsn "$(scripts/svc.sh db dsn pg1test --reveal)"
⑧ 把开发库搬到 prod 实例:
scripts/svc.sh db copy pgdev pgprod --db app --force
⑨ 换端口:
scripts/svc.sh set pg1 --port 8895 --apply
⑩ 总览与追溯:
scripts/svc.sh list --tag test
scripts/svc.sh history pg1 50
EOF
return 0 ;;
safety)
cat >&2 <<'EOF'
【safety】安全约束与边界
写入边界:
- 只写 runtime/<name>/、runtime/.worktrees/(构建 worktree,用后即收)与独立
部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src,可随时删除重建);
- 不改主检出 git 状态;--from local 是唯一写主检出构建产物(bin/、node_modules/、
dist/——均 gitignore)的路径,也是唯一依赖当前工作区状态的来源;
- rm --purge 只删 runtime/<name>/,且要求服务已登记。
进程边界:
- 只停"自己 pidfile + /proc cmdline 标记双校验"都通过的进程;
- postgres 走 pg_ctl -m fast,失败退 SIGTERM,SIGKILL 不用于 postmaster;
- agent/static:TERM 优雅退出,10s 未退才 KILL。
端口边界:
- 被非本脚本进程占用:报错并给出占用者,不抢不杀;
- 显式选端口(--port / set --port / clone --port)会同时查登记与监听;
- 派生端口在 8800-9099 内避让已登记与监听端口。
秘密与数据:
- svc.env(含 DB 密码)与备份文件权限 600;数据/环境目录 700;
- DSN 默认掩码密码,--reveal 才显示;
- restore/copy 覆盖非空库必须显式 --force;备份/克隆只增不删;
- dotenv 解析不执行文件内容(防注入)。
EOF
return 0 ;;
*)
c_err "未知帮助主题:$topic(可选:$HELP_TOPICS;总览:scripts/svc.sh help)"
return 2 ;;
esac
}
# 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_SEED_OWNER=""
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
CLI_ALLOW_SOURCE_SWITCH=0
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=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 ;;
--seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;;
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
--pick) CLI_PICK="dev"
case "${2:-}" in dev|release|rdm) 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 ;;
--apply) CLI_APPLY=1; shift ;;
--force) CLI_FORCE=1; shift ;;
--allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;;
--migrate-schema) CLI_MIGRATE_SCHEMA=1; shift ;;
--confirm) need_value "$@"; CLI_CONFIRM="$2"; shift 2 ;;
--online) CLI_ONLINE=1; shift ;;
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
--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_short; 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 set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog backup backups "
# 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 PORT:端口是否被监听。优先 ss;不可用或无命中时退化为 bash 内建
# /dev/tcp 连接探测(CI 最小容器无 iproute2 时端口检查会失明——2026-10-11 实测:
# thief 占口,recover 未识别照常启动,健康探测打到 thief 上误报已启动)。
port_listening() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' && return 0
fi
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0
return 1
}
# 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 + db/dbref/dsn/conffile(跨重启保留的数据库配置与配置文件引用);
# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
record_state() {
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
prev_dbref="$(svc_get dbref)"
prev_conffile="$(svc_get conffile)"
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
local db_out="" dbref_out="" dsn_out=""
if [ "${SVC_KIND:-agent}" = agent ]; then
if [ -n "${SVC_DB_MODE:-}" ]; then
case "$SVC_DB_MODE" in
pg)
# 只在有可恢复来源(dbref=/dsn=/conffile)时记 pg 标记——纯环境层来源
# 不落标记,环境撤除后回到原语义,不产生"db=pg 却无可恢复 DSN"的死锁。
[ -n "$SVC_DB_REF" ] || [ -n "$SVC_DB_DSN_SAVE" ] || [ "$SVC_DB_SOURCE" = conffile ] && db_out=pg
;;
mock) db_out=mock ;;
sqlite) [ "$SVC_DB_SOURCE" != none ] && db_out=sqlite ;; # 显式解绑才记;从未配置不写
esac
dbref_out="${SVC_DB_REF:-}"
dsn_out="${SVC_DB_DSN_SAVE:-}"
else
db_out="$prev_db"; dbref_out="$prev_dbref"; dsn_out="$prev_dsn"
fi
fi
mkdir -p "$ENV_DIR"
local tmp="$ENV_DIR/svc.env.tmp.$$"
{
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:-}"
[ -n "$db_out" ] && printf 'db=%s\n' "$db_out"
[ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out"
[ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out"
[ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
# U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链,
# revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。
if manifest_ok; then
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then
rm -f "$tmp"
return 0
fi
c_err "[$ENV_NAME] manifest 提交失败,保留旧配置(详见上方错误)"
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$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_PIDCHECK → svc.env pidcheck → agent-system)。
# 启动路径在 record_state 之前就会调本函数;static 首启若只读 svc.env 会误回退到
# agent-system,把 python http.server 判成「进程即退」。
pid_alive() {
local pid="$1" mark
mark="${SVC_PIDCHECK:-}"
[ -z "$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
}
# port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。
port_owner_pid() {
local line pid
line="$(port_owner)"
[ -n "$line" ] || return 1
pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)"
[ -n "$pid" ] || return 1
printf '%s' "$pid"
}
# cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN;
# 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。
cmdline_matches_own() {
local pid="$1" mark base cmd
mark="${SVC_PIDCHECK:-}"
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
[ -z "$mark" ] && mark="${ENV_BIN:-}"
[ -n "$mark" ] || return 1
[ -r "/proc/$pid/cmdline" ] || return 1
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
case "$cmd" in *"$mark"*) return 0 ;; esac
base="$(basename "$mark" 2>/dev/null || true)"
[ -n "$base" ] && [ "$base" != "$mark" ] || return 1
case "$cmd" in
*"$base"*)
[ -n "${ENV_DIR:-}" ] || return 1
case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac
;;
esac
return 1
}
# find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。
# 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。
find_own_in_ancestry() {
local cur="$1" i=0 next
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do
if cmdline_matches_own "$cur"; then
printf '%s' "$cur"
return 0
fi
next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)"
[ -n "$next" ] && [ "$next" != "$cur" ] || return 1
cur="$next"
i=$((i + 1))
done
return 1
}
# reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。
# 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。
# 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿,
# check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。
reclaim_own_port() {
local port="${ENV_ADDR##*:}" listener target i
[ -n "$port" ] || return 0
port_listening "$port" || return 0
listener="$(port_owner_pid 2>/dev/null || true)"
[ -n "$listener" ] || return 1
target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)"
if [ -n "$target" ]; then
c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收"
proc_stop "$target"
rm -f "$ENV_PID"
hist_event stop ok "$target" "reclaim_own_port"
for i in $(seq 1 50); do
port_listening "$port" || return 0
sleep 0.1
done
c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用"
return 1
fi
return 1
}
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
SVC_LOCK_HELD=0
svc_lock() {
[ "$SVC_LOCK_HELD" = 1 ] && return 0
if ! command -v flock >/dev/null 2>&1; then
c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)"
return 1
fi
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
exec 9>>"$ENV_DIR/.start.lock" || return 1
if [ "$1" = "-n" ]; then
flock -n 9 || { exec 9>&-; return 1; }
else
if ! flock -w 300 9; then
exec 9>&-
c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)"
return 1
fi
fi
SVC_LOCK_HELD=1
return 0
}
svc_unlock() {
[ "$SVC_LOCK_HELD" = 1 ] || return 0
exec 9>&- || true
SVC_LOCK_HELD=0
return 0
}
# ---- U1 实例身份 manifest(设计:docs/recovery/u1-identity-manifest.md,已批准)----
# manifest.json 为启动权威(identity 锚点冻结数据根/数据库身份;kv 为权威键值;
# desired 为期望态),svc.env 降为派生只读视图,未迁移实例沿用旧路径。
MANIFEST_PY="$ROOT/scripts/svc_manifest.py"
manifest_ok() {
[ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ]
}
# svc_manifest_migrate:锁内一次性迁移(幂等)。已有数据视为"领养"——按当前数据根
# 建立 marker;数据根不存在或携带他实例 marker 时拒绝(不新建空系统掩盖丢数据),
# 失败保持 legacy 治理继续运行。
svc_manifest_migrate() {
[ -f "$ENV_DIR/svc.env" ] && [ ! -f "$ENV_DIR/manifest.json" ] || return 0
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
c_warn "[$ENV_NAME] 跳过 manifest 迁移:需 python3 与 scripts/svc_manifest.py"
return 0
}
local out rc=0
out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$?
if [ "$rc" -eq 0 ]; then
c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)"
# fresh:库文件已在时用当前制品补行并升为 established;无能力则记 pending。
# 库还不存在时保持 fresh,等启动建库后再 ensure。不在这里为缺库建文件。
resolve_db_config 2>/dev/null || true
local marker data_root
marker="$(svc_db_marker)"
if [ "$marker" = fresh ]; then
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
if ! svc_bin_has_identity; then
svc_db_marker_set pending || return 1
elif [ -f "$data_root/agent-system.db" ]; then
svc_db_identity_ensure || return 1
fi
fi
return 0
fi
if [ "$rc" -eq 4 ]; then
# 数据根携带他实例 marker:身份治理冲突(如 manifest 丢失后试图重建)——
# 中止而非降级 legacy(I4:丢身份是事件,禁止新建空系统掩盖)。
c_err "[$ENV_NAME] 身份治理冲突,中止:$(printf '%s' "$out" | tail -1)"
return 1
fi
c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)"
return 0
}
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
# 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate)
# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备
# identity 能力的产物更新时由候选门禁补齐)。
svc_manifest_verify() {
manifest_ok || return 0
local out
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \
--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then
c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)"
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
return 1
fi
# 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证;
# pending 仅当库文件已在且制品有能力时补齐。无能力制品不得启动已治理实例。
local kind marker data_root
kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
marker="$(svc_db_marker)"
case "$marker" in
fresh) return 0 ;;
pending)
if ! svc_bin_has_identity; then
c_err "[$ENV_NAME] 身份尚未建立(pending),当前制品不能验证身份,拒绝启动"
return 1
fi
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
if [ ! -f "$data_root/agent-system.db" ] && [ "${DB_MODE:-}" != pg ]; then
c_err "[$ENV_NAME] 身份 pending 但库文件不存在,拒绝启动(不新建空库)"
return 1
fi
svc_db_identity_ensure || return 1
svc_db_identity_verify || return 1
;;
*)
if ! svc_bin_has_identity; then
c_err "[$ENV_NAME] 已建立身份的实例只接受能验证身份的制品,拒绝启动"
return 1
fi
svc_db_identity_verify || return 1
;;
esac
return 0
}
# svc_db_identity_*:DB marker(设计 §5.2)。与 --instance(runs 进程标识)无关。
# dbref 稳定串:pg 用托管绑定或 "pg";其余归一为 "sqlite"。
svc_db_identity_args() {
# 输出:INSTANCE_ID\tDBREF\tDATA(供 ensure/verify 共用)
manifest_ok || return 1
python3 - "$ENV_DIR" "${ENV_DATA:-}" <<'PY'
import json, os, sys
env_dir, data_fallback = sys.argv[1], sys.argv[2]
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
ident = m.get("identity") or {}
mode = ident.get("db_mode") or ""
ref = ident.get("db_ref") or ""
if mode == "pg":
dbref = ref or "pg"
else:
dbref = "sqlite"
data = ident.get("data_root") or data_fallback
print("%s\t%s\t%s" % (m["instance_id"], dbref, data))
PY
}
# svc_bin_caps [BIN]:只执行 version --json,在空目录、清空的环境里。非 JSON 或
# protocol<1 一律无能力。旧制品对 version 立即退出,不会进入服务路径。
# 按二进制摘要缓存;缓存目录默认在临时目录,不写进实例运行时。
svc_bin_caps() {
local bin="${1:-${ENV_BIN:-}}"
[ -x "$bin" ] || return 1
local digest cache tmp out
digest="$(sha256sum "$bin" 2>/dev/null | awk '{print $1}')"
[ -n "$digest" ] || return 1
cache="${SVC_CAPS_CACHE_DIR:-${TMPDIR:-/tmp}}/svc-caps-${digest}"
if [ -f "$cache" ]; then
cat "$cache"
return 0
fi
tmp="$(mktemp -d)"
out="$(cd "$tmp" && timeout -k 2 5 env -i PATH=/usr/bin:/bin HOME="$tmp" "$bin" version --json 2>/dev/null)" || true
rm -rf "$tmp"
out="$(printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert int(d.get("protocol",0))>=1; print(json.dumps(d,separators=(",",":")))' 2>/dev/null)" || return 1
printf '%s\n' "$out" > "$cache"
printf '%s\n' "$out"
}
# svc_bin_has_identity [BIN]:能力文档声明 identity.ops 含 ensure 与 verify。
svc_bin_has_identity() {
local bin="${1:-${ENV_BIN:-}}" doc
doc="$(svc_bin_caps "$bin")" || return 1
printf '%s' "$doc" | python3 -c 'import json,sys; d=json.load(sys.stdin); ops=(d.get("identity") or {}).get("ops") or []; raise SystemExit(0 if "ensure" in ops and "verify" in ops else 1)'
}
# svc_db_marker:manifest.identity.db_marker。缺字段的旧 manifest 视为 established
#(已在治理下跑过的实例,缺行即事件,不再自动补)。
svc_db_marker() {
manifest_ok || { printf '%s' established; return 0; }
python3 - "$ENV_DIR" <<'PY'
import json, os, sys
p = os.path.join(sys.argv[1], "manifest.json")
m = json.load(open(p, encoding="utf-8"))
print((m.get("identity") or {}).get("db_marker") or "established")
PY
}
svc_db_marker_set() {
local marker="$1"
manifest_ok || return 0
python3 - "$ENV_DIR" "$marker" <<'PY'
import json, os, sys
env_dir, marker = sys.argv[1], sys.argv[2]
path = os.path.join(env_dir, "manifest.json")
m = json.load(open(path, encoding="utf-8"))
ident = dict(m.get("identity") or {})
if ident.get("db_marker") == marker:
sys.exit(0)
ident["db_marker"] = marker
m["identity"] = ident
m["revision"] = int(m.get("revision") or 0) + 1
tmp = path + ".new"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(m, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, path)
PY
}
# svc_identity_run BIN OP:身份子命令。DSN 只经环境(AGENT_SYSTEM_DSN),不进 argv。
svc_identity_run() {
local bin="$1" op="$2" iid="$3" dbref="$4" data="$5"
local -a args=(env -i "PATH=$PATH" "HOME=${HOME:-/tmp}" "TMPDIR=${TMPDIR:-/tmp}"
"AGENT_SYSTEM_INSTANCE_ID=$iid" "AGENT_SYSTEM_DBREF=$dbref" "AGENT_SYSTEM_DATA=$data")
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
args+=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
args+=("$bin" identity "$op" --instance-id "$iid" --dbref "$dbref" --data "$data")
"${args[@]}"
}
svc_db_identity_ensure() {
# 仅 fresh/pending 或显式 adopt 调用。established 缺行不得走这里自动补。
# 非 agent 或无能力:跳过。可选参数 BIN:部署候选。
local bin="${1:-${ENV_BIN:-}}"
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
svc_bin_has_identity "$bin" || return 0
local line iid dbref data
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local out rc=0
out="$(svc_identity_run "$bin" ensure "$iid" "$dbref" "$data" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
return 1
fi
svc_db_marker_set established || return 1
return 0
}
svc_db_identity_verify() {
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
local bin="${1:-${ENV_BIN:-}}"
svc_bin_has_identity "$bin" || return 0
local line iid dbref data
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local out rc=0
out="$(svc_identity_run "$bin" verify "$iid" "$dbref" "$data" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out"
c_err " 先调查库文件与身份行;确认归属后 scripts/svc.sh $ENV_NAME adopt --confirm $iid"
return 1
fi
AGENT_SYSTEM_INSTANCE_ID="$iid"
AGENT_SYSTEM_DBREF="$dbref"
export AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
return 0
}
# svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前
# 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩
# 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则:
# 1) 未入 manifest 治理(全新实例,无 manifest.json):放行——首次部署无旧状态可保;
# 2) 非 agent 类型:放行;
# 3) 候选产物无 identity 管理能力:拒绝——装上即启动被基座身份门禁拒绝,
# 登记与旧制品/旧进程保持不变(下游组合仓接齐 identity 接口前不可更新);
# 4) 候选有能力:先以候选幂等补齐 DB 身份行(领养时旧产物可能无能力而未补;
# 外宾行/不一致 fail-closed),成功才放行——把「缺行」在停服前治好,
# 避免换完制品才发现 start 起不来。
svc_deploy_identity_gate() {
local srcbin="$1"
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
if ! svc_bin_has_identity "$srcbin"; then
c_err "[$ENV_NAME] 候选产物不支持 identity 管理:$srcbin"
c_err " manifest 治理实例拒绝安装(装上即被启动身份校验拒绝);旧服务/旧制品/登记保持不变"
c_err " 处理:换具备 identity ensure/verify 的版本部署,或 scripts/svc.sh rm $ENV_NAME --purge 后放弃治理重新部署"
return 1
fi
resolve_db_config 2>/dev/null || true
local marker; marker="$(svc_db_marker)"
case "$marker" in
fresh|pending)
# 库文件还不存在时不补行、不建库;留给启动后的受控 ensure。
local data_root
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
if [ -f "$data_root/agent-system.db" ] || { [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; }; then
svc_db_identity_ensure "$srcbin" || {
c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变"
return 1
}
fi
;;
*)
# established:只验证,不补行。缺行或不匹配在停旧服务前拒绝。
svc_db_identity_verify "$srcbin" || {
c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变"
return 1
}
;;
esac
svc_schema_compatible "$ENV_BIN" "$srcbin" || return 1
return 0
}
# svc_schema_compatible INSTALLED CANDIDATE:候选 compatFloor 高于已装制品的
# schema.target 时,迁移会让旧制品读不了库。停旧之前拒绝。缺 schema 字段视为兼容。
svc_schema_compatible() {
local installed="$1" candidate="$2"
local icaps ccaps
icaps="$(svc_bin_caps "$installed" 2>/dev/null || true)"
ccaps="$(svc_bin_caps "$candidate" 2>/dev/null || true)"
python3 - "$icaps" "$ccaps" <<'PY'
import json, sys
def load(raw):
try:
return json.loads(raw) if raw else {}
except Exception:
return {}
old, new = load(sys.argv[1]), load(sys.argv[2])
osch, nsch = old.get("schema") or {}, new.get("schema") or {}
if "target" not in osch or "compatFloor" not in nsch:
sys.exit(0)
if int(osch["target"]) < int(nsch["compatFloor"]):
sys.exit(1)
sys.exit(0)
PY
local rc=$?
if [ "$rc" -eq 0 ]; then
return 0
fi
if [ "${CLI_MIGRATE_SCHEMA:-0}" = 1 ]; then
local iid backup
iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")"
[ "$CLI_CONFIRM" = "$iid" ] || { c_err "[$ENV_NAME] --migrate-schema 需要 --confirm $iid"; return 1; }
backup="$(agent_backup_root)"
if ! find "$backup" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then
c_err "[$ENV_NAME] forward-only 迁移必须先 backup"
return 1
fi
c_warn "[$ENV_NAME] forward-only schema 迁移已确认,回滚不会覆盖业务库"
return 0
fi
c_err "[$ENV_NAME] 候选 schema 与已装制品不兼容,停旧服务前拒绝"
c_err " 若必须前向迁移:scripts/svc.sh $ENV_NAME deploy --migrate-schema --confirm <instance_id>(会先 backup)"
return 1
}
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running,
# 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。
# svc_write_serve_env:首次显式启动把白名单环境(不含凭据、不含 AGENT_SYSTEM_*)
# 写入 0600 的 serve.env。裸启动与 recover 复用它,避免交互壳与 watchdog 环境不同。
svc_write_serve_env() {
local f="$ENV_DIR/serve.env"
[ -f "$f" ] && return 0
( umask 077
{
printf 'PATH=%s\n' "${PATH:-/usr/bin:/bin}"
printf 'HOME=%s\n' "${HOME:-/tmp}"
printf 'USER=%s\n' "${USER:-}"
printf 'LOGNAME=%s\n' "${LOGNAME:-${USER:-}}"
printf 'LANG=%s\n' "${LANG:-C.UTF-8}"
printf 'TZ=%s\n' "${TZ:-UTC}"
printf 'TMPDIR=%s\n' "${TMPDIR:-/tmp}"
} > "$f"
)
chmod 0600 "$f" 2>/dev/null || true
}
# svc_launch_serve ARGS:env -i 只带 serve.env 与本次构造的 guard_env。
svc_launch_serve() {
local exec_args="$1"
local -a args=(env -i)
local line
if [ -f "$ENV_DIR/serve.env" ]; then
while IFS= read -r line || [ -n "$line" ]; do
[ -z "$line" ] && continue
case "$line" in
AGENT_SYSTEM_*|*' '*) continue ;;
*=*) args+=("$line") ;;
esac
done < "$ENV_DIR/serve.env"
fi
if [ "${#guard_env[@]}" -gt 0 ]; then
args+=("${guard_env[@]}")
fi
# 桩只靠 SVC_STUB_* 找到记录目录。这些变量不写入 serve.env,避免 recover
# 把某次测试路径固化进实例;生产调用方没有它们。
# RDM_DISABLE_EXEC_TOOLS 是产品兜底开关,必须到达 serve,否则无 bwrap 时拒启。
local k
while IFS= read -r k; do
[ -n "$k" ] || continue
args+=("$k=${!k}")
done < <(compgen -e | grep -E '^SVC_STUB_' || true)
if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
fi
# shellcheck disable=SC2086
nohup "${args[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
}
# svc_lifecycle_child ACTION [extra args...]:用清空的环境拉起本脚本的
# __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*。
# 从不传入 AGENT_SYSTEM_*。
svc_lifecycle_child() {
local -a args=(env -i "PATH=${PATH:-/usr/bin:/bin}" "HOME=${HOME:-/tmp}"
"USER=${USER:-}" "LOGNAME=${LOGNAME:-${USER:-}}" "LANG=${LANG:-C.UTF-8}"
"TZ=${TZ:-UTC}" "TMPDIR=${TMPDIR:-/tmp}")
local k
for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY; do
if [ -n "${!k:-}" ]; then
args+=("$k=${!k}")
fi
done
while IFS= read -r k; do
[ -n "$k" ] || continue
args+=("$k=${!k}")
done < <(compgen -e | grep -E '^(SVC_|LC_)' || true)
if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
fi
args+=(bash "$0" __lifecycle "$@")
"${args[@]}"
}
svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; }
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
check_port_free() {
# 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。
reclaim_own_port || true
local owner pid own
owner="$(port_owner)"
if [ -n "$owner" ]; then
pid="$(port_owner_pid 2>/dev/null || true)"
own=""
[ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)"
if [ -n "$own" ]; then
c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner"
else
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
fi
c_err "处理:停掉该进程,或换端口(--port / --addr)"
return 1
fi
# ss 不可用时 port_owner 为空——再经 port_listening(含 /dev/tcp 兜底)确认,
# 否则无 iproute2 的环境里启动守卫完全失明(CI 容器实测)。
if port_listening "${ENV_ADDR##*:}"; then
c_err "[$ENV_NAME] 端口 ${ENV_ADDR##*:} 正被监听(未能识别占用者;无 ss 时仅能探测不能归因)"
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"
# U2:releases/ 不可变版本目录 + current 原子指针。已切换实例经 current 解析
# 产物(进程 cmdline 保留未解析路径——指针切换不破坏 pidcheck 匹配,也彻底
# 消除 exe=(deleted) 覆盖孤儿);legacy 实例沿用原路径(首次 deploy 领养,
# svc_release_adopt_legacy)。
if [ -L "$ENV_DIR/current" ]; then
ENV_BIN_DIR="$ENV_DIR/current/bin"
ENV_PLUGINS="$ENV_DIR/current/plugins"
else
ENV_BIN_DIR="$ENV_DIR/bin"
ENV_PLUGINS="$ENV_DIR/plugins"
fi
ENV_BIN="$ENV_BIN_DIR/agent-system"
# 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。
# DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与
# reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
# U1:身份环境同样按实例重置——上一服务 verify 成功后导出的
# AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析;
# 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令
# 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify
# 写入的身份。
unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
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 用法过滤旧产物不认识的 --auth/--config。
# 匹配按"去横线的旗标名 + 1-2 个横线"(Go flag 帮助打印单横线 -dsn,2026-10-04 实测
# 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。
adjust_args_for_bin() {
local bin="$1" args="$2" out
# timeout 护栏:组合二进制若把 serve 当位置参数忽略而直接进入服务态,探测会挂死。
out="$(timeout 5 "$bin" serve -h 2>&1 || true)"
local filtered="" w drop_next=0 flag
for w in $args; do
case "$w" in
--auth|--config)
flag="${w#--}"
if printf '%s\n' "$out" | grep -Eq -- "(^|[[:space:]])-{1,2}${flag}([[:space:]]|=|$)"; 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
}
# svc_src_ready:确保独立部署源仓就绪(不存在则从 $ROOT 的 origin 首次克隆)并 fetch
# 远端;--no-fetch 跳过网络。同一次命令运行内幂等(首个调用定下 fetch 策略)。
# 失败 return 1(无 origin 可克隆/克隆失败),调用方自行决定报错或降级。
SRC_READY_DONE=0
svc_src_ready() {
[ "$SRC_READY_DONE" = 1 ] && return 0
if [ ! -d "$SRC_DIR/.git" ]; then
local url
url="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)"
[ -n "$url" ] || { c_err "无法确定 origin 地址($ROOT 无 origin 远端);可用 SVC_SRC_DIR 指向已有仓"; return 1; }
c_info "初始化独立部署源仓:$SRC_DIR(← origin;与当前工作区无关,可随时删除重建)"
mkdir -p "$(dirname "$SRC_DIR")"
if ! git clone --quiet "$url" "$SRC_DIR" >&2; then
c_err "部署源仓克隆失败:$url → $SRC_DIR"
return 1
fi
fi
if [ "${1:-}" != "--no-fetch" ]; then
c_info "git fetch(独立部署源仓:$SRC_DIR)"
git -C "$SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "部署源仓 fetch 失败——基于本地缓存($SRC_DIR)"
fi
SRC_READY_DONE=1
return 0
}
# rdm_src_ready:确保独立 RDM 源仓就绪(缺省从 RDM 远端克隆,SVC_RDM_ORIGIN 覆盖来源、
# SVC_RDM_SRC_DIR 覆盖路径)并 fetch;--no-fetch 跳过网络。与 svc_src_ready 同构,
# 面向下游组合仓 Agent-System-RDM(构建组合二进制 agent-system-rdm)。
RDM_SRC_READY_DONE=0
rdm_src_ready() {
[ "$RDM_SRC_READY_DONE" = 1 ] && return 0
if [ ! -d "$RDM_SRC_DIR/.git" ]; then
local url="${SVC_RDM_ORIGIN:-$RDM_ORIGIN_DEFAULT}"
c_info "初始化独立 RDM 源仓:$RDM_SRC_DIR(← $url;与当前工作区无关,可随时删除重建)"
mkdir -p "$(dirname "$RDM_SRC_DIR")"
if ! git clone --quiet "$url" "$RDM_SRC_DIR" >&2; then
c_err "RDM 源仓克隆失败:$url → $RDM_SRC_DIR(可用 SVC_RDM_ORIGIN 指定克隆来源)"
return 1
fi
fi
if [ "${1:-}" != "--no-fetch" ]; then
c_info "git fetch(独立 RDM 源仓:$RDM_SRC_DIR)"
git -C "$RDM_SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "RDM 源仓 fetch 失败——基于本地缓存($RDM_SRC_DIR)"
fi
RDM_SRC_READY_DONE=1
return 0
}
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
svc_src_ready || { c_err "部署源仓不可用,无法列出可部署提交"; return 1; }
c_info "Dev 仓提交(独立部署源仓 $SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from dev@<sha>"
printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题'
local latest_short; latest_short="$(git -C "$SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)"
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" = "$latest_short" ] && marks="${marks}[最新]"
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 "$SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main)
elif [ "$src" = rdm ]; then
rdm_src_ready || { c_err "RDM 源仓不可用,无法列出可部署提交"; return 1; }
c_info "RDM 仓提交(独立 RDM 源仓 $RDM_SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from rdm@<sha>"
printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题'
local latest_short; latest_short="$(git -C "$RDM_SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)"
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" = "$latest_short" ] && marks="${marks}[最新]"
envs="$(deployed_envs "rdm@$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 "$RDM_SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main)
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|rdm) ;;
*) c_err "用法:scripts/svc.sh commits [dev|release|rdm] [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:-dev}"
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@*)
# 缺省 ref=origin/main(独立部署源仓的远端最新);显式 @<sha> 亦可。
local ref="origin/main"
case "$spec" in dev@*) ref="${spec#dev@}" ;; esac
svc_src_ready || { c_err "部署源仓不可用——dev 源依赖它;或改用 --from local 构建当前工作区"; exit 2; }
local full
full="$(git -C "$SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || {
c_err "部署源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits dev)"
exit 2
}
SRC_KINDSRC=dev
SRC_FULL="$full"
SRC_SHA="$(git -C "$SRC_DIR" 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"
;;
rdm|rdm@*)
# 缺省 ref=origin/main(独立 RDM 源仓远端最新);显式 @<sha> 亦可。
local ref="origin/main"
case "$spec" in rdm@*) ref="${spec#rdm@}" ;; esac
rdm_src_ready || { c_err "独立 RDM 源仓不可用——rdm 源依赖它(可用 SVC_RDM_ORIGIN 指定克隆来源、SVC_RDM_SRC_DIR 指定路径)"; exit 2; }
local full
full="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || {
c_err "RDM 源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits rdm)"
exit 2
}
SRC_KINDSRC=rdm
SRC_FULL="$full"
SRC_SHA="$(git -C "$RDM_SRC_DIR" rev-parse --short "$full")"
SRC_LABEL="rdm@$SRC_SHA"
;;
*)
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>] | rdm[@<sha>])"
exit 2
;;
esac
}
# svc_fault_check PHASE:仅当 SVC_TEST_FAULTS=1 且 ROOT 在临时目录时,于该阶段退出。
svc_fault_check() {
local phase="$1"
[ "${SVC_TEST_FAULTS:-}" = 1 ] || return 0
[ "${SVC_FAULT_AT:-}" = "$phase" ] || return 0
case "$ROOT" in
/tmp/*|/var/tmp/*) ;;
*) c_err "[$ENV_NAME] 忽略故障注入:ROOT 不在临时目录"; return 0 ;;
esac
c_err "[$ENV_NAME] 故障注入于 $phase"
exit 86
}
svc_journal_write() {
local phase="$1"
SVC_OP_ID="${SVC_OP_ID:-op-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
python3 - "$ENV_DIR/deploy-journal.json" "$phase" "$SVC_OP_ID" \
"${SVC_FROM_RID:-}" "${SVC_TO_RID:-}" "${SVC_FROM_DESIRED:-running}" \
"${SVC_WAS_RUNNING:-0}" <<'PY'
import json, os, sys
path, phase, op, fr, to, desired, running = sys.argv[1:]
doc = {
"op_id": op,
"from_rid": fr,
"to_rid": to,
"from_desired": desired,
"was_running": running == "1",
"phase": phase,
}
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".new"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False)
f.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
os.chmod(path, 0o600)
PY
svc_fault_check "$phase"
}
svc_journal_phase() {
[ -f "$ENV_DIR/deploy-journal.json" ] || return 0
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$ENV_DIR/deploy-journal.json"
}
# svc_release_switch RID:current 用 ln -s + mv -T 原子切换,bin/plugins 保持稳定链接。
svc_release_switch() {
local rid="$1"
local rel="$ENV_DIR/releases/$rid"
[ -d "$rel/bin" ] || { c_err "[$ENV_NAME] 版本目录缺少 bin:$rel"; return 1; }
ln -s "$rid" "$ENV_DIR/current.new"
mv -Tf "$ENV_DIR/current.new" "$ENV_DIR/current"
ln -sfn "current/bin" "$ENV_DIR/bin"
ln -sfn "current/plugins" "$ENV_DIR/plugins"
if [ -d "$rel/htdocs" ]; then
ln -sfn "current/htdocs" "$ENV_DIR/htdocs"
fi
}
svc_release_stage() { # SRC BIN PLUGINS → 打印 rid
local srcbin="$1" srcplug="$2"
local digest sha rid dest
digest="$(sha256sum "$srcbin" | awk '{print substr($1,1,12)}')"
sha="$(printf '%s' "${SRC_SHA:-inplace}" | cut -c1-12)"
rid="${SRC_KINDSRC:-inplace}-${sha}-${digest}"
dest="$ENV_DIR/releases/$rid"
if [ ! -d "$dest/bin" ]; then
rm -rf "${dest}.partial"
mkdir -p "${dest}.partial/bin" "${dest}.partial/plugins"
cp -f "$srcbin" "${dest}.partial/bin/agent-system" || return 1
chmod 755 "${dest}.partial/bin/agent-system"
if [ -d "$srcplug" ]; then
cp -a "$srcplug/." "${dest}.partial/plugins/" || return 1
fi
python3 - "${dest}.partial/release.json" "$rid" "${SRC_LABEL:-}" "$digest" <<'PY'
import json, sys
path, rid, label, digest = sys.argv[1:]
with open(path, "w", encoding="utf-8") as f:
json.dump({"rid": rid, "source": label, "bin_digest12": digest}, f)
f.write("\n")
PY
mv "${dest}.partial" "$dest"
fi
printf '%s' "$rid"
}
svc_release_rollback() {
local from
from="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("from_rid",""))' "$ENV_DIR/deploy-journal.json" 2>/dev/null || true)"
svc_journal_write rolling_back || true
if [ -z "$from" ] || [ ! -d "$ENV_DIR/releases/$from/bin" ]; then
svc_journal_write aborted || true
c_err "[$ENV_NAME] 无法回滚:没有上一版本"
return 3
fi
svc_release_switch "$from" || { svc_journal_write aborted || true; return 3; }
local was
was="$(python3 -c 'import json,sys; print("1" if json.load(open(sys.argv[1])).get("was_running") else "0")' "$ENV_DIR/deploy-journal.json")"
if [ "$was" = 1 ]; then
if agent_do_start; then
svc_journal_write rolled_back
return 0
fi
svc_journal_write aborted || true
c_err "[$ENV_NAME] 回滚后的旧版本也未能启动,期望态保持"
return 3
fi
svc_journal_write rolled_back
return 0
}
svc_reconcile_journal() {
[ -f "$ENV_DIR/deploy-journal.json" ] || return 0
local phase
phase="$(svc_journal_phase)"
case "$phase" in
""|committed|rolled_back|aborted) return 0 ;;
esac
c_warn "[$ENV_NAME] 发现未完成的切换(phase=$phase),回滚到上一版本"
svc_release_rollback
}
install_artifacts() { # SRCBIN SRCPLUGINS:写入不可变版本目录并原子切换 current。
local srcbin="$1" srcplug="$2"
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
if ! reclaim_own_port; then
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
return 1
fi
if [ -d "$ENV_BIN_DIR" ] && [ ! -L "$ENV_BIN_DIR" ] && [ -x "$ENV_BIN" ]; then
local old_digest old_rid
old_digest="$(sha256sum "$ENV_BIN" | awk '{print substr($1,1,12)}')"
old_rid="inplace-${old_digest}"
if [ ! -d "$ENV_DIR/releases/$old_rid/bin" ]; then
mkdir -p "$ENV_DIR/releases/$old_rid"
mv "$ENV_BIN_DIR" "$ENV_DIR/releases/$old_rid/bin"
if [ -d "$ENV_DIR/plugins" ] && [ ! -L "$ENV_DIR/plugins" ]; then
mv "$ENV_DIR/plugins" "$ENV_DIR/releases/$old_rid/plugins"
else
mkdir -p "$ENV_DIR/releases/$old_rid/plugins"
fi
fi
SVC_FROM_RID="$old_rid"
svc_release_switch "$old_rid"
elif [ -L "$ENV_DIR/current" ]; then
SVC_FROM_RID="$(readlink "$ENV_DIR/current")"
fi
local rid
rid="$(svc_release_stage "$srcbin" "$srcplug")" || return 1
SVC_TO_RID="$rid"
svc_journal_write switched || return 1
svc_release_switch "$rid" || return 1
c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(release $rid)"
return 0
}
# ---- U2:不可变版本目录(releases/)+ current 原子指针 + 切换 journal + 失败回退 ----
# 布局:releases/<id>/{bin/agent-system, plugins/, htdocs/, release.json};
# current → releases/<id>(相对符号链接,temp+rename 原子切换);deploy.journal
# 记录切换过程(staging→switched→done/rolledback/blocked),任意步骤中断后由
# svc_deploy_journal_resolve 判定并恢复实际状态。legacy 目录永不删除:旧进程
# cmdline 指向 legacy 路径,pidcheck 兼容到自然换版。
svc_release_cur() { [ -L "$ENV_DIR/current" ] && readlink "$ENV_DIR/current" || true; }
svc_release_cur_id() { local c; c="$(svc_release_cur)"; printf '%s' "${c##*/}"; }
# svc_release_adopt_legacy:legacy 布局(bin/ 直接落服务目录)→ 领养为首个
# release(幂等:已有 current 即跳过;无产物即跳过——全新部署无需领养)。
svc_release_adopt_legacy() {
[ -L "$ENV_DIR/current" ] && return 0
[ -x "$ENV_DIR/bin/agent-system" ] || return 0
# 注意:bash 5.3 set -u 下同一 local 行不得引用本行先声明变量(拆行)。
local id="legacy-$(date -u +%Y%m%dT%H%M%SZ)"
local dir="$ENV_DIR/releases/$id"
mkdir -p "$dir/bin" || return 1
cp -f "$ENV_DIR/bin/agent-system" "$dir/bin/agent-system" || return 1
if [ -d "$ENV_DIR/plugins" ]; then
cp -r "$ENV_DIR/plugins" "$dir/plugins" || return 1
else
mkdir -p "$dir/plugins"
fi
[ -d "$ENV_DIR/htdocs" ] && cp -r "$ENV_DIR/htdocs" "$dir/htdocs" || true
printf '{"id":"%s","source":"legacy","created":"%s"}\n' "$id" "$(date -u +%FT%TZ)" > "$dir/release.json"
ln -s "releases/$id" "$ENV_DIR/.current.new" && mv -Tf "$ENV_DIR/.current.new" "$ENV_DIR/current" || {
rm -f "$ENV_DIR/.current.new"; return 1; }
c_info "[$ENV_NAME] 领养 legacy 制品为首个 release:releases/$id"
}
# svc_release_id:源标签 + 时钟 + 调用进程身份,同源同秒部署仍使用不同目录。
svc_release_id() {
local label
label="$(printf '%s' "${SRC_LABEL:-manual}" | tr '@' '-' | tr -cd 'a-zA-Z0-9._-' | cut -c1-40)"
printf '%s-%s-%s' "$label" "$(date -u +%Y%m%dT%H%M%SZ)" "$BASHPID"
}
# svc_release_stage ID SRCBIN SRCPLUGINS [HTDOCS]:候选产物入不可变版本目录。
# stdout 回显 release 目录(供门禁/探测引用)。
svc_release_stage() {
local id="$1" srcbin="$2" srcplug="$3" htdocs="${4:-}"
local dir="$ENV_DIR/releases/$id"
mkdir -p "$ENV_DIR/releases" || return 1
# 排他创建:重复 id 必须拒绝,不能覆盖仍用于运行或回退的历史制品。
mkdir "$dir" || { c_err "[$ENV_NAME] release 目录已存在或无法创建:$dir"; return 1; }
mkdir "$dir/bin" || return 1
cp -f "$srcbin" "$dir/bin/agent-system" || return 1
if [ -d "$srcplug" ]; then
cp -r "$srcplug" "$dir/plugins" || return 1
else
mkdir -p "$dir/plugins"
fi
if [ -n "$htdocs" ] && [ -d "$htdocs" ]; then
cp -r "$htdocs" "$dir/htdocs" || return 1
fi
printf '{"id":"%s","source":"%s","created":"%s"}\n' \
"$id" "${SRC_LABEL:-}" "$(date -u +%FT%TZ)" > "$dir/release.json"
printf '%s' "$dir"
}
# svc_release_switch ID:原子切换 current(temp 符号链接 + rename)。
svc_release_switch() {
ln -s "releases/$1" "$ENV_DIR/.current.new" || return 1
mv -Tf "$ENV_DIR/.current.new" "$ENV_DIR/current" || {
rm -f "$ENV_DIR/.current.new"; return 1; }
}
# svc_release_gc [KEEP]:裁剪历史 release(保留最近 KEEP 个,缺省 5;current 与
# legacy 永不删除)。失败静默——目录非关键路径。
svc_release_gc() {
local keep="${1:-5}" cur id dirs=()
[ -d "$ENV_DIR/releases" ] || return 0
cur="$(svc_release_cur_id)"
while IFS= read -r d; do dirs+=("$(basename "$d")"); done \
< <(ls -1dt "$ENV_DIR/releases"/*/ 2>/dev/null)
local n=${#dirs[@]} i rmv
for ((i = keep; i < n; i++)); do
rmv="${dirs[$i]}"
[ "$rmv" = "$cur" ] && continue
case "$rmv" in legacy-*) continue ;; esac
rm -rf "$ENV_DIR/releases/$rmv"
done
return 0
}
# ---- 切换 journal(deploy.journal):python3 原子写;缺 python3 时降级为无 journal ----
svc_journal_path() { printf '%s' "$ENV_DIR/deploy.journal"; }
svc_journal_write() { # PHASE [k=v ...]
local phase="$1"; shift
command -v python3 >/dev/null 2>&1 || return 0
python3 - "$phase" "$(svc_journal_path)" "$@" <<'PYJ'
import json, os, sys, time
phase, out, args = sys.argv[1], sys.argv[2], sys.argv[3:]
j = {"phase": phase, "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
for kv in args:
if "=" in kv:
k, v = kv.split("=", 1)
j[k] = v
tmp = out + ".new"
with open(tmp, "w") as f:
f.write(json.dumps(j, ensure_ascii=False) + "\n")
os.replace(tmp, out)
PYJ
svc_fault_check "$phase"
}
svc_journal_get() { # KEY → 值(journal 缺失/损坏 → 空)
local f; f="$(svc_journal_path)"
[ -f "$f" ] || return 0
python3 -c 'import json,sys
try:
d = json.load(open(sys.argv[1])); v = d.get(sys.argv[2], "")
print(v if isinstance(v, str) else json.dumps(v))
except Exception:
pass' "$f" "$1" 2>/dev/null
}
svc_journal_phase() { svc_journal_get phase; }
svc_journal_clear() { rm -f "$(svc_journal_path)"; }
# svc_db_schema_version:SQLite 档当前 schema(schema_migrations 最大 id;
# 无库/无表/pg → 空)。只读连接,绝不写库。
svc_db_schema_version() {
local db="$ENV_DATA/agent-system.db"
[ -f "$db" ] || return 0
python3 -c 'import sqlite3,sys
try:
con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)
print(con.execute("select max(id) from schema_migrations").fetchone()[0])
con.close()
except Exception:
pass' "$db" 2>/dev/null
}
# svc_release_schema_supported ID:release.json 的 schema_at_commit(无则空)。
svc_release_schema_supported() {
local f="$ENV_DIR/releases/$1/release.json"
[ -f "$f" ] || return 0
python3 -c 'import json,sys
try:
print(json.load(open(sys.argv[1])).get("schema_at_commit") or "")
except Exception:
pass' "$f" 2>/dev/null
}
# svc_release_commit_schema ID:成功提交时把当前库 schema 记入 release.json
# (回退时据此判定旧制品可否运行;pg/无库静默跳过)。
svc_release_commit_schema() {
local f="$ENV_DIR/releases/$1/release.json" v
v="$(svc_db_schema_version)"
[ -n "$v" ] || return 0
python3 -c 'import json,sys
try:
d = json.load(open(sys.argv[1])); d["schema_at_commit"] = sys.argv[2]
open(sys.argv[1], "w").write(json.dumps(d, ensure_ascii=False) + "\n")
except Exception:
pass' "$f" "$v" 2>/dev/null || true
}
# svc_rollback_deploy REASON:切换失败 → 回退上一完整版本并恢复期望运行态。
# schema 阻断(库 schema 超过上一 release 提交时点)不自动回退启动、绝不触碰
# 业务数据库——登记 blocked 待受控迁移(U2 约束:不能靠覆盖数据库「回滚」)。
svc_rollback_deploy() {
local reason="$1"
local prev new desired pid cur_schema prev_schema
prev="$(svc_journal_get prev)"
new="$(svc_journal_get new)"
desired="$(svc_journal_get desired)"
pid="$(read_pid)"
if [ -n "$pid" ] && pid_alive "$pid"; then
c_warn "[$ENV_NAME] 回退前停止新制品进程 pid=$pid"
proc_stop "$pid" || true
rm -f "$ENV_PID"
fi
if [ -z "$prev" ] || [ ! -d "$ENV_DIR/releases/$prev" ]; then
c_err "[$ENV_NAME] 无可回退版本(prev 缺失,$reason)——保持停止;清理:rm 前先取证"
svc_journal_write blocked reason="无 prev release:$reason"
hist_event deploy fail "$pid" "回退无 prev($reason)"
return 1
fi
cur_schema="$(svc_db_schema_version)"
prev_schema="$(svc_release_schema_supported "$prev")"
if [ -n "$cur_schema" ] && [ -n "$prev_schema" ] \
&& [ "$cur_schema" -gt "$prev_schema" ] 2>/dev/null; then
c_err "[$ENV_NAME] schema 已前进(库 v$cur_schema > 上一 release 提交 v$prev_schema):旧制品无法运行,禁止自动回退"
c_err " 不触碰业务数据库。处理:deploy 换可运行制品(受控迁移后),或人工处置;登记 blocked"
svc_journal_write blocked reason="schema v$cur_schema > prev v$prev_schema($reason)"
hist_event deploy fail "$pid" "回退阻断:schema 前进($reason)"
return 1
fi
svc_release_switch "$prev" || {
svc_journal_write blocked reason="指针回退失败:$reason"
return 1
}
c_ok "[$ENV_NAME] 已回退上一版本:releases/$prev($reason)"
# 指针已切回:重解析产物路径到回退后的 release。
ENV_BIN_DIR="$ENV_DIR/current/bin"
ENV_PLUGINS="$ENV_DIR/current/plugins"
ENV_BIN="$ENV_BIN_DIR/agent-system"
hist_event deploy rollback "$pid" "new=${new:-?} prev=$prev($reason)"
svc_journal_write rolledback "prev=$prev" "new=${new:-}" "reason=$reason"
if [ "$desired" = "running" ]; then
c_info "[$ENV_NAME] 按原期望态恢复运行(上一版本)"
# 登记一致性:清掉新部署的源标签——回退后的 record_state 须回落到 svc.env
# 里上一版的 source(否则实际跑旧制品、登记却写新标签)。
SRC_LABEL=""
SVC_SOURCE="$(svc_get source)"
agent_do_start || { svc_journal_clear; return 1; }
fi
svc_journal_clear
return 0
}
# svc_deploy_journal_resolve [ACTION]:生命周期入口处理中断的切换。
# switched/verifying:指针已切但未经完整验证 → 回退上一完整版本(无 prev 的
# 首次部署中断 → 保持停止)。blocked:拒绝除 deploy 外的动作(显式换制品即
# 解除路径)。staging:未切换,清记录(孤儿 release 目录无害,随 gc 裁剪)。
svc_deploy_journal_resolve() {
local action="${1:-}" phase
[ -f "$(svc_journal_path)" ] || return 0
phase="$(svc_journal_phase)"
case "$phase" in
""|done|rolledback)
svc_journal_clear; return 0 ;;
staging)
c_warn "[$ENV_NAME] 发现中断的暂存(staging,未切换)——忽略"
svc_journal_clear; return 0 ;;
blocked)
if [ "$action" = "deploy" ]; then
c_warn "[$ENV_NAME] 存在 blocked 切换记录,本次 deploy 尝试跨越(换可运行制品)"
return 0
fi
c_err "[$ENV_NAME] 切换被阻断($(svc_journal_get reason)):先 deploy 换可运行制品,或受控迁移后再操作"
return 1 ;;
switched|verifying)
c_warn "[$ENV_NAME] 发现中断的切换($phase,new=$(svc_journal_get new))——回退上一完整版本"
svc_rollback_deploy "中断的切换(journal=$phase)" || return 1
return 0 ;;
esac
return 0
}
# svc_deploy_recycle_wt:deploy 各失败/成功出口统一回收构建 worktree(尽力而为)。
svc_deploy_recycle_wt() {
case "${SRC_KINDSRC:-}" in
dev) [ -n "${wt:-}" ] && recycle_worktree "$SRC_SHA" ;;
rdm) [ -n "${wt:-}" ] && recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR" ;;
esac
return 0
}
# 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(源:独立部署源仓 $SRC_DIR,不触碰当前工作区)"
git -C "$SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
fi
c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)"
# stdout 只留给最后的 worktree 路径(调用方 $() 捕获)——子模块/构建输出全部走 stderr。
git -C "$wt" submodule update --init --recursive >&2 || {
c_err "[$ENV_NAME] 子模块物化失败(网络或对象缺失;重试会自动 fetch 部署源仓)"
return 1
}
c_info "[$ENV_NAME] 构建(make $target:npm ci + 构建,需网络)"
make -C "$wt" "$target" >&2 || return 1
echo "$wt"
}
# recycle_worktree SHA [REPO]:回收构建 worktree(尽力而为)。REPO 缺省为 Dev 源仓。
recycle_worktree() {
local repo="${2:-$SRC_DIR}"
local wt="$RUNTIME/.worktrees/$1"
c_info "[$ENV_NAME] 回收构建 worktree"
git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 \
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $repo worktree remove --force $wt"
}
# materialize_rdm_commit FULL SHA:RDM 源的隔离 worktree 物化+构建;stdout 只回显
# worktree 路径。构建 = make build(纯 Go:pins/ 子模块经 go.mod replace 就位后无需
# npm 与网络);产物固定为 bin/agent-system-rdm(缺失即失败,绝不装半成品)。
materialize_rdm_commit() {
local full="$1" sha="$2"
local wt="$RUNTIME/.worktrees/rdm-$sha"
if [ -d "$wt" ]; then
c_info "[$ENV_NAME] 复用已有构建 worktree:$wt"
else
c_info "[$ENV_NAME] 物化 RDM 提交 $sha → 隔离 worktree(源:独立 RDM 源仓 $RDM_SRC_DIR,不触碰当前工作区)"
git -C "$RDM_SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
fi
c_info "[$ENV_NAME] 物化基座 pins(git submodule update --init --recursive;缺对象时需网络)"
git -C "$wt" submodule update --init --recursive >&2 || {
c_err "[$ENV_NAME] pins 子模块物化失败(网络或对象缺失;重试会自动 fetch RDM 源仓)"
return 1
}
c_info "[$ENV_NAME] 构建(make build:纯 Go,无需 npm)"
make -C "$wt" build >&2 || return 1
[ -x "$wt/bin/agent-system-rdm" ] || {
c_err "[$ENV_NAME] 构建产物缺失:$wt/bin/agent-system-rdm(RDM Makefile 的 build 目标应产出该名称)"
return 1
}
echo "$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:账户 CLI(绑定实例存储;禁止回落默认 ./data)----
# 用法:scripts/svc.sh <env> user <create|list|...> [--owner …]
# 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing,
# 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。
agent_do_user() {
local a
for a in "$@"; do
case "$a" in
--data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*)
c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)"
return 2 ;;
esac
done
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; }
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
local mf_root
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
if [ -n "$mf_root" ]; then
ENV_DATA="$mf_root"
fi
fi
case "${ENV_DATA:-}" in
""|./data|data)
c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)"
return 1 ;;
esac
resolve_db_config || {
c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)"
return 1
}
if [ "$DB_MODE" = mock ]; then
c_err "[$ENV_NAME] mock 档不支持账户操作"
return 1
fi
if [ ! -x "${ENV_BIN:-}" ]; then
c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)"
return 1
fi
if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then
c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作"
return 1
fi
# 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。
if [ "$DB_MODE" != pg ]; then
if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then
c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)"
return 1
fi
fi
local bin_args=(--open-existing --data "$ENV_DATA")
if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then
bin_args+=(--dsn "$DB_DSN")
fi
c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)"
# 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
}
# ---- agent:部署与启动 ----
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
source_kind_prefix() {
case "$1" in
*@*) printf '%s' "${1%%@*}" ;;
*) printf '%s' "$1" ;;
esac
}
# require_allow_source_switch:同名 agent 换源种类(dev↔rdm↔release↔local)须显式确认;
# 同种类换 sha(含 update)不要求。与 service kind 冲突门禁正交。
require_allow_source_switch() {
local prev prev_kind new_kind
prev="$(svc_get source)"
[ -n "$prev" ] || return 0
prev_kind="$(source_kind_prefix "$prev")"
new_kind="$(source_kind_prefix "$SRC_LABEL")"
[ -n "$prev_kind" ] && [ -n "$new_kind" ] || return 0
[ "$prev_kind" = "$new_kind" ] && return 0
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
c_warn "[$ENV_NAME] 换源已确认:$prev → $SRC_LABEL(--allow-source-switch)"
return 0
fi
c_err "[$ENV_NAME] 换源种类 $prev_kind → $new_kind($prev → $SRC_LABEL)需显式确认"
c_err " 加 --allow-source-switch 后重试(会改变环境是否自带 UI)"
return 1
}
rdm_companion_name() { printf '%sweb' "$1"; }
# http_code URL:返回 HTTP 状态码(连接失败 → 000)。
http_code() {
curl -s -o /dev/null -m 2 -w '%{http_code}' "$1" 2>/dev/null || printf '000'
}
# rdm_workbench_missing NAME:rdm@* agent 且伴生未登记或未运行 → 0(真)。
rdm_workbench_missing() {
local name="$1" src cname st
src="$(env_kv_of "$name" source)"
case "$src" in rdm@*) ;; *) return 1 ;; esac
cname="$(rdm_companion_name "$name")"
[ -f "$RUNTIME/$cname/svc.env" ] || return 0
st="$(svc_pid_state "$cname")"
[ "${st%% *}" = alive ] && return 1
return 0
}
# deploy_rdm_companion_ui:子进程部署 ${ENV_NAME}web --kind static --dir htdocs。
deploy_rdm_companion_ui() {
local cname="$1" htdocs="$2"
if ! valid_env "$cname"; then
c_err "[$ENV_NAME] 伴生名非法:$cname(服务名 ≤16 字符;ui-missing)"
return 1
fi
if [ ! -d "$htdocs" ] || [ ! -f "$htdocs/index.html" ]; then
c_err "[$ENV_NAME] RDM 工作台缺失:$htdocs(ui-missing)"
return 1
fi
c_info "[$ENV_NAME] 自动部署伴生工作台:$cname --kind static --dir $htdocs"
if ! "$0" "$cname" deploy --kind static --dir "$htdocs"; then
c_err "[$ENV_NAME] 伴生工作台部署失败(ui-missing)"
return 1
fi
return 0
}
# assert_ui_ready:按源种类断言页面入口 HTTP 200(与进程探活分层)。
# rdm@* → 伴生 ${name}web;dev@*/local@*/release@* → agent 自身 GET /。
assert_ui_ready() {
local src="${SRC_LABEL:-$(svc_get source)}"
local code url host port addr cname probe
case "$src" in
rdm@*)
cname="$(rdm_companion_name "$ENV_NAME")"
addr="$(env_kv_of "$cname" addr)"
if [ -z "$addr" ]; then
c_err "[$ENV_NAME] UI 探活失败:伴生 $cname 未登记(ui-missing)"
return 1
fi
host="${addr%:*}"; port="${addr##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port/"
code="$(http_code "$url")"
if [ "$code" != 200 ]; then
c_err "[$ENV_NAME] UI 探活失败:伴生 $url → HTTP $code(期望 200;ui-missing)"
return 1
fi
c_ok "[$ENV_NAME] UI 就绪:伴生 $cname → $url"
return 0
;;
dev@*|local@*|release@*)
probe="${ENV_ADDR}"
case "$(svc_get exec)" in *--addr\ *)
probe="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
esac
[ -n "$probe" ] || probe="$ENV_ADDR"
host="${probe%:*}"; port="${probe##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port/"
code="$(http_code "$url")"
if [ "$code" != 200 ]; then
c_err "[$ENV_NAME] UI 探活失败:$url → HTTP $code(期望 200;ui-missing)"
return 1
fi
c_ok "[$ENV_NAME] UI 就绪:$url"
return 0
;;
*)
return 0
;;
esac
}
agent_do_deploy() {
resolve_source
if ! require_allow_source_switch; then
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
return 1
fi
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
if ! agent_static_preflight; then
hist_event deploy fail "$(read_pid)" "预检拒绝:数据库配置/配置文件无效(登记未变更)"
return 1
fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
# U2(不可变版本目录 + 失败回退):构建 → 暂存 release → 门禁(身份/环境 DSN)
# → 停旧 → current 原子切换 → 启动与 UI 验证 → 成功提交(schema 记录 + journal
# 清理 + release 裁剪);任一步失败回退上一完整版本并恢复期望运行态
# (svc_rollback_deploy;库 schema 已前进时阻断,绝不覆盖业务数据库)。
local wt relid staged srcbin srcplug srchtdocs="" prev_id desired
svc_release_adopt_legacy \
|| { hist_event deploy fail "$(read_pid)" "legacy 领养失败($SRC_LABEL)"; return 1; }
case "$SRC_KINDSRC" in
local)
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
make -C "$ROOT" build || {
c_err "[$ENV_NAME] 构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"
return 1
}
srcbin="$BIN"; srcplug="$ROOT/plugins" ;;
dev)
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins" ;;
release)
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
if [ ! -f "$relbin" ]; then
c_err "[$ENV_NAME] Releases 仓无制品:$relbin"
c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)"
hist_event deploy fail "$(read_pid)" "制品缺失($SRC_LABEL)"
return 1
fi
local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" want have
if [ -f "$mf" ]; then
want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)"
have="$(sha256sum "$relbin" | cut -d' ' -f1)"
if [ -n "$want" ] && [ "$want" != "$have" ]; then
c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($relbin)——拒绝部署"
hist_event deploy fail "$(read_pid)" "sha256 校验失败($SRC_LABEL)"
return 1
fi
[ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)"
else
c_warn "[$ENV_NAME] 无 manifest 可校验:$mf"
fi
srcbin="$relbin"; srcplug="$REL_ROOT/build/$REL_VERSION/plugins" ;;
rdm)
wt="$(materialize_rdm_commit "$SRC_FULL" "$SRC_SHA")" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
# RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集。
mkdir -p "$wt/.svc-empty-plugins"
srcbin="$wt/bin/agent-system-rdm"; srcplug="$wt/.svc-empty-plugins"
[ -d "$wt/web" ] && srchtdocs="$wt/web" ;;
esac
relid="$(svc_release_id)"
staged="$(svc_release_stage "$relid" "$srcbin" "$srcplug" ${srchtdocs:+"$srchtdocs"})" \
|| { hist_event deploy fail "$(read_pid)" "产物暂存失败($SRC_LABEL)"; svc_deploy_recycle_wt; return 1; }
if ! svc_deploy_identity_gate "$staged/bin/agent-system"; then
hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"
rm -rf "$staged"
svc_deploy_recycle_wt
return 1
fi
# 新产物承接能力复核(环境 DSN 交付;候选上、停服前):不支持即失败——绑定
# 保持可恢复,不降级启动。
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && ! bin_env_dsn_supported "$staged/bin/agent-system"; then
c_err "[$ENV_NAME] 新产物不消费环境变量 DSN(AGENT_SYSTEM_DSN)——部署中止;旧绑定保留在 svc.env"
c_err " 恢复:换部署版本,或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
hist_event deploy fail "$(read_pid)" "新产物不支持环境 DSN($SRC_LABEL)"
rm -rf "$staged"
return 1
fi
prev_id="$(svc_release_cur_id)"
desired="stopped"; pid_alive "$(read_pid)" 2>/dev/null && desired="running"
svc_journal_write staging "new=$relid" "prev=$prev_id" "desired=$desired"
do_stop_quiet
svc_release_switch "$relid" || {
svc_journal_write blocked reason="current 指针切换失败"
hist_event deploy fail "$(read_pid)" "current 指针切换失败($SRC_LABEL)"
return 1
}
svc_journal_write switched "new=$relid" "prev=$prev_id" "desired=$desired"
c_ok "[$ENV_NAME] 已切换 release:releases/$relid(prev:${prev_id:-无})"
# 指针已切:重解析产物路径(env_config 解析时 current 可能尚不存在)。
ENV_BIN_DIR="$ENV_DIR/current/bin"
ENV_PLUGINS="$ENV_DIR/current/plugins"
ENV_BIN="$ENV_BIN_DIR/agent-system"
if ! agent_do_start; then
svc_rollback_deploy "新制品启动失败($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "启动失败已回退($SRC_LABEL)"
return 1
fi
case "$SRC_KINDSRC" in
rdm)
# 顶层 htdocs → current/htdocs 符号链接:伴生经它伺服,指针切换自动换内容
# (legacy 实体目录内容已随领养保入 release,替换不丢)。
if [ -n "$srchtdocs" ]; then
if [ ! -L "$ENV_DIR/htdocs" ]; then
rm -rf "$ENV_DIR/htdocs"
ln -s current/htdocs "$ENV_DIR/htdocs"
fi
c_ok "[$ENV_NAME] RDM 工作台就位:releases/$relid/htdocs → $ENV_DIR/htdocs(伴生 $(rdm_companion_name "$ENV_NAME"))"
if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then
svc_rollback_deploy "RDM 伴生工作台部署失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败,已回退)"
return 1
fi
else
svc_rollback_deploy "RDM 源无 web/ 工作台(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(源无 web/,已回退)"
return 1
fi
;;
esac
if ! assert_ui_ready; then
svc_rollback_deploy "UI 探活失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(已回退)"
return 1
fi
# 首次部署:agent_do_start 的 record_state 建立 svc.env 后,同轮补走 manifest
# 领养(ensure 身份行)——否则身份治理要到下一次生命周期动作才生效
#(U3 全轨实测:全新 rdm 部署当轮无 manifest)。
if ! svc_manifest_migrate; then
svc_rollback_deploy "manifest 领养失败($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "manifest 领养失败(已回退)"
return 1
fi
svc_release_commit_schema "$relid"
svc_journal_clear
svc_release_gc
svc_deploy_recycle_wt
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL release=$relid"
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;独立部署源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$SRC_DIR"
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 ;;
rdm)
printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR"
printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN"
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
esac
return 0
}
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。
# 认证开启(PG 档默认)时 PUT /api/settings/user 同样要求凭证:自动铸一把临时
# API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员(回落
# 首个账号),--seed-owner / SVC_SEED_OWNER 可指定。铸 key 失败维持无头 PUT(旧行为)。
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
)"
# 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。
# apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走
# AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。
local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}"
local -a seed_hdr=() seed_dbf=()
if db_auth_enabled; then
if [ "$DB_MODE" = pg ]; then seed_dbf=(--dsn "$DB_DSN"); else seed_dbf=(--data "$ENV_DATA"); fi
if [ -z "$seed_owner" ]; then
seed_owner="$("$ENV_BIN" user list ${seed_dbf[@]+"${seed_dbf[@]}"} 2>/dev/null | python3 -c 'import json, sys
try:
users = json.load(sys.stdin)
except Exception:
sys.exit(0)
for u in users:
if u.get("isAdmin"):
print(u["owner"]); break
else:
for u in users:
print(u["owner"]); break' 2>/dev/null || true)"
fi
if [ -n "$seed_owner" ]; then
seed_key="$("$ENV_BIN" apikey create ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed 2>/dev/null || true)"
[ -n "$seed_key" ] && seed_hdr=(-H "Authorization: Bearer $seed_key")
fi
fi
local resp
resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' ${seed_hdr[@]+"${seed_hdr[@]}"} -d "$payload")"
# 用后即回收;失败只提示吊销途径,不回显任何密钥材料。
if [ -n "$seed_key" ]; then
"$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed >/dev/null 2>&1 \
|| c_warn "[$ENV_NAME] 临时播种密钥回收失败:请手动吊销 owner=$seed_owner name=svc-seed"
fi
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:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
fi
}
# dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。
# 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。
dsn_from_dbref() {
local dbref="$1" inst tdb taddr tuser tpass
inst="${dbref%%/*}"; tdb="${dbref#*/}"
{ [ -n "$inst" ] && [ -n "$tdb" ] && [ "$inst" != "$dbref" ]; } || return 1
[ -f "$RUNTIME/$inst/svc.env" ] || return 1
[ "$(svc_kind "$inst")" = postgres ] || return 1
taddr="$(env_kv_of "$inst" addr)"
tuser="$(env_kv_of "$inst" user)"
tpass="$(env_kv_of "$inst" password)"
{ [ -n "$taddr" ] && [ -n "$tuser" ] && [ -n "$tpass" ]; } || return 1
printf 'postgresql://%s:%s@%s:%s/%s' "$tuser" "$tpass" "${taddr%:*}" "${taddr##*:}" "$tdb"
}
# addr_is_loopback HOST:回环写法白名单判定(127.x / ::1 / localhost)。
# 只服务启动预检的快速拦截:其余形态一律按非回环处理,权威判定在二进制 listen guard。
addr_is_loopback() {
case "$1" in
localhost|127.*|::1|[::1]) return 0 ;;
*) return 1 ;;
esac
}
# dsn_target DSN → host:port/db(配置一致性比较用;解析失败回空串)。
dsn_target() {
local t="${1%%\?*}"
case "$t" in *://*) t="${t#*://}" ;; esac
case "$t" in *@*) t="${t#*@}" ;; esac
local hostport="${t%%/*}" db="${t#*/}"
[ "$db" = "$t" ] && db=""
printf '%s/%s' "$hostport" "$db"
}
# dsn_ref_match DSN:DSN 的 host:port/db 命中已登记 postgres 实例时回显 <inst>/<db>(无匹配回空)。
dsn_ref_match() {
local tgt pg paddr
tgt="$(dsn_target "$1")"
[ -n "$tgt" ] || return 0
while IFS= read -r pg; do
[ "$(svc_kind "$pg")" = postgres ] || continue
paddr="$(env_kv_of "$pg" addr)"
[ -n "$paddr" ] || continue
if [ "$tgt" = "$paddr/${tgt#*/}" ]; then
printf '%s/%s' "$pg" "${tgt#*/}"
return 0
fi
done < <(registered_names)
return 0
}
# auth_pick CLI ENV PERSISTED:生效认证方式(缺省 auto)。
auth_pick() {
case "${1:-}" in ?*) printf '%s' "$1"; return ;; esac
case "${2:-}" in ?*) printf '%s' "$2"; return ;; esac
case "${3:-}" in ?*) printf '%s' "$3"; return ;; esac
printf auto
}
# migrate_legacy_db_config:存量 exec= 的 --dsn 迁移为可恢复配置(dbref=/dsn=)并从 exec
# 剥离——此后 exec= 只存非敏感启动参数,DSN 经环境变量传递(G1-7)。幂等:再次运行时
# exec= 已无 --dsn,直接返回。命中已登记实例 → dbref(密码经实例登记存放,不重复保存);
# 否则 → 受保护 dsn= 字段。已有 dbref= 时 exec 的 DSN 只作历史残留处理,直接剥离。
migrate_legacy_db_config() {
local exec_old dsn new_exec ref
[ -f "$ENV_DIR/svc.env" ] || return 0
exec_old="$(svc_get exec)"
case "$exec_old" in *--dsn\ *) ;; *) return 0 ;; esac
dsn="$(printf '%s' "$exec_old" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
if [ -n "$(svc_get dbref)" ]; then
c_info "[$ENV_NAME] 存量 exec DSN 与 dbref=$(svc_get dbref) 并存,已剥离 exec 残留(绑定不受影响)"
return 0
fi
ref="$(dsn_ref_match "$dsn")"
if [ -n "$ref" ]; then
svc_set_key dbref "$ref"
svc_set_key db pg
c_info "[$ENV_NAME] 存量 exec DSN 已迁移为托管绑定 dbref=$ref(DSN 改由实例登记重建)"
else
svc_set_key dsn "$dsn"
svc_set_key db pg
c_info "[$ENV_NAME] 存量 exec DSN 已迁移至受保护 dsn= 字段(未命中本机实例登记)"
fi
return 0
}
# ---- 数据库配置统一解析(start/restart/deploy/update/db 共用单点)----
# 核心约束:修改端口、监听地址或部署版本不改变数据库选择;绑定失效或配置冲突时
# 拒绝启动(返回 1),绝不静默回退内嵌 SQLite。
# 结果全局变量(入口全部重置——批量 update 逐服务重解析,不串用):
# DB_MODE pg | sqlite | mock
# DB_SOURCE cli | env | dbref | dsn | conffile | db | mock | none
# DB_REF <inst>/<db>(命中/保持托管绑定时;否则空)
# DB_DSN 有效连接串(sqlite/mock 为空;conffile 来源=文件内值,仅用于判定不外传)
# DB_AUTH on | off | auto(生效值,对齐二进制 AuthEnabled 口径)
# DB_DSN_SAVE 需持久化到 dsn= 的外部直连 DSN(仅 cli/dsn 来源且未命中实例)
resolve_db_config() {
DB_MODE="sqlite"; DB_SOURCE="none"; DB_REF=""; DB_DSN=""; DB_AUTH="auto"; DB_DSN_SAVE=""
migrate_legacy_db_config
local env_auth env_mock env_dsn
env_auth="$(env_var "$ENV_NAME" AUTH)"
env_mock="$(env_var "$ENV_NAME" MOCK)"
env_dsn="$(env_var "$ENV_NAME" DSN)"
local p_dbref p_dsn p_db p_conf p_exec p_auth
p_dbref="$(svc_get dbref)"
p_dsn="$(svc_get dsn)"
p_db="$(svc_get db)"
p_conf="$(svc_get conffile)"
p_exec="$(svc_get exec)"
p_auth="$(printf '%s' "$p_exec" | sed -n 's/.*--auth \([^ ]*\).*/\1/p')"
[ -n "$p_auth" ] || p_auth="$(svc_get auth)"
DB_AUTH="$(auth_pick "$CLI_AUTH" "$env_auth" "$p_auth")"
case "$DB_AUTH" in on|off|auto) ;; *)
c_err "[$ENV_NAME] 拒绝启动:无效 auth 值「$DB_AUTH」(可用 on/off/auto)"
return 1 ;;
esac
local mock=0
{ [ "$CLI_MOCK" = 1 ] || [ "$env_mock" = "1" ]; } && mock=1
[ "$mock" = 0 ] && case "$p_exec" in *--mock*) mock=1 ;; esac
# conffile 的 dsn 键(二进制自行读文件,连接串不经环境重复传递)。
local cf_dsn=""
if [ -n "$p_conf" ] && [ -f "$p_conf" ]; then
cf_dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$p_conf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')"
fi
# mock 档与数据库配置共存 = 冲突,拒绝(行为要明确,不隐式降级)。
if [ "$mock" = 1 ]; then
if [ -n "$p_dbref" ] || [ -n "$p_dsn" ] || [ -n "$CLI_DSN" ] || { [ -n "$env_dsn" ] && [ "$p_db" != "sqlite" ]; }; then
c_err "[$ENV_NAME] 拒绝启动:mock 档与数据库配置冲突(dbref=${p_dbref:-无}、dsn=${p_dsn:+已设、}CLI/环境 DSN=${CLI_DSN:+已设}${env_dsn:+已设})"
c_err " 先解绑(scripts/svc.sh $ENV_NAME db --clear)再用 mock,或去掉 --mock / SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_MOCK"
return 1
fi
DB_MODE=mock; DB_SOURCE=mock
return 0
fi
# 显式解绑(db=sqlite):残留 SVC_*_DSN 不再悄悄重新选库;CLI 显式 --dsn 仍可覆盖。
if [ -n "$env_dsn" ] && [ "$p_db" = "sqlite" ] && [ -z "$CLI_DSN" ]; then
c_info "[$ENV_NAME] 已显式解绑(db=sqlite),忽略环境层 SVC_*_DSN 残留(显式 --dsn 或重新绑定可覆盖)"
env_dsn=""
fi
# 有效 DSN 选择:CLI > 环境 > 持久化(dbref / dsn) > conffile > 无(SQLite)。
local eff_dsn="" eff_src="" bt
if [ -n "$CLI_DSN" ]; then
eff_dsn="$CLI_DSN"; eff_src=cli
elif [ -n "$env_dsn" ]; then
eff_dsn="$env_dsn"; eff_src=env
elif [ -n "$p_dbref" ] && [ -n "$p_dsn" ]; then
bt="$(dsn_from_dbref "$p_dbref" 2>/dev/null || true)"
if [ -n "$bt" ] && [ "$(dsn_target "$bt")" = "$(dsn_target "$p_dsn")" ]; then
eff_dsn="$p_dsn"; eff_src=dsn
else
c_err "[$ENV_NAME] 拒绝启动:配置冲突——dbref=$p_dbref 与 dsn= 指向不同数据库,不能静默取其一"
c_err " 保留其一:重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 后重新直连"
return 1
fi
elif [ -n "$p_dbref" ]; then
if ! eff_dsn="$(dsn_from_dbref "$p_dbref")"; then
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无法重建 DSN(实例未登记 / 类型错误 / addr·user·password 登记不全)"
c_err " 恢复实例登记或重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>);不回退内嵌 SQLite"
return 1
fi
eff_src=dbref
elif [ -n "$p_dsn" ]; then
eff_dsn="$p_dsn"; eff_src=dsn
elif [ -n "$cf_dsn" ]; then
eff_dsn="$cf_dsn"; eff_src=conffile
fi
# 直接 DSN(CLI/环境/持久化)与 dbref 并存:目标库必须一致,否则配置冲突。
if [ -n "$eff_dsn" ] && [ -n "$p_dbref" ] && [ "$eff_src" != dbref ]; then
if ! bt="$(dsn_from_dbref "$p_dbref")"; then
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无效(无法重建 DSN)且存在直接 DSN——先修复或解除绑定"
return 1
fi
if [ "$(dsn_target "$bt")" != "$(dsn_target "$eff_dsn")" ]; then
c_err "[$ENV_NAME] 拒绝启动:直接 DSN($(mask_dsn "$eff_dsn"))与 dbref=$p_dbref 指向不同数据库,不能静默取其一"
return 1
fi
fi
# conffile 一致性:文件内 dsn 与有效来源并存时必须同库。
if [ -n "$cf_dsn" ] && [ "$eff_src" != conffile ] && [ -n "$eff_dsn" ] \
&& [ "$(dsn_target "$cf_dsn")" != "$(dsn_target "$eff_dsn")" ]; then
c_err "[$ENV_NAME] 拒绝启动:配置文件 dsn($(mask_dsn "$cf_dsn"))与数据库绑定指向不同库——先对齐(改文件或换绑定)"
return 1
fi
# 已选 PostgreSQL 但拿不到任何有效 DSN → 拒绝(不降级 SQLite)。
if [ -z "$eff_dsn" ] && [ "$p_db" = "pg" ]; then
c_err "[$ENV_NAME] 拒绝启动:已登记 db=pg 但无可恢复 DSN(dbref/dsn/conffile 均缺失)"
c_err " 重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 显式回内嵌 SQLite"
return 1
fi
if [ -n "$eff_dsn" ]; then
DB_MODE=pg; DB_DSN="$eff_dsn"; DB_SOURCE="$eff_src"
local ref=""
case "$eff_src" in
cli|dsn)
if [ -n "$p_dbref" ]; then
DB_REF="$p_dbref" # 一致性已验证:保持既有托管绑定
else
ref="$(dsn_ref_match "$eff_dsn")"
if [ -n "$ref" ]; then DB_REF="$ref" # 直连命中本机实例 → 升级托管绑定(密码不重复保存)
else DB_DSN_SAVE="$eff_dsn"
fi
fi
;;
env)
[ -n "$p_dbref" ] && DB_REF="$p_dbref" # 环境层本身不持久化;已有绑定/直连保持
[ -n "$p_dsn" ] && DB_DSN_SAVE="$p_dsn" # 环境覆盖不丢已持久化的直连配置
;;
dbref)
DB_REF="$p_dbref"
;;
esac
else
DB_MODE=sqlite
[ "$p_db" = "sqlite" ] && DB_SOURCE=db # 显式解绑的选择跨重启保留
fi
return 0
}
# db_auth_enabled:启动预检的认证判定,对齐二进制 AuthEnabled:显式 on → 开;
# 显式 off → 关;auto → 存在有效 DSN(含环境与配置文件来源)即开。
db_auth_enabled() {
case "$DB_AUTH" in
on) return 0 ;;
off) return 1 ;;
*) [ -n "$DB_DSN" ] ;;
esac
}
# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE:启动预检整体判定(0=放行,
# 1=拒绝)。与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开
# (db_auth_enabled,依赖解析出的 DB_AUTH/DB_DSN)不拦;仅非回环 + 未放行 + 认证关
# 才拦。抽出纯判定便于治理测试覆盖(tests/test_svc_precheck.py)。
agent_listen_rejected() {
local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
addr="${addr:-$2}"
addr_is_loopback "${addr%:*}" && return 1
[ -n "$3" ] && return 1
db_auth_enabled && return 1
return 0
}
# bin_env_dsn_supported BIN:二进制是否消费环境层 AGENT_SYSTEM_DSN(经 config explain
# 探测:explain 对 DSN 值脱敏但用户名保留,探测标记用户即可)。旧产物不支持环境层时
# 返回 1——调用方拒绝以环境传递 DSN(防静默按内嵌 SQLite 运行)。
bin_env_dsn_supported() {
local out
out="$(AGENT_SYSTEM_DSN='postgresql://svcprobe:svcprobe@127.0.0.1:9/svcprobe' "$1" config explain 2>/dev/null || true)"
case "$out" in *svcprobe*) return 0 ;; esac
# 无 config explain 子命令的组合二进制(下游仓产物):静态证据兜底——ELF 二进制内
# 引用 AGENT_SYSTEM_DSN 即视为消费环境层 DSN(下游经基座 extension.Main 的 env
# 回退生效)。仅对 ELF 生效:脚本文本必然含该字符串字样,会把「不支持」误判为
# 「支持」(回归教训:test_svc 的 legacy 桩即脚本形态)。
[ "$(head -c 4 "$1" 2>/dev/null)" = $'\x7fELF' ] || return 1
command -v strings >/dev/null 2>&1 || return 1
strings "$1" 2>/dev/null | grep -q 'AGENT_SYSTEM_DSN' && return 0
return 1
}
# agent_static_preflight:停止旧进程 / 更换产物之前可静态检查的配置验证——数据库配置
# 可解析(含绑定有效性与冲突)、conffile 存在、现产物能承接环境 DSN。失败返回 1(输出
# 脱敏原因;除存量迁移外不写登记)。
agent_static_preflight() {
resolve_db_config || return 1
local cf
cf="$(svc_get conffile)"
if [ -n "$cf" ] && [ ! -f "$cf" ]; then
c_err "[$ENV_NAME] 拒绝启动:配置文件不存在:$cf(恢复文件或 config --clear)"
return 1
fi
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && [ -x "$ENV_BIN" ] \
&& ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝启动:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定非降级交付数据库配置"
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
return 1
fi
return 0
}
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[@<版本>]|rdm[@<sha>]]"
return 1
fi
# timeout 护栏:组合二进制若不识别子命令可能直接进入服务态阻塞探测(旧产物形态)。
SVC_VERSION="$(timeout 5 "$ENV_BIN" version 2>/dev/null || true)"
# 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 SQLite)。
# 2026-10-11 修复:复用 exec= 分支此前不查 dbref——G1-7 迁移把 --dsn 剥入环境后
# record_state 写回不含 DSN 的 exec=,重新绑定后的第二次裸 restart 即丢库。
if ! resolve_db_config; then
hist_event start fail "" "数据库配置解析拒绝(绑定失效/配置冲突)"
return 1
fi
SVC_DB_MODE="$DB_MODE"; SVC_DB_SOURCE="$DB_SOURCE"; SVC_DB_REF="$DB_REF"; SVC_DB_DSN_SAVE="$DB_DSN_SAVE"
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=(DSN 已由解析迁移出 exec)。
local exec_args
if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then
exec_args="$(svc_get exec)"
# U2:release 治理实例的持久化 exec 里 legacy plugins 路径跟随 current
#(指针切换后插件目录随 release 解析)。
[ -L "$ENV_DIR/current" ] && exec_args="${exec_args//$ENV_DIR\/plugins/$ENV_PLUGINS}"
c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")"
else
local args="" explicit_auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
if [ "$DB_MODE" = mock ]; then
args="--mock"
elif [ "$DB_MODE" = pg ] || [ -n "$explicit_auth" ] || [ "$ENV_NAME" != dev ]; then
args="--auth $DB_AUTH"
fi
# 保留既有配置文件引用:显式参数重建时不丢 --config(conffile= 持久化跨重启)。
local conf; conf="$(svc_get conffile)"
[ -n "$conf" ] && [ -f "$conf" ] && args="$args --config $conf"
local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")"
# adjust_args_for_bin 返回值已去首空格——比较前对 args 同样归一,否则每次带
# --auth/--config 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。
if [ "$adjusted" != "${args# }" ]; then
c_warn "[$ENV_NAME] 该产物不支持部分启动参数(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}"
fi
args="$adjusted"
exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_PLUGINS} $args $CLI_EXTRA"
exec_args="$(printf '%s' "$exec_args" | sed 's/[[:space:]]*$//')"
fi
SVC_EXEC="$exec_args"
SVC_PIDCHECK="$ENV_BIN"
local guard_env=() insecure hosts
insecure="$(env_var "$ENV_NAME" INSECURE_LISTEN)"
hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
[ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure")
[ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts")
# U1 身份校验:文件 marker + DB verify(不一致 fail-closed;ensure 只在领养与
# 显式 init,普通启动不补行——缺行即事件,见 svc_manifest_verify)。
svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; }
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
# 与二进制 AuthEnabled 口径一致(2026-10-11 修复:此前只看命令行 --dsn,
# DSN 迁入环境变量后 auto 被误判为未开认证;PR #317 同因,本方案为其超集)。
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure"; then
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
pf_addr="${pf_addr:-$ENV_ADDR}"
c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。"
c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db <pg实例>;"
c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。"
c_err " 登记未变更(svc.env 保留上次配置);二进制侧 listen guard 同样会拒绝。"
hist_event start fail "" "预检拒绝:非回环且未开认证"
return 1
fi
c_info "[$ENV_NAME] 启动:$ENV_BIN serve $(mask_dsn "$exec_args")"
if [ -n "$insecure" ] || [ -n "$hosts" ]; then
c_info "[$ENV_NAME] 监听守卫:insecureListen=${insecure:-0} allowedHosts=${hosts:-无}"
fi
# 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。
# 守卫变量走环境,不写进 exec=;旧二进制不认识这两个变量,会忽略。
# G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不进命令行——命令行对同机任何
# 进程可见(含智能体生成脚本的 /proc/<pid>/cmdline)。conffile 来源由二进制自行
# 读文件,不重复传递。环境层交付前再核一次产物能力(start 可能紧跟 deploy 换产物),
# 不支持即拒绝——绝不静默按内嵌 SQLite 运行。
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ]; then
if ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝启动:产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定交付数据库配置(不回退 SQLite)"
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
hist_event start fail "" "产物不支持环境 DSN,拒绝启动"
return 1
fi
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
# U1 I3:把服务实例身份交给 serve 二次门禁(与 --instance 进程标识无关)。
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
guard_env+=("AGENT_SYSTEM_INSTANCE_ID=$AGENT_SYSTEM_INSTANCE_ID")
guard_env+=("AGENT_SYSTEM_DBREF=${AGENT_SYSTEM_DBREF:-sqlite}")
fi
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
svc_write_serve_env
svc_launch_serve "$exec_args"
echo $! > "$ENV_PID"
# 健康探测:取 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
# 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置
#(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。
# fresh:库由本次启动建立后再 ensure,升为 established。失败则停掉进程,
# 不把半套身份登记成已启动。
if [ "$(svc_db_marker 2>/dev/null || true)" = fresh ]; then
if ! svc_db_identity_ensure; then
kill "$(read_pid)" 2>/dev/null || true
c_err "[$ENV_NAME] 启动后建立 DB 身份失败,已停进程"
hist_event start fail "$(read_pid)" "fresh ensure 失败"
return 1
fi
fi
record_state
svc_mark_autostart
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##*:}"
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
# 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。
case "${SVC_SOURCE:-}" in
rdm@*) ;;
*) seed_settings "$phost" "${probe##*:}" ;;
esac
return 0
fi
pid_alive "$(read_pid)" || {
c_err "[$ENV_NAME] 进程启动后即退出(登记未变更,svc.env 保留上次 exec=),查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)"
return 1
}
sleep 0.2
done
# 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。
record_state
svc_mark_autostart
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
;;
rdm)
c_err "[$ENV_NAME] rdm 源不支持 static 构建(RDM 工作台为 agent 环境内 htdocs/ 的零构建资产)"
c_err " 用法:先 <agent环境> deploy --from rdm,再 scripts/svc.sh <name> deploy --kind static --dir <agent环境>/htdocs"
hist_event deploy fail "$(read_pid)" "rdm 源不支持 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 覆写)。
# 同步写入 pidcheck,避免首启在 record_state 前 pid_alive 误回退到 agent-system。
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"
SVC_PIDCHECK="--directory $htdocs"
{
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"
printf 'pidcheck=%s\n' "$SVC_PIDCHECK"
} > "$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;独立部署源仓 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \
"$SRC_LABEL" "$SRC_FULL" "$SRC_DIR" "$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 9>&- &
echo $! > "$ENV_PID"
local probe_host="$host" code
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
for _ in $(seq 1 50); do
code="$(http_code "http://$probe_host:$port/")"
if [ "$code" = 200 ]; then
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
svc_mark_autostart
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] 进程启动后即退出(登记未变更,svc.env 保留上次配置),查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "进程即退"
return 1
}
sleep 0.2
done
record_state "htdocs=$htdocs"
svc_mark_autostart
c_err "[$ENV_NAME] 10s 健康检查未通过(末次 HTTP ${code:-?})"
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 "$@" ;;
backup) db_backup "$@" ;;
backups) db_backups "$@" ;;
restore) db_restore "$@" ;;
clone) db_clone "$@" ;;
copy) db_copy "$@" ;;
ls) db_ls "$@" ;;
set) cmd_set "$@" ;;
*) c_err "用法:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set <name> [...]
db create <name> [--port --user --password --db --auth-mode --tag]
db dsn <name> [--reveal] [--db X]
db backup <name> [--db X] [--out F] | db backups <name>
db restore <name> --file F [--db X] [--force]
db clone <src> <dst> [--port P] [--password PW] [--tag T] [--online]
db copy <src> <dst> [--db X] [--force]
db ls <name> | db set <name> [--port N --apply]"; 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)"
c_info "[$ENV_NAME] 服务绑定:scripts/svc.sh <agent名> db $ENV_NAME [--db X](运行中自动重启生效)"
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 9>&-; 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"
svc_mark_autostart
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"
svc_clear_autostart
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"
svc_clear_autostart
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"; svc_clear_autostart; 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
}
# ---- 数据库运维:换端口 / 备份 / 恢复 / 克隆 / 搬库 ----
# check_port_available PORT SKIP_NAME:显式选端口时校验(未被其他登记占用且未在监听)。
check_port_available() {
local p="$1" skip="$2"
if ports_recorded "$p" "$skip"; then
c_err "[$ENV_NAME] 端口 $p 已被其他服务登记占用"
return 1
fi
if port_listening "$p"; then
c_err "[$ENV_NAME] 端口 $p 正被监听(非本脚本登记的进程)"
return 1
fi
return 0
}
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键。manifest 治理下经 manifest
# 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。
svc_set_key() {
local key="$1" val="$2" rest tmp
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0
c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
tmp="$ENV_DIR/svc.env.tmp.$$"
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$ENV_DIR/svc.env"
return 0
}
# svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。manifest 治理下
# 同样经 manifest 整份提交。
svc_del_key() {
local key="$1"
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0
c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || return 0
{ grep -v "^$key=" "$ENV_DIR/svc.env" || true; } > "$ENV_DIR/svc.env.tmp"
mv "$ENV_DIR/svc.env.tmp" "$ENV_DIR/svc.env"
chmod 600 "$ENV_DIR/svc.env"
return 0
}
# 显式参数版 psql/dump/restore:clone/copy 需要同时操作两个实例,不依赖全局 ENV_*。
pg_run() { # HOST PORT USER PASS DB SQL [tA]
local host="$1" port="$2" user="$3" pass="$4" db="$5" sql="$6" ta="${7:-}" pgbin
pgbin="$(pg_bindir)"
PGPASSWORD="$pass" "$pgbin/psql" -h "$host" -p "$port" -U "$user" -d "$db" ${ta:+-tA} -c "$sql" 2>/dev/null
}
pg_dump_run() { # HOST PORT USER PASS DB OUT
local host="$1" port="$2" user="$3" pass="$4" db="$5" out="$6" pgbin
pgbin="$(pg_bindir)"
PGPASSWORD="$pass" "$pgbin/pg_dump" -h "$host" -p "$port" -U "$user" -d "$db" -Fc -f "$out"
}
pg_restore_list() { # FILE:exit 0 = 合法 pg_dump 归档
"$(pg_bindir)/pg_restore" --list "$1" >/dev/null 2>&1
}
pg_restore_run() { # HOST PORT USER PASS DB FILE:出错回显 stderr 尾部
local host="$1" port="$2" user="$3" pass="$4" db="$5" file="$6" pgbin err
pgbin="$(pg_bindir)"
if ! err="$(PGPASSWORD="$pass" "$pgbin/pg_restore" -h "$host" -p "$port" -U "$user" -d "$db" \
--no-owner --no-privileges "$file" 2>&1)"; then
printf '%s\n' "$err" | tail -5 >&2
return 1
fi
return 0
}
# copy_database SH SP SU SPASS SDB DH DP DU DPASS DDB [FORCE]:单库逻辑复制
# (dump → 建库/覆盖 → restore);目标库非空且无 FORCE 时拒绝。返回 0 = 成功。
copy_database() {
local sh="$1" sp="$2" su="$3" spass="$4" sdb="$5"
local dh="$6" dp="$7" du="$8" dpass="$9" ddb="${10}" force="${11:-0}"
local tmp exists objects
tmp="$(mktemp /tmp/svc-copy-XXXXXXXX.dump)"
if ! pg_dump_run "$sh" "$sp" "$su" "$spass" "$sdb" "$tmp"; then
rm -f "$tmp"; c_err "pg_dump $sdb 失败(源 $sh:$sp)"; return 1
fi
if ! pg_restore_list "$tmp"; then
rm -f "$tmp"; c_err "备份归档自检失败"; return 1
fi
exists="$(pg_run "$dh" "$dp" "$du" "$dpass" postgres "SELECT 1 FROM pg_database WHERE datname='$ddb'" tA || true)"
if [ -n "$exists" ]; then
objects="$(pg_run "$dh" "$dp" "$du" "$dpass" "$ddb" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)"
case "$objects" in ''|*[!0-9]*) objects=0 ;; esac
if [ "$objects" -gt 0 ] && [ "$force" != 1 ]; then
rm -f "$tmp"
c_err "目标库 $ddb 已有 $objects 张表——拒绝覆盖(--force 覆盖)"
return 1
fi
if [ "$force" = 1 ]; then
pg_run "$dh" "$dp" "$du" "$dpass" postgres "DROP DATABASE IF EXISTS \"$ddb\" WITH (FORCE)" >/dev/null \
|| { rm -f "$tmp"; c_err "drop $ddb 失败"; return 1; }
pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \
|| { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; }
fi
else
pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \
|| { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; }
fi
if ! pg_restore_run "$dh" "$dp" "$du" "$dpass" "$ddb" "$tmp"; then
rm -f "$tmp"; return 1
fi
rm -f "$tmp"
return 0
}
db_backup() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db backup <name> [--db X] [--out FILE]"; exit 2; }
shift
parse_args "$@"
env_config "$name"
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
if [ -z "$(read_pid)" ] || ! pid_alive "$(read_pid)"; then
c_err "[$ENV_NAME] 未运行——备份走 TCP,请先 scripts/svc.sh $ENV_NAME start"
exit 1
fi
local db="${CLI_DB_NAME:-$(svc_get dbname)}"
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
local user pass; user="$(svc_get user)"; pass="$(svc_get password)"
local out="${CLI_OUT:-$ENV_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)-$db.dump}"
mkdir -p "$(dirname "$out")"; chmod 700 "$(dirname "$out")"
( umask 077; : > "$out" )
c_info "[$ENV_NAME] pg_dump $db → $out"
if ! pg_dump_run "$host" "$port" "$user" "$pass" "$db" "$out"; then
rm -f "$out"
hist_event backup fail "$(read_pid)" "db=$db"
c_err "[$ENV_NAME] 备份失败(上方输出)"
exit 1
fi
if ! pg_restore_list "$out"; then
rm -f "$out"
hist_event backup fail "$(read_pid)" "归档自检失败 db=$db"
c_err "[$ENV_NAME] 备份归档自检失败(pg_restore --list 不通过),已删除"
exit 1
fi
hist_event backup ok "$(read_pid)" "db=$db file=$(basename "$out") size=$(du -h "$out" | cut -f1)"
c_ok "[$ENV_NAME] 备份完成:$out($(du -h "$out" | cut -f1))"
}
db_backups() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db backups <name>"; exit 2; }
env_config "$name"
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
local dir="$ENV_DIR/backups" f
if [ ! -d "$dir" ] || [ -z "$(ls "$dir"/*.dump 2>/dev/null || true)" ]; then
c_info "[$ENV_NAME] 暂无备份(scripts/svc.sh db backup $ENV_NAME)"
return 0
fi
c_info "[$ENV_NAME] 备份列表($dir)"
for f in "$dir"/*.dump; do
[ -f "$f" ] || continue
printf ' %s %6s %s\n' "$(stat -c %y "$f" | cut -d. -f1)" "$(du -h "$f" | cut -f1)" "$(basename "$f")"
done
return 0
}
db_restore() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db restore <name> --file F [--db X] [--force]"; exit 2; }
shift
parse_args "$@"
[ -n "$CLI_FILE" ] || { c_err "需要 --file <备份文件>"; exit 2; }
[ -f "$CLI_FILE" ] || { c_err "备份文件不存在:$CLI_FILE"; exit 2; }
env_config "$name"
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
if [ -z "$(read_pid)" ] || ! pid_alive "$(read_pid)"; then
c_err "[$ENV_NAME] 未运行——恢复走 TCP,请先 scripts/svc.sh $ENV_NAME start"
exit 1
fi
pg_restore_list "$CLI_FILE" || { c_err "不是有效的 pg_dump 归档:$CLI_FILE"; exit 2; }
local db="${CLI_DB_NAME:-$(svc_get dbname)}"
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
local user pass; user="$(svc_get user)"; pass="$(svc_get password)"
local exists objects
exists="$(pg_run "$host" "$port" "$user" "$pass" postgres "SELECT 1 FROM pg_database WHERE datname='$db'" tA || true)"
if [ -n "$exists" ]; then
objects="$(pg_run "$host" "$port" "$user" "$pass" "$db" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)"
case "$objects" in ''|*[!0-9]*) objects=0 ;; esac
if [ "$objects" -gt 0 ] && [ "$CLI_FORCE" != 1 ]; then
c_err "[$ENV_NAME] 目标库 $db 已存在且含 $objects 张表——拒绝覆盖;确认覆盖加 --force"
exit 1
fi
if [ "$CLI_FORCE" = 1 ]; then
c_warn "[$ENV_NAME] --force:删除并重建库 $db"
pg_run "$host" "$port" "$user" "$pass" postgres "DROP DATABASE IF EXISTS \"$db\" WITH (FORCE)" >/dev/null \
|| { c_err "[$ENV_NAME] drop $db 失败"; exit 1; }
pg_run "$host" "$port" "$user" "$pass" postgres "CREATE DATABASE \"$db\"" >/dev/null \
|| { c_err "[$ENV_NAME] create $db 失败"; exit 1; }
fi
else
pg_run "$host" "$port" "$user" "$pass" postgres "CREATE DATABASE \"$db\"" >/dev/null \
|| { c_err "[$ENV_NAME] 建库 $db 失败"; exit 1; }
fi
c_info "[$ENV_NAME] 恢复 $db ← $CLI_FILE"
if ! pg_restore_run "$host" "$port" "$user" "$pass" "$db" "$CLI_FILE"; then
hist_event restore fail "$(read_pid)" "db=$db file=$(basename "$CLI_FILE")"
c_err "[$ENV_NAME] 恢复失败(上方输出)"
exit 1
fi
local tables
tables="$(pg_run "$host" "$port" "$user" "$pass" "$db" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)"
hist_event restore ok "$(read_pid)" "db=$db file=$(basename "$CLI_FILE") tables=${tables:-?}"
c_ok "[$ENV_NAME] 恢复完成:库 $db(${tables:-?} 张表)← $(basename "$CLI_FILE")"
}
db_clone() {
local src="${1:-}" dst="${2:-}"
[ -n "$src" ] && [ -n "$dst" ] || {
c_err "用法:scripts/svc.sh db clone <src> <dst> [--port P] [--host H] [--password PW] [--tag T] [--online]"
exit 2
}
shift 2
parse_args "$@"
local req_port="${CLI_PORT:-}" req_host="${CLI_HOST:-}"
CLI_PORT=""; CLI_HOST=""; CLI_ADDR="" # 克隆不消费地址覆盖——防泄漏到源复启与目标解析
valid_env "$dst" || { c_err "非法目标服务名:$dst"; exit 2; }
[ "$dst" != "$src" ] || { c_err "目标不能与源相同"; exit 2; }
env_config "$src"
[ "$SVC_KIND" = postgres ] || { c_err "[$src] kind=$SVC_KIND 不是 postgres"; exit 2; }
local src_name="$ENV_NAME" src_addr="$ENV_ADDR" src_dir="$ENV_DIR"
local src_user pass db auth pgdata ver tags
src_user="$(svc_get user)"; pass="$(svc_get password)"; db="$(svc_get dbname)"
auth="$(svc_get authmode)"; pgdata="$(svc_get pgdata)"; ver="$(svc_get version)"; tags="$(svc_get tags)"
[ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$src] 无数据目录"; exit 1; }
if [ "$CLI_ONLINE" = 1 ]; then
# 逻辑克隆:不停源;目标须已 create+start,复制源登记库。
[ -f "$RUNTIME/$dst/svc.env" ] || {
c_err "[$dst] 未登记——--online 要求目标先 db create + start(或省略 --online 走物理克隆)"
exit 2
}
env_config "$dst"
[ "$SVC_KIND" = postgres ] || { c_err "[$dst] kind=$SVC_KIND 不是 postgres"; exit 2; }
[ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$dst] 未运行——先 start"; exit 1; }
local dh="${ENV_ADDR%:*}" dp="${ENV_ADDR##*:}" du dpass dname
du="$(svc_get user)"; dpass="$(svc_get password)"; dname="$ENV_NAME"
c_info "[$src].$db → [$dst].$db 在线克隆(不停源)"
if copy_database "${src_addr%:*}" "${src_addr##*:}" "$src_user" "$pass" "$db" \
"$dh" "$dp" "$du" "$dpass" "$db" "$CLI_FORCE"; then
ENV_NAME="$dname"; ENV_DIR="$RUNTIME/$dname"; ENV_ADDR="$dh:$dp"; SVC_KIND=postgres; SRC_LABEL="clone@$src_name"
hist_event clone ok "" "online db=$db from $src_name"
ENV_NAME="$src_name"; ENV_DIR="$src_dir"; ENV_ADDR="$src_addr"
hist_event clone ok "" "online db=$db to $dst"
c_ok "[$src] → [$dst] 在线克隆完成(库 $db)"
return 0
fi
ENV_NAME="$src_name"; ENV_DIR="$src_dir"; ENV_ADDR="$src_addr"
hist_event clone fail "" "online db=$db to $dst"
c_err "在线克隆失败(上方输出)"
return 1
fi
# 物理克隆(默认):短暂停源 → cp pgdata → 登记目标 → 复启源;目标不自动启动。
[ -e "$RUNTIME/$dst" ] && { c_err "[$dst] 目录已存在:$RUNTIME/$dst(换名或 rm)"; exit 2; }
local saved_name="$ENV_NAME"
ENV_NAME="$dst" # 端口校验以目标视角提示
local dst_port="${req_port:-}"
if [ -n "$dst_port" ]; then
check_port_available "$dst_port" "$dst" || exit 1
else
dst_port="$(derive_port "$dst")" || { c_err "[$dst] 无空闲派生端口,请 --port 指定"; exit 1; }
fi
ENV_NAME="$saved_name"
local dst_host="${req_host:-127.0.0.1}"
local dst_pass="${CLI_DB_PASSWORD:-$pass}"
local was_running=0
if [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then was_running=1; fi
if [ "$was_running" = 1 ]; then
c_info "[$src] 物理克隆需短暂停止源实例(一致性),完成后自动复启"
pg_do_stop || { c_err "[$src] 停止失败,克隆中止(数据未动)"; return 1; }
fi
mkdir -p "$RUNTIME/$dst"; chmod 700 "$RUNTIME/$dst"
c_info "[$dst] 复制数据目录($pgdata → $RUNTIME/$dst/pgdata)"
if ! cp -a "$pgdata" "$RUNTIME/$dst/pgdata"; then
c_err "[$dst] 数据目录复制失败"
[ "$was_running" = 1 ] && { env_config "$src_name"; pg_do_start; }
return 1
fi
rm -f "$RUNTIME/$dst/pgdata/postmaster.pid" "$RUNTIME/$dst/pgdata/postmaster.opts"
local t out_tags="" seen=" "
for t in $(printf '%s' "$tags" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do
[ -n "$t" ] || continue
case "$seen" in *" $t "*) continue ;; esac
seen="$seen $t "; out_tags="${out_tags:+$out_tags,}$t"
done
{
printf 'kind=postgres\n'
printf 'env=%s\n' "$dst"
printf 'pid=\n'
printf 'addr=%s:%s\n' "$dst_host" "$dst_port"
printf 'tags=%s\n' "$out_tags"
printf 'source=clone@%s\n' "$src_name"
printf 'version=%s\n' "$ver"
printf 'pidcheck=%s\n' "$RUNTIME/$dst/pgdata"
printf 'user=%s\n' "$src_user"
printf 'password=%s\n' "$dst_pass"
printf 'dbname=%s\n' "$db"
printf 'authmode=%s\n' "$auth"
printf 'pgdata=%s\n' "$RUNTIME/$dst/pgdata"
} > "$RUNTIME/$dst/svc.env"
chmod 600 "$RUNTIME/$dst/svc.env"
ENV_NAME="$dst"; ENV_DIR="$RUNTIME/$dst"; ENV_ADDR="$dst_host:$dst_port"; SVC_KIND=postgres
SRC_LABEL="clone@$src_name"
hist_event create ok "" "physical clone from $src_name port=$dst_port"
if [ "$was_running" = 1 ]; then
env_config "$src_name"
pg_do_start || c_warn "[$src] 复启失败(克隆已成功;查看 $ENV_DIR/pg.log)"
fi
c_ok "[$dst] 克隆完成(未启动):scripts/svc.sh $dst start;DSN:scripts/svc.sh db dsn $dst --reveal"
return 0
}
db_copy() {
local src="${1:-}" dst="${2:-}"
[ -n "$src" ] && [ -n "$dst" ] || { c_err "用法:scripts/svc.sh db copy <src> <dst> [--db X] [--force]"; exit 2; }
shift 2
parse_args "$@"
CLI_PORT=""; CLI_HOST=""; CLI_ADDR="" # copy 不消费地址覆盖
[ "$src" != "$dst" ] || { c_err "源与目标不能相同"; exit 2; }
env_config "$src"
[ "$SVC_KIND" = postgres ] || { c_err "[$src] kind=$SVC_KIND 不是 postgres"; exit 2; }
[ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$src] 未运行——copy 走 TCP,双方都需 start"; exit 1; }
local sh="${ENV_ADDR%:*}" sp="${ENV_ADDR##*:}" su pass sdb sname sdir
su="$(svc_get user)"; pass="$(svc_get password)"; sname="$ENV_NAME"; sdir="$ENV_DIR"
sdb="${CLI_DB_NAME:-$(svc_get dbname)}"
env_config "$dst"
[ "$SVC_KIND" = postgres ] || { c_err "[$dst] kind=$SVC_KIND 不是 postgres"; exit 2; }
[ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$dst] 未运行——copy 走 TCP,双方都需 start"; exit 1; }
local dh="${ENV_ADDR%:*}" dp="${ENV_ADDR##*:}" du dpass dname
du="$(svc_get user)"; dpass="$(svc_get password)"; dname="$ENV_NAME"
c_info "[$sname].$sdb → [$dname].$sdb(逻辑复制)"
if copy_database "$sh" "$sp" "$su" "$pass" "$sdb" "$dh" "$dp" "$du" "$dpass" "$sdb" "$CLI_FORCE"; then
ENV_NAME="$dname"; ENV_DIR="$RUNTIME/$dname"; ENV_ADDR="$dh:$dp"; SVC_KIND=postgres
hist_event copy ok "" "from $sname db=$sdb"
ENV_NAME="$sname"; ENV_DIR="$sdir"; ENV_ADDR="$sh:$sp"
hist_event copy ok "" "to $dname db=$sdb"
c_ok "复制完成:$sname/$sdb → $dname/$sdb"
return 0
fi
ENV_NAME="$sname"; ENV_DIR="$sdir"; ENV_ADDR="$sh:$sp"
hist_event copy fail "" "to $dname db=$sdb"
c_err "复制失败(上方输出)"
return 1
}
db_ls() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db ls <name>"; exit 2; }
env_config "$name"
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
[ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 未运行——先 start"; exit 1; }
pg_run "${ENV_ADDR%:*}" "${ENV_ADDR##*:}" "$(svc_get user)" "$(svc_get password)" postgres \
"SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY 1" tA || true
return 0
}
# mask_json TEXT:JSON 文本里键名含 key/pass/secret/token/dsn(不区分大小写)的
# 字符串值掩为 ***(配置文件内容快照用)。输出带尾换行。
mask_json() {
printf '%s\n' "$1" | sed -E 's/("[^"]*(key|pass|secret|token|dsn)[^"]*"[[:space:]]*:[[:space:]]*")[^"]*"/\1***"/Ig'
}
# ---- agent 服务的配置文件(<name> config ...)----
# svc_config [FILE|--clear] [--no-restart]:查看 / 更新 / 移除 agent 服务使用的
# 配置文件(agent 二进制的 --config,JSON,schema 严格校验)。更新 = 校验通过后
# 把 --config <绝对路径> 写入持久化 exec= 并登记 conffile=,运行中自动重启生效
# (agent 无 SIGHUP 热重载,重启即"及时生效",秒级);--clear 移除回命令行参数形态。
svc_config() {
[ "$SVC_KIND" = agent ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 无配置文件绑定(仅 agent 服务)"; return 2; }
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 deploy)"; return 2; }
local target="" clear=0 restart=1
while [ $# -gt 0 ]; do
case "$1" in
--clear) clear=1; shift ;;
--no-restart) restart=0; shift ;;
-h|--help)
cat >&2 <<EOF
[$ENV_NAME] 配置文件:
scripts/svc.sh $ENV_NAME config 查看当前配置文件与生效值
scripts/svc.sh $ENV_NAME config <file.json> [--no-restart] 更新使用的配置文件
(schema 校验通过才切换;运行中自动重启生效)
scripts/svc.sh $ENV_NAME config --clear [--no-restart] 移除(回命令行参数形态)
提示:配置文件绑定后,改文件内容 + restart 即生效;config <同一文件> 也会校验并重启。
EOF
return 0 ;;
-*) c_err "[$ENV_NAME] config 未知参数:$1(可用:<文件> | --clear,--no-restart)"; return 2 ;;
*) [ -z "$target" ] || { c_err "[$ENV_NAME] config 只接受一个文件路径"; return 2; }
target="$1"; shift ;;
esac
done
# 当前配置文件(conffile= 优先;旧服务回退 exec= 的 --config)。
local cur=""
cur="$(svc_get conffile)"
[ -z "$cur" ] && cur="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
# 无参:查看(文件状态 + 掩码内容 + 二进制视角的生效值)。
if [ -z "$target" ] && [ "$clear" = 0 ]; then
if [ -n "$cur" ]; then
printf ' [%s] 配置文件:%s\n' "$ENV_NAME" "$cur"
if [ -f "$cur" ]; then
printf ' 权限 %s;内容(敏感值掩码):\n' "$(info_file_mode "$cur")"
mask_json "$(cat "$cur")" | sed 's/^/ /'
if [ -x "$ENV_BIN" ] && "$ENV_BIN" config explain -config "$cur" >/dev/null 2>&1; then
printf ' 生效值(agent 视角,敏感值掩码):\n'
"$ENV_BIN" config explain -config "$cur" 2>/dev/null \
| sed -E 's#(postgres(ql)?://[^:/@[:space:]]+:)[^@[:space:]]+@#\1***@#g; s/^/ /'
else
printf ' (当前产物无法校验该文件:config explain 不可用或文件不合法)\n'
fi
else
printf ' 文件不存在!重启将失败——请恢复文件或 config --clear\n'
fi
else
printf ' [%s] 配置文件:无(命令行参数形态;绑定:config <file.json>)\n' "$ENV_NAME"
fi
return 0
fi
# 移除。
if [ "$clear" = 1 ]; then
[ -n "$target" ] && { c_err "--clear 不接受文件参数"; return 2; }
local exec_old new_exec
exec_old="$(svc_get exec)"
if printf '%s' "$exec_old" | grep -q -- '--config '; then
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--config [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
fi
svc_del_key conffile
hist_event set ok "$(read_pid)" "config unbind"
c_ok "[$ENV_NAME] 已移除配置文件(回命令行参数形态)"
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
do_stop; do_start || { c_err "[$ENV_NAME] 重启失败——移除已登记,排查后 start"; return 1; }
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
fi
# 更新。
[ -f "$target" ] || { c_err "配置文件不存在:$target"; return 2; }
local abs
abs="$(cd "$(dirname "$target")" && pwd)/$(basename "$target")"
if [ -x "$ENV_BIN" ]; then
if ! "$ENV_BIN" config explain -config "$abs" >/dev/null 2>&1; then
c_err "[$ENV_NAME] 配置文件未通过 schema 校验:$abs"
"$ENV_BIN" config explain -config "$abs" >&2 || true
c_err "(键与取值以 config explain 为准;先修正文件再切换)"
return 2
fi
if ! "$ENV_BIN" serve -h 2>&1 | grep -Eq -- "(^|[[:space:]])-{1,2}config([[:space:]]|=|$)"; then
c_warn "[$ENV_NAME] 当前产物不识别 --config(旧版本二进制)——绑定已登记,但重启将忽略该参数"
fi
fi
local exec_old new_exec
exec_old="$(svc_get exec)"
if printf '%s' "$exec_old" | grep -q -- '--config '; then
new_exec="$(printf '%s' "$exec_old" | sed -E "s|--config [^ ]+|--config $abs|")"
else
new_exec="${exec_old:+$exec_old }--config $abs"
fi
svc_set_key exec "$new_exec"
svc_set_key conffile "$abs"
hist_event set ok "$(read_pid)" "config → $abs"
c_ok "[$ENV_NAME] 配置文件已更新:$abs(schema 校验通过;svc.env 600 保存)"
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
c_info "[$ENV_NAME] 重启以应用新配置……"
do_stop
do_start || { c_err "[$ENV_NAME] 重启失败——绑定已登记,排查日志后 scripts/svc.sh $ENV_NAME start"; return 1; }
c_ok "[$ENV_NAME] 新配置已生效:$abs"
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
}
# ---- agent 服务的数据库绑定(<name> db ...)----
# svc_db_bind [pg名] [--db X] [--clear] [--no-restart]:查看 / 绑定切换 / 解除
# agent 服务的数据库。绑定 = dbref=<pg>/<库> + db=pg 登记(600);DSN 不落 exec=,
# 启动时由实例登记重建并经 AGENT_SYSTEM_DSN 环境变量交付(实例运行中缺库自动建;
# 服务运行中自动重启生效,--no-restart 只改登记)。--clear 显式解绑:db=sqlite、
# 清除 dbref 与直接 dsn=,残留 SVC_*_DSN 不再悄悄重新选库。
svc_db_bind() {
[ "$SVC_KIND" = agent ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 无数据库绑定(仅 agent 服务)"; return 2; }
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 deploy)"; return 2; }
local target="" clear=0 restart=1
while [ $# -gt 0 ]; do
case "$1" in
--clear) clear=1; shift ;;
--no-restart) restart=0; shift ;;
--db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;;
-h|--help)
cat >&2 <<EOF
[$ENV_NAME] 数据库绑定:
scripts/svc.sh $ENV_NAME db 查看当前绑定与可用实例
scripts/svc.sh $ENV_NAME db <pg名> [--db X] [--no-restart] 绑定/切换(运行中自动重启生效)
scripts/svc.sh $ENV_NAME db --clear [--no-restart] 解除绑定(显式回内嵌 SQLite 档)
EOF
return 0 ;;
-*) c_err "[$ENV_NAME] db 未知参数:$1(可用:<pg名> | --clear,--db X,--no-restart)"; return 2 ;;
*) [ -z "$target" ] || { c_err "[$ENV_NAME] db 只接受一个 postgres 服务名"; return 2; }
target="$1"; shift ;;
esac
done
# 无参:查看当前绑定 + 可用实例。
if [ -z "$target" ] && [ "$clear" = 0 ]; then
local ref dsn pg st mode
mode="$(svc_get db)"
ref="$(db_ref_of "$ENV_NAME")"
if [ -n "$ref" ]; then
printf ' [%s] 数据库绑定:%s\n' "$ENV_NAME" "$ref"
dsn="$(svc_get dsn)"
[ -z "$dsn" ] && dsn="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
[ -z "$dsn" ] && [ -n "$(svc_get dbref)" ] && dsn="$(dsn_from_dbref "$(svc_get dbref)" 2>/dev/null || true)"
[ -n "$dsn" ] && printf ' DSN %s\n' "$(mask_dsn "$dsn")"
elif [ "$mode" = "sqlite" ]; then
printf ' [%s] 数据库绑定:已显式解绑(db=sqlite,data=%s;残留 SVC_*_DSN 不再自动选库)\n' "$ENV_NAME" "$ENV_DATA"
else
printf ' [%s] 数据库绑定:无(内嵌 SQLite 档,data=%s)\n' "$ENV_NAME" "$ENV_DATA"
fi
printf ' 可用实例:\n'
local found=0
while IFS= read -r pg; do
st="$(svc_pid_state "$pg")"
printf ' %-14s %-7s %s\n' "$pg" \
"$(if [ "${st%% *}" = alive ]; then printf 运行中; else printf 未运行; fi)" \
"$(env_kv_of "$pg" addr)"
found=1
done < <(registered_names | while IFS= read -r n; do [ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"; done)
[ "$found" = 0 ] && printf ' (无;scripts/svc.sh db create <name> 创建)\n'
printf ' 切换:scripts/svc.sh %s db <pg名> [--db X];解除:--clear\n' "$ENV_NAME"
return 0
fi
# 解除绑定(显式选择 SQLite:清除 dbref 与直接 dsn,环境残留不再重新选库)。
if [ "$clear" = 1 ]; then
[ -n "$target" ] && { c_err "--clear 不接受服务名参数"; return 2; }
local exec_old new_exec
exec_old="$(svc_get exec)"
if printf '%s' "$exec_old" | grep -q -- '--dsn '; then
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
fi
svc_del_key dbref
svc_del_key dsn
svc_set_key db sqlite
hist_event set ok "$(read_pid)" "db unbind"
c_ok "[$ENV_NAME] 已解除数据库绑定(显式 db=sqlite;数据目录 $ENV_DATA)"
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
do_stop; do_start || { c_err "[$ENV_NAME] 重启失败——解绑已登记,排查后 start"; return 1; }
c_ok "[$ENV_NAME] 已重启(内嵌 SQLite 档)"
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
fi
# 绑定/切换。
valid_env "$target" || { c_err "非法服务名:$target"; return 2; }
[ -f "$RUNTIME/$target/svc.env" ] || { c_err "[$target] 未登记(scripts/svc.sh db create $target)"; return 2; }
[ "$(svc_kind "$target")" = postgres ] || { c_err "[$target] kind=$(svc_kind "$target") 不是 postgres 实例"; return 2; }
local taddr thost tport tuser tpass regdb tdb dsn st
taddr="$(env_kv_of "$target" addr)"
thost="${taddr%:*}"; tport="${taddr##*:}"
tuser="$(env_kv_of "$target" user)"
tpass="$(env_kv_of "$target" password)"
regdb="$(env_kv_of "$target" dbname)"
tdb="${CLI_DB_NAME:-$regdb}"
{ [ -n "$tuser" ] && [ -n "$tpass" ] && [ -n "$taddr" ]; } || { c_err "[$target] 登记缺 addr/user/password(重新 db create)"; return 2; }
# mock 档与绑定冲突:拒绝——绑定后按 mock 运行等于静默丢库。
local exec_now mock_now=0
exec_now="$(svc_get exec)"
{ [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; } && mock_now=1
case "$exec_now" in *--mock*) mock_now=1 ;; esac
if [ "$mock_now" = 1 ]; then
c_err "[$ENV_NAME] 拒绝绑定:当前为 mock 档(--mock)与数据库配置冲突;先去 mock(重新 deploy 不带 --mock)或保持 mock 不绑定"
return 2
fi
# 产物承接能力预检:绑定后 DSN 经环境变量交付,产物须消费 AGENT_SYSTEM_DSN。
if [ -x "$ENV_BIN" ] && ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝绑定:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),绑定后无法生效;先更新产物再绑定"
return 2
fi
# 实例运行中:确保目标库存在(缺则建,幂等)。
st="$(svc_pid_state "$target")"
if [ "${st%% *}" = alive ]; then
if ! pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "SELECT 1 FROM pg_database WHERE datname='$tdb'" tA | grep -q 1; then
if pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "CREATE DATABASE \"$tdb\"" >/dev/null; then
c_ok "[$target] 已建库:$tdb"
else
c_warn "[$target] 建库 $tdb 未成功——绑定继续,请手动建库后重启服务"
fi
fi
elif [ "$tdb" != "$regdb" ]; then
c_warn "[$target] 未运行——库 $tdb 暂未创建(先 start $target 再重跑,或绑定默认库 $regdb)"
fi
dsn="postgresql://$tuser:$tpass@$thost:$tport/$tdb"
# 写持久化配置(托管绑定形态):dbref= + db=pg;清除直接 dsn=;剥离 exec 残留 --dsn。
# DSN 不落 exec=——启动时由实例登记重建(实例端口/密码变更自动跟随,密码不重复保存)。
if printf '%s' "$exec_now" | grep -q -- '--dsn '; then
local new_exec
new_exec="$(printf '%s' "$exec_now" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
fi
svc_set_key dbref "$target/$tdb"
svc_set_key db pg
svc_del_key dsn
hist_event set ok "$(read_pid)" "db bind → $target/$tdb"
c_ok "[$ENV_NAME] 数据库绑定:$target/$tdb(启动时由实例登记重建 DSN:$(mask_dsn "$dsn");svc.env 600 保存,info 可查)"
# 运行中:重启生效(start 按新登记重建 DSN 交付)。
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
c_info "[$ENV_NAME] 重启以切换数据库……"
do_stop
do_start || { c_err "[$ENV_NAME] 重启失败——绑定已登记,排查日志后 scripts/svc.sh $ENV_NAME start"; return 1; }
c_ok "[$ENV_NAME] 已切换数据库:$target/$tdb(生效中)"
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
}
# ---- agent 数据目录快照(SQLite 一致性备份;2026-10-12)----
# 快照根在 runtime-backups/<name>/(runtime/ 之外):2026-10-11 runtime/ 被整体
# 误删的事故里,放在 runtime/<name>/backups/ 的任何东西都会一起消失。每个 *.db
# 经 sqlite3 .backup 导出一致性副本(并入 WAL 内容)并 integrity_check 自检,
# 不裸拷 .db(约束见 modules/documents/docs/proposals/
# 2026-10-12-service-restart-data-recovery.md §6);-wal/-shm 不入快照。
BACKUP_KEEP_DEFAULT=7
BACKUP_TS_GLOB='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z'
agent_backup_root() { printf '%s/runtime-backups/%s' "$ROOT" "$ENV_NAME"; }
agent_backup_guard() { # 公共前置:agent 已登记(backups 列表也走这里)
if [ "$SVC_KIND" = postgres ]; then
c_err "[$ENV_NAME] postgres 用 db backup(pg_dump);backup 是 agent 数据目录快照"
exit 2
elif [ "$SVC_KIND" != agent ]; then
c_err "[$ENV_NAME] kind=$SVC_KIND 无数据目录,不适用 backup"
exit 2
fi
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 未登记(无 svc.env);先 deploy"; exit 2; }
}
agent_backup_snapshot() { # $1=dest:svc.env + history.jsonl + data/(*.db 一致性化)
local dest="$1" rel
cp -p "$ENV_DIR/svc.env" "$dest/svc.env" || return 1
[ -f "$ENV_DIR/history.jsonl" ] && cp -p "$ENV_DIR/history.jsonl" "$dest/history.jsonl"
mkdir -p "$dest/data" || return 1
# 先整树拷贝(非 db 内容的原样副本),再对 *.db 用一致性导出覆盖:cp 得到的
# 撕裂 db/-wal 副本在下一步被丢弃替换,不影响最终一致性。
cp -a "$ENV_DATA/." "$dest/data/" || return 1
while IFS= read -r rel; do
[ -f "$ENV_DATA/$rel" ] || continue
case "$rel" in
*"'"*|*'"'*)
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
c_err "[$ENV_NAME] 路径含引号,无法走 sqlite 一致性导出:$rel"
return 1
fi
continue ;;
esac
if sqlite3 "$ENV_DATA/$rel" ".backup '$dest/data/$rel.bk'" 2>/dev/null \
&& [ "$(sqlite3 "$dest/data/$rel.bk" 'PRAGMA integrity_check;' 2>/dev/null)" = ok ]; then
mv -f "$dest/data/$rel.bk" "$dest/data/$rel" || return 1
chmod 600 "$dest/data/$rel"
rm -f "$dest/data/$rel-wal" "$dest/data/$rel-shm" # .backup 已并入 WAL 内容
else
rm -f "$dest/data/$rel.bk" # 失败尝试的半成品不留在快照里
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
c_err "[$ENV_NAME] SQLite 一致性备份/自检失败:$rel"
return 1
fi
c_warn "[$ENV_NAME] $rel 不是 SQLite 库(无文件头),保留原样副本"
fi
done < <(cd "$dest/data" 2>/dev/null && find . -type f -name '*.db' | sort)
return 0
}
agent_backup_manifest() { # $1=dest:快照自描述(600)——逐文件 sha256 供恢复时校验
local dest="$1"
{
printf 'service=%s\nkind=%s\ncreated=%s\nsource=%s\nversion=%s\ndata_dir=%s\n' \
"$ENV_NAME" "$SVC_KIND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$(svc_get source)" "$(svc_get version)" "$ENV_DATA"
( cd "$dest" && find . -type f ! -name MANIFEST -print0 | sort -z | xargs -0 -r sha256sum )
} > "$dest/MANIFEST"
chmod 600 "$dest/MANIFEST"
}
agent_backup_prune() { # $1=root $2=keep:只清理符合时间戳命名的快照目录(最新在前保留 keep 份)
local root="$1" keep="$2" n=0 d
[ -d "$root" ] || return 0
while IFS= read -r d; do
n=$((n + 1))
[ "$n" -le "$keep" ] && continue
rm -rf "$root/$d"
c_info "[$ENV_NAME] 清理旧快照:$d(保留最近 $keep 份)"
done < <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r)
return 0
}
svc_agent_backup() { # [--out DIR] [--keep N]
local out="" keep="$BACKUP_KEEP_DEFAULT"
while [ $# -gt 0 ]; do
case "$1" in
--out) need_value "$@"; out="$2"; shift 2 ;;
--keep) need_value "$@"; keep="$2"; shift 2
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
*) c_err "用法:scripts/svc.sh <name> backup [--out DIR] [--keep N](未知参数:$1)"; exit 2 ;;
esac
done
agent_backup_guard
[ -d "$ENV_DATA" ] || { c_err "[$ENV_NAME] 数据目录不存在:$ENV_DATA"; exit 2; }
command -v sqlite3 >/dev/null 2>&1 \
|| { c_err "缺少 sqlite3——一致性备份必需,不回退裸拷贝(会漏 WAL 得到撕裂副本)"; exit 1; }
local ts dest size
ts="$(date -u +%Y%m%dT%H%M%SZ)"
dest="${out:-$(agent_backup_root)/$ts}"
case "$dest" in /*) ;; *) dest="$PWD/$dest" ;; esac
[ ! -e "$dest" ] || { c_err "备份目标已存在:$dest"; exit 1; }
mkdir -p "$dest" || { c_err "无法创建备份目录:$dest"; exit 1; }
if ! agent_backup_snapshot "$dest" || ! agent_backup_manifest "$dest"; then
rm -rf "$dest"
hist_event backup fail "$(read_pid)" "dir=$dest"
c_err "[$ENV_NAME] 备份失败,半成品已清理:$dest"
exit 1
fi
chmod 700 "$dest" 2>/dev/null || true
size="$(du -sh "$dest" 2>/dev/null | cut -f1)"
hist_event backup ok "$(read_pid)" "dir=$dest size=$size"
[ -n "$out" ] || agent_backup_prune "$(agent_backup_root)" "$keep"
c_ok "[$ENV_NAME] 数据目录快照完成:$dest($size;svc.env+history+data,db 已一致性自检)"
}
svc_agent_backups() { # [--prune] [--keep N]
local prune=0 keep="$BACKUP_KEEP_DEFAULT"
while [ $# -gt 0 ]; do
case "$1" in
--prune) prune=1; shift ;;
--keep) need_value "$@"; keep="$2"; shift 2
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
*) c_err "用法:scripts/svc.sh <name> backups [--prune] [--keep N](未知参数:$1)"; exit 2 ;;
esac
done
agent_backup_guard
local root n=0
root="$(agent_backup_root)"
if [ -d "$root" ]; then
while IFS= read -r _; do n=$((n + 1)); done \
< <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB")
fi
if [ "$n" -eq 0 ]; then
c_info "[$ENV_NAME] 暂无快照(root=$root;scripts/svc.sh $ENV_NAME backup)"
return 0
fi
c_info "[$ENV_NAME] 快照列表($root,最新在前)"
( cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r ) \
| while IFS= read -r d; do
printf ' %s %6s %s\n' "${d#./}" "$(du -sh "$root/$d" | cut -f1)" \
"$(find "$root/$d/data" -type f 2>/dev/null | wc -l) 个文件"
done
if [ "$prune" = 1 ]; then agent_backup_prune "$root" "$keep"; fi
return 0
}
cmd_set() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh set <name> [--port N] [--host H] [--apply] [--tag T...]"; exit 2; }
shift
parse_args "$@"
env_config "$name"
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 db create / deploy)"; exit 2; }
# tags:给了 --tag 才改(合并去重;postgres/agent/static 通用)。
if [ "${#CLI_TAGS[@]}" -gt 0 ]; then
local merged; merged="$(svc_merge_tags)"
svc_set_key tags "$merged"
c_ok "[$ENV_NAME] 标签更新:$merged"
fi
case "$SVC_KIND" in
postgres)
if [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ]; then
local old; old="$(svc_get addr)"; [ -n "$old" ] || old="$ENV_ADDR"
local running=0
if [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then running=1; fi
if [ "$running" = 1 ] && [ "$CLI_APPLY" != 1 ]; then
c_err "[$ENV_NAME] 运行中——端口/地址变更需重启生效:加 --apply(停止→改登记→重启),或先 stop 再 set"
exit 1
fi
check_port_available "${ENV_ADDR##*:}" "$ENV_NAME" || exit 1
if [ "$running" = 1 ]; then
pg_do_stop
svc_set_key addr "$ENV_ADDR"
pg_do_start || { c_err "[$ENV_NAME] 新地址重启失败(登记已是 $ENV_ADDR);查看 $ENV_DIR/pg.log"; return 1; }
else
svc_set_key addr "$ENV_ADDR"
fi
hist_event set ok "$(read_pid)" "addr $old → $ENV_ADDR"
c_ok "[$ENV_NAME] 地址已更新:$old → $ENV_ADDR"
elif [ "${#CLI_TAGS[@]}" -eq 0 ]; then
c_warn "[$ENV_NAME] 未指定任何变更(--port/--host/--addr/--tag)"
fi
;;
agent|static)
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ] && \
c_warn "[$ENV_NAME] agent/static 换端口请用 restart --port N(进程与登记同步更新);set 只改登记"
[ "${#CLI_TAGS[@]}" -eq 0 ] && c_warn "[$ENV_NAME] 未指定任何变更(--tag)"
;;
*) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;;
esac
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——没有由本脚本启动的实例"
# 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。
reclaim_own_port || true
svc_clear_autostart
return 0
fi
if ! pid_alive "$pid"; then
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
rm -f "$ENV_PID"
reclaim_own_port || true
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
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"
svc_clear_autostart
svc_wait_port_released
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
esac
return 0
}
# svc_wait_port_released:停机后等子进程释放端口(最多 3s)。桩/组合产物的 http
# 子进程经 ppid-watch 自杀(≤0.5s 语义),慢机上略晚;不等会让紧跟的 start 把
# 已无父的孤儿占口判成「非本服务」拒启(U2 回退实测,CI 慢机 run 3774)。
svc_wait_port_released() {
local port="${ENV_ADDR##*:}" i=0
[ -n "$port" ] || return 0
while [ "$i" -lt 30 ] && port_listening "$port"; do
sleep 0.1
i=$((i + 1))
done
return 0
}
# do_stop_quiet [--keep-desired]:deploy 前置。切换事务必须保留期望态,
# 失败回滚后仍按原期望拉起。显式 stop 仍清除期望态。未运行时静默返回。
do_stop_quiet() {
local keep=0
[ "${1:-}" = --keep-desired ] && keep=1
local pid; pid="$(read_pid)"
if [ -z "$pid" ]; then
reclaim_own_port || true
[ "$keep" = 1 ] || svc_clear_autostart
return 0
fi
if ! pid_alive "$pid"; then
rm -f "$ENV_PID"
reclaim_own_port || true
[ "$keep" = 1 ] || svc_clear_autostart
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"
[ "$keep" = 1 ] || svc_clear_autostart
svc_wait_port_released
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
esac
return 0
}
# do_restart:agent 先完成可静态检查的配置验证(数据库绑定可解析、conffile 存在、
# 产物承接能力)再停旧进程——配置性拒绝不制造停机窗口,登记保持可恢复;其余类型
# 直接停止后启动。
do_restart() {
if [ "$SVC_KIND" = agent ]; then
agent_static_preflight || { hist_event start fail "" "restart 预检拒绝(数据库配置/配置文件无效)"; return 1; }
fi
do_stop
do_start
}
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:?}"
local bkroot="$ROOT/runtime-backups/$name"
if [ -d "$bkroot" ]; then
rm -rf "$bkroot"
c_ok "[$ENV_NAME] 数据目录快照已删除:$bkroot"
fi
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
}
# registered_names:runtime 下已登记服务名(stdout 每行一个;list/info 共用)。
registered_names() {
local d e
for d in "$RUNTIME"/*/; do
[ -f "${d}svc.env" ] || continue
e="$(basename "$d")"
[ "$e" = ".worktrees" ] && continue
valid_env "$e" || continue
printf '%s\n' "$e"
done
return 0
}
# db_ref_of NAME:agent 服务的数据库绑定标识(list/status/info 共用)。
# dbref= 优先;否则从 exec= 的 --dsn 或配置文件(conffile=)的 dsn 键提取 host:port
# 匹配已登记 postgres 实例(兼容直接 --dsn/--config 部署、尚未登记 dbref 的服务);
# 都匹配不上则回 外部@host:port。
db_ref_of() {
local name="$1" ref dsn hostport pg addr cf
ref="$(env_kv_of "$name" dbref)"
if [ -n "$ref" ]; then printf '%s' "$ref"; return 0; fi
dsn="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
[ -z "$dsn" ] && dsn="$(env_kv_of "$name" dsn)"
if [ -z "$dsn" ]; then
cf="$(env_kv_of "$name" conffile)"
[ -z "$cf" ] && cf="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
if [ -n "$cf" ] && [ -f "$cf" ]; then
dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$cf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')"
fi
fi
[ -n "$dsn" ] || return 0
hostport="$(printf '%s' "$dsn" | sed -n 's#.*@\([^/?]*\).*#\1#p')"
while IFS= read -r pg; do
[ "$(svc_kind "$pg")" = postgres ] || continue
addr="$(env_kv_of "$pg" addr)"
if [ "$addr" = "$hostport" ]; then
printf '%s/%s' "$pg" "${dsn##*/}"
return 0
fi
done < <(registered_names)
printf '外部@%s' "$hostport"
return 0
}
global_list() {
c_info "服务总览(root: $ROOT)"
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' NAME KIND STATE PID ADDR DB SOURCE TAGS
local names=() d name kind addr src tags st dbref
while IFS= read -r name; do names+=("$name"); done < <(registered_names)
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")"
dbref="-"
[ "$kind" = agent ] && dbref="$(db_ref_of "$name")"
[ -n "$dbref" ] || dbref="-"
if [ "${st%% *}" = alive ]; then
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "$dbref" "${src:--}" "${tags:--}"
else
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "$dbref" "${src:--}" "${tags:--}"
fi
done
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
for name in ${names[@]+"${names[@]}"}; do
if rdm_workbench_missing "$name"; then
c_warn " [$name] 无工作台入口(伴生 $(rdm_companion_name "$name") 未登记或未运行)"
fi
done
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
agent)
local dbref cfile
dbref="$(db_ref_of "$ENV_NAME")"
printf ' 数据库 %s\n' "${dbref:-无(内嵌 SQLite 档;绑定:<name> db <pg名>)}"
cfile="$(svc_get conffile)"
[ -z "$cfile" ] && cfile="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
printf ' 配置文件 %s\n' "${cfile:-无(命令行参数形态;<name> config <file> 绑定)}"
printf ' 监听守卫 insecureListen=%s allowedHosts=%s\n' \
"$(env_var "$ENV_NAME" INSECURE_LISTEN)" \
"$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
if rdm_workbench_missing "$ENV_NAME"; then
printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \
"$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR"
fi
;;
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 条)"
history_print_rows "$n" "${files[@]}"
}
# history_print_rows N FILES...:合并多个 history.jsonl、按时间排序、取最近 N 条打印
# (cmd_history 与 info 共用;行格式:时间/服务/kind/事件/结果/来源/pid/详情)。
history_print_rows() {
local n="$1"; shift
cat "$@" | 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
return 0
}
# ---- info:综合信息报告(只读) ----
# 报告整体走 stdout(区别于提示走 stderr 的惯例)——info 的产物就是报告本身,
# 便于整页重定向/保存为快照;敏感值默认掩码,--reveal 才显示。
# mask_dsn STRING:把 postgres DSN 里的密码掩为 ***(postgresql://user:pass@ → user:***@)。
mask_dsn() {
printf '%s' "$1" | sed -E 's#(postgres(ql)?://[^:/@[:space:]]+:)[^@[:space:]]+@#\1***@#g'
}
# mask_kv LINE:KEY=VALUE 行的敏感值掩码——password/dsn 键与键名含 KEY/PASS/SECRET/
# TOKEN/DSN 的值整段掩为 ***;其余行只掩值内 DSN 密码(exec= 里可能带完整连接串)。
mask_kv() {
local line="$1" k="${1%%=*}"
if [ "$k" != "$1" ]; then
case "$k" in
password|dsn|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*)
printf '%s=***' "$k"; return 0 ;;
esac
fi
mask_dsn "$line"
}
# info_section TITLE:stdout 分节标题。
info_section() { printf '\n────── %s ──────\n' "$*"; }
# info_du PATH:目录/文件大小(du -sh;不存在或失败回 '-')。
info_du() {
local s=""
[ -e "$1" ] && s="$(du -sh "$1" 2>/dev/null | cut -f1)"
printf '%s' "${s:--}"
}
# info_file_mode PATH:八进制权限(不存在回 '-')。
info_file_mode() {
local m=""
[ -f "$1" ] && m="$(stat -c %a "$1" 2>/dev/null || true)"
printf '%s' "${m:--}"
}
# env_kv_of NAME KEY:读 runtime/<name>/svc.env 单键首个值(不依赖 ENV_* 全局,
# info 遍历多服务时使用;无则空)。
env_kv_of() {
[ -f "$RUNTIME/$1/svc.env" ] || return 0
sed -n "s/^$2=//p" "$RUNTIME/$1/svc.env" | head -1
}
# info_snapshot_lines FILE:逐行打印 KEY=VALUE 内容快照(按 CLI_REVEAL 决定掩码)。
info_snapshot_lines() {
local line
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
if [ "$CLI_REVEAL" = 1 ]; then printf ' %s\n' "$line"
else printf ' %s\n' "$(mask_kv "$line")"; fi
done < "$1"
return 0
}
# info_pg_block NAME:postgres 实例详情块——登记信息、数据目录与备份体量;
# 运行中则列出实例内部数据库(含大小,按大小降序)。
info_pg_block() {
local name="$1" d="$RUNTIME/$1"
local addr user db auth ver pgdata src st pass rows
addr="$(env_kv_of "$name" addr)"
user="$(env_kv_of "$name" user)"
db="$(env_kv_of "$name" dbname)"
auth="$(env_kv_of "$name" authmode)"
ver="$(env_kv_of "$name" version)"
pgdata="$(env_kv_of "$name" pgdata)"
src="$(env_kv_of "$name" source)"
st="$(svc_pid_state "$name")"
if [ "${st%% *}" = alive ]; then
printf ' [%s] postgres 运行中 pid=%s → %s\n' "$name" "${st#alive }" "$addr"
else
printf ' [%s] postgres 未运行(内部库不可见,start 后可列出) → %s\n' "$name" "${addr:--}"
fi
printf ' 版本 %s 用户 %s 认证 %s 默认库 %s 来源 %s\n' \
"${ver:--}" "${user:--}" "${auth:--}" "${db:--}" "${src:--}"
# 反向绑定:哪些 agent 服务当前指向本实例。
local bound="" bn bref
while IFS= read -r bn; do
[ "$(svc_kind "$bn")" = agent ] || continue
bref="$(db_ref_of "$bn")"
case "$bref" in "$name/"*) bound="${bound:+$bound、}$bn(${bref#*/})" ;; esac
done < <(registered_names)
printf ' 绑定服务 %s\n' "${bound:-无(绑定:scripts/svc.sh <agent名> db $name)}"
[ -n "$pgdata" ] && printf ' 数据目录 %s(%s)\n' "$pgdata" "$(info_du "$pgdata")"
local nb=0 bsize="" latest="" f
if [ -d "$d/backups" ]; then
for f in "$d/backups"/*.dump; do
[ -f "$f" ] || continue
nb=$((nb + 1))
latest="$f"
done
[ "$nb" -gt 0 ] && bsize="$(info_du "$d/backups")"
fi
if [ "$nb" -gt 0 ]; then
printf ' 备份 %d 份(合计 %s,最近 %s)\n' "$nb" "$bsize" "$(basename "$latest")"
else
printf ' 备份 0 份\n'
fi
if [ "${st%% *}" = alive ]; then
pass="$(env_kv_of "$name" password)"
rows="$(pg_run "${addr%:*}" "${addr##*:}" "$user" "$pass" postgres \
"SELECT datname || '|' || pg_size_pretty(pg_database_size(datname)) FROM pg_database WHERE NOT datistemplate ORDER BY pg_database_size(datname) DESC" tA)" || rows=""
if [ -n "$rows" ]; then
printf ' 内部库:\n'
printf '%s\n' "$rows" | while IFS='|' read -r dn ds; do
printf ' %-24s %s\n' "$dn" "$ds"
done
else
printf ' 内部库:查询失败(psql 连接异常)\n'
fi
fi
return 0
}
# info_config_block NAME:单服务 svc.env 内容快照(文件路径+权限+逐行内容),
# agent 服务另附其配置文件(conffile)的掩码内容快照。
info_config_block() {
local name="$1" f="$RUNTIME/$1/svc.env"
printf ' [%s] %s(权限 %s,kind=%s)\n' "$name" "$f" "$(info_file_mode "$f")" "$(svc_kind "$name")"
info_snapshot_lines "$f"
local cf
cf="$(env_kv_of "$name" conffile)"
[ -z "$cf" ] && cf="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
if [ -n "$cf" ]; then
if [ -f "$cf" ]; then
printf ' ── 配置文件 %s(权限 %s)──\n' "$cf" "$(info_file_mode "$cf")"
if [ "$CLI_REVEAL" = 1 ]; then
cat "$cf" | sed 's/^/ /'
else
mask_json "$(cat "$cf")" | sed 's/^/ /'
fi
else
printf ' ── 配置文件 %s:文件不存在(重启将失败,恢复或 %s config --clear)──\n' "$cf" "$name"
fi
fi
return 0
}
# info_global_config:全局配置层快照——$ROOT/.svc.env(自动加载层)、本次 --config
# 文件、shell 层已导出的 SVC_* 变量;值同掩码规则。
info_global_config() {
local f="$ROOT/.svc.env" exported=""
if [ -f "$f" ]; then
printf ' %s(自动加载层,权限 %s)\n' "$f" "$(info_file_mode "$f")"
info_snapshot_lines "$f"
else
printf ' .svc.env(自动加载层):不存在\n'
fi
[ -n "${SVC_ENVFILE:-}" ] && printf ' 本次 --config/--env 文件:%s(值已并入生效配置)\n' "$SVC_ENVFILE"
exported="$(env | grep '^SVC_' | sort || true)"
if [ -n "$exported" ]; then
printf ' shell 层已导出的 SVC_* 变量:\n'
if [ "$CLI_REVEAL" = 1 ]; then
printf '%s\n' "$exported" | sed 's/^/ /'
else
printf '%s\n' "$exported" | while IFS= read -r line; do printf ' %s\n' "$(mask_kv "$line")"; done
fi
else
printf ' shell 层 SVC_* 变量:无(.svc.env 里的值是脚本内层变量,不出现在 env)\n'
fi
return 0
}
# info_names_filtered:LIST_TAG 过滤后的已登记服务名(stdout 每行一个;与
# global_list 的 --tag 过滤同一语义)。
info_names_filtered() {
local name tags
while IFS= read -r name; do
if [ -n "${LIST_TAG:-}" ]; then
tags="$(env_kv_of "$name" tags)"
case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac
fi
printf '%s\n' "$name"
done < <(registered_names)
return 0
}
# cmd_info [NAME] [--tag T] [--reveal]:综合信息报告(只读)。
# 无名 = 全景:服务表+运行汇总 → postgres 实例与内部库 → 各服务 svc.env 快照 +
# 全局配置层 → 最近事件;有名 = 单服务深度:状态详情 +(postgres)实例 + 快照 + 事件。
cmd_info() {
CLI_REVEAL=0; LIST_TAG=""
local name=""
while [ $# -gt 0 ]; do
case "$1" in
--reveal) CLI_REVEAL=1; shift ;;
--tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;;
-h|--help) cmd_help lifecycle; return 0 ;;
-*) c_err "info 未知参数:$1(可用:--tag T --reveal)"; return 2 ;;
*) [ -z "$name" ] || { c_err "info 只接受一个服务名"; return 2; }
name="$1"; shift ;;
esac
done
if [ -n "$name" ]; then
valid_env "$name" || { c_err "非法服务名:$name"; return 2; }
[ -f "$RUNTIME/$name/svc.env" ] || { c_err "[$name] 无登记(scripts/svc.sh list 查看)"; return 2; }
env_config "$name"
info_section "服务 $name"
env_status_detail
if [ "$SVC_KIND" = postgres ]; then
info_section "数据库实例 $name"
info_pg_block "$name"
fi
info_section "配置快照 $name"
info_config_block "$name"
info_section "最近事件 $name"
local hf="$RUNTIME/$name/history.jsonl"
if [ -f "$hf" ]; then history_print_rows 5 "$hf"
else printf ' (暂无事件)\n'; fi
return 0
fi
# 全景报告。
local names=() n kind st total=0 running=0 ka=0 kp=0 ks=0
while IFS= read -r n; do names+=("$n"); done < <(info_names_filtered)
info_section "服务"
global_list
for n in ${names[@]+"${names[@]}"}; do
total=$((total + 1))
st="$(svc_pid_state "$n")"
[ "${st%% *}" = alive ] || continue
running=$((running + 1))
case "$(svc_kind "$n")" in
agent) ka=$((ka + 1)) ;;
postgres) kp=$((kp + 1)) ;;
static) ks=$((ks + 1)) ;;
esac
done
[ "$total" -gt 0 ] && printf ' 汇总:%d 已登记 / %d 运行中(agent %d、postgres %d、static %d)\n' \
"$total" "$running" "$ka" "$kp" "$ks"
info_section "数据库"
local pgs=()
for n in ${names[@]+"${names[@]}"}; do
[ "$(svc_kind "$n")" = postgres ] && pgs+=("$n")
done
if [ "${#pgs[@]}" -eq 0 ]; then
printf ' (无 postgres 实例;scripts/svc.sh db create <name> 创建)\n'
else
for n in ${pgs[@]+"${pgs[@]}"}; do info_pg_block "$n"; done
fi
info_section "配置"
if [ "${#names[@]}" -eq 0 ]; then
printf ' (无已登记服务)\n'
else
for n in ${names[@]+"${names[@]}"}; do info_config_block "$n"; done
fi
info_global_config
info_section "最近事件"
local files=() f
for n in ${names[@]+"${names[@]}"}; do
f="$RUNTIME/$n/history.jsonl"
[ -f "$f" ] && files+=("$f")
done
if [ "${#files[@]}" -eq 0 ]; then
printf ' (暂无事件)\n'
else
history_print_rows 5 "${files[@]}"
printf ' (更多:scripts/svc.sh history%s)\n' "${LIST_TAG:+ --tag 对应服务名}"
fi
return 0
}
# ---- 自动检索与更新 ----
# cmd_update [NAME...] [--check] [--all] [--no-fetch]:
# fetch 独立部署源仓 → 逐服务比较登记 source 与远端 main 最新(Releases 最新版本)→
# --check 只报告;否则更新"过时且运行中"的服务(点名或 --all 时含已停止)。
# local@/dev@ 源一律重新部署为 dev@<远端最新>(隔离 worktree 构建)——与主检出
# 工作树状态完全无关(脏树、任意分支均可);领先/分叉/提交不在远端不动作。
cmd_update() {
local names=() a
while [ $# -gt 0 ]; do
case "$1" in
--check) CLI_CHECK=1; shift ;;
--all) CLI_ALL=1; shift ;;
--quiet) CLI_QUIET=1; shift ;;
--no-fetch) CLI_NOFETCH=1; shift ;;
--) shift; break ;;
-h|--help) usage; exit 0 ;;
-*) c_err "update 未知参数:$1(可用:--check --all --no-fetch)"; return 2 ;;
*) names+=("$1"); shift ;;
esac
done
local targets=() d e
if [ "${#names[@]}" -gt 0 ]; then
for a in ${names[@]+"${names[@]}"}; do
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
targets+=("$a")
done
else
for d in "$RUNTIME"/*/; do
[ -f "${d}svc.env" ] || continue
e="$(basename "$d")"
[ "$e" = ".worktrees" ] && continue
valid_env "$e" || continue
targets+=("$e")
done
fi
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
# 按目标来源决定访问哪些仓:local@/dev@ 走独立部署源仓;release@ 走 Releases 仓;
# rdm@ 走独立 RDM 源仓。
local latest_sha="" latest_rel="" latest_rdm="" need_rel=0 need_dev=0 need_rdm=0 e_src
for e in ${targets[@]+"${targets[@]}"}; do
e_src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)"
case "$e_src" in release@*) need_rel=1 ;; local@*|dev@*) need_dev=1 ;; rdm@*) need_rdm=1 ;; esac
done
local fetch_note="" src_args=""
[ "$CLI_NOFETCH" = 1 ] && { fetch_note="--no-fetch,基于本地缓存引用"; src_args="--no-fetch"; }
if [ "$need_dev" = 1 ]; then
if svc_src_ready $src_args; then
latest_sha="$(git -C "$SRC_DIR" rev-parse --verify --quiet origin/main || true)"
else
c_warn "部署源仓不可用——local/dev 源服务本轮无法检测与更新"
fi
fi
if [ "$need_rel" = 1 ]; then
releases_root
if [ "$CLI_NOFETCH" != 1 ]; then
git -C "$REL_ROOT" fetch --quiet 2>/dev/null || c_warn "Releases 仓 fetch 失败——基于本地缓存"
fi
local rel_head
rel_head="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD || true)"
if [ -n "$rel_head" ]; then
latest_rel="$(git -C "$REL_ROOT" ls-tree --name-only "$rel_head:versions" 2>/dev/null | sort -V | tail -1 || true)"
fi
fi
if [ "$need_rdm" = 1 ]; then
if rdm_src_ready $src_args; then
latest_rdm="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet origin/main || true)"
else
c_warn "独立 RDM 源仓不可用——rdm 源服务本轮无法检测与更新"
fi
fi
# 逐服务分析:状态判定 + 收集可更新动作。
c_info "更新检测($fetch_note;root: $ROOT)"
printf ' %-12s %-20s %-12s %s\n' 服务 当前来源 最新 状态
local acts=() state to latest_disp="" updated=0 failed=0 skipped=0
for e in ${targets[@]+"${targets[@]}"}; do
local kind src cur st_running
kind="$(svc_kind "$e")"
src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)"
to=""
latest_disp=""
case "$kind" in
postgres)
state="不适用(数据库)" ;;
*)
case "$src" in
dir@*)
state="不适用(外部目录)" ;;
"")
state="来源未知(重新 deploy 后可参与更新)" ;;
local@*|dev@*)
cur="${src##*@}"
if [ -z "$latest_sha" ]; then
state="远端 main 未知"; latest_disp="-"
elif ! git -C "$SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
state="提交不在远端(不自动更新;重新 deploy 可纳入更新轨道)"
latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
elif [ "${latest_sha:0:${#cur}}" = "$cur" ]; then
state="✓ 最新"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
elif git -C "$SRC_DIR" merge-base --is-ancestor "$cur" "$latest_sha" 2>/dev/null; then
to="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || true)"
latest_disp="$to"
state="可更新"
elif git -C "$SRC_DIR" merge-base --is-ancestor "$latest_sha" "$cur" 2>/dev/null; then
state="领先远端 main(不动作)"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
else
state="与远端 main 分叉(不自动更新)"; latest_disp="-"
fi
;;
release@*)
cur="${src#release@}"
latest_disp="${latest_rel:--}"
if [ -z "$latest_rel" ]; then
state="Releases 仓不可用"
elif [ "$cur" = "$latest_rel" ]; then
state="✓ 最新"
else
local ordered
ordered="$(printf '%s\n%s\n' "$cur" "$latest_rel" | sort -V | tail -1)"
if [ "$ordered" = "$latest_rel" ]; then
state="可更新"; to="$latest_rel"
else
state="领先 Releases(不动作)"
fi
fi
;;
rdm@*)
cur="${src##*@}"
if [ -z "$latest_rdm" ]; then
state="RDM 远端 main 未知"; latest_disp="-"
elif ! git -C "$RDM_SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
state="提交不在 RDM 远端(不自动更新;重新 deploy 可纳入更新轨道)"
latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
elif [ "${latest_rdm:0:${#cur}}" = "$cur" ]; then
state="✓ 最新"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$cur" "$latest_rdm" 2>/dev/null; then
to="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || true)"
latest_disp="$to"
state="可更新"
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$latest_rdm" "$cur" 2>/dev/null; then
state="领先 RDM 远端 main(不动作)"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
else
state="与 RDM 远端 main 分叉(不自动更新)"; latest_disp="-"
fi
;;
*)
state="来源未知(重新 deploy 后可参与更新)" ;;
esac
;;
esac
printf ' %-12s %-20s %-12s %s\n' "$e" "${src:--}" "$latest_disp" "$state"
if [ "$state" = "可更新" ]; then
st_running="$(svc_pid_state "$e")"
if [ "${st_running%% *}" = alive ] || [ "$CLI_ALL" = 1 ] || [ "${#names[@]}" -gt 0 ]; then
acts+=("$e|$src|$to")
else
c_warn "[$e] 已停止且可更新——加 --all 或点名以更新(更新会启动服务)"
fi
fi
done
if [ "$CLI_CHECK" = 1 ]; then
c_info "--check:仅报告,未做任何变更"
return 0
fi
[ ${#acts[@]} -gt 0 ] || { c_ok "无需更新(全部最新或不适用)"; return 0; }
# 执行更新:逐服务走 deploy 全链路(复用持久化配置与标签)。
for a in ${acts[@]+"${acts[@]}"}; do
local name="${a%%|*}" rest="${a#*|}"
local from="${rest%%|*}" tov="${rest#*|}"
local deploy_from=""
env_config "$name"
# local@/dev@ 源统一重新部署为 dev@<远端最新>:独立部署源仓 + 隔离 worktree 构建,
# 不 pull 主检出、不看工作树状态(脏树、任意分支均可更新)。
case "$from" in
local@*|dev@*) deploy_from="dev@$tov" ;;
release@*) deploy_from="release@$tov" ;;
rdm@*) deploy_from="rdm@$tov" ;;
esac
c_info "[$name] 更新:$from → $tov($deploy_from)"
hist_event update begin "$(read_pid)" "from=$from to=$tov"
# 每个服务独立子进程、清空环境,避免批内 AGENT_SYSTEM_* 串实例。
if svc_lifecycle_child deploy "$name" --from "$deploy_from"; then
hist_event update ok "$(read_pid)" "from=$from to=$tov"
c_ok "[$name] 已更新:$from → $tov"
updated=$((updated + 1))
else
hist_event update fail "$(read_pid)" "from=$from to=$tov(部署失败)"
c_err "[$name] 更新失败($from → $tov),其余服务继续"
failed=$((failed + 1))
fi
done
c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用"
[ "$failed" -eq 0 ] && return 0 || return 1
}
# ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)----
# recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。
recover_targets() {
local n
while IFS= read -r n; do
[ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"
done < <(registered_names)
while IFS= read -r n; do
[ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n"
done < <(registered_names)
}
# cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化
# exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。
# 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。
cmd_recover() {
local names=() a
while [ $# -gt 0 ]; do
case "$1" in
--quiet) CLI_QUIET=1; shift ;;
--) shift; break ;;
-h|--help) cat >&2 <<'EOF'
用法:scripts/svc.sh recover [name...] [--quiet]
自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1
且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。
通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。
EOF
return 0 ;;
-*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;;
*) names+=("$1"); shift ;;
esac
done
local targets=() n rc=0 recovered=0 failed=0 skipped=0
if [ "${#names[@]}" -gt 0 ]; then
for a in ${names[@]+"${names[@]}"}; do
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
done
# 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。
local pgs=() others=()
for a in ${names[@]+"${names[@]}"}; do
[ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a")
done
targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"})
else
while IFS= read -r n; do targets+=("$n"); done < <(recover_targets)
fi
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
for n in ${targets[@]+"${targets[@]}"}; do
[ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; }
local st; st="$(svc_pid_state "$n")"
if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi
c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)"
# 子进程自己抢锁;锁被占用时 __lifecycle 以 75 退出,本轮记跳过。
local child_rc=0
svc_lifecycle_child recover "$n" || child_rc=$?
env_config "$n"
if [ "$child_rc" -eq 0 ]; then
hist_event recover ok "$(read_pid)" "autostart revival"
recovered=$((recovered + 1))
elif [ "$child_rc" -eq 75 ]; then
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
skipped=$((skipped + 1))
else
hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)"
failed=$((failed + 1)); rc=1
fi
done
if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then
c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped"
fi
return "$rc"
}
# ---- watchdog:systemd 用户级 timer,每分钟执行 recover ----
WD_UNIT_BASE="agent-system-svc-recover"
WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
# wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。
wd_service_text() {
printf '%s
' \
"# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \
"[Unit]" \
"Description=agent-system svc auto-recovery (restart crashed/killed services)" \
"" \
"[Service]" \
"Type=oneshot" \
"# KillMode=process:oneshot 结束时只回收脚本自身——默认 control-group 会把" \
"# recover 刚拉起的服务一并杀掉(2026-10-11 实测:recover ok 后进程立即消失)。" \
"KillMode=process" \
"ExecStart=$1/scripts/svc.sh recover --quiet" \
"Nice=5"
}
wd_timer_text() {
printf '%s\n' \
"[Unit]" \
"Description=Run agent-system svc recovery every minute" \
"" \
"[Timer]" \
"OnBootSec=1min" \
"OnUnitActiveSec=60s" \
"AccuracySec=10s" \
"Unit=${WD_UNIT_BASE}.service" \
"" \
"[Install]" \
"WantedBy=timers.target"
}
# wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。
wd_systemd_ok() {
command -v systemctl >/dev/null 2>&1 || return 1
systemctl --user show-environment >/dev/null 2>&1
}
# cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。
cmd_watchdog() {
local sub="${1:-status}"; [ $# -gt 0 ] && shift || true
case "$sub" in
install)
local print=0
case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac
local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
if [ "$print" = 1 ]; then
c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd"
printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")"
printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)"
wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)"
return 0
fi
wd_systemd_ok || {
c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer"
c_err " 降级方案(cron,每分钟):"
c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -"
return 1
}
mkdir -p "$WD_UNIT_DIR"
{ wd_service_text "$ROOT"; printf '\n'; } > "$svc"
{ wd_timer_text; printf '\n'; } > "$tmr"
systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; }
systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || {
c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; }
c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)"
[ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \
|| c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响"
c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service"
return 0
;;
remove|uninstall)
wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; }
if wd_systemd_ok; then
systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true
systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true
fi
rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
if wd_systemd_ok; then systemctl --user daemon-reload || true; fi
c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)"
return 0
;;
status)
local n auto_list=""
if wd_systemd_ok; then
systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true
local exec_line
exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)"
if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then
c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)"
fi
else
c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)"
fi
while IFS= read -r n; do
[ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n"
done < <(registered_names)
c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}"
return 0
;;
-h|--help|help)
cat >&2 <<'EOF'
用法:scripts/svc.sh watchdog install|remove|status [install --print]
install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet)
install --print 只打印将写入的单元文件与计划(不落盘)
remove 停用并删除 timer(服务登记与运行状态不受影响)
status timer 状态 + autostart=1 的服务清单
EOF
return 0 ;;
*) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;;
esac
}
# ---- main ----
# svc_lifecycle_main ACTION NAME [--from SPEC] [--quiet]
# 锁内:迁移 → 身份策略(在各动作里)→ 动作 → 解锁。供 update/recover 子进程使用。
svc_lifecycle_main() {
local action="${1:-}" name="${2:-}"
[ -n "$action" ] && [ -n "$name" ] || { c_err "__lifecycle 需要动作与服务名"; exit 2; }
shift 2
valid_env "$name" || exit 2
while [ $# -gt 0 ]; do
case "$1" in
--from) CLI_FROM="$2"; shift 2 ;;
--quiet) CLI_QUIET=1; shift ;;
*) c_err "__lifecycle 未知参数:$1"; exit 2 ;;
esac
done
env_config "$name"
if ! svc_lock -n; then
exit 75
fi
case "$action" in
deploy|recover|start) svc_manifest_migrate || { svc_unlock; exit 1; } ;;
esac
# 对账必须发生在干净子进程里:父进程的调用方环境不得进入部署。
case "$action" in
deploy|recover) svc_deploy_journal_resolve || { svc_unlock; exit 1; } ;;
esac
local rc=0
case "$action" in
deploy)
case "$SVC_KIND" in
agent) agent_do_deploy || rc=$? ;;
static) static_do_deploy && static_do_start || rc=$? ;;
*) c_err "[$name] 该类型不支持 deploy"; rc=2 ;;
esac
;;
recover|start) do_start || rc=$? ;;
*) c_err "未知生命周期动作:$action"; rc=2 ;;
esac
svc_unlock
exit "$rc"
}
main() {
if [ "${1:-}" = "__lifecycle" ]; then
shift
svc_lifecycle_main "$@"
fi
[ $# -ge 1 ] || { usage_short; 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) usage_short; exit 0 ;;
--help) usage; exit 0 ;;
help) shift; cmd_help "$@"; exit $? ;;
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 ;;
set) shift; cmd_set "$@"; exit 0 ;;
rm) shift; cmd_rm "$@"; exit 0 ;;
info) shift; cmd_info "$@" || exit $?; exit 0 ;;
commits) shift; cmd_commits "$@"; exit 0 ;;
history) shift; cmd_history "$@"; exit 0 ;;
update) shift; cmd_update "$@" || exit $?; exit 0 ;;
recover) shift; cmd_recover "$@" || exit $?; exit 0 ;;
watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;;
esac
ENV_NAME="$1"; shift
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
local action="deploy"
case "${1:-}" in
deploy|stop|start|restart|status|logs|db|config|init|adopt|user|backup|backups) action="$1"; shift ;;
esac
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
CLI_LOGS_N="$1"; shift
fi
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
# 未知 flags 由其自行拒绝)。
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
env_config "$ENV_NAME"
case "$action" in
db) svc_db_bind "$@" || exit $? ;;
config) svc_config "$@" || exit $? ;;
user) agent_do_user "$@" || exit $? ;;
esac
exit 0
fi
if [ "$action" = backup ] || [ "$action" = backups ]; then
env_config "$ENV_NAME"
case "$action" in
backup) svc_agent_backup "$@" || exit $? ;;
backups) svc_agent_backups "$@" || exit $? ;;
esac
exit 0
fi
local adopt_confirm=""
if [ "$action" = adopt ]; then
while [ $# -gt 0 ]; do
case "$1" in
--confirm) adopt_confirm="$2"; shift 2 ;;
*) c_err "adopt 用法:scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"; exit 2 ;;
esac
done
fi
parse_args "$@"
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作"
exit 2
fi
env_config "$ENV_NAME"
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
case "$action" in
deploy|stop|start|restart|init|adopt) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
esac
# U1 身份:锁内一次性迁移(幂等;含既有数据领养 marker)。归属冲突(rc=4)中止;
# 其余失败保持 legacy 治理继续运行。
case "$action" in
start|restart|deploy|init|adopt) svc_manifest_migrate || exit 1 ;;
esac
# U2:入口处理中断的切换(deploy.journal)。blocked 拒绝除 deploy 外的动作
#(显式换制品即解除路径);start/restart 触发回退判定。
case "$action" in
start|restart|deploy) svc_deploy_journal_resolve "$action" || exit 1 ;;
esac
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_restart ;;
init)
# 只用于 fresh/pending 的显式建立。established 缺行不得用 init 补,走 adopt。
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; }
if [ -f "$ENV_DIR/manifest.json" ]; then
case "$(svc_db_marker)" in
established)
c_err "[$ENV_NAME] 身份已建立。缺行或不匹配时先调查,再 scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"
exit 1 ;;
esac
c_ok "[$ENV_NAME] 已在 manifest 治理下(init 补齐 fresh/pending):"
elif [ -f "$ENV_DIR/svc.env" ]; then
python3 "$MANIFEST_PY" "$ENV_DIR" init \
|| { c_err "[$ENV_NAME] init 失败(数据根冲突等,见上)"; exit 1; }
else
c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2
fi
resolve_db_config 2>/dev/null || true
svc_db_identity_ensure || { c_err "[$ENV_NAME] DB 身份 ensure 失败"; exit 1; }
python3 "$MANIFEST_PY" "$ENV_DIR" status ;;
adopt)
# 显式重审已建立实例的 DB 身份。先打印库大小、行数、最近写入,再要求 --confirm。
[ -n "$adopt_confirm" ] || { c_err "adopt 必须 --confirm <instance_id>"; exit 2; }
[ -f "$ENV_DIR/manifest.json" ] || { c_err "[$ENV_NAME] 无 manifest,不能 adopt"; exit 1; }
local iid
iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")"
[ "$adopt_confirm" = "$iid" ] || { c_err "[$ENV_NAME] --confirm 与 instance_id 不符($iid)"; exit 1; }
resolve_db_config 2>/dev/null || true
python3 - "$ENV_DIR" <<'PY'
import json, os, sqlite3, sys, time
env_dir = sys.argv[1]
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
data = (m.get("identity") or {}).get("data_root") or ""
db = os.path.join(data, "agent-system.db")
print("adopt 证据 instance_id=%s" % m["instance_id"])
if not os.path.isfile(db):
print("库文件不存在:%s" % db)
sys.exit(0)
st = os.stat(db)
print("库大小=%d 字节 最近写入=%s" % (st.st_size, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime))))
con = sqlite3.connect(db)
try:
for table in ("agent_system_identity", "conversations", "users"):
try:
n = con.execute("SELECT COUNT(*) FROM %s" % table).fetchone()[0]
print("%s 行数=%s" % (table, n))
except sqlite3.Error:
print("%s 行数=(无此表)" % table)
finally:
con.close()
PY
svc_db_marker_set pending || exit 1
svc_db_identity_ensure || { c_err "[$ENV_NAME] adopt ensure 失败"; exit 1; }
c_ok "[$ENV_NAME] adopt 完成,db_marker=established" ;;
status) env_status_detail ;;
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
esac
}
# 仅作为脚本直接执行时进入主流程;被 source(治理单测 fixtures)时只定义函数。
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi